From b99c34d12005ddf2718e6a502f7566f408437eb9 Mon Sep 17 00:00:00 2001
From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com>
Date: Sat, 11 Jul 2026 11:14:51 +0530
Subject: [PATCH 1/9] feat(dashboard): handle auth-disabled servers in SPA
---
dashboard/src/features/auth/components/auth-gate.tsx | 10 ++++++++--
dashboard/src/features/auth/types.ts | 2 ++
dashboard/src/routes/login.tsx | 2 +-
3 files changed, 11 insertions(+), 3 deletions(-)
diff --git a/dashboard/src/features/auth/components/auth-gate.tsx b/dashboard/src/features/auth/components/auth-gate.tsx
index 4bcafc63..a5a7b976 100644
--- a/dashboard/src/features/auth/components/auth-gate.tsx
+++ b/dashboard/src/features/auth/components/auth-gate.tsx
@@ -7,6 +7,7 @@ import { useAuthStatus, useWhoami } from "../hooks";
/**
* Wraps the authenticated portion of the dashboard.
*
+ * - When the server reports auth disabled, children render immediately.
* - When setup is required, redirects to ``/login`` (which shows the setup
* form).
* - When the user isn't signed in, redirects to ``/login``.
@@ -20,16 +21,21 @@ export function AuthGate({ children }: { children: ReactNode }) {
const status = useAuthStatus();
const whoami = useWhoami();
+ const authDisabled = status.data?.auth_enabled === false;
const setupRequired = status.data?.setup_required === true;
const authenticated = !!whoami.data?.user;
const loading = status.isLoading || whoami.isLoading;
useEffect(() => {
- if (loading) return;
+ if (loading || authDisabled) return;
if (setupRequired || !authenticated) {
void navigate({ to: "/login" });
}
- }, [loading, setupRequired, authenticated, navigate]);
+ }, [loading, authDisabled, setupRequired, authenticated, navigate]);
+
+ if (authDisabled) {
+ return <>{children}>;
+ }
if (loading || setupRequired || !authenticated) {
return (
diff --git a/dashboard/src/features/auth/types.ts b/dashboard/src/features/auth/types.ts
index e61b6328..6d219c0d 100644
--- a/dashboard/src/features/auth/types.ts
+++ b/dashboard/src/features/auth/types.ts
@@ -23,6 +23,8 @@ export interface SetupResponse {
export interface AuthStatus {
setup_required: boolean;
+ /** Absent on older servers, which always enforce auth. */
+ auth_enabled?: boolean;
}
export interface WhoamiResponse {
diff --git a/dashboard/src/routes/login.tsx b/dashboard/src/routes/login.tsx
index cdbb321b..edc8e0db 100644
--- a/dashboard/src/routes/login.tsx
+++ b/dashboard/src/routes/login.tsx
@@ -16,7 +16,7 @@ function LoginPage() {
const status = useAuthStatus();
const whoami = useWhoami();
- if (whoami.data?.user) {
+ if (status.data?.auth_enabled === false || whoami.data?.user) {
return ;
}
From 97eca77af0bc2ba9d0036a28eb804274171579e2 Mon Sep 17 00:00:00 2001
From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com>
Date: Sat, 11 Jul 2026 11:15:04 +0530
Subject: [PATCH 2/9] feat(python): dashboard auth now opt-in, off by default
---
sdks/python/taskito/cli.py | 7 ++
.../management/commands/taskito_dashboard.py | 14 +++-
.../python/taskito/dashboard/handlers/auth.py | 18 ++---
sdks/python/taskito/dashboard/routes.py | 5 +-
sdks/python/taskito/dashboard/server.py | 48 ++++++++++---
sdks/python/tests/dashboard/test_auth.py | 70 ++++++++++++++++++-
.../tests/dashboard/test_oauth_endpoints.py | 2 +-
7 files changed, 140 insertions(+), 24 deletions(-)
diff --git a/sdks/python/taskito/cli.py b/sdks/python/taskito/cli.py
index 8ddd017b..fae8ba92 100644
--- a/sdks/python/taskito/cli.py
+++ b/sdks/python/taskito/cli.py
@@ -60,6 +60,12 @@ def _build_parser() -> argparse.ArgumentParser:
"--host", default="127.0.0.1", help="Bind address (default: 127.0.0.1)"
)
dash_parser.add_argument("--port", type=int, default=8080, help="Bind port (default: 8080)")
+ dash_parser.add_argument(
+ "--auth",
+ action="store_true",
+ default=False,
+ help="Enable session authentication (login/setup, CSRF, RBAC); off by default",
+ )
dash_parser.add_argument(
"--insecure-cookies",
action="store_true",
@@ -295,6 +301,7 @@ def run_dashboard(args: argparse.Namespace) -> None:
host=args.host,
port=args.port,
secure_cookies=not args.insecure_cookies,
+ auth_enabled=args.auth,
)
diff --git a/sdks/python/taskito/contrib/django/management/commands/taskito_dashboard.py b/sdks/python/taskito/contrib/django/management/commands/taskito_dashboard.py
index 6117f14b..b0781ff8 100644
--- a/sdks/python/taskito/contrib/django/management/commands/taskito_dashboard.py
+++ b/sdks/python/taskito/contrib/django/management/commands/taskito_dashboard.py
@@ -18,6 +18,7 @@ def add_arguments(self, parser): # type: ignore[no-untyped-def]
default_host = getattr(settings, "TASKITO_DASHBOARD_HOST", "127.0.0.1")
default_port = getattr(settings, "TASKITO_DASHBOARD_PORT", 8080)
+ default_auth = getattr(settings, "TASKITO_DASHBOARD_AUTH", False)
parser.add_argument(
"--host",
@@ -30,10 +31,21 @@ def add_arguments(self, parser): # type: ignore[no-untyped-def]
default=default_port,
help=f"Bind port (default: {default_port})",
)
+ parser.add_argument(
+ "--auth",
+ action="store_true",
+ default=default_auth,
+ help="Enable session authentication; off by default",
+ )
def handle(self, **options): # type: ignore[no-untyped-def]
from taskito.contrib.django.settings import get_queue
from taskito.dashboard import serve_dashboard
queue = get_queue()
- serve_dashboard(queue, host=options["host"], port=options["port"])
+ serve_dashboard(
+ queue,
+ host=options["host"],
+ port=options["port"],
+ auth_enabled=options["auth"],
+ )
diff --git a/sdks/python/taskito/dashboard/handlers/auth.py b/sdks/python/taskito/dashboard/handlers/auth.py
index e9c4b299..218afc4b 100644
--- a/sdks/python/taskito/dashboard/handlers/auth.py
+++ b/sdks/python/taskito/dashboard/handlers/auth.py
@@ -37,15 +37,17 @@ def _serialize_session(session: Any) -> dict[str, Any]:
}
-def handle_auth_status(queue: Queue, _qs: dict) -> dict[str, Any]:
- """Public endpoint: tells the SPA whether setup is required.
-
- Returns ``{setup_required: bool}``. The SPA uses this on cold-load to
- decide between showing the setup page and the login page. Provider
- listing is fetched separately via ``GET /api/auth/providers`` so this
- endpoint stays free of any OAuth dependency.
+def handle_auth_status(queue: Queue, _qs: dict, *, auth_enabled: bool = True) -> dict[str, Any]:
+ """Public endpoint: tells the SPA whether auth applies and setup is required.
+
+ Returns ``{auth_enabled: bool, setup_required: bool}``. The SPA uses this
+ on cold-load to decide between rendering the app directly (auth disabled),
+ showing the setup page, or showing the login page. Provider listing is
+ fetched separately via ``GET /api/auth/providers`` so this endpoint stays
+ free of any OAuth dependency.
"""
- return {"setup_required": AuthStore(queue).count_users() == 0}
+ setup_required = auth_enabled and AuthStore(queue).count_users() == 0
+ return {"auth_enabled": auth_enabled, "setup_required": setup_required}
def handle_setup(queue: Queue, body: dict) -> dict[str, Any]:
diff --git a/sdks/python/taskito/dashboard/routes.py b/sdks/python/taskito/dashboard/routes.py
index 4c44c2a9..84f06643 100644
--- a/sdks/python/taskito/dashboard/routes.py
+++ b/sdks/python/taskito/dashboard/routes.py
@@ -5,7 +5,8 @@
:class:`~taskito.dashboard.errors._BadRequest` (→ 400) or
:class:`~taskito.dashboard.errors._NotFound` (→ 404).
-Authentication and authorization:
+Authentication and authorization (only when the server runs with
+``auth_enabled=True``; the default is an open dashboard):
- ``PUBLIC_PATHS`` — exact paths that bypass auth entirely. Used for the
setup/login/status endpoints, health checks, and Prometheus metrics.
@@ -23,7 +24,6 @@
from typing import Any
from taskito.dashboard.handlers.auth import (
- handle_auth_status,
handle_change_password,
handle_login,
handle_logout,
@@ -138,7 +138,6 @@ def is_public_path(path: str) -> bool:
"/api/stats/queues": _handle_stats_queues,
"/api/scaler": lambda q, qs: build_scaler_response(q, queue_name=qs.get("queue", [None])[0]),
"/api/settings": _handle_list_settings,
- "/api/auth/status": handle_auth_status,
"/api/webhooks": handle_list_webhooks,
"/api/event-types": handle_list_event_types,
"/api/tasks": handle_list_tasks,
diff --git a/sdks/python/taskito/dashboard/server.py b/sdks/python/taskito/dashboard/server.py
index 543e915b..ea59e39c 100644
--- a/sdks/python/taskito/dashboard/server.py
+++ b/sdks/python/taskito/dashboard/server.py
@@ -1,11 +1,13 @@
"""HTTP server that wires routes to a Queue instance and serves the SPA.
-The server enforces dashboard authentication when at least one user has been
-registered with :class:`taskito.dashboard.auth.AuthStore`. Until the first
-user is created, all API routes return ``503 setup_required`` so the SPA can
-guide the operator through one-time setup. ``TASKITO_DASHBOARD_ADMIN_USER`` /
-``TASKITO_DASHBOARD_ADMIN_PASSWORD`` environment variables bootstrap a user
-idempotently on server start.
+Authentication is opt-in via ``serve_dashboard(auth_enabled=True)``; by
+default the dashboard serves openly with the auth endpoints disabled. When
+auth is enabled and at least one user has been registered with
+:class:`taskito.dashboard.auth.AuthStore`, sessions are enforced. Until the
+first user is created, all API routes return ``503 setup_required`` so the
+SPA can guide the operator through one-time setup.
+``TASKITO_DASHBOARD_ADMIN_USER`` / ``TASKITO_DASHBOARD_ADMIN_PASSWORD``
+environment variables bootstrap a user idempotently on server start.
"""
from __future__ import annotations
@@ -24,6 +26,7 @@
bootstrap_admin_from_env,
)
from taskito.dashboard.errors import _BadRequest, _NotFound
+from taskito.dashboard.handlers.auth import handle_auth_status
from taskito.dashboard.handlers.oauth import (
OAuthRedirect,
handle_providers,
@@ -118,6 +121,7 @@ def serve_dashboard(
static_assets: StaticAssets | None = None,
oauth_flow: OAuthFlow | None = None,
secure_cookies: bool = True,
+ auth_enabled: bool = False,
) -> None:
"""Start the dashboard HTTP server (blocking).
@@ -130,16 +134,20 @@ def serve_dashboard(
customised dashboard bundle from a different location.
oauth_flow: Configured :class:`OAuthFlow` to enable social login.
When unset, OAuth endpoints respond 404 and the providers list
- is empty.
+ is empty. Ignored unless ``auth_enabled`` is true.
+ auth_enabled: Enforce session auth (login/setup, CSRF, RBAC). Off
+ by default — the dashboard serves openly.
"""
- bootstrap_admin_from_env(queue)
- if oauth_flow is None:
- oauth_flow = _build_oauth_flow_from_env(queue)
+ if auth_enabled:
+ bootstrap_admin_from_env(queue)
+ if oauth_flow is None:
+ oauth_flow = _build_oauth_flow_from_env(queue)
handler = _make_handler(
queue,
static_assets=static_assets,
oauth_flow=oauth_flow,
secure_cookies=secure_cookies,
+ auth_enabled=auth_enabled,
)
server = ThreadingHTTPServer((host, port), handler)
print(f"taskito dashboard → http://{host}:{port}")
@@ -178,6 +186,7 @@ def _make_handler(
static_assets: StaticAssets | None = None,
oauth_flow: OAuthFlow | None = None,
secure_cookies: bool = True,
+ auth_enabled: bool = False,
) -> type:
"""Create a request handler class bound to the given queue."""
assets = static_assets if static_assets is not None else _get_default_assets()
@@ -237,6 +246,14 @@ def _handle_get(self) -> None:
if denied:
return
+ # ── Auth status (public; reports the auth mode) ─────────
+ if path == "/api/auth/status":
+ self._dispatch_with_handler(
+ handle_auth_status,
+ lambda h: h(queue, qs, auth_enabled=auth_enabled),
+ )
+ return
+
# ── OAuth flow paths (public, redirect-emitting) ────────
if path == "/api/auth/providers":
self._dispatch_with_handler(handle_providers, lambda h: h(queue, qs, oauth_flow))
@@ -412,6 +429,8 @@ def _handle_delete(self) -> None:
def _authorize(self, path: str, method: str) -> tuple[RequestContext, bool]:
"""Return ``(ctx, denied)``. When ``denied`` is true a response
has already been written and the caller must return."""
+ if not auth_enabled:
+ return self._authorize_open(path)
ctx = self._build_context()
# Setup-required short-circuit: before the first user is created
@@ -451,6 +470,15 @@ def _authorize(self, path: str, method: str) -> tuple[RequestContext, bool]:
return ctx, False
+ def _authorize_open(self, path: str) -> tuple[RequestContext, bool]:
+ """Auth disabled: allow everything, but 404 the auth endpoints
+ (except ``/api/auth/status``) so the SPA hides login affordances."""
+ ctx = build_context(self.headers, None)
+ if path.startswith("/api/auth/") and path != "/api/auth/status":
+ self._json_response({"error": "auth_disabled"}, status=404)
+ return ctx, True
+ return ctx, False
+
def _build_context(self) -> RequestContext:
cookies_header = self.headers.get("Cookie")
session = None
diff --git a/sdks/python/tests/dashboard/test_auth.py b/sdks/python/tests/dashboard/test_auth.py
index 599e6894..367123ea 100644
--- a/sdks/python/tests/dashboard/test_auth.py
+++ b/sdks/python/tests/dashboard/test_auth.py
@@ -337,6 +337,20 @@ def test_oauth_users_namespace_by_slot(queue: Queue) -> None:
@pytest.fixture
def dashboard_server(queue: Queue) -> Generator[tuple[str, Queue]]:
+ handler = _make_handler(queue, auth_enabled=True)
+ server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
+ port = server.server_address[1]
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ try:
+ yield f"http://127.0.0.1:{port}", queue
+ finally:
+ server.shutdown()
+
+
+@pytest.fixture
+def open_dashboard_server(queue: Queue) -> Generator[tuple[str, Queue]]:
+ """Server with the default configuration — auth disabled."""
handler = _make_handler(queue)
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
port = server.server_address[1]
@@ -401,7 +415,7 @@ def test_auth_status_before_setup(dashboard_server: tuple[str, Queue]) -> None:
base, _ = dashboard_server
status, body, _ = _get(f"{base}/api/auth/status")
assert status == 200
- assert body == {"setup_required": True}
+ assert body == {"auth_enabled": True, "setup_required": True}
def test_protected_route_returns_503_before_setup(dashboard_server: tuple[str, Queue]) -> None:
@@ -586,3 +600,57 @@ def test_health_endpoint_is_public(dashboard_server: tuple[str, Queue]) -> None:
AuthStore(queue).create_user("alice", "hunter2-secret")
status, _, _ = _get(f"{base}/health")
assert status == 200
+
+
+# ── Auth disabled (the default) ────────────────────────────────────────
+
+
+def test_auth_disabled_status(open_dashboard_server: tuple[str, Queue]) -> None:
+ base, _ = open_dashboard_server
+ status, body, _ = _get(f"{base}/api/auth/status")
+ assert status == 200
+ assert body == {"auth_enabled": False, "setup_required": False}
+
+
+def test_auth_disabled_serves_api_without_session(
+ open_dashboard_server: tuple[str, Queue],
+) -> None:
+ base, _ = open_dashboard_server
+ status, _, _ = _get(f"{base}/api/stats")
+ assert status == 200
+
+
+def test_auth_disabled_allows_mutations_without_csrf(
+ open_dashboard_server: tuple[str, Queue],
+) -> None:
+ base, _ = open_dashboard_server
+ status, _, _ = _post(f"{base}/api/dead-letters/purge", {})
+ assert status == 200
+
+
+def test_auth_disabled_ignores_existing_users(
+ open_dashboard_server: tuple[str, Queue],
+) -> None:
+ base, queue = open_dashboard_server
+ AuthStore(queue).create_user("alice", "hunter2-secret")
+ status, _, _ = _get(f"{base}/api/stats")
+ assert status == 200
+
+
+def test_auth_disabled_rejects_auth_endpoints(
+ open_dashboard_server: tuple[str, Queue],
+) -> None:
+ base, _ = open_dashboard_server
+ for method, path in [
+ ("GET", "/api/auth/whoami"),
+ ("GET", "/api/auth/providers"),
+ ("POST", "/api/auth/login"),
+ ("POST", "/api/auth/setup"),
+ ("POST", "/api/auth/logout"),
+ ]:
+ if method == "GET":
+ status, body, _ = _get(f"{base}{path}")
+ else:
+ status, body, _ = _post(f"{base}{path}", {})
+ assert status == 404, path
+ assert body == {"error": "auth_disabled"}, path
diff --git a/sdks/python/tests/dashboard/test_oauth_endpoints.py b/sdks/python/tests/dashboard/test_oauth_endpoints.py
index 505dd502..4d6ddcfc 100644
--- a/sdks/python/tests/dashboard/test_oauth_endpoints.py
+++ b/sdks/python/tests/dashboard/test_oauth_endpoints.py
@@ -132,7 +132,7 @@ def server_factory(
handles: list[ThreadingHTTPServer] = []
def _factory(flow: OAuthFlow | None) -> str:
- handler = _make_handler(queue, oauth_flow=flow)
+ handler = _make_handler(queue, oauth_flow=flow, auth_enabled=True)
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
handles.append(server)
port = server.server_address[1]
From f7d625ea57e35d95485cf924f1716d8c91146133 Mon Sep 17 00:00:00 2001
From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com>
Date: Sat, 11 Jul 2026 11:15:18 +0530
Subject: [PATCH 3/9] feat(node): dashboard auth now opt-in, off by default
---
sdks/node/src/cli/commands/dashboard.ts | 3 +
sdks/node/src/dashboard/auth/handlers.ts | 2 +-
sdks/node/src/dashboard/handlers/core.ts | 9 ++-
sdks/node/src/dashboard/index.ts | 25 ++++--
sdks/node/src/dashboard/server.ts | 42 +++++++++-
.../test/dashboard/oauthEndpoints.test.ts | 8 +-
sdks/node/test/dashboard/openMode.test.ts | 80 +++++++++++++++++++
sdks/node/test/dashboard/server.test.ts | 7 +-
sdks/node/test/dashboard/sessionAuth.test.ts | 2 +-
sdks/node/test/integrations/express.test.ts | 2 +-
sdks/node/test/integrations/fastify.test.ts | 2 +-
11 files changed, 160 insertions(+), 22 deletions(-)
create mode 100644 sdks/node/test/dashboard/openMode.test.ts
diff --git a/sdks/node/src/cli/commands/dashboard.ts b/sdks/node/src/cli/commands/dashboard.ts
index f740cc48..7e1752eb 100644
--- a/sdks/node/src/cli/commands/dashboard.ts
+++ b/sdks/node/src/cli/commands/dashboard.ts
@@ -7,6 +7,7 @@ import { positiveIntFlag } from "../parse";
interface DashboardFlags {
port?: string;
host?: string;
+ auth?: boolean;
token?: string;
insecureCookies?: boolean;
}
@@ -17,6 +18,7 @@ export function registerDashboard(program: Command): void {
.description("Serve the web dashboard")
.option("-p, --port ", "port to listen on", "8787")
.option("--host ", "host to bind", "127.0.0.1")
+ .option("--auth", "enable session authentication (login/setup, CSRF, roles); off by default")
.option("--token ", "legacy shared-token gate (disables the login flow)")
.option("--insecure-cookies", "drop the Secure cookie attribute for plain-HTTP dev")
.action(async (options: DashboardFlags, command: Command) => {
@@ -30,6 +32,7 @@ export function registerDashboard(program: Command): void {
port,
host,
auth: options.token ? { token: options.token } : undefined,
+ authEnabled: options.auth,
secureCookies: options.insecureCookies ? false : undefined,
});
// Confirm the bind succeeded before reporting success (e.g. port in use).
diff --git a/sdks/node/src/dashboard/auth/handlers.ts b/sdks/node/src/dashboard/auth/handlers.ts
index fade1646..1c156d4d 100644
--- a/sdks/node/src/dashboard/auth/handlers.ts
+++ b/sdks/node/src/dashboard/auth/handlers.ts
@@ -35,7 +35,7 @@ function serializeSession(session: DashboardSession) {
/** Public: whether the SPA should show the first-run setup page. */
export function authStatus(queue: Queue) {
- return { setup_required: new AuthStore(queue).countUsers() === 0 };
+ return { auth_enabled: true, setup_required: new AuthStore(queue).countUsers() === 0 };
}
/** Create the first admin user. Only callable when zero users exist. */
diff --git a/sdks/node/src/dashboard/handlers/core.ts b/sdks/node/src/dashboard/handlers/core.ts
index 5e0ab392..0a6fb5ec 100644
--- a/sdks/node/src/dashboard/handlers/core.ts
+++ b/sdks/node/src/dashboard/handlers/core.ts
@@ -258,10 +258,11 @@ function parseWebhookInput(body: unknown): Partial {
return input;
}
-// Token-mode auth boot responses: with a shared-token gate there is no user
-// store, so the SPA gets a fixed identity once past the token check.
-export function openAuthStatus() {
- return { setup_required: false };
+// Auth boot responses for the non-session modes. Token mode reports
+// `auth_enabled: true` (the SPA gets a fixed identity once past the token
+// check); open mode reports `auth_enabled: false` so the SPA skips login.
+export function openAuthStatus(authEnabled = true) {
+ return { auth_enabled: authEnabled, setup_required: false };
}
export function openWhoami() {
return {
diff --git a/sdks/node/src/dashboard/index.ts b/sdks/node/src/dashboard/index.ts
index e8934f2e..6cb23ec7 100644
--- a/sdks/node/src/dashboard/index.ts
+++ b/sdks/node/src/dashboard/index.ts
@@ -15,6 +15,12 @@ export interface DashboardOptions {
host?: string;
/** Path to the built SPA assets. Defaults to the package's bundled `static/dashboard`. */
staticDir?: string;
+ /**
+ * Enable session authentication (first-run setup, password login, CSRF,
+ * admin/viewer roles). Off by default — the dashboard serves openly.
+ * Ignored when the legacy `auth` token gate is set.
+ */
+ authEnabled?: boolean;
/**
* Legacy shared-token gate: require this bearer token on every `/api/*`
* request (except `/api/auth/status`). When set, the session-auth login
@@ -40,24 +46,27 @@ const defaultStaticDir = (): string => fileURLToPath(new URL(STATIC_REL, import.
/**
* Start the web dashboard over `queue` — serves the React SPA plus a JSON API.
- * Without `auth`, the dashboard runs the full session flow: first-run setup,
- * password login, CSRF protection, and admin/viewer roles. An initial admin
- * can be bootstrapped from `TASKITO_DASHBOARD_ADMIN_USER` /
+ * By default the dashboard serves openly with no authentication. Pass
+ * `authEnabled: true` to run the full session flow: first-run setup, password
+ * login, CSRF protection, and admin/viewer roles. An initial admin can be
+ * bootstrapped from `TASKITO_DASHBOARD_ADMIN_USER` /
* `TASKITO_DASHBOARD_ADMIN_PASSWORD`.
* Returns the listening HTTP server; call `.close()` to stop it.
*/
export function serveDashboard(queue: Queue, options: DashboardOptions = {}): Server {
- const bootstrap = options.auth
- ? Promise.resolve()
- : bootstrapAdminFromEnv(queue)
+ const sessionMode = !options.auth && options.authEnabled === true;
+ const bootstrap = sessionMode
+ ? bootstrapAdminFromEnv(queue)
.then(() => undefined)
.catch((error) => {
log.warn(() => `admin bootstrap failed: ${String(error)}`);
- });
+ })
+ : Promise.resolve();
const server = createDashboardServer(queue, options.staticDir ?? defaultStaticDir(), {
auth: options.auth,
+ authEnabled: options.authEnabled,
secureCookies: options.secureCookies,
- oauth: options.oauth ?? (options.auth ? undefined : buildOauthFlowFromEnv(queue)),
+ oauth: options.oauth ?? (sessionMode ? buildOauthFlowFromEnv(queue) : undefined),
});
// A bind failure (e.g. EADDRINUSE) without an 'error' listener crashes the process.
server.on("error", (error) => {
diff --git a/sdks/node/src/dashboard/server.ts b/sdks/node/src/dashboard/server.ts
index c10b7f45..f72a55ac 100644
--- a/sdks/node/src/dashboard/server.ts
+++ b/sdks/node/src/dashboard/server.ts
@@ -1,9 +1,11 @@
// Dashboard HTTP dispatch: /api/* -> JSON handlers, everything else -> the SPA.
//
-// Two auth modes:
-// - Session mode (default): full login flow — first-run setup, password
-// sessions, CSRF double-submit, and admin/viewer roles, all persisted in
-// the queue's settings store.
+// Three auth modes:
+// - Open mode (default): no authentication — every route serves openly and
+// the auth endpoints (except /api/auth/status) respond 404.
+// - Session mode (`authEnabled: true`): full login flow — first-run setup,
+// password sessions, CSRF double-submit, and admin/viewer roles, all
+// persisted in the queue's settings store.
// - Token mode (legacy, `auth: {token}`): a single shared token gates the
// API; the SPA gets a fixed identity once past the token check.
@@ -43,6 +45,8 @@ const MAX_BODY_BYTES = 1024 * 1024;
export interface DashboardHandlerOptions {
/** Legacy shared-token gate. When set, the session-auth flow is disabled. */
auth?: DashboardAuth;
+ /** Enable the session-auth flow (default false — the dashboard serves openly). */
+ authEnabled?: boolean;
/** Mark session cookies `Secure` (default true). Disable only for plain-HTTP dev. */
secureCookies?: boolean /** OAuth login flow (built from env by `serveDashboard` when omitted). */;
oauth?: OAuthFlow;
@@ -125,6 +129,11 @@ async function dispatch(
return;
}
+ if (options.authEnabled !== true) {
+ await dispatchOpenMode(queue, req, res, url, path);
+ return;
+ }
+
const store = new AuthStore(queue);
const ctx = buildContext(req, (token) => store.getSession(token));
const denied = authorize(store, ctx, path, req.method ?? "GET");
@@ -347,6 +356,31 @@ function clearSessionCookies(secure: boolean): string[] {
return [`${SESSION_COOKIE}=; HttpOnly; ${attrs}`, `${CSRF_COOKIE}=; ${attrs}`];
}
+/** Open dispatch (auth disabled, the default): every route serves without a
+ * session; the auth endpoints respond 404 so the SPA hides login affordances. */
+async function dispatchOpenMode(
+ queue: Queue,
+ req: IncomingMessage,
+ res: ServerResponse,
+ url: URL,
+ path: string,
+): Promise {
+ if (path.startsWith("/api/auth/")) {
+ if (path === "/api/auth/status") {
+ sendJson(res, 200, openAuthStatus(false));
+ } else {
+ sendJson(res, 404, { error: "auth_disabled" });
+ }
+ return;
+ }
+ const openCtx: RequestContext = {
+ session: undefined,
+ csrfCookie: undefined,
+ csrfHeader: undefined,
+ };
+ await runRoute(queue, req, res, url, path, openCtx, true);
+}
+
/** Legacy shared-token dispatch: stub auth boot endpoints + token-gated API. */
async function dispatchTokenMode(
queue: Queue,
diff --git a/sdks/node/test/dashboard/oauthEndpoints.test.ts b/sdks/node/test/dashboard/oauthEndpoints.test.ts
index 96d297f6..5e114e37 100644
--- a/sdks/node/test/dashboard/oauthEndpoints.test.ts
+++ b/sdks/node/test/dashboard/oauthEndpoints.test.ts
@@ -75,7 +75,13 @@ beforeEach(async () => {
},
{ providers: new Map([["fake", provider]]) },
);
- server = serveDashboard(queue, { port: 0, staticDir, secureCookies: false, oauth: flow });
+ server = serveDashboard(queue, {
+ port: 0,
+ staticDir,
+ secureCookies: false,
+ authEnabled: true,
+ oauth: flow,
+ });
await once(server, "listening");
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
diff --git a/sdks/node/test/dashboard/openMode.test.ts b/sdks/node/test/dashboard/openMode.test.ts
new file mode 100644
index 00000000..14c74ef3
--- /dev/null
+++ b/sdks/node/test/dashboard/openMode.test.ts
@@ -0,0 +1,80 @@
+// Open mode — the default: no authentication, every route serves openly and
+// the auth endpoints (except /api/auth/status) respond 404.
+
+import { execSync } from "node:child_process";
+import { once } from "node:events";
+import { existsSync, mkdtempSync } from "node:fs";
+import type { Server } from "node:http";
+import type { AddressInfo } from "node:net";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
+import { AuthStore } from "../../src/dashboard/auth";
+import { Queue, serveDashboard } from "../../src/index";
+
+const pkgRoot = fileURLToPath(new URL("../..", import.meta.url));
+const staticDir = join(pkgRoot, "static", "dashboard");
+
+beforeAll(() => {
+ if (!existsSync(join(staticDir, "index.html"))) {
+ execSync("pnpm run build:dashboard", { cwd: pkgRoot, stdio: "ignore" });
+ }
+}, 120_000);
+
+let server: Server | undefined;
+let queue: Queue;
+let base = "";
+
+beforeEach(async () => {
+ const db = join(mkdtempSync(join(tmpdir(), "taskito-dashopen-")), "q.db");
+ queue = new Queue({ dbPath: db });
+ queue.task("add", (a: number, b: number) => a + b);
+ server = serveDashboard(queue, { port: 0, staticDir, secureCookies: false });
+ await once(server, "listening");
+ base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
+});
+
+afterEach(() => {
+ server?.close();
+ server = undefined;
+});
+
+describe("open mode (auth disabled by default)", () => {
+ it("reports auth disabled on /api/auth/status", async () => {
+ const res = await fetch(`${base}/api/auth/status`);
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ auth_enabled: false, setup_required: false });
+ });
+
+ it("serves the API without a session", async () => {
+ const res = await fetch(`${base}/api/stats`);
+ expect(res.status).toBe(200);
+ });
+
+ it("allows mutations without a CSRF token", async () => {
+ const res = await fetch(`${base}/api/dead-letters/purge`, { method: "POST" });
+ expect(res.status).toBe(200);
+ });
+
+ it("stays open even when users exist in the auth store", async () => {
+ await new AuthStore(queue).createUser("alice", "hunter2-secret", "admin");
+ const res = await fetch(`${base}/api/stats`);
+ expect(res.status).toBe(200);
+ });
+
+ it("responds 404 on the auth endpoints", async () => {
+ const paths: Array<[string, string]> = [
+ ["GET", "/api/auth/whoami"],
+ ["GET", "/api/auth/providers"],
+ ["POST", "/api/auth/login"],
+ ["POST", "/api/auth/setup"],
+ ["POST", "/api/auth/logout"],
+ ];
+ for (const [method, path] of paths) {
+ const res = await fetch(`${base}${path}`, { method });
+ expect(res.status, path).toBe(404);
+ expect(await res.json(), path).toEqual({ error: "auth_disabled" });
+ }
+ });
+});
diff --git a/sdks/node/test/dashboard/server.test.ts b/sdks/node/test/dashboard/server.test.ts
index b17c6081..dce19c65 100644
--- a/sdks/node/test/dashboard/server.test.ts
+++ b/sdks/node/test/dashboard/server.test.ts
@@ -26,7 +26,12 @@ async function startDash(queue: Queue): Promise<{
server: Server;
}> {
const { headers } = await seedAdminAndSession(queue);
- const server = serveDashboard(queue, { port: 0, staticDir, secureCookies: false });
+ const server = serveDashboard(queue, {
+ port: 0,
+ staticDir,
+ secureCookies: false,
+ authEnabled: true,
+ });
await once(server, "listening");
const address = server.address() as AddressInfo;
return { base: `http://127.0.0.1:${address.port}`, headers, server };
diff --git a/sdks/node/test/dashboard/sessionAuth.test.ts b/sdks/node/test/dashboard/sessionAuth.test.ts
index 7136c101..dffa9e71 100644
--- a/sdks/node/test/dashboard/sessionAuth.test.ts
+++ b/sdks/node/test/dashboard/sessionAuth.test.ts
@@ -33,7 +33,7 @@ beforeEach(async () => {
const db = join(mkdtempSync(join(tmpdir(), "taskito-dashsess-")), "q.db");
queue = new Queue({ dbPath: db });
queue.task("add", (a: number, b: number) => a + b);
- server = serveDashboard(queue, { port: 0, staticDir, secureCookies: false });
+ server = serveDashboard(queue, { port: 0, staticDir, secureCookies: false, authEnabled: true });
await once(server, "listening");
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
diff --git a/sdks/node/test/integrations/express.test.ts b/sdks/node/test/integrations/express.test.ts
index a406dc97..812fc10f 100644
--- a/sdks/node/test/integrations/express.test.ts
+++ b/sdks/node/test/integrations/express.test.ts
@@ -102,5 +102,5 @@ it("mounts the dashboard JSON API", async () => {
const base = await serve((app) => app.use("/admin", taskitoDashboard(queue)));
const res = await fetch(`${base}/admin/api/auth/status`);
expect(res.status).toBe(200);
- expect(await res.json()).toMatchObject({ setup_required: true });
+ expect(await res.json()).toMatchObject({ auth_enabled: false, setup_required: false });
});
diff --git a/sdks/node/test/integrations/fastify.test.ts b/sdks/node/test/integrations/fastify.test.ts
index e2047460..a3d88cf6 100644
--- a/sdks/node/test/integrations/fastify.test.ts
+++ b/sdks/node/test/integrations/fastify.test.ts
@@ -79,5 +79,5 @@ it("mounts the dashboard JSON API under a prefix", async () => {
const res = await fetch(`http://127.0.0.1:${port}/admin/api/auth/status`);
expect(res.status).toBe(200);
- expect(await res.json()).toMatchObject({ setup_required: true });
+ expect(await res.json()).toMatchObject({ auth_enabled: false, setup_required: false });
});
From eb84cfc778f40e41ee0e475424725b64b0b5ff30 Mon Sep 17 00:00:00 2001
From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com>
Date: Sat, 11 Jul 2026 11:15:22 +0530
Subject: [PATCH 4/9] feat(java): dashboard auth now opt-in, off by default
---
.../TaskitoDashboardAutoConfiguration.java | 3 +-
.../taskito/spring/TaskitoProperties.java | 13 ++-
.../java/org/byteveda/taskito/Taskito.java | 10 +-
.../java/org/byteveda/taskito/cli/Cli.java | 6 +-
.../taskito/dashboard/DashboardServer.java | 96 +++++++++++++++----
.../taskito/dashboard/auth/AuthHandlers.java | 2 +-
.../taskito/dashboard/auth/TokenAuth.java | 2 +-
.../taskito/dashboard/DashboardAuthTest.java | 72 +++++++++++---
8 files changed, 166 insertions(+), 38 deletions(-)
diff --git a/sdks/java/spring/src/main/java/org/byteveda/taskito/spring/TaskitoDashboardAutoConfiguration.java b/sdks/java/spring/src/main/java/org/byteveda/taskito/spring/TaskitoDashboardAutoConfiguration.java
index 4d1d3cdc..64a15d5f 100644
--- a/sdks/java/spring/src/main/java/org/byteveda/taskito/spring/TaskitoDashboardAutoConfiguration.java
+++ b/sdks/java/spring/src/main/java/org/byteveda/taskito/spring/TaskitoDashboardAutoConfiguration.java
@@ -32,6 +32,7 @@ public DashboardServer taskitoDashboardServer(Taskito taskito, TaskitoProperties
dashboard.getPort(),
dashboard.getToken(),
dashboard.getStaticDir(),
- dashboard.isSecureCookies());
+ dashboard.isSecureCookies(),
+ dashboard.isAuthEnabled());
}
}
diff --git a/sdks/java/spring/src/main/java/org/byteveda/taskito/spring/TaskitoProperties.java b/sdks/java/spring/src/main/java/org/byteveda/taskito/spring/TaskitoProperties.java
index cad39689..462c89d5 100644
--- a/sdks/java/spring/src/main/java/org/byteveda/taskito/spring/TaskitoProperties.java
+++ b/sdks/java/spring/src/main/java/org/byteveda/taskito/spring/TaskitoProperties.java
@@ -56,7 +56,10 @@ public static class Dashboard {
*/
private int port = 8081;
- /** Optional shared token gating {@code /api/*}; null enables the session flow. */
+ /** Whether to enforce session authentication (login/setup, CSRF, RBAC). Off by default. */
+ private boolean authEnabled = false;
+
+ /** Optional shared token gating {@code /api/*}; overrides the session flow. */
private String token;
/** Optional unpacked SPA directory; null auto-discovers the bundled assets. */
@@ -81,6 +84,14 @@ public void setPort(int port) {
this.port = port;
}
+ public boolean isAuthEnabled() {
+ return authEnabled;
+ }
+
+ public void setAuthEnabled(boolean authEnabled) {
+ this.authEnabled = authEnabled;
+ }
+
public String getToken() {
return token;
}
diff --git a/sdks/java/src/main/java/org/byteveda/taskito/Taskito.java b/sdks/java/src/main/java/org/byteveda/taskito/Taskito.java
index ad987d54..d4b59f3c 100644
--- a/sdks/java/src/main/java/org/byteveda/taskito/Taskito.java
+++ b/sdks/java/src/main/java/org/byteveda/taskito/Taskito.java
@@ -313,11 +313,19 @@ static Builder builder() {
// ── Dashboard ───────────────────────────────────────────────────
- /** Start the dashboard HTTP server over this client on {@code port} (0 = ephemeral). */
+ /**
+ * Start the dashboard HTTP server over this client on {@code port}
+ * (0 = ephemeral). Serves openly — no authentication.
+ */
default DashboardServer dashboard(int port) throws IOException {
return DashboardServer.start(this, port);
}
+ /** As {@link #dashboard(int)}; {@code authEnabled=true} enables the session login flow. */
+ default DashboardServer dashboard(int port, boolean authEnabled) throws IOException {
+ return DashboardServer.start(this, port, authEnabled);
+ }
+
/** As {@link #dashboard(int)} but gating {@code /api/*} behind a shared {@code token}. */
default DashboardServer dashboard(int port, String token) throws IOException {
return DashboardServer.start(this, port, token);
diff --git a/sdks/java/src/main/java/org/byteveda/taskito/cli/Cli.java b/sdks/java/src/main/java/org/byteveda/taskito/cli/Cli.java
index da4d53a2..39e21400 100644
--- a/sdks/java/src/main/java/org/byteveda/taskito/cli/Cli.java
+++ b/sdks/java/src/main/java/org/byteveda/taskito/cli/Cli.java
@@ -255,6 +255,9 @@ static final class Dashboard implements Callable {
@Option(names = "--port", defaultValue = "8080")
int port;
+ @Option(names = "--auth", description = "Enable session authentication (off by default).")
+ boolean auth;
+
@Option(names = "--token", description = "Require this token for API access.")
String token;
@@ -269,7 +272,8 @@ static final class Dashboard implements Callable {
@Override
public Integer call() throws Exception {
try (Taskito queue = parent.open();
- DashboardServer server = DashboardServer.start(queue, port, token, staticDir, !insecureCookies)) {
+ DashboardServer server =
+ DashboardServer.start(queue, port, token, staticDir, !insecureCookies, auth)) {
System.out.println("dashboard on http://localhost:" + server.port());
new CountDownLatch(1).await();
}
diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/DashboardServer.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/DashboardServer.java
index e84ec2eb..aa5dfb1e 100644
--- a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/DashboardServer.java
+++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/DashboardServer.java
@@ -47,11 +47,14 @@
* built on the JDK's {@code com.sun.net.httpserver}. The JSON contract is
* snake_case with Unix-millisecond timestamps.
*
- * Two auth modes:
+ *
Three auth modes:
*
- * - Session (default): password users + sessions in the settings KV,
- * CSRF double-submit, admin/viewer RBAC, first-run setup. Bootstrap an
- * admin with {@code TASKITO_DASHBOARD_ADMIN_USER}/{@code _PASSWORD}.
+ *
- Open (default): no authentication — every route serves openly
+ * and the auth endpoints (except {@code /api/auth/status}) respond 404.
+ *
- Session ({@code authEnabled=true}): password users + sessions in
+ * the settings KV, CSRF double-submit, admin/viewer RBAC, first-run
+ * setup. Bootstrap an admin with
+ * {@code TASKITO_DASHBOARD_ADMIN_USER}/{@code _PASSWORD}.
*
- Legacy token: pass a shared {@code token} to gate {@code /api/*}
* as a fixed admin identity (no users/sessions). Kept for back-compat.
*
@@ -67,6 +70,7 @@ public final class DashboardServer implements AutoCloseable {
private final AuthStore authStore;
private final TokenAuth tokenAuth;
+ private final boolean authEnabled;
private final AuthHandlers authHandlers;
private final OAuthHandlers oauthHandlers;
private final OpsHandlers ops;
@@ -74,13 +78,20 @@ public final class DashboardServer implements AutoCloseable {
private final Router router;
private DashboardServer(
- HttpServer server, Taskito queue, Path staticDir, boolean secureCookies, String token, OAuthFlow oauth) {
+ HttpServer server,
+ Taskito queue,
+ Path staticDir,
+ boolean secureCookies,
+ String token,
+ boolean authEnabled,
+ OAuthFlow oauth) {
this.server = server;
this.queue = queue;
this.staticDir = staticDir;
this.secureCookies = secureCookies;
this.authStore = new AuthStore(SettingsAccess.of(queue));
this.tokenAuth = token != null ? new TokenAuth(token) : null;
+ this.authEnabled = authEnabled;
this.authHandlers = new AuthHandlers(authStore);
this.oauthHandlers = new OAuthHandlers(oauth, secureCookies);
this.ops = new OpsHandlers(queue);
@@ -88,49 +99,69 @@ private DashboardServer(
this.router = buildRouter();
}
- /** Start on {@code port} (0 = ephemeral) in session-auth mode. */
+ /** Start on {@code port} (0 = ephemeral) in open mode — no authentication. */
public static DashboardServer start(Taskito queue, int port) throws IOException {
- return start(queue, port, null, null, true);
+ return start(queue, port, null, null, true, false);
+ }
+
+ /** Start on {@code port}; {@code authEnabled=true} enables the session-auth flow. */
+ public static DashboardServer start(Taskito queue, int port, boolean authEnabled) throws IOException {
+ return start(queue, port, null, null, true, authEnabled);
}
/** Start in legacy shared-token mode; the session flow is disabled. */
public static DashboardServer start(Taskito queue, int port, String token) throws IOException {
- return start(queue, port, token, null, true);
+ return start(queue, port, token, null, true, false);
}
/** As {@link #start(Taskito, int, String)} but with an unpacked SPA directory. */
public static DashboardServer start(Taskito queue, int port, String token, String staticDir) throws IOException {
- return start(queue, port, token, staticDir, true);
+ return start(queue, port, token, staticDir, true, false);
+ }
+
+ /** As the full variant with auth disabled (the default). */
+ public static DashboardServer start(Taskito queue, int port, String token, String staticDir, boolean secureCookies)
+ throws IOException {
+ return start(queue, port, token, staticDir, secureCookies, false);
}
/**
- * Start on {@code port} (0 = ephemeral). A null {@code token} enables the
- * session flow; a null {@code staticDir} auto-discovers the bundled SPA.
- * {@code secureCookies=false} drops the {@code Secure} cookie attribute for
- * local HTTP development.
+ * Start on {@code port} (0 = ephemeral). A non-null {@code token} selects the
+ * legacy shared-token mode; otherwise {@code authEnabled} picks the session
+ * flow (true) or open mode (false, the default). A null {@code staticDir}
+ * auto-discovers the bundled SPA. {@code secureCookies=false} drops the
+ * {@code Secure} cookie attribute for local HTTP development.
*/
- public static DashboardServer start(Taskito queue, int port, String token, String staticDir, boolean secureCookies)
+ public static DashboardServer start(
+ Taskito queue, int port, String token, String staticDir, boolean secureCookies, boolean authEnabled)
throws IOException {
- // OAuth is session-mode only; the legacy shared-token mode has no login UI.
- OAuthFlow oauth = token == null ? buildOAuthFlow(queue, System.getenv()) : null;
- return startInternal(queue, port, token, staticDir, secureCookies, oauth);
+ // OAuth is session-mode only; open and legacy token modes have no login UI.
+ boolean sessionMode = token == null && authEnabled;
+ OAuthFlow oauth = sessionMode ? buildOAuthFlow(queue, System.getenv()) : null;
+ return startInternal(queue, port, token, staticDir, secureCookies, authEnabled, oauth);
}
/** Test seam: start in session mode with an explicitly built OAuth flow. */
static DashboardServer startWithOAuth(Taskito queue, int port, boolean secureCookies, OAuthFlow oauth)
throws IOException {
- return startInternal(queue, port, null, null, secureCookies, oauth);
+ return startInternal(queue, port, null, null, secureCookies, true, oauth);
}
private static DashboardServer startInternal(
- Taskito queue, int port, String token, String staticDir, boolean secureCookies, OAuthFlow oauth)
+ Taskito queue,
+ int port,
+ String token,
+ String staticDir,
+ boolean secureCookies,
+ boolean authEnabled,
+ OAuthFlow oauth)
throws IOException {
// Resolve assets before binding so a discovery failure can't leak a bound port.
Path dir = staticDir != null ? Paths.get(staticDir).normalize() : DashboardAssets.resolveOrNull();
HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
- DashboardServer dashboard = new DashboardServer(server, queue, dir, secureCookies, token, oauth);
+ DashboardServer dashboard = new DashboardServer(server, queue, dir, secureCookies, token, authEnabled, oauth);
// Seed an env admin before serving so no request races the open setup endpoint.
- if (token == null) {
+ if (token == null && authEnabled) {
dashboard.authStore.bootstrapAdminFromEnv();
}
server.createContext("/", dashboard::dispatch);
@@ -214,6 +245,10 @@ private void handleApi(HttpExchange exchange, String path) throws IOException {
handleTokenMode(exchange, path, method, query);
return;
}
+ if (!authEnabled) {
+ handleOpenMode(exchange, path, method, query);
+ return;
+ }
// OAuth routes emit redirects (not JSON), so they bypass the router; they
// are public, so this runs before the auth gate.
if (oauthHandlers.serve(exchange, path, method, query)) {
@@ -226,6 +261,25 @@ private void handleApi(HttpExchange exchange, String path) throws IOException {
}
}
+ /**
+ * Open mode (auth disabled, the default): every route serves without a
+ * session; the auth endpoints respond 404 so the SPA hides login affordances.
+ */
+ private void handleOpenMode(HttpExchange exchange, String path, String method, Map query)
+ throws IOException {
+ if (path.equals("/api/auth/status")) {
+ Http.respondJson(exchange, 200, Map.of("auth_enabled", false, "setup_required", false));
+ return;
+ }
+ if (path.startsWith("/api/auth/")) {
+ Http.respondError(exchange, 404, "auth_disabled");
+ return;
+ }
+ if (!router.dispatch(exchange, method, path, query, RequestContext.open())) {
+ Http.respondError(exchange, 404, "not found");
+ }
+ }
+
private void handleTokenMode(HttpExchange exchange, String path, String method, Map query)
throws IOException {
String queryToken = query.get("token");
diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/AuthHandlers.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/AuthHandlers.java
index 0b0502a5..7afceff6 100644
--- a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/AuthHandlers.java
+++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/AuthHandlers.java
@@ -18,7 +18,7 @@ public AuthHandlers(AuthStore store) {
}
public Map status() {
- return Map.of("setup_required", store.countUsers() == 0);
+ return Map.of("auth_enabled", true, "setup_required", store.countUsers() == 0);
}
public Map setup(Map body) {
diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/TokenAuth.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/TokenAuth.java
index c3b4023b..419e2333 100644
--- a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/TokenAuth.java
+++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/TokenAuth.java
@@ -52,7 +52,7 @@ public static String openCookie(String token, boolean secure) {
}
public static Map openStatus() {
- return Map.of("setup_required", false);
+ return Map.of("auth_enabled", true, "setup_required", false);
}
public static Map openWhoami() {
diff --git a/sdks/java/src/test/java/org/byteveda/taskito/dashboard/DashboardAuthTest.java b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/DashboardAuthTest.java
index e83d85a2..a2959296 100644
--- a/sdks/java/src/test/java/org/byteveda/taskito/dashboard/DashboardAuthTest.java
+++ b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/DashboardAuthTest.java
@@ -36,7 +36,7 @@ private static HttpResponse raw(int port, String method, String path, St
@Test
void gatesUntilFirstAdminExists(@TempDir Path dir) throws Exception {
try (Taskito queue = open(dir);
- DashboardServer server = DashboardServer.start(queue, 0)) {
+ DashboardServer server = DashboardServer.start(queue, 0, true)) {
int port = server.port();
assertEquals(503, raw(port, "GET", "/api/stats", null).statusCode());
assertTrue(raw(port, "GET", "/api/auth/status", null).body().contains("\"setup_required\":true"));
@@ -46,7 +46,7 @@ void gatesUntilFirstAdminExists(@TempDir Path dir) throws Exception {
@Test
void setupCreatesAdminOnce(@TempDir Path dir) throws Exception {
try (Taskito queue = open(dir);
- DashboardServer server = DashboardServer.start(queue, 0)) {
+ DashboardServer server = DashboardServer.start(queue, 0, true)) {
int port = server.port();
HttpResponse setup =
raw(port, "POST", "/api/auth/setup", "{\"username\":\"root\",\"password\":\"password123\"}");
@@ -65,7 +65,7 @@ void setupCreatesAdminOnce(@TempDir Path dir) throws Exception {
@Test
void loginSetsCookiesAndRedactsToken(@TempDir Path dir) throws Exception {
try (Taskito queue = open(dir);
- DashboardServer server = DashboardServer.start(queue, 0)) {
+ DashboardServer server = DashboardServer.start(queue, 0, true)) {
int port = server.port();
raw(port, "POST", "/api/auth/setup", "{\"username\":\"root\",\"password\":\"password123\"}");
HttpResponse login =
@@ -83,7 +83,7 @@ void loginSetsCookiesAndRedactsToken(@TempDir Path dir) throws Exception {
@Test
void rejectsBadCredentials(@TempDir Path dir) throws Exception {
try (Taskito queue = open(dir);
- DashboardServer server = DashboardServer.start(queue, 0)) {
+ DashboardServer server = DashboardServer.start(queue, 0, true)) {
int port = server.port();
raw(port, "POST", "/api/auth/setup", "{\"username\":\"root\",\"password\":\"password123\"}");
HttpResponse bad =
@@ -96,7 +96,7 @@ void rejectsBadCredentials(@TempDir Path dir) throws Exception {
@Test
void unauthenticatedApiIsRejected(@TempDir Path dir) throws Exception {
try (Taskito queue = open(dir);
- DashboardServer server = DashboardServer.start(queue, 0)) {
+ DashboardServer server = DashboardServer.start(queue, 0, true)) {
DashboardClient.seedAdmin(queue); // a user now exists
assertEquals(401, raw(server.port(), "GET", "/api/stats", null).statusCode());
}
@@ -105,7 +105,7 @@ void unauthenticatedApiIsRejected(@TempDir Path dir) throws Exception {
@Test
void authenticatedReadsSucceed(@TempDir Path dir) throws Exception {
try (Taskito queue = open(dir);
- DashboardServer server = DashboardServer.start(queue, 0)) {
+ DashboardServer server = DashboardServer.start(queue, 0, true)) {
DashboardClient client = new DashboardClient(server.port()).as(DashboardClient.seedAdmin(queue));
assertEquals(200, client.get("/api/stats").statusCode());
assertEquals(200, client.get("/api/auth/whoami").statusCode());
@@ -115,7 +115,7 @@ void authenticatedReadsSucceed(@TempDir Path dir) throws Exception {
@Test
void csrfIsRequiredForWrites(@TempDir Path dir) throws Exception {
try (Taskito queue = open(dir);
- DashboardServer server = DashboardServer.start(queue, 0)) {
+ DashboardServer server = DashboardServer.start(queue, 0, true)) {
DashboardClient client = new DashboardClient(server.port()).as(DashboardClient.seedAdmin(queue));
assertEquals(
403,
@@ -127,7 +127,7 @@ void csrfIsRequiredForWrites(@TempDir Path dir) throws Exception {
@Test
void viewersCannotWrite(@TempDir Path dir) throws Exception {
try (Taskito queue = open(dir);
- DashboardServer server = DashboardServer.start(queue, 0)) {
+ DashboardServer server = DashboardServer.start(queue, 0, true)) {
DashboardClient.seedAdmin(queue); // keep setup satisfied
DashboardClient viewer =
new DashboardClient(server.port()).as(DashboardClient.seedUser(queue, "read-only", "viewer"));
@@ -139,7 +139,7 @@ void viewersCannotWrite(@TempDir Path dir) throws Exception {
@Test
void logoutInvalidatesSession(@TempDir Path dir) throws Exception {
try (Taskito queue = open(dir);
- DashboardServer server = DashboardServer.start(queue, 0)) {
+ DashboardServer server = DashboardServer.start(queue, 0, true)) {
DashboardClient client = new DashboardClient(server.port()).as(DashboardClient.seedAdmin(queue));
assertEquals(200, client.post("/api/auth/logout", null).statusCode());
assertEquals(401, client.get("/api/auth/whoami").statusCode());
@@ -149,7 +149,7 @@ void logoutInvalidatesSession(@TempDir Path dir) throws Exception {
@Test
void changePasswordRotatesCredential(@TempDir Path dir) throws Exception {
try (Taskito queue = open(dir);
- DashboardServer server = DashboardServer.start(queue, 0)) {
+ DashboardServer server = DashboardServer.start(queue, 0, true)) {
int port = server.port();
DashboardClient client = new DashboardClient(port).as(DashboardClient.seedUser(queue, "root", "admin"));
HttpResponse changed = client.post(
@@ -165,7 +165,7 @@ void changePasswordRotatesCredential(@TempDir Path dir) throws Exception {
@Test
void oauthEndpointsReport404WhenUnconfigured(@TempDir Path dir) throws Exception {
try (Taskito queue = open(dir);
- DashboardServer server = DashboardServer.start(queue, 0)) {
+ DashboardServer server = DashboardServer.start(queue, 0, true)) {
int port = server.port();
assertTrue(raw(port, "GET", "/api/auth/providers", null).body().contains("\"password_enabled\":true"));
assertEquals(
@@ -183,4 +183,54 @@ void legacyTokenModeStillWorks(@TempDir Path dir) throws Exception {
assertEquals(200, raw(port, "GET", "/api/stats?token=sekret", null).statusCode());
}
}
+
+ @Test
+ void openModeIsTheDefault(@TempDir Path dir) throws Exception {
+ try (Taskito queue = open(dir);
+ DashboardServer server = DashboardServer.start(queue, 0)) {
+ int port = server.port();
+ HttpResponse status = raw(port, "GET", "/api/auth/status", null);
+ assertEquals(200, status.statusCode());
+ assertTrue(status.body().contains("\"auth_enabled\":false"));
+ assertTrue(status.body().contains("\"setup_required\":false"));
+ assertEquals(200, raw(port, "GET", "/api/stats", null).statusCode());
+ }
+ }
+
+ @Test
+ void openModeAllowsWritesWithoutCsrf(@TempDir Path dir) throws Exception {
+ try (Taskito queue = open(dir);
+ DashboardServer server = DashboardServer.start(queue, 0)) {
+ assertEquals(
+ 200,
+ raw(server.port(), "POST", "/api/queues/emails/pause", null).statusCode());
+ }
+ }
+
+ @Test
+ void openModeStaysOpenWhenUsersExist(@TempDir Path dir) throws Exception {
+ try (Taskito queue = open(dir);
+ DashboardServer server = DashboardServer.start(queue, 0)) {
+ DashboardClient.seedAdmin(queue);
+ assertEquals(200, raw(server.port(), "GET", "/api/stats", null).statusCode());
+ }
+ }
+
+ @Test
+ void openModeRejectsAuthEndpoints(@TempDir Path dir) throws Exception {
+ try (Taskito queue = open(dir);
+ DashboardServer server = DashboardServer.start(queue, 0)) {
+ int port = server.port();
+ for (String path : List.of("/api/auth/whoami", "/api/auth/providers")) {
+ HttpResponse response = raw(port, "GET", path, null);
+ assertEquals(404, response.statusCode(), path);
+ assertTrue(response.body().contains("auth_disabled"), path);
+ }
+ for (String path : List.of("/api/auth/login", "/api/auth/setup", "/api/auth/logout")) {
+ HttpResponse response = raw(port, "POST", path, "{}");
+ assertEquals(404, response.statusCode(), path);
+ assertTrue(response.body().contains("auth_disabled"), path);
+ }
+ }
+ }
}
From 79549e20a042e251e28bc2ff5ad3cca60cbb5106 Mon Sep 17 00:00:00 2001
From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com>
Date: Sat, 11 Jul 2026 11:25:52 +0530
Subject: [PATCH 5/9] fix(java): guard null session in whoami/change-password
---
.../org/byteveda/taskito/dashboard/auth/AuthHandlers.java | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/AuthHandlers.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/AuthHandlers.java
index 7afceff6..5b3d207e 100644
--- a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/AuthHandlers.java
+++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/AuthHandlers.java
@@ -57,6 +57,9 @@ public Map logout(RequestContext ctx) {
public Map whoami(RequestContext ctx) {
Session session = ctx.session();
+ if (session == null) {
+ throw DashboardError.notFound("not_authenticated");
+ }
User user = store.getUser(session.username()).orElse(null);
if (user == null) {
store.deleteSession(session.token());
@@ -71,6 +74,9 @@ public Map whoami(RequestContext ctx) {
public Map changePassword(RequestContext ctx, Map body) {
Session session = ctx.session();
+ if (session == null) {
+ throw DashboardError.badRequest("not_authenticated");
+ }
String oldPassword = requireField(body, "old_password");
String newPassword = requireField(body, "new_password");
User user = store.getUser(session.username()).orElse(null);
From e4e7d5682f1c86a1b72bb921f7c3bbcd561e3bb2 Mon Sep 17 00:00:00 2001
From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com>
Date: Sat, 11 Jul 2026 11:28:29 +0530
Subject: [PATCH 6/9] docs: dashboard auth now opt-in across SDKs
---
.../docs/java/guides/integrations/spring.mdx | 9 ++-
.../docs/java/guides/operations/cli.mdx | 7 ++-
.../docs/java/guides/operations/dashboard.mdx | 59 ++++++++++++------
.../docs/java/guides/operations/security.mdx | 15 +++--
.../docs/java/guides/operations/sso.mdx | 8 ++-
.../docs/node/guides/operations/cli.mdx | 4 ++
.../node/guides/operations/dashboard-api.mdx | 29 ++++++---
.../docs/node/guides/operations/dashboard.mdx | 28 +++++++--
.../docs/node/guides/operations/security.mdx | 13 ++--
.../content/docs/python/api-reference/cli.mdx | 3 +-
.../guides/dashboard/authentication.mdx | 62 ++++++++++++++++---
.../docs/python/guides/dashboard/index.mdx | 18 ++++--
.../docs/python/guides/dashboard/rest-api.mdx | 31 +++++++---
.../docs/python/guides/dashboard/sso.mdx | 5 +-
.../python/guides/integrations/django.mdx | 1 +
.../python/guides/operations/security.mdx | 13 ++--
.../shared/guides/operations/deployment.mdx | 33 +++++-----
17 files changed, 244 insertions(+), 94 deletions(-)
diff --git a/docs/content/docs/java/guides/integrations/spring.mdx b/docs/content/docs/java/guides/integrations/spring.mdx
index 091626c6..bbd8b364 100644
--- a/docs/content/docs/java/guides/integrations/spring.mdx
+++ b/docs/content/docs/java/guides/integrations/spring.mdx
@@ -83,7 +83,8 @@ taskito:
dashboard:
enabled: true
port: 8080
- # token: ${DASH_TOKEN:} # omit for session auth (default); set to use legacy shared-token mode
+ # auth-enabled: true # opt in to session auth; the dashboard serves openly by default
+ # token: ${DASH_TOKEN:} # legacy shared-token mode; overrides auth-enabled
# static-dir: /opt/taskito/dashboard-static
# secure-cookies: true # false drops the Secure cookie attribute for local HTTP
```
@@ -92,11 +93,13 @@ taskito:
|---|---|---|---|
| `taskito.dashboard.enabled` | `boolean` | `false` | Auto-start the dashboard server |
| `taskito.dashboard.port` | `int` | `8081` | Bind port (`0` for ephemeral); defaults off Spring Boot's own `8080` |
-| `taskito.dashboard.token` | `String` | none | Legacy shared token gating `/api/*`; unset enables [session auth](/java/guides/operations/dashboard#session-auth-default) |
+| `taskito.dashboard.auth-enabled` | `boolean` | `false` | Enable [session auth](/java/guides/operations/dashboard#session-auth-opt-in) (login/setup, CSRF, roles); off means the dashboard serves openly |
+| `taskito.dashboard.token` | `String` | none | Legacy shared token gating `/api/*`; overrides `auth-enabled` |
| `taskito.dashboard.static-dir` | `String` | auto-discovered | Directory of a prebuilt SPA, overriding the jar's extracted copy |
| `taskito.dashboard.secure-cookies` | `boolean` | `true` | Drop the `Secure` cookie attribute for local HTTP development |
OAuth/OIDC env vars (`TASKITO_DASHBOARD_OAUTH_*`, see
[SSO](/java/guides/operations/sso)) and the admin bootstrap
(`TASKITO_DASHBOARD_ADMIN_USER`/`_PASSWORD`) are read the same way regardless
-of Spring — they aren't Spring properties.
+of Spring — they aren't Spring properties, and they only apply when
+`auth-enabled` is set.
diff --git a/docs/content/docs/java/guides/operations/cli.mdx b/docs/content/docs/java/guides/operations/cli.mdx
index 1b9ad3c5..7c08f6b6 100644
--- a/docs/content/docs/java/guides/operations/cli.mdx
+++ b/docs/content/docs/java/guides/operations/cli.mdx
@@ -29,12 +29,13 @@ Read commands print pretty JSON, so the output pipes straight into `jq`.
## Serving the dashboard
```bash
-taskito --url taskito.db dashboard --port 8080 --token "$DASH_TOKEN"
+taskito --url taskito.db dashboard --port 8080 --auth
```
Serves the bundled [dashboard](/java/guides/operations/dashboard) until
-interrupted. `--static ` overrides the SPA assets; `--token` gates the
-API.
+interrupted — openly by default. `--auth` enables session authentication;
+`--token ` gates the API with a legacy shared token instead;
+`--static ` overrides the SPA assets.
## Other backends
diff --git a/docs/content/docs/java/guides/operations/dashboard.mdx b/docs/content/docs/java/guides/operations/dashboard.mdx
index 759b553a..11f2eef5 100644
--- a/docs/content/docs/java/guides/operations/dashboard.mdx
+++ b/docs/content/docs/java/guides/operations/dashboard.mdx
@@ -1,6 +1,6 @@
---
title: Dashboard
-description: "Serve the bundled web dashboard and its REST API — session auth, OAuth/SSO, and legacy token mode."
+description: "Serve the bundled web dashboard and its REST API — open by default, with opt-in session auth, OAuth/SSO, and legacy token mode."
---
The jar bundles the web dashboard SPA; `DashboardServer` serves it plus a JSON
@@ -19,10 +19,11 @@ try (Taskito taskito = Taskito.builder().sqlite("taskito.db").open();
}
```
-`DashboardServer.start(queue, port)` opens in [session-auth
-mode](#session-auth-default). `start(queue, port, token)` switches to
-[legacy shared-token mode](#legacy-shared-token-mode); four- and five-argument
-overloads add an unpacked SPA directory and a `secureCookies` flag.
+`DashboardServer.start(queue, port)` serves openly — no authentication.
+`start(queue, port, true)` enables [session auth](#session-auth-opt-in);
+`start(queue, port, token)` switches to
+[legacy shared-token mode](#legacy-shared-token-mode). The full variant is
+`start(queue, port, token, staticDir, secureCookies, authEnabled)`.
@@ -33,12 +34,16 @@ try (Taskito taskito = Taskito.builder().sqlite("taskito.db").open();
// ...
}
+// Session auth (login/setup, CSRF, roles):
+taskito.dashboard(8080, true);
+
// Legacy shared-token mode, gating /api/* as a fixed admin identity:
taskito.dashboard(8080, System.getenv("DASH_TOKEN"));
```
-`Taskito.dashboard(port)` / `dashboard(port, token)` are convenience defaults
-over `DashboardServer.start(...)` for the common case — one fewer import.
+`Taskito.dashboard(port)` / `dashboard(port, authEnabled)` /
+`dashboard(port, token)` are convenience defaults over
+`DashboardServer.start(...)` for the common case — one fewer import.
@@ -50,6 +55,7 @@ taskito --url taskito.db dashboard --port 8080
| Flag | Default | Description |
|---|---|---|
| `--port` | `8080` | Bind port (`0` for ephemeral) |
+| `--auth` | off | Enable session authentication (login/setup, CSRF, roles) |
| `--token` | none | Legacy shared token gating `/api/*` — disables session auth and OAuth |
| `--static` | bundled SPA | Directory of a prebuilt SPA, overriding the jar's extracted copy |
| `--insecure-cookies` | off | Drop the `Secure` cookie attribute — for local HTTP development |
@@ -71,21 +77,35 @@ assets only `/api/*` responds.
## Auth
-Two modes, chosen by whether a `token` is passed to `start(...)` /
-`dashboard(...)`.
+Three modes: **open** (the default — no authentication), **session auth**
+(opt-in via `authEnabled` / `--auth`), and **legacy shared-token** (a
+non-null `token`, which overrides `authEnabled`).
+
+### Open mode (default)
-### Session auth (default)
+With neither `authEnabled` nor a `token`, the dashboard serves openly —
+no setup screen, no login, no CSRF or roles. The auth endpoints respond
+`404 {"error": "auth_disabled"}`, except `GET /api/auth/status`, which
+returns `{"auth_enabled": false, "setup_required": false}` so the SPA
+skips the login flow. Suits local development; production deployments
+should enable session auth or keep the port on a private network.
-With no token, the dashboard runs password sign-in (and optionally
-[OAuth/OIDC](/java/guides/operations/sso)) with server-side sessions. Users
-and sessions live in the queue's settings key/value store — no dedicated
-tables — so the model is identical across SQLite, PostgreSQL, and Redis.
+### Session auth (opt-in)
+
+With `authEnabled=true` (or `--auth`), the dashboard runs password sign-in
+(and optionally [OAuth/OIDC](/java/guides/operations/sso)) with server-side
+sessions. Users and sessions live in the queue's settings key/value store —
+no dedicated tables — so the model is identical across SQLite, PostgreSQL,
+and Redis.
- **First-run setup.** On a fresh database every route except the public set
(`/api/auth/status`, `/api/auth/login`, `/api/auth/setup`,
`/api/auth/providers`, `/health`, `/readiness`, `/metrics`) returns
`503 setup_required` until an admin exists. `POST /api/auth/setup` creates
it (and signs it in); the route locks itself after the first user.
+ `GET /api/auth/status` reports
+ `{"auth_enabled": true, "setup_required": bool}` so the SPA knows which
+ screen to show.
- **Env-admin bootstrap.** Set both `TASKITO_DASHBOARD_ADMIN_USER` and
`TASKITO_DASHBOARD_ADMIN_PASSWORD` before starting the process to seed the
first admin without visiting a browser — useful for containers. It's
@@ -95,9 +115,11 @@ tables — so the model is identical across SQLite, PostgreSQL, and Redis.
```bash
export TASKITO_DASHBOARD_ADMIN_USER=admin
export TASKITO_DASHBOARD_ADMIN_PASSWORD='change-me-on-first-login'
- taskito --url taskito.db dashboard --port 8080
+ taskito --url taskito.db dashboard --port 8080 --auth
```
+ The env vars are only read when session auth is enabled.
+
Unlike a scripting-language runtime, the JVM cannot scrub a variable out
of its own process environment once it has been read — the password
@@ -190,7 +212,8 @@ the same contract the bundled SPA consumes. All paths below are relative to
| Settings | `settings`, `settings/{key}` |
- Every route above is auth-gated: session mode requires a valid session (plus
- CSRF on writes) except the public auth routes; legacy mode requires the
- matching token. See [Auth](#auth).
+ Gating depends on the [auth mode](#auth): open mode (the default) serves
+ every route without credentials; session mode requires a valid session
+ (plus CSRF on writes) except the public auth routes; legacy mode requires
+ the matching token.
diff --git a/docs/content/docs/java/guides/operations/security.mdx b/docs/content/docs/java/guides/operations/security.mdx
index 5a294562..63db4048 100644
--- a/docs/content/docs/java/guides/operations/security.mdx
+++ b/docs/content/docs/java/guides/operations/security.mdx
@@ -55,14 +55,16 @@ permits any path**, so always set roots in production.
## Dashboard
-The [dashboard](/java/guides/operations/dashboard) requires sign-in by
-default: on a fresh database every route except a small public set returns
+The [dashboard](/java/guides/operations/dashboard) serves **openly by
+default** — anyone who reaches the port has full control. Production
+deployments should enable session auth (`authEnabled=true` / `--auth`): on a
+fresh database every route except a small public set then returns
`503 setup_required` until an admin exists, and every route after that needs
a valid session (admin/viewer RBAC, CSRF on writes) or, if configured,
[OAuth/OIDC](/java/guides/operations/sso). Passing a `token` instead switches
to legacy shared-token mode — no users, no sessions, no RBAC — kept for
-back-compat; anyone with the token has full control. Either way, bind the
-port to a trusted network or front it with a reverse proxy for
+back-compat; anyone with the token has full control. Whatever the mode, bind
+the port to a trusted network or front it with a reverse proxy for
defense in depth.
## Mesh gossip
@@ -87,7 +89,8 @@ hosts with a `noexec` `/tmp`.
- [ ] Payloads signed or encrypted where the storage host isn't fully trusted.
- [ ] Webhook receivers verify the HMAC signature; webhook URLs are trusted.
- [ ] `FileProxyHandler` configured with allowlisted roots.
-- [ ] Dashboard admin account created (or env-bootstrapped) on first deploy;
- bound to a trusted network regardless of auth mode.
+- [ ] Dashboard auth enabled (`authEnabled=true` / `--auth`) with an admin
+ created (or env-bootstrapped) on first deploy; bound to a trusted
+ network regardless of auth mode.
- [ ] Logs don't echo sensitive payloads (redact in `onEnqueue`
[middleware](/java/guides/extensibility/middleware)).
diff --git a/docs/content/docs/java/guides/operations/sso.mdx b/docs/content/docs/java/guides/operations/sso.mdx
index 9ba3426a..2c922378 100644
--- a/docs/content/docs/java/guides/operations/sso.mdx
+++ b/docs/content/docs/java/guides/operations/sso.mdx
@@ -3,12 +3,14 @@ title: SSO (OAuth & OIDC)
description: "Sign in to the dashboard with Google, GitHub, or any OIDC provider. Domain/org allowlists, OAuth-only mode."
---
-The dashboard's [session auth](/java/guides/operations/dashboard#session-auth-default)
+The dashboard's [session auth](/java/guides/operations/dashboard#session-auth-opt-in)
supports native sign-in for **Google**, **GitHub**, and any
**OIDC-compliant** provider (Okta, Auth0, Keycloak, Microsoft Entra, Dex, …).
Multiple OIDC providers can run side by side, each its own button on the
-login screen. OAuth is off by default — setting any provider's env vars turns
-it on, alongside password login unless you opt out.
+login screen. OAuth requires session auth to be enabled
+(`authEnabled=true` / `--auth`; the dashboard serves openly by default) and
+is itself off by default — setting any provider's env vars turns it on,
+alongside password login unless you opt out.
ID-token validation for Google and generic OIDC needs
diff --git a/docs/content/docs/node/guides/operations/cli.mdx b/docs/content/docs/node/guides/operations/cli.mdx
index bc124d41..921896c7 100644
--- a/docs/content/docs/node/guides/operations/cli.mdx
+++ b/docs/content/docs/node/guides/operations/cli.mdx
@@ -37,6 +37,10 @@ over them. Use it as your container entrypoint for worker processes.
taskito --db taskito.db dashboard --port 8787
```
+Serves openly by default. `--auth` enables session authentication
+(login/setup, CSRF, roles); `--token ` uses the legacy shared-token
+gate instead — see [Dashboard](/node/guides/operations/dashboard).
+
## KEDA scaler
```bash
diff --git a/docs/content/docs/node/guides/operations/dashboard-api.mdx b/docs/content/docs/node/guides/operations/dashboard-api.mdx
index 0e71ec6c..1ca6d22e 100644
--- a/docs/content/docs/node/guides/operations/dashboard-api.mdx
+++ b/docs/content/docs/node/guides/operations/dashboard-api.mdx
@@ -71,18 +71,29 @@ returns a page, fields in snake_case:
## Auth
-By default the dashboard runs in **open mode**: `GET /api/auth/status` reports
-`{ setup_required: false }` and `GET /api/auth/whoami` returns an admin identity.
+By default the dashboard runs in **open mode**: every route serves without
+credentials, `GET /api/auth/status` reports
+`{ auth_enabled: false, setup_required: false }`, and the other auth
+endpoints respond `404 { "error": "auth_disabled" }`.
-Pass `auth: { token }` to [`serveDashboard`](/node/guides/operations/dashboard) to
-require a bearer token. Then every `/api/*` request except `GET /api/auth/status`
-must present the token via `Authorization: Bearer `, an `X-Taskito-Token`
-header, a `?token=` query, or the `taskito_token` cookie; otherwise it returns
-`401`. This is a single shared token — there is no per-user login, RBAC, or SSO.
+Pass `authEnabled: true` to
+[`serveDashboard`](/node/guides/operations/dashboard) for **session mode**:
+first-run setup, password login with server-side sessions, CSRF on writes,
+and admin/viewer roles. `GET /api/auth/status` then reports
+`{ auth_enabled: true, setup_required: }`, and every route outside
+the public set requires a valid session.
+
+Pass `auth: { token }` instead for the **legacy token mode**: every `/api/*`
+request except `GET /api/auth/status` must present the token via
+`Authorization: Bearer `, an `X-Taskito-Token` header, a `?token=`
+query, or the `taskito_token` cookie; otherwise it returns `401`. This is a
+single shared token — no per-user login or roles — and it overrides
+`authEnabled`.
Open mode means anyone who can reach the port has full control. Don't expose it
- on an untrusted network — bind to localhost, or mount it behind your own auth
- with the [Express](/node/guides/integrations/express#dashboard) /
+ on an untrusted network — enable session auth, bind to localhost, or mount it
+ behind your own auth with the
+ [Express](/node/guides/integrations/express#dashboard) /
[Fastify](/node/guides/integrations/fastify) helpers.
diff --git a/docs/content/docs/node/guides/operations/dashboard.mdx b/docs/content/docs/node/guides/operations/dashboard.mdx
index 3ef31f2f..fd5d375a 100644
--- a/docs/content/docs/node/guides/operations/dashboard.mdx
+++ b/docs/content/docs/node/guides/operations/dashboard.mdx
@@ -31,8 +31,28 @@ It serves the SPA plus the `/api/*` REST contract over the queue:
See the [REST API reference](/node/guides/operations/dashboard-api) for the full
endpoint list.
-Auth runs **open** by default. Pass `auth: { token }` to require a bearer token
-on every `/api/*` request (except `/api/auth/status`):
+Auth runs **open** by default — no setup screen, no login, no CSRF or
+roles. The auth endpoints respond `404 {"error": "auth_disabled"}`, except
+`GET /api/auth/status`, which reports
+`{ auth_enabled: false, setup_required: false }` so the SPA skips the login
+flow. Production deployments should enable session authentication (or keep
+the dashboard on a private network behind their own auth):
+
+```ts
+serveDashboard(queue, { port: 8787, authEnabled: true });
+```
+
+(or `taskito dashboard --auth` from the CLI). With `authEnabled: true` the
+full session flow runs: a one-time setup screen creates the first admin —
+or bootstrap it headlessly with `TASKITO_DASHBOARD_ADMIN_USER` /
+`TASKITO_DASHBOARD_ADMIN_PASSWORD` — then password login with server-side
+sessions, CSRF protection on writes, and admin/viewer roles. OAuth/SSO
+providers configured via `TASKITO_DASHBOARD_OAUTH_*` env vars apply only in
+this mode.
+
+The legacy shared-token gate is unchanged: pass `auth: { token }` to require
+a bearer token on every `/api/*` request (except `/api/auth/status`). It
+overrides `authEnabled`.
```ts
serveDashboard(queue, { port: 8787, auth: { token: process.env.DASH_TOKEN! } });
@@ -41,8 +61,8 @@ serveDashboard(queue, { port: 8787, auth: { token: process.env.DASH_TOKEN! } });
Requests authenticate via `Authorization: Bearer `, an `X-Taskito-Token`
header, or `?token=`. Opening `/?token=` once sets an httpOnly
cookie so the SPA works for the rest of the session. The token is compared in
-constant time. For full login / RBAC / SSO, mount the dashboard inside an existing
-app behind your own auth via the
+constant time. You can also mount the dashboard inside an existing app behind
+your own auth via the
[Express](/node/guides/integrations/express#dashboard) or
[Fastify](/node/guides/integrations/fastify) helpers.
diff --git a/docs/content/docs/node/guides/operations/security.mdx b/docs/content/docs/node/guides/operations/security.mdx
index c3e8b43a..2b5e9971 100644
--- a/docs/content/docs/node/guides/operations/security.mdx
+++ b/docs/content/docs/node/guides/operations/security.mdx
@@ -52,10 +52,12 @@ request timeout.
## Dashboard
The [dashboard](/node/guides/operations/dashboard) runs in **open mode** by
-default — anyone who reaches the port has full control. Pass `auth: { token }` to
-gate the API with a shared bearer token, bind it to localhost, or mount it behind
-your own auth (login / RBAC / SSO) via the
-[Express](/node/guides/integrations/express#dashboard) /
+default — anyone who reaches the port has full control. For production,
+pass `authEnabled: true` (or `--auth`) to enable the session flow —
+first-run setup, password login, CSRF, admin/viewer roles — or
+`auth: { token }` to gate the API with a legacy shared bearer token. Either
+way, bind it to localhost or a private network, or mount it behind your own
+auth via the [Express](/node/guides/integrations/express#dashboard) /
[Fastify](/node/guides/integrations/fastify) helpers.
## Hardening checklist
@@ -64,6 +66,7 @@ your own auth (login / RBAC / SSO) via the
- [ ] Enqueue behind your own authn/authz — never expose storage to clients.
- [ ] No secrets in task args; fetch them inside the handler.
- [ ] Webhook receivers verify the HMAC signature; webhook URLs are trusted.
-- [ ] Dashboard bound to localhost or behind auth.
+- [ ] Dashboard auth enabled (`authEnabled: true` / `--auth`), and the port
+ bound to localhost or behind auth.
- [ ] Logs don't echo sensitive payloads (use `onEnqueue` to
[redact](/node/guides/resources/interception)).
diff --git a/docs/content/docs/python/api-reference/cli.mdx b/docs/content/docs/python/api-reference/cli.mdx
index 039eb412..dd226aae 100644
--- a/docs/content/docs/python/api-reference/cli.mdx
+++ b/docs/content/docs/python/api-reference/cli.mdx
@@ -56,7 +56,7 @@ shutdown — in-flight tasks are allowed to complete before the process exits.
Start the web dashboard (SPA + REST API) for the queue.
```bash
-taskito dashboard --app [--host ] [--port ] [--insecure-cookies]
+taskito dashboard --app [--host ] [--port ] [--auth] [--insecure-cookies]
```
| Flag | Default | Description |
@@ -64,6 +64,7 @@ taskito dashboard --app [--host ] [--port ] [--in
| `--app` | — | Python path to the `Queue` instance |
| `--host` | `127.0.0.1` | Bind address |
| `--port` | `8080` | Bind port |
+| `--auth` | `False` | Enable session authentication (login/setup, CSRF, roles); off by default |
| `--insecure-cookies` | `False` | Drop the `Secure` flag on session cookies (local HTTP dev only) |
**Example:**
diff --git a/docs/content/docs/python/guides/dashboard/authentication.mdx b/docs/content/docs/python/guides/dashboard/authentication.mdx
index bd20dcee..6e28145e 100644
--- a/docs/content/docs/python/guides/dashboard/authentication.mdx
+++ b/docs/content/docs/python/guides/dashboard/authentication.mdx
@@ -1,16 +1,61 @@
---
title: Authentication
-description: "Session-based login, CSRF, env-var bootstrap, and the setup-required flow."
+description: "Opt-in session login, CSRF, env-var bootstrap, and the setup-required flow."
---
import { Callout } from "fumadocs-ui/components/callout";
import { Tab, Tabs } from "fumadocs-ui/components/tabs";
-The taskito dashboard is auth-gated by default. Until the first admin
-exists, every protected API route returns `503 setup_required` and the
-SPA shows the one-time setup form. Once an admin is registered the
+Dashboard authentication is **opt-in and off by default**. Out of the box
+the dashboard serves openly — no setup screen, no login, no CSRF or roles
+— which suits local development on a trusted machine. Production
+deployments should enable authentication (or keep the dashboard on a
+private network behind their own auth).
+
+## Enabling authentication
+
+
+
+
+```python
+from taskito.dashboard import serve_dashboard
+from myapp import queue
+
+serve_dashboard(queue, auth_enabled=True)
+```
+
+
+
+
+```bash
+taskito dashboard --app myapp:queue --auth
+```
+
+
+
+
+```bash
+python manage.py taskito_dashboard --auth
+```
+
+Or set `TASKITO_DASHBOARD_AUTH = True` in `settings.py` to make it the
+management command's default.
+
+
+
+
+With auth **disabled** (the default), every API route serves without a
+session and the auth endpoints respond `404 {"error": "auth_disabled"}` —
+except `GET /api/auth/status`, which reports
+`{"auth_enabled": false, "setup_required": false}` so the SPA skips the
+login flow entirely.
+
+With auth **enabled**, the full session flow applies. Until the first
+admin exists, every protected API route returns `503 setup_required` and
+the SPA shows the one-time setup form. Once an admin is registered the
dashboard requires a valid session cookie on every API call and a CSRF
-token on every state-changing request.
+token on every state-changing request. Everything below this point
+describes the auth-enabled dashboard.
## How auth works
@@ -52,11 +97,12 @@ vars before starting the dashboard:
```bash
export TASKITO_DASHBOARD_ADMIN_USER=admin
export TASKITO_DASHBOARD_ADMIN_PASSWORD='change-me-on-first-login'
-taskito dashboard --app myapp:queue
+taskito dashboard --app myapp:queue --auth
```
The bootstrap is **idempotent** — once a user with that name exists,
-subsequent dashboard restarts read the env vars but skip creation.
+subsequent dashboard restarts read the env vars but skip creation. The
+env vars are only read when auth is enabled.
Rotate the password after first login (use ``POST /api/auth/change-password``
@@ -94,7 +140,7 @@ All routes live under `/api/auth/`:
| Method | Path | What it does |
|---|---|---|
-| `GET` | `/api/auth/status` | Public. Returns `{setup_required: bool}` |
+| `GET` | `/api/auth/status` | Public. Returns `{auth_enabled: bool, setup_required: bool}` |
| `POST` | `/api/auth/setup` | Public, locks itself after the first user |
| `POST` | `/api/auth/login` | Returns the user + session and sets cookies |
| `POST` | `/api/auth/logout` | Invalidates the current session, clears cookies |
diff --git a/docs/content/docs/python/guides/dashboard/index.mdx b/docs/content/docs/python/guides/dashboard/index.mdx
index 8d2c49b1..1288e89f 100644
--- a/docs/content/docs/python/guides/dashboard/index.mdx
+++ b/docs/content/docs/python/guides/dashboard/index.mdx
@@ -20,7 +20,7 @@ dependencies required**.
-
- On a fresh database the dashboard refuses every API request with
- ``503 setup_required`` until you create the first admin. See
+
+ Authentication is opt-in — without it, anyone who can reach the port
+ has full control. For production, enable it with
+ `serve_dashboard(auth_enabled=True)` or `taskito dashboard --auth`, or
+ keep the dashboard on a private network. See
[Authentication](/python/guides/dashboard/authentication) for the full
flow, including the env-var bootstrap path useful for managed
deployments.
@@ -158,8 +161,11 @@ hash-busted multi-file assets under `sdks/python/taskito/static/dashboard/`.
### Sign in
-On the first visit you'll see the setup form. After you create the
-first admin, every subsequent visit shows the sign-in form.
+By default there is no sign-in — the dashboard loads straight into the
+overview. With authentication enabled (`--auth` /
+`serve_dashboard(auth_enabled=True)`), the first visit shows the setup
+form; after you create the first admin, every subsequent visit shows the
+sign-in form.

diff --git a/docs/content/docs/python/guides/dashboard/rest-api.mdx b/docs/content/docs/python/guides/dashboard/rest-api.mdx
index 57d8dbfa..043807c4 100644
--- a/docs/content/docs/python/guides/dashboard/rest-api.mdx
+++ b/docs/content/docs/python/guides/dashboard/rest-api.mdx
@@ -9,27 +9,37 @@ The dashboard exposes a JSON API you can use independently of the UI.
All endpoints return `application/json` and live under the same origin
as the dashboard itself.
-
- Every route requires a valid session cookie obtained from `POST
- /api/auth/login` **except** this public set: `/api/auth/status`,
- `/api/auth/login`, `/api/auth/setup`, `/api/auth/providers`,
- `/api/auth/oauth/start/{slot}`, `/api/auth/oauth/callback/{slot}`,
- `/health`, `/readiness`, and `/metrics`. State-changing requests
- (POST/PUT/DELETE) additionally require a CSRF header. See
+
+ Authentication is opt-in (`serve_dashboard(auth_enabled=True)` /
+ `taskito dashboard --auth`) — by default every route serves openly.
+ With auth enabled, every route requires a valid session cookie obtained
+ from `POST /api/auth/login` **except** this public set:
+ `/api/auth/status`, `/api/auth/login`, `/api/auth/setup`,
+ `/api/auth/providers`, `/api/auth/oauth/start/{slot}`,
+ `/api/auth/oauth/callback/{slot}`, `/health`, `/readiness`, and
+ `/metrics`. State-changing requests (POST/PUT/DELETE) additionally
+ require a CSRF header. See
[Dashboard Authentication](/python/guides/dashboard/authentication) for
the login flow and headless usage examples.
## Auth
+When authentication is disabled (the default), every auth endpoint below
+**except** `GET /api/auth/status` responds `404 {"error": "auth_disabled"}`.
+
### `GET /api/auth/status`
-Public. Returns whether the dashboard needs first-run setup.
+Public. Reports whether authentication applies and whether the dashboard
+needs first-run setup.
```json
-{ "setup_required": false }
+{ "auth_enabled": true, "setup_required": false }
```
+With auth disabled it returns
+`{"auth_enabled": false, "setup_required": false}`.
+
### `POST /api/auth/setup`
Public, but locks itself after the first user is created. Body:
@@ -578,6 +588,9 @@ runtime overrides live, all under namespaced prefixes (`auth:*`,
## Using the API programmatically
+The login/CSRF steps below apply to an auth-enabled dashboard; with auth
+disabled (the default) plain requests work directly.
+
```python
import requests
diff --git a/docs/content/docs/python/guides/dashboard/sso.mdx b/docs/content/docs/python/guides/dashboard/sso.mdx
index ff9ea47d..732f79bc 100644
--- a/docs/content/docs/python/guides/dashboard/sso.mdx
+++ b/docs/content/docs/python/guides/dashboard/sso.mdx
@@ -11,7 +11,10 @@ The dashboard ships native sign-in for **Google**, **GitHub**, and any
…). Multiple OIDC providers can run side-by-side, each rendered as its
own button on the login screen.
-OAuth is **off by default**. Setting any provider's env vars turns it
+OAuth requires
+[dashboard authentication](/python/guides/dashboard/authentication) to be
+enabled (`serve_dashboard(auth_enabled=True)` / `taskito dashboard --auth`)
+and is itself **off by default**. Setting any provider's env vars turns it
on; password login remains enabled unless you opt out explicitly.
Quick glossary for the acronyms used on this page:
diff --git a/docs/content/docs/python/guides/integrations/django.mdx b/docs/content/docs/python/guides/integrations/django.mdx
index c1c8c529..60a98691 100644
--- a/docs/content/docs/python/guides/integrations/django.mdx
+++ b/docs/content/docs/python/guides/integrations/django.mdx
@@ -69,6 +69,7 @@ The following settings can be defined in your Django `settings.py`:
| `TASKITO_WATCH_INTERVAL` | `2` | Polling interval in seconds for `manage.py taskito_info --watch`. |
| `TASKITO_DASHBOARD_HOST` | `"127.0.0.1"` | Default bind host for `manage.py taskito_dashboard`. |
| `TASKITO_DASHBOARD_PORT` | `8080` | Default bind port for `manage.py taskito_dashboard`. |
+| `TASKITO_DASHBOARD_AUTH` | `False` | Enable dashboard session authentication by default (same as passing `--auth` to `manage.py taskito_dashboard`). |
Example:
diff --git a/docs/content/docs/python/guides/operations/security.mdx b/docs/content/docs/python/guides/operations/security.mdx
index 2e7c8aff..e4927309 100644
--- a/docs/content/docs/python/guides/operations/security.mdx
+++ b/docs/content/docs/python/guides/operations/security.mdx
@@ -78,9 +78,13 @@ queue.max_payload_bytes = 0 # disable the cap
## Dashboard
-The dashboard enforces authentication once at least one user exists; until
-then every API route returns `503 setup_required`. Bootstrap the first admin
-with `TASKITO_DASHBOARD_ADMIN_USER` / `TASKITO_DASHBOARD_ADMIN_PASSWORD`
+The dashboard serves **openly by default** — authentication is opt-in via
+`serve_dashboard(auth_enabled=True)` or `taskito dashboard --auth`. Production
+deployments should enable it (or keep the dashboard on a private network
+behind their own auth). With auth enabled, the dashboard enforces sessions
+once at least one user exists; until then every API route returns
+`503 setup_required`. Bootstrap the first admin with
+`TASKITO_DASHBOARD_ADMIN_USER` / `TASKITO_DASHBOARD_ADMIN_PASSWORD`
(the password is removed from the environment after first use).
- **Roles.** Mutating routes (cancel/replay jobs, purge dead-letters, pause
@@ -147,7 +151,8 @@ network boundary (e.g. a KEDA sidecar on a private network).
- [ ] Network-isolate Postgres/Redis; least-privilege credentials.
- [ ] Use `SignedSerializer` (and/or `EncryptedSerializer`) for the queue.
- [ ] Set `recipe_signing_key` if any proxy handler is registered.
-- [ ] Create a dashboard admin; serve the dashboard over TLS.
+- [ ] Enable dashboard auth (`--auth` / `auth_enabled=True`) and create an
+ admin; serve the dashboard over TLS.
- [ ] Set `TASKITO_DASHBOARD_METRICS_TOKEN` if metrics are network-reachable.
- [ ] Auth-gate any mounted FastAPI/Flask contrib routers.
- [ ] Leave `max_payload_bytes` enabled (default 1 MiB) unless you need larger.
diff --git a/docs/content/docs/shared/guides/operations/deployment.mdx b/docs/content/docs/shared/guides/operations/deployment.mdx
index f2ab0ef8..ffba1cb8 100644
--- a/docs/content/docs/shared/guides/operations/deployment.mdx
+++ b/docs/content/docs/shared/guides/operations/deployment.mdx
@@ -792,10 +792,11 @@ taskito --url taskito.db dashboard --port 8080
-The dashboard enforces session authentication once at least one admin exists;
-until then every `/api/*` route returns `503 setup_required`. Bootstrap the
-first admin with `TASKITO_DASHBOARD_ADMIN_USER` / `TASKITO_DASHBOARD_ADMIN_PASSWORD`,
-or create one through the SPA's first-run setup. Serve it over TLS — see
+The dashboard serves **openly by default** — enable session authentication
+for production with `--auth` (or `serve_dashboard(auth_enabled=True)`).
+With auth enabled, bootstrap the first admin with
+`TASKITO_DASHBOARD_ADMIN_USER` / `TASKITO_DASHBOARD_ADMIN_PASSWORD`, or
+create one through the SPA's first-run setup. Serve it over TLS — see
[Security: Dashboard](/python/guides/operations/security#dashboard) for the
full auth, cookie, and role model.
@@ -804,19 +805,23 @@ full auth, cookie, and role model.
Auth runs **open** by default — anyone who can reach the port has full
-access. Pass a bearer token (`--token`, or `auth: { token }` to
-`serveDashboard`) for a quick gate, or mount the dashboard behind your own
-auth via the Express or
-[Fastify](/node/guides/integrations/fastify) helpers for real login/RBAC.
-Either way, put it behind TLS in production.
+access. Enable session authentication for production with `--auth` (or
+`authEnabled: true` to `serveDashboard`), pass a legacy bearer token
+(`--token`, or `auth: { token }`) for a quick gate, or mount the dashboard
+behind your own auth via the
+Express or
+[Fastify](/node/guides/integrations/fastify) helpers. Either way, put it
+behind TLS in production.
-The dashboard enforces session authentication (with OAuth/SSO support) by
-default; pass a `token` to `start(...)`/`dashboard(...)` for the legacy
-single-credential mode instead. Bootstrap the first admin with
+The dashboard serves **openly by default** — enable session authentication
+(with OAuth/SSO support) for production with `--auth` (or
+`DashboardServer.start(queue, port, true)`); pass a `token` to
+`start(...)`/`dashboard(...)` for the legacy single-credential mode
+instead. With auth enabled, bootstrap the first admin with
`TASKITO_DASHBOARD_ADMIN_USER` / `TASKITO_DASHBOARD_ADMIN_PASSWORD`, or
through the SPA's first-run setup. See
[Dashboard: Auth](/java/guides/operations/dashboard#auth) for the full model.
@@ -1213,5 +1218,5 @@ handlers rather than assuming generic numbers transfer.
(SIGINT} node={<>SIGINT/SIGTERM, mind the fixed 200ms CLI grace>} java={<>SIGTERM via a shutdown hook>} />)
- [ ] Set up failure hooks or monitoring for alerting
- [ ] Back up the database using `sqlite3 .backup` (not file copy), or `pg_dump` for Postgres
-- [ ] Put the dashboard behind TLS
- ()
+- [ ] Put the dashboard behind TLS and enable auth — it serves openly by default
+ ()
From c66b9992c74a74791e0db9101a8dafce60aad857 Mon Sep 17 00:00:00 2001
From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com>
Date: Sat, 11 Jul 2026 11:53:49 +0530
Subject: [PATCH 7/9] fix(java): 401 for unauthenticated whoami/change-password
---
.../org/byteveda/taskito/dashboard/auth/AuthHandlers.java | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/AuthHandlers.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/AuthHandlers.java
index 5b3d207e..90f6da1b 100644
--- a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/AuthHandlers.java
+++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/AuthHandlers.java
@@ -58,7 +58,7 @@ public Map logout(RequestContext ctx) {
public Map whoami(RequestContext ctx) {
Session session = ctx.session();
if (session == null) {
- throw DashboardError.notFound("not_authenticated");
+ throw DashboardError.unauthorized("not_authenticated");
}
User user = store.getUser(session.username()).orElse(null);
if (user == null) {
@@ -75,7 +75,7 @@ public Map whoami(RequestContext ctx) {
public Map changePassword(RequestContext ctx, Map body) {
Session session = ctx.session();
if (session == null) {
- throw DashboardError.badRequest("not_authenticated");
+ throw DashboardError.unauthorized("not_authenticated");
}
String oldPassword = requireField(body, "old_password");
String newPassword = requireField(body, "new_password");
From 4bd06c293ed58d6e42f9beff5f40777a3e680cf3 Mon Sep 17 00:00:00 2001
From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com>
Date: Sat, 11 Jul 2026 11:53:49 +0530
Subject: [PATCH 8/9] docs(java): clarify CLI dashboard example enables auth
---
docs/content/docs/java/guides/operations/cli.mdx | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/docs/content/docs/java/guides/operations/cli.mdx b/docs/content/docs/java/guides/operations/cli.mdx
index 7c08f6b6..8238bdf4 100644
--- a/docs/content/docs/java/guides/operations/cli.mdx
+++ b/docs/content/docs/java/guides/operations/cli.mdx
@@ -33,9 +33,10 @@ taskito --url taskito.db dashboard --port 8080 --auth
```
Serves the bundled [dashboard](/java/guides/operations/dashboard) until
-interrupted — openly by default. `--auth` enables session authentication;
-`--token ` gates the API with a legacy shared token instead;
-`--static ` overrides the SPA assets.
+interrupted. This example enables session authentication with `--auth`;
+omit the flag to serve openly (the default). `--token ` gates the
+API with a legacy shared token instead; `--static ` overrides the
+SPA assets.
## Other backends
From 1b0b963546f5e45c74ec341d56a21de50fd35701 Mon Sep 17 00:00:00 2001
From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com>
Date: Sat, 11 Jul 2026 11:53:49 +0530
Subject: [PATCH 9/9] docs(java): scope session requirement to non-public
routes
---
docs/content/docs/java/guides/operations/security.mdx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/content/docs/java/guides/operations/security.mdx b/docs/content/docs/java/guides/operations/security.mdx
index 63db4048..a21ba719 100644
--- a/docs/content/docs/java/guides/operations/security.mdx
+++ b/docs/content/docs/java/guides/operations/security.mdx
@@ -59,8 +59,8 @@ The [dashboard](/java/guides/operations/dashboard) serves **openly by
default** — anyone who reaches the port has full control. Production
deployments should enable session auth (`authEnabled=true` / `--auth`): on a
fresh database every route except a small public set then returns
-`503 setup_required` until an admin exists, and every route after that needs
-a valid session (admin/viewer RBAC, CSRF on writes) or, if configured,
+`503 setup_required` until an admin exists, and every non-public route after
+that needs a valid session (admin/viewer RBAC, CSRF on writes) or, if configured,
[OAuth/OIDC](/java/guides/operations/sso). Passing a `token` instead switches
to legacy shared-token mode — no users, no sessions, no RBAC — kept for
back-compat; anyone with the token has full control. Whatever the mode, bind