Skip to content

feat: add basic web UI authentication with environment variables#519

Closed
rowanchen-com wants to merge 1 commit intoagentscope-ai:mainfrom
rowanchen-com:main
Closed

feat: add basic web UI authentication with environment variables#519
rowanchen-com wants to merge 1 commit intoagentscope-ai:mainfrom
rowanchen-com:main

Conversation

@rowanchen-com
Copy link

@rowanchen-com rowanchen-com commented Mar 3, 2026

Description

This PR adds an optional authentication system and login page for the Web UI to prevent unauthorized access on public-facing deployments.

Key changes:

  • Clean, lightweight login page built with existing Ant Design components (zero new frontend dependencies).
  • Backend auth module using only Python stdlib (hashlib, hmac, secrets) — zero new backend dependencies.
  • Passwords stored as salted SHA-256 hashes in auth.json (persisted in the Docker-mapped working directory).
  • HMAC-SHA256 signed tokens with 7-day expiry, stored in localStorage.
  • Auth is only enabled when ADMIN_PASSWORD environment variable is explicitly set. Without it, the app behaves exactly like upstream — no login required.
  • Credential changes automatically rotate the signing secret, invalidating all existing tokens immediately.
  • Auth file written with 0600 permissions; auth state fails closed if the file is unreadable.
  • WebSocket auth supported via query parameter (restricted to upgrade requests only).
  • CORS preflight (OPTIONS) requests pass through the auth middleware.
  • Dedicated /api/auth/verify endpoint for token validation in the frontend AuthGuard.

Related Issue: Fixes #492

Security Considerations:

  • Credentials are read from environment variables (ADMIN_USERNAME / ADMIN_PASSWORD), not from a .env file — no hardcoded secrets.
  • Password rotation invalidates all active sessions instantly.
  • Fail-closed design: if auth.json cannot be read, auth is treated as enabled rather than bypassed.
  • Query-param token fallback is restricted to WebSocket upgrade requests to minimize token leakage risk.

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation
  • Refactoring

Component(s) Affected

  • Core / Backend (app, agents, config, providers, utils, local_models)
  • Console (frontend web UI)
  • Channels (DingTalk, Feishu, QQ, Discord, iMessage, etc.)
  • Skills
  • CLI
  • Documentation (website)
  • Tests
  • CI/CD
  • Scripts / Deploy

Checklist

  • Pre-commit hooks pass (pre-commit run --all-files or CI)
  • Tests pass locally (pytest or as relevant)
  • Documentation updated (if needed)
  • Ready for review

Testing

  1. Docker (auth enabled by default): docker compose up — Dockerfile sets ADMIN_USERNAME=admin and ADMIN_PASSWORD=copaw. Navigate to the web console, you should see the login page.
  2. Without Docker (auth disabled): Run copaw app without setting ADMIN_PASSWORD. The app behaves exactly like upstream — no login page, direct access.
  3. Enable auth manually: Set ADMIN_PASSWORD=your_password as an environment variable, restart the app. Login page appears.
  4. Test login: Try incorrect credentials → error message. Try correct credentials → redirected to dashboard.
  5. Session persistence: Refresh the page after login — session persists (token in localStorage).
  6. Password rotation: Change ADMIN_PASSWORD, restart. All existing sessions are invalidated, users must re-login.
  7. Logout: Click "Sign Out" in the sidebar → redirected to login page.

Additional Notes

  • 5 new files, 9 modified files. Zero new dependencies on both frontend and backend.
  • Auth data (auth.json) is stored in the Docker volume-mapped working directory (/app/working), persisted across container rebuilds.
  • All issues raised by Qodo and CodeRabbit review bots have been addressed across 3 commits.

@gemini-code-assist
Copy link
Contributor

Warning

Gemini encountered an error creating the summary. You can try again by commenting /gemini summary.

@qodo-code-review
Copy link

Review Summary by Qodo

Add web UI authentication with JWT tokens and environment-based credentials

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Implement authentication system with JWT tokens and salted password hashing
• Add login page UI with form validation and error handling
• Protect API endpoints with Bearer token middleware authentication
• Initialize credentials from ADMIN_USERNAME and ADMIN_PASSWORD environment variables
• Add logout functionality and auth status checking to sidebar
Diagram
flowchart LR
  A["Environment Variables<br/>ADMIN_USERNAME<br/>ADMIN_PASSWORD"] -->|init_auth_from_env| B["auth.json<br/>Credentials Storage"]
  C["Login Form"] -->|POST /api/auth/login| D["authenticate()"]
  D -->|verify password| B
  D -->|create JWT token| E["Token"]
  E -->|localStorage| F["Frontend"]
  F -->|Bearer Token| G["AuthMiddleware"]
  G -->|verify_token| H["Protected API"]
  I["Sidebar"] -->|clearAuthToken| J["Logout"]
Loading

Grey Divider

File Changes

1. src/copaw/app/auth.py ✨ Enhancement +263/-0

Core authentication module with password hashing and JWT tokens

src/copaw/app/auth.py


2. src/copaw/app/_app.py ✨ Enhancement +7/-0

Initialize auth and apply authentication middleware

src/copaw/app/_app.py


3. src/copaw/app/routers/auth.py ✨ Enhancement +45/-0

Login and auth status API endpoints

src/copaw/app/routers/auth.py


View more (11)
4. src/copaw/app/routers/__init__.py ✨ Enhancement +2/-0

Register authentication router with API

src/copaw/app/routers/init.py


5. console/src/pages/Login/index.tsx ✨ Enhancement +116/-0

New login page with form and authentication flow

console/src/pages/Login/index.tsx


6. console/src/App.tsx ✨ Enhancement +59/-2

Add routing and auth guard for protected pages

console/src/App.tsx


7. console/src/api/config.ts ✨ Enhancement +19/-1

Add token storage and retrieval from localStorage

console/src/api/config.ts


8. console/src/api/modules/auth.ts ✨ Enhancement +35/-0

New authentication API client module

console/src/api/modules/auth.ts


9. console/src/api/request.ts ✨ Enhancement +10/-1

Handle 401 responses and redirect to login

console/src/api/request.ts


10. console/src/layouts/Sidebar.tsx ✨ Enhancement +25/-1

Add logout button and auth status detection

console/src/layouts/Sidebar.tsx


11. console/src/locales/en.json 📝 Documentation +11/-0

Add English translations for login UI

console/src/locales/en.json


12. console/src/locales/zh.json 📝 Documentation +11/-0

Add Chinese translations for login UI

console/src/locales/zh.json


13. deploy/Dockerfile ⚙️ Configuration changes +4/-0

Set default auth environment variables

deploy/Dockerfile


14. docker-compose.yml ⚙️ Configuration changes +15/-0

Add docker-compose configuration with auth credentials

docker-compose.yml


Grey Divider

Qodo Logo

@qodo-code-review
Copy link

qodo-code-review bot commented Mar 3, 2026

Code Review by Qodo

🐞 Bugs (6) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider


Action required

1. Auth always enabled/overwritten🐞 Bug ✓ Correctness
Description
init_auth_from_env() always persists a password_hash using default values when env vars are unset,
so auth cannot be disabled by leaving credentials blank and credentials can revert to defaults on
restart. This is both a security footgun and inconsistent with the intended “optional auth”
behavior.
Code

src/copaw/app/auth.py[R151-188]

+def init_auth_from_env() -> None:
+    """Initialize auth from ADMIN_USERNAME / ADMIN_PASSWORD env vars.
+
+    Called at startup. If env vars are set and auth.json doesn't have
+    credentials yet (or password changed), update auth.json.
+    """
+    username = os.environ.get("ADMIN_USERNAME", "admin").strip()
+    password = os.environ.get("ADMIN_PASSWORD", "copaw").strip()
+
+    if not username:
+        username = "admin"
+
+    data = _load_auth_data()
+    # Check if we need to update
+    existing_hash = data.get("password_hash", "")
+    existing_salt = data.get("password_salt", "")
+    existing_user = data.get("username", "")
+
+    # If credentials already match, skip
+    if (
+        existing_hash
+        and existing_salt
+        and existing_user == username
+        and verify_password(password, existing_hash, existing_salt)
+    ):
+        logger.debug("Auth credentials unchanged, skipping update")
+        return
+
+    # Hash and store
+    pw_hash, salt = _hash_password(password)
+    data["username"] = username
+    data["password_hash"] = pw_hash
+    data["password_salt"] = salt
+    if "jwt_secret" not in data:
+        data["jwt_secret"] = secrets.token_hex(32)
+
+    _save_auth_data(data)
+    logger.info("Auth credentials initialized from environment variables")
Evidence
The app unconditionally calls init_auth_from_env() at startup, which defaults to admin/copaw and
writes password_hash into auth.json. is_auth_enabled() checks only for password_hash, so this
makes auth enabled once the file is written, even if operators intended to leave it unset/blank.

src/copaw/app/_app.py[59-64]
src/copaw/app/auth.py[145-189]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Auth enablement is currently implicit and irreversible-by-config: startup always writes credentials (using defaults if env is missing), so auth is always enabled and can revert to known defaults.
### Issue Context
- `lifespan()` always calls `init_auth_from_env()`.
- `init_auth_from_env()` always writes `password_hash` even when ADMIN_* are not set.
- `is_auth_enabled()` is `bool(password_hash)`.
### Fix Focus Areas
- src/copaw/app/_app.py[59-63]
- src/copaw/app/auth.py[145-189]
### Suggested changes
1. Decide on an explicit enable/disable contract, e.g.:
- `COPAW_AUTH_ENABLED=true|false`, or
- enable only when both `ADMIN_USERNAME` and `ADMIN_PASSWORD` are set and non-empty.
2. Modify `init_auth_from_env()` to:
- not default to a known password,
- not overwrite existing `auth.json` when env vars are absent,
- optionally skip initialization if credentials are already present and env vars are not explicitly provided.
3. If disable is supported, ensure `is_auth_enabled()` reflects it (not solely password_hash presence).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. CORS preflight blocked by auth🐞 Bug ⛯ Reliability
Description
AuthMiddleware is added after CORSMiddleware, making it the outer wrapper; for cross-origin setups
with auth enabled, browser preflight OPTIONS requests to protected /api/* endpoints can be rejected
with 401 and/or miss CORS headers. This breaks deployments that rely on COPAW_CORS_ORIGINS + a
separate console origin (or BASE_URL use).
Code

src/copaw/app/_app.py[R173-178]

      allow_headers=["*"],
  )
+# Apply authentication middleware
+app.add_middleware(AuthMiddleware)
+
Evidence
Middleware order shows CORSMiddleware is added first and AuthMiddleware second; in Starlette this
means AuthMiddleware runs before CORS. AuthMiddleware does not exempt OPTIONS and returns a raw 401
Response, which would not be decorated with CORS headers when AuthMiddleware is outermost.

src/copaw/app/_app.py[165-178]
src/copaw/app/auth.py[214-251]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Cross-origin browser clients can fail because preflight OPTIONS requests hit AuthMiddleware first and receive 401 without CORS headers.
### Issue Context
This only manifests when:
- `COPAW_CORS_ORIGINS` is set (cross-origin requests), and
- auth is enabled.
### Fix Focus Areas
- src/copaw/app/_app.py[165-178]
- src/copaw/app/auth.py[214-251]
### Suggested changes
1. Middleware ordering: add `AuthMiddleware` before `CORSMiddleware` (so CORS is outermost).
2. Add an early exemption in AuthMiddleware:
- if `request.method == &amp;amp;quot;OPTIONS&amp;amp;quot;`: `return await call_next(request)`
3. (Optional) Consider returning `fastapi.HTTPException` responses and ensuring CORS headers are applied consistently.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. AuthGuard uses wrong URL and check🐞 Bug ✓ Correctness
Description
Frontend AuthGuard hardcodes fetch('/api/version') (ignores BASE_URL) and lacks a .catch() for
that fetch, so network/CORS failures can leave the app stuck in a blank loading state. Additionally,
/api/version is explicitly a public path on the backend, so this call cannot validate whether a
stored token is actually valid/expired.
Code

console/src/App.tsx[R24-48]

+  useEffect(() => {
+    authApi
+      .getStatus()
+      .then((res) => {
+        if (!res.enabled) {
+          setStatus("ok");
+          return;
+        }
+        // Auth is enabled, check if we have a valid token
+        const token = getApiToken();
+        if (!token) {
+          setStatus("auth-required");
+          return;
+        }
+        // Verify token by making a simple API call
+        fetch("/api/version", {
+          headers: { Authorization: `Bearer ${token}` },
+        }).then((r) => {
+          if (r.ok) {
+            setStatus("ok");
+          } else {
+            clearAuthToken();
+            setStatus("auth-required");
+          }
+        });
Evidence
AuthGuard bypasses getApiUrl() and calls /api/version directly, which breaks when the console is
deployed separately and BASE_URL is configured. The backend also exempts /api/version from auth
checks, so r.ok does not imply the token is valid. Finally, missing error handling on the fetch
can keep status as "loading" forever if the request rejects.

console/src/App.tsx[24-48]
console/src/api/config.ts[11-16]
src/copaw/app/auth.py[31-38]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
AuthGuard currently:
- calls a same-origin hardcoded URL (`/api/version`) instead of using BASE_URL-aware helpers,
- can hang forever on fetch rejection (no catch),
- and uses a backend-public endpoint so it doesn&amp;amp;#x27;t actually validate token validity.
### Issue Context
Backend explicitly exempts `/api/version` from authentication, so it cannot be used as a token validator.
### Fix Focus Areas
- console/src/App.tsx[24-49]
- console/src/api/config.ts[11-16]
- src/copaw/app/auth.py[31-38]
### Suggested changes
1. Replace `fetch(&amp;amp;#x27;/api/version&amp;amp;#x27;, ...)` with either:
- `fetch(getApiUrl(&amp;amp;#x27;/version&amp;amp;#x27;), ...)`, or
- reuse existing API wrapper (`api.getVersion()`), ensuring it uses BASE_URL.
2. Add `.catch(() =&amp;amp;gt; { clearAuthToken(); setStatus(&amp;amp;#x27;auth-required&amp;amp;#x27;); })` (or a safe fallback).
3. Add a dedicated protected endpoint like `GET /api/auth/verify` that returns 200 only when the Bearer token is valid, and call that from AuthGuard instead of `/api/version`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

4. Password rotation keeps tokens🐞 Bug ⛨ Security
Description
Updating ADMIN_PASSWORD updates the stored password hash but does not rotate jwt_secret, so
previously issued tokens remain valid until expiry. This undermines password rotation as an
incident-response mechanism (compromised token stays valid even after changing password).
Code

src/copaw/app/auth.py[R179-186]

+    # Hash and store
+    pw_hash, salt = _hash_password(password)
+    data["username"] = username
+    data["password_hash"] = pw_hash
+    data["password_salt"] = salt
+    if "jwt_secret" not in data:
+        data["jwt_secret"] = secrets.token_hex(32)
+
Evidence
init_auth_from_env() only creates jwt_secret if it doesn't already exist, so password changes do
not impact token signing. verify_token() validates solely against the stored jwt_secret and token
exp.

src/copaw/app/auth.py[145-189]
src/copaw/app/auth.py[100-118]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Changing the admin password does not revoke existing tokens because the signing secret remains stable.
### Issue Context
`jwt_secret` is only initialized once and is reused across password changes.
### Fix Focus Areas
- src/copaw/app/auth.py[151-188]
- src/copaw/app/auth.py[70-79]
### Suggested changes
1. Detect when password/user changed (you already do) and in that case regenerate `jwt_secret`.
2. Alternatively, store a `token_version` and include it in the payload; bump it on password change and reject older versions.
3. Consider reducing `TOKEN_EXPIRY_SECONDS` or making it configurable.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. auth.json lacks secret protections🐞 Bug ⛨ Security
Description
auth.json contains password_hash/salt and jwt_secret but is written into WORKING_DIR without
chmod hardening, unlike other secret material stored under SECRET_DIR with 0700/0600. On multi-user
hosts (or shared volumes) this can allow local users to read jwt_secret and forge tokens.
Code

src/copaw/app/auth.py[R138-143]

+def _save_auth_data(data: dict) -> None:
+    """Save auth.json to WORKING_DIR."""
+    AUTH_FILE.parent.mkdir(parents=True, exist_ok=True)
+    with open(AUTH_FILE, "w", encoding="utf-8") as f:
+        json.dump(data, f, indent=2, ensure_ascii=False)
+
Evidence
The auth module writes its secret-bearing file without applying restrictive permissions. The repo
already has established patterns for secret storage using SECRET_DIR and best-effort chmod to 0700
(dir) and 0600 (file).

src/copaw/app/auth.py[120-143]
src/copaw/providers/store.py[52-63]
src/copaw/providers/store.py[79-83]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`auth.json` contains secrets (jwt_secret) but is stored like normal working data: no dedicated secret directory and no restrictive file permissions.
### Issue Context
Other secret config (providers/envs) is stored under `SECRET_DIR` and chmod’d to 0700/0600 best-effort.
### Fix Focus Areas
- src/copaw/app/auth.py[22-27]
- src/copaw/app/auth.py[127-143]
- src/copaw/providers/store.py[52-63]
- src/copaw/providers/store.py[79-83]
### Suggested changes
1. Move AUTH_FILE from `WORKING_DIR/auth.json` to `SECRET_DIR/auth.json`.
2. Add `_chmod_best_effort` + `_prepare_secret_parent` helpers (reuse pattern from providers/envs stores).
3. After writing, chmod the file to 0o600 best-effort.
4. Consider atomic write (write temp file then rename) to avoid partially-written JSON on crashes.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Token accepted via query param🐞 Bug ⛨ Security
Description
AuthMiddleware accepts tokens from the URL query string for any protected /api/* route. URL tokens
are prone to leakage via logs, proxies, and Referer headers; currently the repo has no FastAPI
websocket endpoints that require this workaround, so this expands attack surface without an observed
need.
Code

src/copaw/app/auth.py[R237-245]

+        # Try Authorization header first, then fall back to query param
+        # (WebSocket connections can't set custom headers from browser)
+        token: Optional[str] = None
+        auth_header = request.headers.get("Authorization", "")
+        if auth_header.startswith("Bearer "):
+            token = auth_header[7:]
+        else:
+            token = request.query_params.get("token")
+
Evidence
Middleware explicitly falls back to request.query_params.get('token') when Authorization header is
not present, enabling authentication via URL token for all API endpoints.

src/copaw/app/auth.py[237-245]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Accepting auth tokens in URLs increases the chance of accidental disclosure.
### Issue Context
This was added for WebSocket limitations, but the current FastAPI app codebase does not define websocket routes.
### Fix Focus Areas
- src/copaw/app/auth.py[237-245]
### Suggested changes
1. Remove query-param fallback entirely if not required.
2. If required for a specific future transport, gate it behind:
- a dedicated endpoint path allowlist, and/or
- a feature flag, and/or
- very short-lived one-time tokens.
3. Add documentation warning against using URL tokens in production.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

@coderabbitai
Copy link

coderabbitai bot commented Mar 3, 2026

📝 Walkthrough

Walkthrough

Adds end-to-end authentication: backend auth module, middleware and /auth endpoints; frontend login page, token storage and AuthGuard-protected routes; global 401 handling, logout UI, translations, and deployment defaults for admin credentials.

Changes

Cohort / File(s) Summary
Backend Auth Core
src/copaw/app/auth.py, src/copaw/app/routers/auth.py, src/copaw/app/routers/__init__.py, src/copaw/app/_app.py
New auth implementation: salted SHA‑256 password hashing, HMAC token creation/verification with expiry, auth persistence (auth.json), AuthMiddleware enforcing protection, /auth router (login/status), and env-based init on startup.
Frontend App Routing & Guard
console/src/App.tsx
Replaces direct layout render with react-router Routes; adds AuthGuard to check auth status/token, redirecting to /login with redirect param when needed.
Frontend Login Page
console/src/pages/Login/index.tsx
New LoginPage component using authApi.login, stores token via setAuthToken, handles redirects, loading, and error states.
Frontend API & Token Storage
console/src/api/modules/auth.ts, console/src/api/config.ts, console/src/api/request.ts
Adds authApi (login, getStatus); localStorage helpers setAuthToken/clearAuthToken and updated getApiToken; request handler clears token and redirects on 401.
Sidebar & Logout
console/src/layouts/Sidebar.tsx
Sidebar queries auth status, conditionally renders a logout menu item, and clears token + navigates to /login on logout.
I18n
console/src/locales/en.json, console/src/locales/zh.json
Adds top-level login translation namespace for UI strings (title, placeholders, validation, submit, errors, logout).
Deployment / Compose
deploy/Dockerfile, docker-compose.yml
Adds default ENV ADMIN_USERNAME/ADMIN_PASSWORD in Dockerfile and a docker-compose.yml service for local deployment with port and volume config.

Sequence Diagram

sequenceDiagram
    participant User as User/Browser
    participant Frontend as Frontend (React)
    participant AuthGuard as AuthGuard
    participant API as Backend API
    participant Auth as Auth Module
    participant Storage as localStorage

    User->>Frontend: Navigate to protected route
    Frontend->>AuthGuard: Mount / check
    AuthGuard->>API: GET /auth/status
    API->>Auth: is_auth_enabled()
    Auth-->>API: enabled? true/false
    API-->>AuthGuard: AuthStatusResponse

    alt Auth Disabled
        AuthGuard->>Frontend: Allow access
    else Auth Enabled
        AuthGuard->>Storage: Read token
        alt No token
            AuthGuard->>User: Redirect to /login?redirect=...
        else Token present
            AuthGuard->>API: Validate (e.g., GET /api/version)
            API->>Auth: verify_token()
            alt Token valid
                Auth-->>API: username
                API-->>AuthGuard: OK
                AuthGuard->>Frontend: Allow access
            else Invalid/expired
                AuthGuard->>Storage: Clear token
                AuthGuard->>User: Redirect to /login
            end
        end
    end
Loading
sequenceDiagram
    participant User as User/Browser
    participant LoginPage as LoginPage
    participant Frontend as Frontend API Client
    participant API as Backend API
    participant Auth as Auth Module
    participant Storage as localStorage

    User->>LoginPage: Submit credentials
    LoginPage->>Frontend: authApi.login(username,password)
    Frontend->>API: POST /auth/login
    API->>Auth: authenticate(username,password)

    alt Auth disabled
        Auth-->>API: indicates disabled
        API-->>Frontend: LoginResponse (message, no token)
        Frontend-->>LoginPage: Show auth-not-enabled message
    else Auth enabled & credentials valid
        Auth-->>API: token
        API-->>Frontend: LoginResponse(token, username)
        Frontend->>Storage: setAuthToken(token)
        Frontend->>User: Redirect to saved redirect or /chat
    else Invalid credentials
        API-->>Frontend: HTTP 401
        Frontend-->>LoginPage: Show login failed
    end
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 I hopped through code and found a key,

Tokens tucked beneath a hashed oak tree,
Login doors swing with a cautious cheer,
AuthGuard watches — the burrow is clear,
Hooray, the warren's safe for you and me 🥕🔐

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main change: adding basic web UI authentication using .env credentials, which aligns with the primary objective of the PR.
Linked Issues check ✅ Passed The PR fully addresses all coding requirements from issue #492: login page UI, backend authentication validation, environment variable credential sourcing, token/session handling, and access interception.
Out of Scope Changes check ✅ Passed All changes directly support the authentication feature objectives. Docker/compose files enable the feature deployment, translation files support UI, and API/middleware changes implement the required validation logic.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🧹 Nitpick comments (3)
src/copaw/app/routers/auth.py (1)

35-37: Move import to module level.

The lazy import of HTTPException inside the function is unconventional. Moving it to the top improves readability and avoids repeated import overhead:

Suggested fix
 from fastapi import APIRouter
+from fastapi import HTTPException
 from pydantic import BaseModel
 ...
     if token is None:
-        from fastapi import HTTPException
-
         raise HTTPException(status_code=401, detail="Invalid credentials")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/copaw/app/routers/auth.py` around lines 35 - 37, Move the inline import
of HTTPException out of the function and into the module-level imports: add
"from fastapi import HTTPException" at the top of the file and remove the local
"from fastapi import HTTPException" inside the function that raises
HTTPException (the block that currently does raise
HTTPException(status_code=401, detail="Invalid credentials")). This avoids
repeated imports and matches project style while keeping the raise statement
unchanged.
src/copaw/app/auth.py (1)

151-158: Defaulting to a known password when env var is empty may be unexpected.

When ADMIN_PASSWORD is empty or unset, the code defaults to "copaw". This means authentication is always enabled with a weak default password, which could catch deployers off guard.

Consider treating an empty password as "auth disabled" to align with the PR description's note about defaulting to open-access behavior:

Suggested alternative behavior
 def init_auth_from_env() -> None:
     username = os.environ.get("ADMIN_USERNAME", "admin").strip()
-    password = os.environ.get("ADMIN_PASSWORD", "copaw").strip()
+    password = os.environ.get("ADMIN_PASSWORD", "").strip()
 
-    if not username:
-        username = "admin"
+    if not password:
+        logger.info("ADMIN_PASSWORD not set - authentication disabled")
+        return
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/copaw/app/auth.py` around lines 151 - 158, The init_auth_from_env
function currently defaults ADMIN_PASSWORD to "copaw" when the env var is
unset/empty, enabling auth with a weak password; change the logic so that an
empty or missing ADMIN_PASSWORD is treated as "auth disabled" (do not
create/overwrite credentials in auth.json), and only enable/update credentials
when ADMIN_PASSWORD is non-empty; reference the init_auth_from_env function, the
ADMIN_PASSWORD and ADMIN_USERNAME environment lookups, and the code path that
writes/updates auth.json to gate that update on a non-empty password value.
console/src/App.tsx (1)

58-58: Preserve full route context in login redirect.

Only window.location.pathname is encoded right now, so query/hash context is lost after login.

↩️ Suggested improvement
-  if (status === "auth-required")
-    return <Navigate to={`/login?redirect=${encodeURIComponent(window.location.pathname)}`} replace />;
+  if (status === "auth-required") {
+    const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}`;
+    return <Navigate to={`/login?redirect=${encodeURIComponent(returnTo)}`} replace />;
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@console/src/App.tsx` at line 58, The redirect currently only preserves
window.location.pathname in the Navigate return inside App.tsx, losing query and
hash parts; update the redirect to include the full route context by encoding
and passing pathname + search + hash (i.e., window.location.pathname combined
with window.location.search and window.location.hash) to the redirect query
parameter so the post-login redirect restores query string and fragment. Locate
the Navigate return expression and replace the redirect value to use the
combined, encodeURIComponent-encoded full location string.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@console/src/App.tsx`:
- Around line 24-54: The inner token verification currently uses a hardcoded
fetch("/api/version") and does not handle fetch rejections, which can leave
status stuck; update the auth check inside the useEffect (where
authApi.getStatus, getApiToken and clearAuthToken are used) to call the
configured API helper instead of a hardcoded path (e.g., use the existing API
URL helper or an authApi.getVersion/ping method) and wrap the token verification
request in a promise chain with a .catch that handles network errors by setting
setStatus("ok"); keep the existing logic that on an ok response sets
setStatus("ok") and on a non-ok response calls clearAuthToken() then
setStatus("auth-required").

In `@console/src/layouts/Sidebar.tsx`:
- Around line 63-65: The dynamic import of "../api/modules/auth" used to call
authApi.getStatus() can reject and currently has no top-level catch; update the
async flow around import("../api/modules/auth") so you handle module-load
failures (add a .catch or use try/catch in an async IIFE) and ensure the UI
state is deterministic by setting setAuthEnabled(false) or leaving previous
state on import failure; also retain the existing .catch() on
authApi.getStatus() but move error handling to log the error or
setAuthEnabled(false) so Sidebar's auth state doesn't become inconsistent —
focus changes around the dynamic import and the authApi.getStatus() call sites
(the import("../api/modules/auth") line, authApi.getStatus(), and
setAuthEnabled).

In `@console/src/pages/Login/index.tsx`:
- Around line 22-29: The redirect query param is used directly; validate and
sanitize it before calling navigate. Replace using redirect from
searchParams.get(...) with a sanitized value (e.g., derive sanitizedRedirect
from redirect) that enforces an internal-only path: must start with a single
slash ("/"), must not start with "//", must not contain "://" or full hostnames,
and optionally match an allowlist (e.g., default "/chat"). Use sanitizedRedirect
in navigate(...) (inside the Login component where setAuthToken(res.token) and
navigate are called) so external/absolute URLs are rejected and navigation
always stays internal.

In `@deploy/Dockerfile`:
- Around line 19-21: Remove the baked-in ADMIN_PASSWORD from the Dockerfile to
avoid exposing credentials: delete or omit the ENV ADMIN_PASSWORD=copaw line and
keep only ENV ADMIN_USERNAME=admin (if desired); then update the application
startup or entrypoint logic (the component that reads ADMIN_PASSWORD at runtime)
to treat an unset ADMIN_PASSWORD as "no password / open-access" or to fail-fast
with a clear error if you prefer requiring it at runtime, ensuring the runtime
check references ADMIN_PASSWORD when deciding auth mode.

In `@docker-compose.yml`:
- Around line 9-12: Replace the hardcoded environment values in
docker-compose.yml with environment variable interpolation using defaults (e.g.,
change ADMIN_USERNAME=admin to ADMIN_USERNAME=${ADMIN_USERNAME:-admin},
ADMIN_PASSWORD=copaw to ADMIN_PASSWORD=${ADMIN_PASSWORD:-copaw}, and
COPAW_PORT=8088 to COPAW_PORT=${COPAW_PORT:-8088}) so credentials can be
overridden via a .env file; add a .env.example documenting ADMIN_USERNAME,
ADMIN_PASSWORD, and COPAW_PORT and ensure .env is listed in .gitignore to avoid
committing secrets.

In `@src/copaw/app/_app.py`:
- Around line 61-63: The startup currently calls init_auth_from_env()
unconditionally which can create default or blank admin credentials; update the
bootstrap to only call init_auth_from_env() when the expected admin credential
environment variables are present and non-empty (e.g., check for the specific
ADMIN_USER/ADMIN_PASSWORD env vars your auth expects) and skip initialization
otherwise to preserve true open-access fallback; locate the call to
init_auth_from_env() in the app startup (function/module where it’s invoked) and
wrap it with a guard that validates the required env values before invoking
init_auth_from_env().

In `@src/copaw/app/auth.py`:
- Around line 52-63: Replace the single-iteration SHA256 in _hash_password and
verify_password with PBKDF2-HMAC-SHA256 using a high iteration count (e.g.,
600000); generate a random salt as bytes (secrets.token_bytes), run
hashlib.pbkdf2_hmac('sha256', password_bytes, salt_bytes, iterations), and
return hex-encoded derived key and hex-encoded salt from _hash_password, then
have verify_password recompute the PBKDF2 result from the provided salt and
compare with hmac.compare_digest; update function signatures/returns for
_hash_password and verify_password accordingly and note that existing auth.json
entries must be re-initialized after this change.

In `@src/copaw/app/routers/auth.py`:
- Around line 27-39: The login handler returns a plain dict with a message when
auth is disabled which doesn't match the Pydantic LoginResponse model; either
update the LoginResponse model to include an optional message: str | None (or
Optional[str]) so the model matches the frontend, or change the early return in
login to return a LoginResponse instance (e.g., LoginResponse(token="",
username="", message="Auth not enabled")) so the response type is consistent;
locate the LoginResponse model definition and the login function to apply one of
these fixes.

---

Nitpick comments:
In `@console/src/App.tsx`:
- Line 58: The redirect currently only preserves window.location.pathname in the
Navigate return inside App.tsx, losing query and hash parts; update the redirect
to include the full route context by encoding and passing pathname + search +
hash (i.e., window.location.pathname combined with window.location.search and
window.location.hash) to the redirect query parameter so the post-login redirect
restores query string and fragment. Locate the Navigate return expression and
replace the redirect value to use the combined, encodeURIComponent-encoded full
location string.

In `@src/copaw/app/auth.py`:
- Around line 151-158: The init_auth_from_env function currently defaults
ADMIN_PASSWORD to "copaw" when the env var is unset/empty, enabling auth with a
weak password; change the logic so that an empty or missing ADMIN_PASSWORD is
treated as "auth disabled" (do not create/overwrite credentials in auth.json),
and only enable/update credentials when ADMIN_PASSWORD is non-empty; reference
the init_auth_from_env function, the ADMIN_PASSWORD and ADMIN_USERNAME
environment lookups, and the code path that writes/updates auth.json to gate
that update on a non-empty password value.

In `@src/copaw/app/routers/auth.py`:
- Around line 35-37: Move the inline import of HTTPException out of the function
and into the module-level imports: add "from fastapi import HTTPException" at
the top of the file and remove the local "from fastapi import HTTPException"
inside the function that raises HTTPException (the block that currently does
raise HTTPException(status_code=401, detail="Invalid credentials")). This avoids
repeated imports and matches project style while keeping the raise statement
unchanged.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 42cd006 and 47f0c11.

📒 Files selected for processing (14)
  • console/src/App.tsx
  • console/src/api/config.ts
  • console/src/api/modules/auth.ts
  • console/src/api/request.ts
  • console/src/layouts/Sidebar.tsx
  • console/src/locales/en.json
  • console/src/locales/zh.json
  • console/src/pages/Login/index.tsx
  • deploy/Dockerfile
  • docker-compose.yml
  • src/copaw/app/_app.py
  • src/copaw/app/auth.py
  • src/copaw/app/routers/__init__.py
  • src/copaw/app/routers/auth.py

Comment on lines +63 to +65
import("../api/modules/auth").then(({ authApi }) => {
authApi.getStatus().then((res) => setAuthEnabled(res.enabled)).catch(() => {});
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Handle dynamic import failure in auth status check.

import("../api/modules/auth") has no top-level .catch(). A failed module load can cause an unhandled rejection and inconsistent auth UI state.

✅ Suggested fix
-    import("../api/modules/auth").then(({ authApi }) => {
-      authApi.getStatus().then((res) => setAuthEnabled(res.enabled)).catch(() => {});
-    });
+    import("../api/modules/auth")
+      .then(({ authApi }) => authApi.getStatus())
+      .then((res) => setAuthEnabled(res.enabled))
+      .catch(() => {
+        setAuthEnabled(false);
+      });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import("../api/modules/auth").then(({ authApi }) => {
authApi.getStatus().then((res) => setAuthEnabled(res.enabled)).catch(() => {});
});
import("../api/modules/auth")
.then(({ authApi }) => authApi.getStatus())
.then((res) => setAuthEnabled(res.enabled))
.catch(() => {
setAuthEnabled(false);
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@console/src/layouts/Sidebar.tsx` around lines 63 - 65, The dynamic import of
"../api/modules/auth" used to call authApi.getStatus() can reject and currently
has no top-level catch; update the async flow around
import("../api/modules/auth") so you handle module-load failures (add a .catch
or use try/catch in an async IIFE) and ensure the UI state is deterministic by
setting setAuthEnabled(false) or leaving previous state on import failure; also
retain the existing .catch() on authApi.getStatus() but move error handling to
log the error or setAuthEnabled(false) so Sidebar's auth state doesn't become
inconsistent — focus changes around the dynamic import and the
authApi.getStatus() call sites (the import("../api/modules/auth") line,
authApi.getStatus(), and setAuthEnabled).

Comment on lines +19 to +21
# Default auth credentials (override at runtime with -e ADMIN_USERNAME=... -e ADMIN_PASSWORD=...)
ENV ADMIN_USERNAME=admin
ENV ADMIN_PASSWORD=copaw
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Default password in Dockerfile is visible in image layers.

The ENV instruction bakes ADMIN_PASSWORD=copaw into the image metadata, making it retrievable via docker inspect or docker history --no-trunc. While the comment notes these should be overridden at runtime, the default credential remains exposed.

Consider one of these approaches:

  1. Remove the default value and require it at runtime (fail-fast if not set).
  2. Keep the default only for ADMIN_USERNAME but omit ADMIN_PASSWORD, letting the app fall back to open-access mode when unset.
Suggested fix (option 2 - no default password)
 # Default auth credentials (override at runtime with -e ADMIN_USERNAME=... -e ADMIN_PASSWORD=...)
 ENV ADMIN_USERNAME=admin
-ENV ADMIN_PASSWORD=copaw
+# ADMIN_PASSWORD intentionally has no default - set at runtime to enable auth
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Default auth credentials (override at runtime with -e ADMIN_USERNAME=... -e ADMIN_PASSWORD=...)
ENV ADMIN_USERNAME=admin
ENV ADMIN_PASSWORD=copaw
# Default auth credentials (override at runtime with -e ADMIN_USERNAME=... -e ADMIN_PASSWORD=...)
ENV ADMIN_USERNAME=admin
# ADMIN_PASSWORD intentionally has no default - set at runtime to enable auth
🧰 Tools
🪛 Trivy (0.69.1)

[error] 21-21: Secrets passed via build-args or envs or copied secret files

Possible exposure of secret env "ADMIN_PASSWORD" in ENV

Rule: DS-0031

Learn more

(IaC/Dockerfile)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@deploy/Dockerfile` around lines 19 - 21, Remove the baked-in ADMIN_PASSWORD
from the Dockerfile to avoid exposing credentials: delete or omit the ENV
ADMIN_PASSWORD=copaw line and keep only ENV ADMIN_USERNAME=admin (if desired);
then update the application startup or entrypoint logic (the component that
reads ADMIN_PASSWORD at runtime) to treat an unset ADMIN_PASSWORD as "no
password / open-access" or to fail-fast with a clear error if you prefer
requiring it at runtime, ensuring the runtime check references ADMIN_PASSWORD
when deciding auth mode.

Comment on lines +9 to +12
environment:
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD=copaw
- COPAW_PORT=8088
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid committing credentials in docker-compose.yml.

Hardcoded credentials will be exposed in version control. Use environment variable interpolation with defaults, allowing users to override via a .env file (which should be gitignored):

Suggested fix
     environment:
-      - ADMIN_USERNAME=admin
-      - ADMIN_PASSWORD=copaw
+      - ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
+      - ADMIN_PASSWORD=${ADMIN_PASSWORD:-}
       - COPAW_PORT=8088

Then add a .env.example file documenting the variables and ensure .env is in .gitignore.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docker-compose.yml` around lines 9 - 12, Replace the hardcoded environment
values in docker-compose.yml with environment variable interpolation using
defaults (e.g., change ADMIN_USERNAME=admin to
ADMIN_USERNAME=${ADMIN_USERNAME:-admin}, ADMIN_PASSWORD=copaw to
ADMIN_PASSWORD=${ADMIN_PASSWORD:-copaw}, and COPAW_PORT=8088 to
COPAW_PORT=${COPAW_PORT:-8088}) so credentials can be overridden via a .env
file; add a .env.example documenting ADMIN_USERNAME, ADMIN_PASSWORD, and
COPAW_PORT and ensure .env is listed in .gitignore to avoid committing secrets.

Comment on lines +61 to +63
# Initialize auth from env vars now that working dir is ready
init_auth_from_env()

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Do not initialize auth when admin credentials are missing/blank.

init_auth_from_env() is called unconditionally. With current auth bootstrap behavior, this can create predictable default credentials (or even a blank password) instead of true open-access fallback, which is a critical security risk.

🔒 Proposed hardening in startup flow
 `@asynccontextmanager`
 async def lifespan(app: FastAPI):  # pylint: disable=too-many-statements
-    # Initialize auth from env vars now that working dir is ready
-    init_auth_from_env()
+    # Initialize auth only when explicit non-empty credentials are provided
+    admin_username = os.environ.get("ADMIN_USERNAME", "").strip()
+    admin_password = os.environ.get("ADMIN_PASSWORD", "").strip()
+    if admin_username and admin_password:
+        init_auth_from_env()
+    else:
+        logger.warning(
+            "Authentication disabled: ADMIN_USERNAME/ADMIN_PASSWORD not fully configured",
+        )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/copaw/app/_app.py` around lines 61 - 63, The startup currently calls
init_auth_from_env() unconditionally which can create default or blank admin
credentials; update the bootstrap to only call init_auth_from_env() when the
expected admin credential environment variables are present and non-empty (e.g.,
check for the specific ADMIN_USER/ADMIN_PASSWORD env vars your auth expects) and
skip initialization otherwise to preserve true open-access fallback; locate the
call to init_auth_from_env() in the app startup (function/module where it’s
invoked) and wrap it with a guard that validates the required env values before
invoking init_auth_from_env().

Comment on lines +52 to +63
def _hash_password(password: str, salt: Optional[str] = None) -> tuple[str, str]:
"""Hash password with salt. Returns (hash_hex, salt_hex)."""
if salt is None:
salt = secrets.token_hex(16)
h = hashlib.sha256((salt + password).encode("utf-8")).hexdigest()
return h, salt


def verify_password(password: str, stored_hash: str, salt: str) -> bool:
"""Verify password against stored hash."""
h, _ = _hash_password(password, salt)
return hmac.compare_digest(h, stored_hash)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, check if the file exists and read the relevant section
head -n 70 src/copaw/app/auth.py | tail -n +40

Repository: agentscope-ai/CoPaw

Length of output: 1200


🌐 Web query:

OWASP password hashing recommendations 2024 2025 PBKDF2 iterations

💡 Result:

OWASP’s Password Storage Cheat Sheet (current guidance as published on the OWASP Cheat Sheet Series site) recommends PBKDF2 only when you need FIPS-140 / NIST-aligned options, and specifically calls out these minimum PBKDF2 iteration counts: (cheatsheetseries.owasp.org)

Also, OWASP’s overall preference for new systems is:

  1. Argon2id first, 2) scrypt next, and then bcrypt for legacy; PBKDF2 mainly for FIPS constraints. (cheatsheetseries.owasp.org)

Citations:


🏁 Script executed:

# Also check if there are other password-related functions in the file
rg -n "def.*password|def.*hash" src/copaw/app/auth.py

Repository: agentscope-ai/CoPaw

Length of output: 292


Single-iteration SHA-256 is weak for password hashing.

SHA-256 is fast by design, making it vulnerable to brute-force attacks. Password hashing should use a slow, adaptive algorithm. Per OWASP's current guidance, the preferred algorithms are Argon2id, scrypt, or bcrypt; however, for a dependency-free solution using only stdlib, use hashlib.pbkdf2_hmac with a minimum of 600,000 iterations for SHA-256.

Suggested fix using PBKDF2
+# PBKDF2 iterations - OWASP minimum for SHA-256
+_PBKDF2_ITERATIONS = 600_000
+
+
 def _hash_password(password: str, salt: Optional[str] = None) -> tuple[str, str]:
-    """Hash password with salt. Returns (hash_hex, salt_hex)."""
+    """Hash password with PBKDF2-SHA256. Returns (hash_hex, salt_hex)."""
     if salt is None:
         salt = secrets.token_hex(16)
-    h = hashlib.sha256((salt + password).encode("utf-8")).hexdigest()
+    h = hashlib.pbkdf2_hmac(
+        "sha256",
+        password.encode("utf-8"),
+        salt.encode("utf-8"),
+        _PBKDF2_ITERATIONS,
+    ).hex()
     return h, salt

This will increase login latency slightly (~300-500ms) but significantly improves security. Existing auth.json files will need re-initialization.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/copaw/app/auth.py` around lines 52 - 63, Replace the single-iteration
SHA256 in _hash_password and verify_password with PBKDF2-HMAC-SHA256 using a
high iteration count (e.g., 600000); generate a random salt as bytes
(secrets.token_bytes), run hashlib.pbkdf2_hmac('sha256', password_bytes,
salt_bytes, iterations), and return hex-encoded derived key and hex-encoded salt
from _hash_password, then have verify_password recompute the PBKDF2 result from
the provided salt and compare with hmac.compare_digest; update function
signatures/returns for _hash_password and verify_password accordingly and note
that existing auth.json entries must be re-initialized after this change.

Comment on lines +27 to +39
@router.post("/login")
async def login(req: LoginRequest):
"""Authenticate with username and password."""
if not is_auth_enabled():
return {"token": "", "username": "", "message": "Auth not enabled"}

token = authenticate(req.username, req.password)
if token is None:
from fastapi import HTTPException

raise HTTPException(status_code=401, detail="Invalid credentials")

return LoginResponse(token=token, username=req.username)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Response type mismatch when auth is disabled.

Line 31 returns a dict with message field, but LoginResponse (lines 18-20) doesn't declare it. The frontend's LoginResponse interface includes message?: string, so it works, but the backend model is inconsistent.

Consider adding the optional field to the Pydantic model or returning the model instance:

Option 1: Add message to LoginResponse
 class LoginResponse(BaseModel):
     token: str
     username: str
+    message: str | None = None
Option 2: Return model instance
     if not is_auth_enabled():
-        return {"token": "", "username": "", "message": "Auth not enabled"}
+        return LoginResponse(token="", username="")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@router.post("/login")
async def login(req: LoginRequest):
"""Authenticate with username and password."""
if not is_auth_enabled():
return {"token": "", "username": "", "message": "Auth not enabled"}
token = authenticate(req.username, req.password)
if token is None:
from fastapi import HTTPException
raise HTTPException(status_code=401, detail="Invalid credentials")
return LoginResponse(token=token, username=req.username)
`@router.post`("/login")
async def login(req: LoginRequest):
"""Authenticate with username and password."""
if not is_auth_enabled():
return LoginResponse(token="", username="")
token = authenticate(req.username, req.password)
if token is None:
from fastapi import HTTPException
raise HTTPException(status_code=401, detail="Invalid credentials")
return LoginResponse(token=token, username=req.username)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/copaw/app/routers/auth.py` around lines 27 - 39, The login handler
returns a plain dict with a message when auth is disabled which doesn't match
the Pydantic LoginResponse model; either update the LoginResponse model to
include an optional message: str | None (or Optional[str]) so the model matches
the frontend, or change the early return in login to return a LoginResponse
instance (e.g., LoginResponse(token="", username="", message="Auth not
enabled")) so the response type is consistent; locate the LoginResponse model
definition and the login function to apply one of these fixes.

rowanchen-com added a commit to rowanchen-com/CoPaw that referenced this pull request Mar 3, 2026
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (1)
src/copaw/app/auth.py (1)

52-57: ⚠️ Potential issue | 🟠 Major

Use an adaptive password KDF instead of single-pass SHA-256 (still unresolved).

At Line 56, password hashing is still a fast single-pass SHA-256, which is weak for stored credentials if auth.json is exposed. This was already raised in prior review and remains open.

Also applies to: 60-63

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/copaw/app/auth.py` around lines 52 - 57, Replace the single-pass SHA-256
in _hash_password with a slow, adaptive KDF (e.g., bcrypt or Argon2) to harden
stored credentials: change _hash_password to call the chosen KDF (generate a
per-password salt via the KDF or secrets.token_bytes if required), store the KDF
output (and any KDF parameters) instead of raw SHA hex, and update any
verification code that relies on _hash_password to use the KDF's verify function
(look for calls to _hash_password and related verification logic around lines
60-63) so password checks use the KDF's verify API rather than comparing SHA256
digests.
🧹 Nitpick comments (1)
console/src/App.tsx (1)

61-64: Consider preserving the full URL including query string and hash.

The redirect only preserves window.location.pathname. If users were at a URL like /chat?tab=settings#section, they would lose the query parameters and hash after login.

♻️ Suggested enhancement to preserve full path
   if (status === "auth-required")
-    return <Navigate to={`/login?redirect=${encodeURIComponent(window.location.pathname)}`} replace />;
+    return <Navigate to={`/login?redirect=${encodeURIComponent(window.location.pathname + window.location.search + window.location.hash)}`} replace />;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@console/src/App.tsx` around lines 61 - 64, The redirect currently uses
window.location.pathname so query string and hash are lost; update the Navigate
redirect to include the full path by concatenating window.location.pathname +
window.location.search + window.location.hash (and encodeURIComponent the
result) instead of just window.location.pathname — locate the conditional that
checks status (status === "auth-required") and the Navigate usage and replace
the redirect value so post-login returns users to the exact original URL.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/copaw/app/auth.py`:
- Around line 160-163: Docstring and implementation disagree: the module-level
docstring claims removing ADMIN_PASSWORD disables auth, but initialize_auth()
(the startup routine that checks ADMIN_PASSWORD and manages auth.json) currently
only skips re-initialization and leaves existing credentials intact. Fix by
implementing the described disable flow in initialize_auth(): when
os.getenv("ADMIN_PASSWORD") is None or empty, explicitly remove or rename the
existing auth store (auth.json) to fully disable auth (ensure safe handling:
check file existence, back it up or remove, and log the action), or
alternatively update the module docstring to accurately state that removing
ADMIN_PASSWORD only prevents re-initialization and does not delete existing
auth.json—pick one and apply consistently across the module-level docstring and
the initialize_auth() behavior.
- Around line 127-135: Currently _load_auth_data() swallows read/parse errors
and returns {}, which makes is_auth_enabled() treat auth as disabled and lets
the auth middleware bypass protection; change _load_auth_data to surface errors
(raise or return a sentinel like None on failure) instead of returning an empty
dict, update is_auth_enabled() to treat a failed/unknown auth read as "auth
enabled" (fail-closed) or return an error state, and modify the auth middleware
to detect that error/None state and respond with a 503 (or other hard-fail
response) rather than allowing access. Reference _load_auth_data(),
is_auth_enabled(), and the auth middleware to implement these changes.
- Around line 119-120: Replace the broad "except Exception: return None" in the
token verification block with explicit handling of expected decode/validation
errors (e.g., jwt.ExpiredSignatureError, jwt.DecodeError, ValueError,
json.JSONDecodeError) so those return None, and add a separate except Exception
as e that logs the unexpected exception (using the module's logger) and then
re-raises or returns None as appropriate; locate the token decode/verify
function (the token verification block) and update its exception clauses to
catch specific exceptions first, log error details for unexpected errors, and
avoid swallowing all exceptions silently.
- Around line 256-264: Currently the code falls back to
request.query_params.get("token") for any request; change it so the query-param
fallback is only used for explicit WebSocket handshakes. Keep the Authorization
header parsing (auth_header.startswith("Bearer ")) and only assign token =
request.query_params.get("token") when the request is a WebSocket handshake
(detectable by headers like Upgrade == "websocket" or presence of
"Sec-WebSocket-Key" on the request). Update the block around auth_header / token
(the token variable and auth_header handling in auth.py) to require that
WebSocket-specific condition before reading the query param.

---

Duplicate comments:
In `@src/copaw/app/auth.py`:
- Around line 52-57: Replace the single-pass SHA-256 in _hash_password with a
slow, adaptive KDF (e.g., bcrypt or Argon2) to harden stored credentials: change
_hash_password to call the chosen KDF (generate a per-password salt via the KDF
or secrets.token_bytes if required), store the KDF output (and any KDF
parameters) instead of raw SHA hex, and update any verification code that relies
on _hash_password to use the KDF's verify function (look for calls to
_hash_password and related verification logic around lines 60-63) so password
checks use the KDF's verify API rather than comparing SHA256 digests.

---

Nitpick comments:
In `@console/src/App.tsx`:
- Around line 61-64: The redirect currently uses window.location.pathname so
query string and hash are lost; update the Navigate redirect to include the full
path by concatenating window.location.pathname + window.location.search +
window.location.hash (and encodeURIComponent the result) instead of just
window.location.pathname — locate the conditional that checks status (status ===
"auth-required") and the Navigate usage and replace the redirect value so
post-login returns users to the exact original URL.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 47f0c11 and ccb8dde.

📒 Files selected for processing (2)
  • console/src/App.tsx
  • src/copaw/app/auth.py

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (2)
src/copaw/app/auth.py (2)

52-57: ⚠️ Potential issue | 🟠 Major

Replace single-round SHA-256 with a slow password KDF.

Line 56 still uses fast SHA-256 for password hashing, which is too weak against offline brute-force if auth.json is exposed.

🔧 Suggested hardening (PBKDF2-SHA256)
+# OWASP-aligned baseline for PBKDF2-SHA256
+_PBKDF2_ITERATIONS = 600_000
+
 def _hash_password(password: str, salt: Optional[str] = None) -> tuple[str, str]:
-    """Hash password with salt. Returns (hash_hex, salt_hex)."""
+    """Hash password with PBKDF2-SHA256. Returns (hash_hex, salt_hex)."""
     if salt is None:
-        salt = secrets.token_hex(16)
-    h = hashlib.sha256((salt + password).encode("utf-8")).hexdigest()
+        salt = secrets.token_hex(16)
+    h = hashlib.pbkdf2_hmac(
+        "sha256",
+        password.encode("utf-8"),
+        salt.encode("utf-8"),
+        _PBKDF2_ITERATIONS,
+    ).hex()
     return h, salt
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/copaw/app/auth.py` around lines 52 - 57, The _hash_password function is
using a single SHA-256 round which is too fast; replace it with a slow KDF
(e.g., hashlib.pbkdf2_hmac with 'sha256' and a high iteration count like
100_000) and generate a cryptographically-random salt (use secrets.token_bytes
or token_hex) and return the derived-key hex and salt hex as before; ensure the
same parameters (algorithm, iterations, salt decoding) are used in any
verification routine that consumes _hash_password outputs so stored hashes can
be validated.

271-279: ⚠️ Potential issue | 🟠 Major

Tighten query-token fallback to explicit WebSocket handshake only.

Line 278 checks Connection for "upgrade"; that is still too broad and allows query-param bearer tokens on non-WebSocket API traffic.

🔒 Narrow fallback scope
         if auth_header.startswith("Bearer "):
             token = auth_header[7:]
-        elif "upgrade" in request.headers.get("connection", "").lower():
+        elif request.headers.get("upgrade", "").lower() == "websocket":
             token = request.query_params.get("token")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/copaw/app/auth.py` around lines 271 - 279, The fallback that reads token
from request.query_params should only run for real WebSocket handshakes; change
the condition that currently checks elif "upgrade" in
request.headers.get("connection", "").lower(): to an explicit WebSocket check
(for example verify request.scope.get("type") == "websocket" or check that
request.headers.get("upgrade", "").lower() == "websocket") before assigning
token = request.query_params.get("token"); keep the existing Authorization
header parsing (auth_header and token) intact.
🧹 Nitpick comments (1)
src/copaw/app/auth.py (1)

139-141: Prefer logger.exception in this handler.

At Line 140, logger.exception(...) gives traceback context automatically and improves operability during incident debugging.

🛠️ Small logging improvement
-        except (json.JSONDecodeError, OSError) as exc:
-            logger.error("Failed to load auth file %s: %s", AUTH_FILE, exc)
+        except (json.JSONDecodeError, OSError):
+            logger.exception("Failed to load auth file %s", AUTH_FILE)
             return {"_auth_load_error": True}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/copaw/app/auth.py` around lines 139 - 141, The exception handler that
catches json.JSONDecodeError and OSError uses logger.error and returns
{"_auth_load_error": True}; replace the logger.error call with logger.exception
so the stack trace is recorded automatically (i.e., in the except block handling
JSONDecodeError/OSError referencing AUTH_FILE, call logger.exception(...)
instead of logger.error(...)) while keeping the same return value.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/copaw/app/auth.py`:
- Around line 52-57: The _hash_password function is using a single SHA-256 round
which is too fast; replace it with a slow KDF (e.g., hashlib.pbkdf2_hmac with
'sha256' and a high iteration count like 100_000) and generate a
cryptographically-random salt (use secrets.token_bytes or token_hex) and return
the derived-key hex and salt hex as before; ensure the same parameters
(algorithm, iterations, salt decoding) are used in any verification routine that
consumes _hash_password outputs so stored hashes can be validated.
- Around line 271-279: The fallback that reads token from request.query_params
should only run for real WebSocket handshakes; change the condition that
currently checks elif "upgrade" in request.headers.get("connection",
"").lower(): to an explicit WebSocket check (for example verify
request.scope.get("type") == "websocket" or check that
request.headers.get("upgrade", "").lower() == "websocket") before assigning
token = request.query_params.get("token"); keep the existing Authorization
header parsing (auth_header and token) intact.

---

Nitpick comments:
In `@src/copaw/app/auth.py`:
- Around line 139-141: The exception handler that catches json.JSONDecodeError
and OSError uses logger.error and returns {"_auth_load_error": True}; replace
the logger.error call with logger.exception so the stack trace is recorded
automatically (i.e., in the except block handling JSONDecodeError/OSError
referencing AUTH_FILE, call logger.exception(...) instead of logger.error(...))
while keeping the same return value.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ccb8dde and 7d4a032.

📒 Files selected for processing (1)
  • src/copaw/app/auth.py

@ekzhu ekzhu added the first-time-contributor PR created by a first time contributor label Mar 3, 2026
@rowanchen-com
Copy link
Author

Renderings
mmexport1772590807774

@rowanchen-com
Copy link
Author

Hi @zhijianma @rayrayraykk,

Just wanted to check in on this PR. I've addressed all the issues raised by both Qodo and CodeRabbit bots, and there are currently no outstanding review comments.

I noticed there are now 4 file conflicts due to recent upstream changes. I'm happy to rebase and resolve them, but as the upstream repo keeps evolving, unmerged PRs will inevitably accumulate more conflicts over time.

Could you let me know if there are any concerns or changes you'd like me to make? I'd love to get this moving forward. Thanks! 🙏

@rowanchen-com rowanchen-com changed the title feat: add basic web UI authentication with .env credentials feat: add basic web UI authentication with environment variables Mar 6, 2026
rowanchen-com added a commit to rowanchen-com/CoPaw that referenced this pull request Mar 6, 2026
@rowanchen-com
Copy link
Author

Rebased onto the latest upstream/main — all merge conflicts have been resolved. Ready for review.

@rowanchen-com
Copy link
Author

Preview of the login UI and sidebar logout:
309c4f10-4de6-4751-856f-a24724fed1c9
08eec944-8947-4491-a939-48b6919777f7

@zhijianma
Copy link
Member

@rowanchen-com

Awesome work.

Note:
Parts of the API have been reused in CLI.
Plz test and review

@rowanchen-com
Copy link
Author

@rowanchen-com

Awesome work.

Note: Parts of the API have been reused in CLI. Plz test and review

Thanks for the heads up! I've reviewed the CLI HTTP client — it calls /api/ endpoints without auth headers, so CLI commands would get 401 when auth is enabled.

Two options to handle this:

  1. Bypass auth for localhost requests (CLI only runs locally, so this is safe)
  2. Add token support to the CLI client (e.g. copaw login command that stores a token)

Which approach do you prefer? Or is there another pattern you'd like to follow?

@rowanchen-com
Copy link
Author

Hi @zhijianma @rayrayraykk, just following up on this — I've tested the CLI (
http.py
). As expected, all /api/ calls return 401 when auth is enabled since the httpx.Client doesn't send auth headers.

I'm leaning toward Option 1: bypass auth for localhost requests — the CLI only runs on 127.0.0.1 so it's safe, and it requires zero changes to the CLI code or user workflow. I can have this ready quickly.

Let me know if that works, or if you'd prefer a different approach. Happy to implement either way!

@rowanchen-com
Copy link
Author

@zhijianma @rayrayraykk Updated! I've rebased onto the latest main and added a fix for CLI compatibility.

The CLI (
http.py
) uses httpx.Client to call /api/ endpoints from 127.0.0.1 without auth headers. To keep CLI working when auth is enabled, the AuthMiddleware now bypasses token checks for localhost requests (127.0.0.1 / ::1). This is safe because the CLI only runs locally.

Changes in this update:

Rebased onto latest upstream/main (resolved Dockerfile conflict)
Added localhost bypass in AuthMiddleware (
auth.py
)
Remote browser access still requires login as before. Let me know if you'd like any changes!

@xieyxclack
Copy link
Member

We will review this pr soon, thank you

Copy link
Member

@rayrayraykk rayrayraykk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The login page should be disabled by default and only enabled via an environment variable. It should also support proper user registration, rather than relying solely on environment-variable–based credentials; otherwise, an agent could easily steal or abuse those credentials. Please refactor your implementation.

@@ -0,0 +1,15 @@
version: "3.8"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file is beyond the scope of this PR. If you want to tell users how to use, please add to docs.

ENV COPAW_WORKING_DIR=/app/working
ENV COPAW_SECRET_DIR=/app/working.secret

# Default auth credentials (override at runtime with -e ADMIN_USERNAME=... -e ADMIN_PASSWORD=...)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please do not modify this file.

"agentConfig": {
"title": "Configuration",
"description": "Configure agent runtime parameters",
"reactAgentTitle": "ReAct Agent",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why delete this?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

first-time-contributor PR created by a first time contributor

Projects

None yet

5 participants