From 9d4b56e1d8208ca8105763c65f509844c322680e Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:17:33 +0530 Subject: [PATCH 01/12] fix(python): gate dashboard probes behind auth or token --- sdks/python/taskito/dashboard/server.py | 26 ++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/sdks/python/taskito/dashboard/server.py b/sdks/python/taskito/dashboard/server.py index ea59e39c..0602ca69 100644 --- a/sdks/python/taskito/dashboard/server.py +++ b/sdks/python/taskito/dashboard/server.py @@ -296,10 +296,10 @@ def _handle_get(self) -> None: if path == "/health": self._json_response(check_health()) elif path == "/readiness": - if self._metrics_token_ok(): + if self._probe_authorized(ctx): self._json_response(check_readiness(queue)) elif path == "/metrics": - if self._metrics_token_ok(): + if self._probe_authorized(ctx): self._serve_prometheus_metrics() else: self._json_response({"error": "Not found"}, status=404) @@ -606,19 +606,23 @@ def _json_response(self, data: Any, status: int = 200) -> None: self.end_headers() self.wfile.write(body) - def _metrics_token_ok(self) -> bool: - """Gate ``/metrics`` and ``/readiness`` behind an optional bearer token. + def _probe_authorized(self, ctx: RequestContext) -> bool: + """Gate ``/metrics`` and ``/readiness``. - When ``TASKITO_DASHBOARD_METRICS_TOKEN`` is unset the endpoints stay - public (probe-friendly default). When set, a matching - ``Authorization: Bearer `` header is required. Writes a 401 - and returns ``False`` when the check fails. + Accepted credentials: the optional ``TASKITO_DASHBOARD_METRICS_TOKEN`` + bearer token (scraper-friendly) or, when session auth is enabled, a + valid dashboard session. Open mode with no token configured stays + public (probe-friendly default). Writes a 401 and returns ``False`` + when every applicable check fails. """ token = os.environ.get("TASKITO_DASHBOARD_METRICS_TOKEN") - if not token: + if token: + header = self.headers.get("Authorization", "") + if hmac.compare_digest(header, f"Bearer {token}"): + return True + elif not auth_enabled: return True - header = self.headers.get("Authorization", "") - if hmac.compare_digest(header, f"Bearer {token}"): + if auth_enabled and ctx.is_authenticated: return True self._json_response({"error": "not_authenticated"}, status=401) return False From a391ec9ccf4870619b31e6a573f97b083e31c1cd Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:17:50 +0530 Subject: [PATCH 02/12] fix(python): oauth users get viewer role without allowlist --- sdks/python/taskito/dashboard/auth.py | 19 ++++++------------- sdks/python/taskito/dashboard/oauth/flow.py | 11 +++++++++++ 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/sdks/python/taskito/dashboard/auth.py b/sdks/python/taskito/dashboard/auth.py index fa43a6d1..018d6be7 100644 --- a/sdks/python/taskito/dashboard/auth.py +++ b/sdks/python/taskito/dashboard/auth.py @@ -179,24 +179,18 @@ def _oauth_bootstrap_role( email: str | None, email_verified: bool, admin_emails: tuple[str, ...], - user_table_empty: bool, ) -> str: """Decide the role for a freshly-created OAuth user. - Order: any path to ``admin`` requires a verified email (defence against - spoofed claims). If an explicit admin list is configured, only listed - emails get ``admin`` — the first-user-wins fallback is skipped. With no - admin list, the very first user (empty table) gets ``admin``, everyone - else gets ``viewer``. + ``admin`` requires a verified email (defence against spoofed claims) + AND membership in the configured admin list. Everyone else — including + the very first user — gets ``viewer``: admin access comes only from the + allowlist, the password setup flow, or the env bootstrap, so a stray + first OAuth login can never win admin. """ if not email_verified or not email: return "viewer" - normalised = email.lower() - if admin_emails: - if normalised in {e.lower() for e in admin_emails}: - return "admin" - return "viewer" - if user_table_empty: + if email.lower() in {e.lower() for e in admin_emails}: return "admin" return "viewer" @@ -337,7 +331,6 @@ def get_or_create_oauth_user( email=email, email_verified=email_verified, admin_emails=admin_emails, - user_table_empty=not users, ) now_ms = int(time.time() * 1000) users[username] = { diff --git a/sdks/python/taskito/dashboard/oauth/flow.py b/sdks/python/taskito/dashboard/oauth/flow.py index aacf8e71..488616d1 100644 --- a/sdks/python/taskito/dashboard/oauth/flow.py +++ b/sdks/python/taskito/dashboard/oauth/flow.py @@ -9,6 +9,7 @@ from __future__ import annotations +import logging from typing import TYPE_CHECKING from taskito.dashboard.auth import AuthStore @@ -37,6 +38,8 @@ from taskito.app import Queue from taskito.dashboard.auth import Session +logger = logging.getLogger("taskito.dashboard.oauth") + def build_providers( config: OAuthConfig, @@ -70,6 +73,14 @@ def __init__( providers if providers is not None else build_providers(config) ) self._state_store = state_store or OAuthStateStore(queue) + if self._providers and not config.admin_emails: + # OAuth users only ever get the viewer role without an allowlist, + # so an OAuth-only deployment would silently have zero admins. + logger.warning( + "OAuth is configured without admin emails: every OAuth login " + "gets the viewer role. Set TASKITO_DASHBOARD_OAUTH_ADMIN_EMAILS " + "(or OAuthConfig.admin_emails) to grant admin access." + ) # ── Introspection ──────────────────────────────────────────────── From 582130d4968d465a4a87d98f93576ead1886dd3b Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:17:59 +0530 Subject: [PATCH 03/12] test(python): cover probe gating and viewer RBAC --- sdks/python/tests/dashboard/test_auth.py | 130 ++++++++++++++++++++++- 1 file changed, 127 insertions(+), 3 deletions(-) diff --git a/sdks/python/tests/dashboard/test_auth.py b/sdks/python/tests/dashboard/test_auth.py index 367123ea..9a70f62b 100644 --- a/sdks/python/tests/dashboard/test_auth.py +++ b/sdks/python/tests/dashboard/test_auth.py @@ -205,9 +205,11 @@ def test_verify_password_rejects_oauth_sentinel_hash() -> None: assert verify_password("anything", "oauth:okta") is False -def test_get_or_create_oauth_user_creates_user_with_admin_when_table_empty( +def test_get_or_create_oauth_user_first_user_is_viewer_without_allowlist( queue: Queue, ) -> None: + # Even on an empty table, admin comes only from the allowlist — never + # from first-login-wins. store = AuthStore(queue) user = store.get_or_create_oauth_user( slot="google", @@ -217,12 +219,25 @@ def test_get_or_create_oauth_user_creates_user_with_admin_when_table_empty( email_verified=True, ) assert user.username == "google:1184283742" - assert user.role == "admin" + assert user.role == "viewer" assert user.email == "alice@acme.com" assert user.display_name == "Alice Example" assert user.is_oauth is True +def test_get_or_create_oauth_user_allowlisted_first_user_is_admin(queue: Queue) -> None: + store = AuthStore(queue) + user = store.get_or_create_oauth_user( + slot="google", + subject="1184283742", + email="alice@acme.com", + name="Alice Example", + email_verified=True, + admin_emails=("alice@acme.com",), + ) + assert user.role == "admin" + + def test_get_or_create_oauth_user_subsequent_user_is_viewer(queue: Queue) -> None: store = AuthStore(queue) store.get_or_create_oauth_user( @@ -362,10 +377,17 @@ def open_dashboard_server(queue: Queue) -> Generator[tuple[str, Queue]]: server.shutdown() -def _get(url: str, *, cookies: dict[str, str] | None = None) -> tuple[int, Any, dict[str, str]]: +def _get( + url: str, + *, + cookies: dict[str, str] | None = None, + headers: dict[str, str] | None = None, +) -> tuple[int, Any, dict[str, str]]: req = urllib.request.Request(url, method="GET") if cookies: req.add_header("Cookie", "; ".join(f"{k}={v}" for k, v in cookies.items())) + for k, v in (headers or {}).items(): + req.add_header(k, v) try: resp = urllib.request.urlopen(req) except urllib.error.HTTPError as e: @@ -602,6 +624,90 @@ def test_health_endpoint_is_public(dashboard_server: tuple[str, Queue]) -> None: assert status == 200 +# ── Viewer role (read-only RBAC) ─────────────────────────────────────── + + +def test_viewer_can_read_but_not_mutate(dashboard_server: tuple[str, Queue]) -> None: + base, queue = dashboard_server + store = AuthStore(queue) + viewer = store.create_user("watcher", "hunter2-secret", role="viewer") + session = store.create_session(viewer) + cookies = {"taskito_session": session.token, "taskito_csrf": session.csrf_token} + + status, _, _ = _get(f"{base}/api/stats", cookies=cookies) + assert status == 200 + + status, body, _ = _post( + f"{base}/api/dead-letters/purge", + {}, + cookies=cookies, + headers={"X-CSRF-Token": session.csrf_token}, + ) + assert status == 403 + assert body["error"] == "forbidden" + + +def test_viewer_keeps_auth_self_service(dashboard_server: tuple[str, Queue]) -> None: + base, queue = dashboard_server + store = AuthStore(queue) + viewer = store.create_user("watcher", "hunter2-secret", role="viewer") + session = store.create_session(viewer) + status, _, _ = _post( + f"{base}/api/auth/change-password", + {"old_password": "hunter2-secret", "new_password": "brand-new-secure"}, + cookies={"taskito_session": session.token, "taskito_csrf": session.csrf_token}, + headers={"X-CSRF-Token": session.csrf_token}, + ) + assert status == 200 + + +# ── Probe endpoints (/readiness, /metrics) ───────────────────────────── + + +def test_readiness_requires_session_when_auth_enabled( + dashboard_server: tuple[str, Queue], +) -> None: + base, queue = dashboard_server + store = AuthStore(queue) + user = store.create_user("alice", "hunter2-secret") + + status, body, _ = _get(f"{base}/readiness") + assert status == 401 + assert body["error"] == "not_authenticated" + + session = store.create_session(user) + status, _, _ = _get(f"{base}/readiness", cookies={"taskito_session": session.token}) + assert status == 200 + + +def test_metrics_requires_auth_when_enabled(dashboard_server: tuple[str, Queue]) -> None: + base, _ = dashboard_server + status, body, _ = _get(f"{base}/metrics") + assert status == 401 + assert body["error"] == "not_authenticated" + + +def test_probe_accepts_metrics_bearer_when_auth_enabled( + dashboard_server: tuple[str, Queue], + monkeypatch: pytest.MonkeyPatch, +) -> None: + base, queue = dashboard_server + monkeypatch.setenv("TASKITO_DASHBOARD_METRICS_TOKEN", "scraper-token") + store = AuthStore(queue) + user = store.create_user("alice", "hunter2-secret") + + status, _, _ = _get(f"{base}/readiness", headers={"Authorization": "Bearer scraper-token"}) + assert status == 200 + + status, _, _ = _get(f"{base}/readiness", headers={"Authorization": "Bearer wrong"}) + assert status == 401 + + # A valid session works even when the bearer token is configured. + session = store.create_session(user) + status, _, _ = _get(f"{base}/readiness", cookies={"taskito_session": session.token}) + assert status == 200 + + # ── Auth disabled (the default) ──────────────────────────────────────── @@ -654,3 +760,21 @@ def test_auth_disabled_rejects_auth_endpoints( status, body, _ = _post(f"{base}{path}", {}) assert status == 404, path assert body == {"error": "auth_disabled"}, path + + +def test_auth_disabled_probes_stay_public(open_dashboard_server: tuple[str, Queue]) -> None: + base, _ = open_dashboard_server + status, _, _ = _get(f"{base}/readiness") + assert status == 200 + + +def test_auth_disabled_probe_honours_metrics_token( + open_dashboard_server: tuple[str, Queue], + monkeypatch: pytest.MonkeyPatch, +) -> None: + base, _ = open_dashboard_server + monkeypatch.setenv("TASKITO_DASHBOARD_METRICS_TOKEN", "scraper-token") + status, _, _ = _get(f"{base}/readiness") + assert status == 401 + status, _, _ = _get(f"{base}/readiness", headers={"Authorization": "Bearer scraper-token"}) + assert status == 200 From b5a9704e701fbd58f418f3ce42146eeb3570bbce Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:23:31 +0530 Subject: [PATCH 04/12] fix(node): oauth users get viewer role without allowlist --- sdks/node/src/dashboard/auth/oauth/flow.ts | 13 +++++++++++++ sdks/node/src/dashboard/auth/store.ts | 13 ++++--------- sdks/node/test/dashboard/oauth.test.ts | 4 ++-- sdks/node/test/dashboard/oauthEndpoints.test.ts | 2 +- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/sdks/node/src/dashboard/auth/oauth/flow.ts b/sdks/node/src/dashboard/auth/oauth/flow.ts index 3affd702..6be5d921 100644 --- a/sdks/node/src/dashboard/auth/oauth/flow.ts +++ b/sdks/node/src/dashboard/auth/oauth/flow.ts @@ -3,6 +3,7 @@ // AuthStore integration. import type { Queue } from "../../../index"; +import { createLogger } from "../../../utils"; import { isSafeRedirect } from "../../urlSafety"; import { AuthStore, type DashboardSession } from "../store"; import { callbackUrl, configuredProviders, type OAuthConfig } from "./config"; @@ -17,6 +18,8 @@ import type { FetchLike } from "./providers"; import { GenericOidcProvider, GitHubProvider, GoogleProvider } from "./providers"; import { OAuthStateStore } from "./stateStore"; +const log = createLogger("dashboard"); + /** Instantiate one provider per configured slot, keyed by slot. */ export function buildProviders( config: OAuthConfig, @@ -47,6 +50,16 @@ export class OAuthFlow { ) { this.providers = options.providers ?? buildProviders(config); this.stateStore = options.stateStore ?? new OAuthStateStore(queue); + if (this.providers.size > 0 && config.adminEmails.length === 0) { + // OAuth users only ever get the viewer role without an allowlist, so an + // OAuth-only deployment would silently have zero admins. + log.warn( + () => + "OAuth is configured without admin emails: every OAuth login gets the " + + "viewer role. Set TASKITO_DASHBOARD_OAUTH_ADMIN_EMAILS (or " + + "OAuthConfig.adminEmails) to grant admin access.", + ); + } } get passwordAuthEnabled(): boolean { diff --git a/sdks/node/src/dashboard/auth/store.ts b/sdks/node/src/dashboard/auth/store.ts index 18533830..1525fdc3 100644 --- a/sdks/node/src/dashboard/auth/store.ts +++ b/sdks/node/src/dashboard/auth/store.ts @@ -155,24 +155,20 @@ function validateRole(role: string): void { } /** - * Role for a freshly-created OAuth user. Any path to `admin` requires a - * verified email. An explicit admin list wins over the first-user-wins - * fallback; with no list, the very first user becomes `admin`. + * Role for a freshly-created OAuth user. `admin` requires a verified email + * AND membership in the admin list — everyone else, including the very first + * user, gets `viewer`, so a stray first OAuth login can never win admin. */ export function oauthBootstrapRole(options: { email: string | null; emailVerified: boolean; adminEmails: readonly string[]; - userTableEmpty: boolean; }): string { if (!options.emailVerified || !options.email) { return "viewer"; } const normalised = options.email.toLowerCase(); - if (options.adminEmails.length > 0) { - return options.adminEmails.some((e) => e.toLowerCase() === normalised) ? "admin" : "viewer"; - } - return options.userTableEmpty ? "admin" : "viewer"; + return options.adminEmails.some((e) => e.toLowerCase() === normalised) ? "admin" : "viewer"; } // ── Persisted row shapes (cross-SDK snake_case JSON) ──────────────────── @@ -348,7 +344,6 @@ export class AuthStore { email: options.email, emailVerified: options.emailVerified, adminEmails: options.adminEmails ?? [], - userTableEmpty: Object.keys(users).length === 0, }); const now = Date.now(); users[username] = { diff --git a/sdks/node/test/dashboard/oauth.test.ts b/sdks/node/test/dashboard/oauth.test.ts index a519cd49..d06ef2cd 100644 --- a/sdks/node/test/dashboard/oauth.test.ts +++ b/sdks/node/test/dashboard/oauth.test.ts @@ -288,8 +288,8 @@ describe("flow", () => { }); expect(nextUrl).toBe("/jobs"); expect(session.username).toBe("fake:sub-1"); - // First OAuth user with a verified email becomes admin. - expect(session.role).toBe("admin"); + // Admin comes only from the allowlist — first OAuth user is a viewer. + expect(session.role).toBe("viewer"); expect(new AuthStore(queue).getUser("fake:sub-1")?.email).toBe("dev@corp.com"); // Replayed state fails. diff --git a/sdks/node/test/dashboard/oauthEndpoints.test.ts b/sdks/node/test/dashboard/oauthEndpoints.test.ts index 5e114e37..bbc4212d 100644 --- a/sdks/node/test/dashboard/oauthEndpoints.test.ts +++ b/sdks/node/test/dashboard/oauthEndpoints.test.ts @@ -134,7 +134,7 @@ it("callback creates a session, sets cookies, and redirects to next", async () = expect(cookies.some((c) => c.startsWith("taskito_csrf="))).toBe(true); const user = new AuthStore(queue).getUser("fake:sub-9"); - expect(user?.role).toBe("admin"); // first user with a verified email + expect(user?.role).toBe("viewer"); // admin comes only from the allowlist expect(user?.email).toBe("dev@corp.com"); // The session cookie authenticates API calls. From d99e32157cb1b53ef007279bf5a0bbc16cc52070 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:23:43 +0530 Subject: [PATCH 05/12] fix(node): harden dashboard probe and token auth --- sdks/node/src/dashboard/auth/tokenAuth.ts | 12 ++-- sdks/node/src/dashboard/server.ts | 61 ++++++++++++++------ sdks/node/test/dashboard/auth.test.ts | 24 +++++++- sdks/node/test/dashboard/sessionAuth.test.ts | 26 +++++++++ 4 files changed, 97 insertions(+), 26 deletions(-) diff --git a/sdks/node/src/dashboard/auth/tokenAuth.ts b/sdks/node/src/dashboard/auth/tokenAuth.ts index 55901867..2fc30735 100644 --- a/sdks/node/src/dashboard/auth/tokenAuth.ts +++ b/sdks/node/src/dashboard/auth/tokenAuth.ts @@ -19,10 +19,12 @@ export function isPublicApiPath(path: string): boolean { /** * The token presented on a request, from (in order) the `Authorization: Bearer` - * header, an `X-Taskito-Token` header, a `?token=` query param, or the - * `taskito_token` cookie. + * header, an `X-Taskito-Token` header, or the `taskito_token` cookie. A + * `?token=` query param is deliberately NOT accepted here — query strings leak + * into access logs, browser history, and the Referer header; it is only + * honoured once on a page load to bootstrap the cookie. */ -export function presentedToken(req: IncomingMessage, url: URL): string | undefined { +export function presentedToken(req: IncomingMessage): string | undefined { const header = req.headers.authorization; if (header?.startsWith("Bearer ")) { return header.slice("Bearer ".length); @@ -31,10 +33,6 @@ export function presentedToken(req: IncomingMessage, url: URL): string | undefin if (typeof custom === "string" && custom.length > 0) { return custom; } - const query = url.searchParams.get("token"); - if (query) { - return query; - } return readCookie(req, TOKEN_COOKIE); } diff --git a/sdks/node/src/dashboard/server.ts b/sdks/node/src/dashboard/server.ts index f72a55ac..18103897 100644 --- a/sdks/node/src/dashboard/server.ts +++ b/sdks/node/src/dashboard/server.ts @@ -105,18 +105,22 @@ async function dispatch( const path = url.pathname; const auth = options.auth; - // Token mode: a valid `?token=` bootstraps an httpOnly cookie so the SPA's - // later API calls authenticate without re-passing the token. - if (auth && url.searchParams.get("token") && tokenMatches(auth.token, presentedToken(req, url))) { - setTokenCookie(res, auth.token); - } - if (path === "/health" || path === "/readiness" || path === "/metrics") { - await serveProbe(queue, req, res, path); + await serveProbe(queue, req, res, path, options); return; } if (!path.startsWith("/api/")) { + // Token mode: a valid `?token=` on a page load (never an API call) + // bootstraps an httpOnly cookie, then redirects to strip the token from + // the URL so it can't leak via history or the Referer header. + const queryToken = url.searchParams.get("token"); + if (auth && req.method === "GET" && queryToken && tokenMatches(auth.token, queryToken)) { + setTokenCookie(res, auth.token); + url.searchParams.delete("token"); + redirect(res, `${url.pathname}${url.search}`); + return; + } if (assets.serve(path, res)) { return; } @@ -390,7 +394,7 @@ async function dispatchTokenMode( path: string, auth: DashboardAuth, ): Promise { - if (!isPublicApiPath(path) && !tokenMatches(auth.token, presentedToken(req, url))) { + if (!isPublicApiPath(path) && !tokenMatches(auth.token, presentedToken(req))) { sendJson(res, 401, { error: "unauthorized" }); return; } @@ -454,21 +458,23 @@ function readBody(req: IncomingMessage): Promise { } /** - * Probe endpoints outside the session gate. `/health` is always public; - * `/readiness` and `/metrics` are optionally gated by - * `TASKITO_DASHBOARD_METRICS_TOKEN` as a bearer token. + * Probe endpoints. `/health` is always public (liveness). `/readiness` and + * `/metrics` accept the optional `TASKITO_DASHBOARD_METRICS_TOKEN` bearer or, + * when dashboard auth is on, that mode's own credential — see + * {@link probeAuthorized}. */ async function serveProbe( queue: Queue, req: IncomingMessage, res: ServerResponse, path: string, + options: DashboardHandlerOptions, ): Promise { if (path === "/health") { sendJson(res, 200, health()); return; } - if (!metricsTokenOk(req)) { + if (!probeAuthorized(queue, req, options)) { sendJson(res, 401, { error: "unauthorized" }); return; } @@ -488,13 +494,34 @@ async function serveProbe( } } -/** Probe-friendly by default; a set env token requires a matching bearer header. */ -function metricsTokenOk(req: IncomingMessage): boolean { - const token = process.env.TASKITO_DASHBOARD_METRICS_TOKEN; - if (!token) { +/** + * Gate for `/readiness` and `/metrics`. Accepted credentials: the optional + * `TASKITO_DASHBOARD_METRICS_TOKEN` bearer (scraper-friendly), the legacy + * shared token in token mode, or a valid session in session mode. Open mode + * with no metrics token stays public (probe-friendly default). + */ +function probeAuthorized( + queue: Queue, + req: IncomingMessage, + options: DashboardHandlerOptions, +): boolean { + const metricsToken = process.env.TASKITO_DASHBOARD_METRICS_TOKEN; + if (metricsToken) { + if (tokenMatches(`Bearer ${metricsToken}`, req.headers.authorization ?? "")) { + return true; + } + } else if (!options.auth && options.authEnabled !== true) { return true; } - return tokenMatches(`Bearer ${token}`, req.headers.authorization ?? ""); + if (options.auth) { + return tokenMatches(options.auth.token, presentedToken(req)); + } + if (options.authEnabled === true) { + const store = new AuthStore(queue); + const ctx = buildContext(req, (token) => store.getSession(token)); + return ctx.session !== undefined; + } + return false; } /** Token-mode session/CSRF cookies so the SPA proceeds without a login. */ diff --git a/sdks/node/test/dashboard/auth.test.ts b/sdks/node/test/dashboard/auth.test.ts index 04d0fcc9..43e6720c 100644 --- a/sdks/node/test/dashboard/auth.test.ts +++ b/sdks/node/test/dashboard/auth.test.ts @@ -55,13 +55,33 @@ it("accepts an X-Taskito-Token header", async () => { expect(res.status).toBe(200); }); -it("accepts a ?token= query and sets a session cookie", async () => { +it("rejects a ?token= query on API calls", async () => { const res = await fetch(`${base}/api/stats?token=${TOKEN}`); - expect(res.status).toBe(200); + expect(res.status).toBe(401); +}); + +it("bootstraps a cookie from ?token= on a page load and strips it", async () => { + const res = await fetch(`${base}/?token=${TOKEN}&tab=jobs`, { redirect: "manual" }); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe("/?tab=jobs"); expect(res.headers.get("set-cookie")).toContain("taskito_token="); }); +it("does not bootstrap from a wrong ?token= on a page load", async () => { + const res = await fetch(`${base}/?token=nope`, { redirect: "manual" }); + expect(res.status).toBe(200); + expect(res.headers.get("set-cookie")).toBeNull(); +}); + it("leaves /api/auth/status public", async () => { const res = await fetch(`${base}/api/auth/status`); expect(res.status).toBe(200); }); + +it("gates probes behind the legacy token; /health stays public", async () => { + expect((await fetch(`${base}/health`)).status).toBe(200); + expect((await fetch(`${base}/readiness`)).status).toBe(401); + expect((await fetch(`${base}/metrics`)).status).toBe(401); + const ok = await fetch(`${base}/readiness`, { headers: { "x-taskito-token": TOKEN } }); + expect(ok.status).not.toBe(401); +}); diff --git a/sdks/node/test/dashboard/sessionAuth.test.ts b/sdks/node/test/dashboard/sessionAuth.test.ts index dffa9e71..41322786 100644 --- a/sdks/node/test/dashboard/sessionAuth.test.ts +++ b/sdks/node/test/dashboard/sessionAuth.test.ts @@ -226,3 +226,29 @@ describe("providers listing", () => { expect(await res.json()).toEqual({ password_enabled: true, providers: [] }); }); }); + +describe("probes", () => { + it("gates /readiness and /metrics behind a session; /health stays public", async () => { + expect((await fetch(`${base}/health`)).status).toBe(200); + expect((await fetch(`${base}/readiness`)).status).toBe(401); + expect((await fetch(`${base}/metrics`)).status).toBe(401); + + const { headers } = await seedAdminAndSession(queue); + expect((await fetch(`${base}/readiness`, { headers })).status).not.toBe(401); + }); + + it("accepts the metrics bearer token alongside sessions", async () => { + process.env.TASKITO_DASHBOARD_METRICS_TOKEN = "scrape-secret"; + try { + const ok = await fetch(`${base}/readiness`, { + headers: { authorization: "Bearer scrape-secret" }, + }); + expect(ok.status).not.toBe(401); + expect( + (await fetch(`${base}/readiness`, { headers: { authorization: "Bearer wrong" } })).status, + ).toBe(401); + } finally { + delete process.env.TASKITO_DASHBOARD_METRICS_TOKEN; + } + }); +}); From 6a70910ba1ab0854bd9bf767395e4ff76f2e0508 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:33:37 +0530 Subject: [PATCH 06/12] fix(java): oauth users get viewer role without allowlist --- .../taskito/dashboard/auth/AuthStore.java | 20 +++++++++---------- .../dashboard/auth/oauth/OAuthFlow.java | 9 +++++++++ .../taskito/dashboard/auth/AuthStoreTest.java | 19 +++++++++--------- .../dashboard/oauth/OAuthFlowTest.java | 4 ++-- 4 files changed, 30 insertions(+), 22 deletions(-) diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/AuthStore.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/AuthStore.java index a45196b5..b82d656f 100644 --- a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/AuthStore.java +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/AuthStore.java @@ -171,7 +171,7 @@ public synchronized User getOrCreateOauthUser( saveUsers(users); return toUser(username, row); } - String role = oauthBootstrapRole(email, emailVerified, adminEmails, users.isEmpty()); + String role = oauthBootstrapRole(email, emailVerified, adminEmails); long now = nowMillis(); Map row = new LinkedHashMap<>(); row.put("password_hash", PasswordHasher.oauthSentinel(slot)); @@ -185,19 +185,19 @@ public synchronized User getOrCreateOauthUser( return toUser(username, row); } - /** Role for a freshly seen OAuth user. Admin needs a verified email. */ - public static String oauthBootstrapRole( - String email, boolean emailVerified, List adminEmails, boolean userTableEmpty) { + /** + * Role for a freshly seen OAuth user: admin requires a verified email AND a + * listed address. Everyone else — including the very first user — gets + * viewer, so a stray first OAuth login can never win admin. + */ + public static String oauthBootstrapRole(String email, boolean emailVerified, List adminEmails) { if (!emailVerified || email == null || email.isBlank()) { return ROLE_VIEWER; } String normalised = email.toLowerCase(Locale.ROOT); - if (adminEmails != null && !adminEmails.isEmpty()) { - boolean listed = adminEmails.stream() - .anyMatch(e -> e.toLowerCase(Locale.ROOT).equals(normalised)); - return listed ? ROLE_ADMIN : ROLE_VIEWER; - } - return userTableEmpty ? ROLE_ADMIN : ROLE_VIEWER; + boolean listed = adminEmails != null + && adminEmails.stream().anyMatch(e -> e.toLowerCase(Locale.ROOT).equals(normalised)); + return listed ? ROLE_ADMIN : ROLE_VIEWER; } // ---- sessions ---------------------------------------------------------- diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/OAuthFlow.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/OAuthFlow.java index d65006ce..17d2faa9 100644 --- a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/OAuthFlow.java +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/OAuthFlow.java @@ -17,6 +17,7 @@ import org.byteveda.taskito.dashboard.auth.oauth.model.ProviderIdentity; import org.byteveda.taskito.dashboard.auth.oauth.provider.OAuthProvider; import org.byteveda.taskito.dashboard.auth.oauth.provider.Providers; +import org.byteveda.taskito.logging.TaskitoLogger; /** * The seam between the HTTP handler layer and the provider implementations. It @@ -25,6 +26,8 @@ * {@link #handleCallback} to land a session. */ public final class OAuthFlow { + private static final TaskitoLogger LOG = TaskitoLogger.create("dashboard"); + private final AuthStore authStore; private final OAuthConfig config; private final OAuthStateStore stateStore; @@ -36,6 +39,12 @@ public OAuthFlow( this.config = config; this.stateStore = stateStore; this.providers = new LinkedHashMap<>(providers); + if (!this.providers.isEmpty() && config.adminEmails().isEmpty()) { + // OAuth users only ever get the viewer role without an allowlist, so an + // OAuth-only deployment would silently have zero admins. + LOG.warn("OAuth is configured without admin emails: every OAuth login gets the viewer role." + + " Set " + OAuthConfig.ENV_ADMIN_EMAILS + " (or OAuthConfig.adminEmails) to grant admin access."); + } } /** The landed session plus the sanitised post-login redirect target. */ diff --git a/sdks/java/src/test/java/org/byteveda/taskito/dashboard/auth/AuthStoreTest.java b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/auth/AuthStoreTest.java index da52ee6f..66225799 100644 --- a/sdks/java/src/test/java/org/byteveda/taskito/dashboard/auth/AuthStoreTest.java +++ b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/auth/AuthStoreTest.java @@ -83,22 +83,21 @@ void deletingUserClearsSessions() { @Test void oauthBootstrapRolePrecedence() { // Unverified / missing email never becomes admin. - assertEquals("viewer", AuthStore.oauthBootstrapRole("a@x.com", false, List.of(), true)); - assertEquals("viewer", AuthStore.oauthBootstrapRole(null, true, List.of(), true)); - // Admin-email list wins (case-insensitive) and disables first-user-wins. - assertEquals("admin", AuthStore.oauthBootstrapRole("A@X.com", true, List.of("a@x.com"), false)); - assertEquals("viewer", AuthStore.oauthBootstrapRole("b@x.com", true, List.of("a@x.com"), true)); - // No admin list: first verified user wins. - assertEquals("admin", AuthStore.oauthBootstrapRole("c@x.com", true, List.of(), true)); - assertEquals("viewer", AuthStore.oauthBootstrapRole("c@x.com", true, List.of(), false)); + assertEquals("viewer", AuthStore.oauthBootstrapRole("a@x.com", false, List.of())); + assertEquals("viewer", AuthStore.oauthBootstrapRole(null, true, List.of())); + // Admin-email list is the only path to admin (case-insensitive). + assertEquals("admin", AuthStore.oauthBootstrapRole("A@X.com", true, List.of("a@x.com"))); + assertEquals("viewer", AuthStore.oauthBootstrapRole("b@x.com", true, List.of("a@x.com"))); + // No admin list: everyone — including the first user — is a viewer. + assertEquals("viewer", AuthStore.oauthBootstrapRole("c@x.com", true, List.of())); } @Test void getOrCreateOauthUserProvisionsThenRefreshes() { AuthStore store = store(); - User created = store.getOrCreateOauthUser("google", "123", "a@x.com", "Ann", true, List.of()); + User created = store.getOrCreateOauthUser("google", "123", "a@x.com", "Ann", true, List.of("a@x.com")); assertEquals("google:123", created.username()); - assertEquals("admin", created.role()); // first user + assertEquals("admin", created.role()); // allowlisted assertTrue(created.isOauth()); User refreshed = store.getOrCreateOauthUser("google", "123", "new@x.com", "Ann N", true, List.of("z@x.com")); diff --git a/sdks/java/src/test/java/org/byteveda/taskito/dashboard/oauth/OAuthFlowTest.java b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/oauth/OAuthFlowTest.java index 71eb417e..3ac6c828 100644 --- a/sdks/java/src/test/java/org/byteveda/taskito/dashboard/oauth/OAuthFlowTest.java +++ b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/oauth/OAuthFlowTest.java @@ -53,12 +53,12 @@ void startRejectsUnknownProvider() { } @Test - void handleCallbackLandsSessionAndMapsFirstUserToAdmin() { + void handleCallbackLandsSessionAsViewerWithoutAllowlist() { flow.start("fake", "/next"); String state = fake.lastState.state(); OAuthFlow.CallbackResult result = flow.handleCallback("fake", "code", state, null); assertEquals("fake:subject-1", result.session().username()); - assertEquals("admin", result.session().role()); // first verified user + assertEquals("viewer", result.session().role()); // admin comes only from the allowlist assertEquals("/next", result.nextUrl()); assertTrue(authStore.getUser("fake:subject-1").isPresent()); } From 3a27d9a60b504bb3463e44ecfb231f66d91d5071 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:33:37 +0530 Subject: [PATCH 07/12] fix(java): harden dashboard probe and token auth --- .../taskito/dashboard/DashboardServer.java | 76 ++++++++++++++++--- .../taskito/dashboard/auth/TokenAuth.java | 11 ++- .../taskito/dashboard/DashboardAuthTest.java | 58 +++++++++++++- .../taskito/dashboard/DashboardTest.java | 15 +++- 4 files changed, 140 insertions(+), 20 deletions(-) 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 aa5dfb1e..a9b7d4f0 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 @@ -223,7 +223,7 @@ private void dispatch(HttpExchange exchange) throws IOException { serveMetrics(exchange); } else if (path.startsWith("/api/")) { handleApi(exchange, path); - } else { + } else if (!tokenBootstrapRedirect(exchange, path)) { serveStatic(exchange, path); } } catch (DashboardError e) { @@ -282,15 +282,11 @@ private void handleOpenMode(HttpExchange exchange, String path, String method, M private void handleTokenMode(HttpExchange exchange, String path, String method, Map query) throws IOException { - String queryToken = query.get("token"); - if (queryToken != null && tokenAuth.matches(queryToken)) { - exchange.getResponseHeaders().add("Set-Cookie", TokenAuth.openCookie(queryToken, secureCookies)); - } if (path.equals("/api/auth/status")) { Http.respondJson(exchange, 200, TokenAuth.openStatus()); return; } - if (!tokenAuth.matches(tokenAuth.presented(exchange, query))) { + if (!tokenAuth.matches(tokenAuth.presented(exchange))) { Http.respondError(exchange, 401, "unauthorized"); return; } @@ -426,7 +422,7 @@ private Object logout(Req req) { // ---- probes ------------------------------------------------------------ private void serveReadiness(HttpExchange exchange) throws IOException { - if (!metricsTokenOk(exchange)) { + if (!probeAuthorized(exchange)) { Http.respondError(exchange, 401, "unauthorized"); return; } @@ -434,7 +430,7 @@ private void serveReadiness(HttpExchange exchange) throws IOException { } private void serveMetrics(HttpExchange exchange) throws IOException { - if (!metricsTokenOk(exchange)) { + if (!probeAuthorized(exchange)) { Http.respondError(exchange, 401, "unauthorized"); return; } @@ -446,11 +442,31 @@ private void serveMetrics(HttpExchange exchange) throws IOException { } } - /** Open when no metrics token is configured; otherwise require a bearer match. */ - private boolean metricsTokenOk(HttpExchange exchange) { - if (metricsToken == null || metricsToken.isEmpty()) { + /** + * Gate for {@code /readiness} and {@code /metrics}. Accepted credentials: the + * optional {@code TASKITO_DASHBOARD_METRICS_TOKEN} bearer (scraper-friendly), + * the legacy shared token in token mode, or a valid session in session mode. + * Open mode with no metrics token stays public (probe-friendly default). + */ + private boolean probeAuthorized(HttpExchange exchange) { + boolean metricsTokenSet = metricsToken != null && !metricsToken.isEmpty(); + if (metricsTokenSet) { + if (metricsBearerMatches(exchange)) { + return true; + } + } else if (tokenAuth == null && !authEnabled) { return true; } + if (tokenAuth != null) { + return tokenAuth.matches(tokenAuth.presented(exchange)); + } + if (authEnabled) { + return RequestContext.build(exchange, authStore).authenticated(); + } + return false; + } + + private boolean metricsBearerMatches(HttpExchange exchange) { String authorization = exchange.getRequestHeaders().getFirst("Authorization"); if (authorization == null || !authorization.startsWith("Bearer ")) { return false; @@ -460,6 +476,44 @@ private boolean metricsTokenOk(HttpExchange exchange) { metricsToken.getBytes(StandardCharsets.UTF_8), presented.getBytes(StandardCharsets.UTF_8)); } + /** + * Token mode: a valid {@code ?token=} on a page load (never an API call) + * bootstraps the httpOnly cookie, then redirects to strip the token from the + * URL so it can't leak via history or the Referer header. + */ + private boolean tokenBootstrapRedirect(HttpExchange exchange, String path) throws IOException { + if (tokenAuth == null || !"GET".equals(exchange.getRequestMethod())) { + return false; + } + String queryToken = Http.query(exchange).get("token"); + if (queryToken == null || !tokenAuth.matches(queryToken)) { + return false; + } + exchange.getResponseHeaders().add("Set-Cookie", TokenAuth.openCookie(queryToken, secureCookies)); + exchange.getResponseHeaders().set("Location", path + queryWithoutToken(exchange)); + exchange.sendResponseHeaders(302, -1); + return true; + } + + /** The raw query minus the {@code token} parameter, with a leading '?' when non-empty. */ + private static String queryWithoutToken(HttpExchange exchange) { + String raw = exchange.getRequestURI().getRawQuery(); + if (raw == null || raw.isEmpty()) { + return ""; + } + StringBuilder kept = new StringBuilder(); + for (String pair : raw.split("&")) { + if (pair.equals("token") || pair.startsWith("token=")) { + continue; + } + if (kept.length() > 0) { + kept.append('&'); + } + kept.append(pair); + } + return kept.length() == 0 ? "" : "?" + kept; + } + // ---- static + helpers -------------------------------------------------- private void serveStatic(HttpExchange exchange, String path) throws IOException { 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 419e2333..49c049ab 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 @@ -12,7 +12,10 @@ * back-compat with {@code --token}; the session flow is the default. * *

The token is accepted, in order, from {@code Authorization: Bearer}, - * {@code X-Taskito-Token}, {@code ?token=}, or the {@code taskito_token} cookie. + * {@code X-Taskito-Token}, or the {@code taskito_token} cookie. A {@code ?token=} + * query param is deliberately NOT accepted here — query strings leak into access + * logs, browser history, and the Referer header; it is only honoured once on a + * page load to bootstrap the cookie. */ public final class TokenAuth { private static final long OPEN_COOKIE_MAX_AGE = 24 * 60 * 60; @@ -23,7 +26,7 @@ public TokenAuth(String token) { this.token = token; } - public String presented(HttpExchange exchange, Map query) { + public String presented(HttpExchange exchange) { String authorization = exchange.getRequestHeaders().getFirst("Authorization"); if (authorization != null && authorization.startsWith("Bearer ")) { return authorization.substring("Bearer ".length()).trim(); @@ -32,10 +35,6 @@ public String presented(HttpExchange exchange, Map query) { if (header != null && !header.isEmpty()) { return header; } - String queryToken = query.get("token"); - if (queryToken != null && !queryToken.isEmpty()) { - return queryToken; - } return Cookies.get(exchange, Cookies.LEGACY_TOKEN); } 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 a2959296..413e335f 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 @@ -180,10 +180,66 @@ void legacyTokenModeStillWorks(@TempDir Path dir) throws Exception { int port = server.port(); assertTrue(raw(port, "GET", "/api/auth/status", null).body().contains("\"setup_required\":false")); assertEquals(401, raw(port, "GET", "/api/stats", null).statusCode()); - assertEquals(200, raw(port, "GET", "/api/stats?token=sekret", null).statusCode()); + assertEquals(200, rawWithToken(port, "/api/stats", "sekret").statusCode()); } } + @Test + void queryTokenOnlyBootstrapsPageLoads(@TempDir Path dir) throws Exception { + try (Taskito queue = open(dir); + DashboardServer server = DashboardServer.start(queue, 0, "sekret", null)) { + int port = server.port(); + // A query token no longer authenticates API calls (it leaks into logs). + assertEquals(401, raw(port, "GET", "/api/stats?token=sekret", null).statusCode()); + + // On a page load it sets the cookie and redirects with the token stripped. + HttpResponse boot = raw(port, "GET", "/?token=sekret&tab=jobs", null); + assertEquals(302, boot.statusCode()); + assertEquals("/?tab=jobs", boot.headers().firstValue("Location").orElse("")); + assertTrue(boot.headers().allValues("set-cookie").stream() + .anyMatch(c -> c.startsWith("taskito_token="))); + + // A wrong query token neither bootstraps nor redirects. + HttpResponse miss = raw(port, "GET", "/?token=nope", null); + assertTrue(miss.statusCode() != 302); + assertTrue(miss.headers().allValues("set-cookie").isEmpty()); + } + } + + @Test + void probesRequireSessionWhenAuthEnabled(@TempDir Path dir) throws Exception { + try (Taskito queue = open(dir); + DashboardServer server = DashboardServer.start(queue, 0, true)) { + int port = server.port(); + assertEquals(200, raw(port, "GET", "/health", null).statusCode()); + assertEquals(401, raw(port, "GET", "/readiness", null).statusCode()); + assertEquals(401, raw(port, "GET", "/metrics", null).statusCode()); + + DashboardClient client = new DashboardClient(port).as(DashboardClient.seedAdmin(queue)); + assertEquals(200, client.get("/readiness").statusCode()); + assertEquals(200, client.get("/metrics").statusCode()); + } + } + + @Test + void probesRequireTokenInLegacyMode(@TempDir Path dir) throws Exception { + try (Taskito queue = open(dir); + DashboardServer server = DashboardServer.start(queue, 0, "sekret", null)) { + int port = server.port(); + assertEquals(200, raw(port, "GET", "/health", null).statusCode()); + assertEquals(401, raw(port, "GET", "/readiness", null).statusCode()); + assertEquals(200, rawWithToken(port, "/readiness", "sekret").statusCode()); + } + } + + private static HttpResponse rawWithToken(int port, String path, String token) throws Exception { + HttpRequest request = HttpRequest.newBuilder(URI.create("http://localhost:" + port + path)) + .header("X-Taskito-Token", token) + .GET() + .build(); + return HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + } + @Test void openModeIsTheDefault(@TempDir Path dir) throws Exception { try (Taskito queue = open(dir); diff --git a/sdks/java/src/test/java/org/byteveda/taskito/dashboard/DashboardTest.java b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/DashboardTest.java index cdd96282..69263003 100644 --- a/sdks/java/src/test/java/org/byteveda/taskito/dashboard/DashboardTest.java +++ b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/DashboardTest.java @@ -34,6 +34,16 @@ private static HttpResponse get(int port, String path) throws Exception HttpResponse.BodyHandlers.ofString()); } + private static HttpResponse getWithToken(int port, String path, String token) throws Exception { + return HttpClient.newHttpClient() + .send( + HttpRequest.newBuilder(URI.create("http://localhost:" + port + path)) + .header("X-Taskito-Token", token) + .GET() + .build(), + HttpResponse.BodyHandlers.ofString()); + } + @Test @Timeout(30) void servesSnakeCaseContract(@TempDir Path dir) throws Exception { @@ -92,7 +102,8 @@ void enforcesToken(@TempDir Path dir) throws Exception { try (DashboardServer server = DashboardServer.start(queue, 0, "sekret", null)) { int port = server.port(); assertEquals(401, get(port, "/api/stats").statusCode()); - assertEquals(200, get(port, "/api/stats?token=sekret").statusCode()); + assertEquals(401, get(port, "/api/stats?token=sekret").statusCode()); + assertEquals(200, getWithToken(port, "/api/stats", "sekret").statusCode()); } } } @@ -104,7 +115,7 @@ void convenienceMethodStartsServer(@TempDir Path dir) throws Exception { Taskito.builder().sqlite(dir.resolve("t.db").toString()).open(); DashboardServer server = queue.dashboard(0, "tok")) { assertTrue(server.port() > 0); - assertEquals(200, get(server.port(), "/api/stats?token=tok").statusCode()); + assertEquals(200, getWithToken(server.port(), "/api/stats", "tok").statusCode()); } } } From 068b4f060a0892bfc3f9b173909a6681584f717d Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:33:45 +0530 Subject: [PATCH 08/12] docs: update dashboard probe gating and oauth roles --- .../docs/java/guides/operations/dashboard.mdx | 32 +++++++++---------- .../docs/java/guides/operations/sso.mdx | 6 ++-- .../node/guides/operations/dashboard-api.mdx | 9 +++--- .../docs/node/guides/operations/dashboard.mdx | 11 ++++--- .../guides/dashboard/authentication.mdx | 9 +++--- .../docs/python/guides/dashboard/sso.mdx | 6 ++-- .../python/guides/operations/security.mdx | 7 ++-- 7 files changed, 43 insertions(+), 37 deletions(-) diff --git a/docs/content/docs/java/guides/operations/dashboard.mdx b/docs/content/docs/java/guides/operations/dashboard.mdx index 11f2eef5..b6231015 100644 --- a/docs/content/docs/java/guides/operations/dashboard.mdx +++ b/docs/content/docs/java/guides/operations/dashboard.mdx @@ -166,31 +166,31 @@ no sessions, no RBAC. Kept for back-compat with the pre-auth dashboard. DashboardServer.start(taskito, 8080, System.getenv("DASH_TOKEN")); ``` -Requests authenticate with `?token=`; opening `/?token=` once -sets an httpOnly `taskito_token` cookie so the SPA works for the rest of the -session. OAuth has no login UI in this mode, so it's disabled automatically — +API requests authenticate via `Authorization: Bearer `, an +`X-Taskito-Token` header, or the `taskito_token` cookie (compared in constant +time). Opening `/?token=` once sets the httpOnly cookie and redirects +with the token stripped from the URL, so the SPA works for the rest of the +session without the secret leaking into browser history, `Referer` headers, or +access logs — a `?token=` query is never accepted on `/api/*` calls. OAuth has +no login UI in this mode, so it's disabled automatically — `start(queue, port, token, ...)` never builds an OAuth flow when `token` is non-null. - - `?token=` puts the secret in the URL, where it can leak via browser history, - `Referer` headers, and proxy or access logs. Use it only over HTTPS, redact - query strings from logs, and rely on the cookie afterwards. - - ## Metrics and health probes -Three routes sit outside `/api/*` and outside the session/token auth gate: +Three routes sit outside `/api/*`: | Route | Access | What it does | |---|---|---| | `GET /health` | Always public | Liveness — always `{"status": "ok"}` | -| `GET /readiness` | Public unless `TASKITO_DASHBOARD_METRICS_TOKEN` is set | Storage/worker/resource readiness | -| `GET /metrics` | Public unless `TASKITO_DASHBOARD_METRICS_TOKEN` is set | Prometheus text exposition | - -Set `TASKITO_DASHBOARD_METRICS_TOKEN` to require an -`Authorization: Bearer ` header (checked in constant time) on -`/readiness` and `/metrics`. `/health` always stays open for liveness probes. +| `GET /readiness` | Gated when auth is on (see below) | Storage/worker/resource readiness | +| `GET /metrics` | Gated when auth is on (see below) | Prometheus text exposition | + +With session or token auth enabled, `/readiness` and `/metrics` require either +that mode's own credential (a valid session, or the shared token) or a +`TASKITO_DASHBOARD_METRICS_TOKEN` bearer header (checked in constant time) — +point scrapers at the bearer token. In open mode they stay public unless that +env token is set. `/health` always stays open for liveness probes. ## REST API diff --git a/docs/content/docs/java/guides/operations/sso.mdx b/docs/content/docs/java/guides/operations/sso.mdx index 2c922378..f642c267 100644 --- a/docs/content/docs/java/guides/operations/sso.mdx +++ b/docs/content/docs/java/guides/operations/sso.mdx @@ -172,9 +172,9 @@ role with this precedence: 1. **`TASKITO_DASHBOARD_OAUTH_ADMIN_EMAILS` match** — case-insensitive match against a verified email → `admin`. -2. **Empty user table** — if no users exist yet (password or OAuth), the - first OAuth user with a verified email becomes `admin`. -3. **Everyone else** → `viewer`. +2. **Everyone else** → `viewer`. There is no first-user fallback: admin + access comes only from the allowlist, the setup flow, or the env + bootstrap. ```bash export TASKITO_DASHBOARD_OAUTH_ADMIN_EMAILS=alice@your-company.com,bob@your-company.com diff --git a/docs/content/docs/node/guides/operations/dashboard-api.mdx b/docs/content/docs/node/guides/operations/dashboard-api.mdx index 1ca6d22e..14bd60c1 100644 --- a/docs/content/docs/node/guides/operations/dashboard-api.mdx +++ b/docs/content/docs/node/guides/operations/dashboard-api.mdx @@ -85,10 +85,11 @@ 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`. +`Authorization: Bearer `, an `X-Taskito-Token` header, or the +`taskito_token` cookie; otherwise it returns `401`. A `?token=` query is only +honoured once on a page load, where it sets the cookie and redirects with the +token stripped. 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 diff --git a/docs/content/docs/node/guides/operations/dashboard.mdx b/docs/content/docs/node/guides/operations/dashboard.mdx index fd5d375a..f78f36a0 100644 --- a/docs/content/docs/node/guides/operations/dashboard.mdx +++ b/docs/content/docs/node/guides/operations/dashboard.mdx @@ -58,10 +58,13 @@ overrides `authEnabled`. 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. You can also mount the dashboard inside an existing app behind +API requests authenticate via `Authorization: Bearer `, an +`X-Taskito-Token` header, or the `taskito_token` cookie — a `?token=` query is +never accepted on `/api/*` calls. Opening `/?token=` once sets the +httpOnly cookie and redirects with the token stripped from the URL, so the SPA +works for the rest of the session without the secret leaking into browser +history, `Referer` headers, or access logs. The token is compared in 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/python/guides/dashboard/authentication.mdx b/docs/content/docs/python/guides/dashboard/authentication.mdx index 6e28145e..d4394a41 100644 --- a/docs/content/docs/python/guides/dashboard/authentication.mdx +++ b/docs/content/docs/python/guides/dashboard/authentication.mdx @@ -148,10 +148,11 @@ All routes live under `/api/auth/`: | `POST` | `/api/auth/change-password` | Requires the current password | Every other route under `/api/` is auth-gated. Public exceptions: -`/health`, `/readiness`, `/metrics` (Prometheus), and the static SPA -assets. Set `TASKITO_DASHBOARD_METRICS_TOKEN` to additionally require an -`Authorization: Bearer ` header on `/metrics` and `/readiness` -(`/health` stays open for liveness probes). +`/health` (liveness — always open) and the static SPA assets. With auth +enabled, `/readiness` and `/metrics` (Prometheus) require either a valid +session or the `TASKITO_DASHBOARD_METRICS_TOKEN` bearer — point scrapers +at the token via `Authorization: Bearer `. Without auth they stay +public unless that token is set. ## Headless requests diff --git a/docs/content/docs/python/guides/dashboard/sso.mdx b/docs/content/docs/python/guides/dashboard/sso.mdx index 732f79bc..5cf227d3 100644 --- a/docs/content/docs/python/guides/dashboard/sso.mdx +++ b/docs/content/docs/python/guides/dashboard/sso.mdx @@ -171,9 +171,9 @@ role using this rule: 1. **`TASKITO_DASHBOARD_OAUTH_ADMIN_EMAILS` match** — case-insensitive match against a verified email → role `admin`. -2. **Empty user table fallback** — if no users (password or OAuth) exist - yet, the first OAuth user with a verified email becomes `admin`. -3. **Everyone else** → role `viewer`. +2. **Everyone else** → role `viewer`. There is no first-user fallback: + admin access comes only from the allowlist, the password setup flow, + or the env bootstrap. ```bash export TASKITO_DASHBOARD_OAUTH_ADMIN_EMAILS=alice@your-company.com,bob@your-company.com diff --git a/docs/content/docs/python/guides/operations/security.mdx b/docs/content/docs/python/guides/operations/security.mdx index e4927309..e6181b91 100644 --- a/docs/content/docs/python/guides/operations/security.mdx +++ b/docs/content/docs/python/guides/operations/security.mdx @@ -94,9 +94,10 @@ once at least one user exists; until then every API route returns `SameSite=Strict`, and `Secure`. Behind a TLS-terminating proxy that speaks plain HTTP to the backend you may need `serve_dashboard(secure_cookies=False)` or `taskito dashboard --insecure-cookies` — only on a trusted network. -- **Metrics.** `/metrics` and `/readiness` are public by default for probes. - Set `TASKITO_DASHBOARD_METRICS_TOKEN` to require an - `Authorization: Bearer ` header on them; `/health` stays open. +- **Metrics.** With auth enabled, `/metrics` and `/readiness` require a valid + session or the `TASKITO_DASHBOARD_METRICS_TOKEN` bearer (give scrapers the + token). Without auth they stay public unless that token is set; `/health` + is always open for liveness probes. - **Settings.** The settings API never exposes or accepts keys under the internal `auth:` namespace (password hashes, sessions, CSRF secret). From a81a5b1ab81ac0b46a58a06de419c043c0ed254f Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:57:39 +0530 Subject: [PATCH 09/12] style(java): apply spotless formatting --- .../org/byteveda/taskito/dashboard/auth/oauth/OAuthFlow.java | 4 ++-- .../org/byteveda/taskito/dashboard/DashboardAuthTest.java | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/OAuthFlow.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/OAuthFlow.java index 17d2faa9..de651ec9 100644 --- a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/OAuthFlow.java +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/OAuthFlow.java @@ -42,8 +42,8 @@ public OAuthFlow( if (!this.providers.isEmpty() && config.adminEmails().isEmpty()) { // OAuth users only ever get the viewer role without an allowlist, so an // OAuth-only deployment would silently have zero admins. - LOG.warn("OAuth is configured without admin emails: every OAuth login gets the viewer role." - + " Set " + OAuthConfig.ENV_ADMIN_EMAILS + " (or OAuthConfig.adminEmails) to grant admin access."); + LOG.warn("OAuth is configured without admin emails: every OAuth login gets the viewer role." + " Set " + + OAuthConfig.ENV_ADMIN_EMAILS + " (or OAuthConfig.adminEmails) to grant admin access."); } } 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 413e335f..87b248e0 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 @@ -196,8 +196,7 @@ void queryTokenOnlyBootstrapsPageLoads(@TempDir Path dir) throws Exception { HttpResponse boot = raw(port, "GET", "/?token=sekret&tab=jobs", null); assertEquals(302, boot.statusCode()); assertEquals("/?tab=jobs", boot.headers().firstValue("Location").orElse("")); - assertTrue(boot.headers().allValues("set-cookie").stream() - .anyMatch(c -> c.startsWith("taskito_token="))); + assertTrue(boot.headers().allValues("set-cookie").stream().anyMatch(c -> c.startsWith("taskito_token="))); // A wrong query token neither bootstraps nor redirects. HttpResponse miss = raw(port, "GET", "/?token=nope", null); From 870d8d158c53e92ad4b0140fbd403ab432b28870 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:15:08 +0530 Subject: [PATCH 10/12] fix(node): mark bootstrapped token cookie Secure --- sdks/node/src/dashboard/auth/tokenAuth.ts | 4 ++-- sdks/node/src/dashboard/server.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/sdks/node/src/dashboard/auth/tokenAuth.ts b/sdks/node/src/dashboard/auth/tokenAuth.ts index 2fc30735..a67b9c3c 100644 --- a/sdks/node/src/dashboard/auth/tokenAuth.ts +++ b/sdks/node/src/dashboard/auth/tokenAuth.ts @@ -47,10 +47,10 @@ export function tokenMatches(expected: string, presented: string | undefined): b } /** Persist a valid token as an httpOnly cookie so the SPA's later calls authenticate. */ -export function setTokenCookie(res: ServerResponse, token: string): void { +export function setTokenCookie(res: ServerResponse, token: string, secure: boolean): void { res.setHeader( "set-cookie", - `${TOKEN_COOKIE}=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400`, + `${TOKEN_COOKIE}=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400${secure ? "; Secure" : ""}`, ); } diff --git a/sdks/node/src/dashboard/server.ts b/sdks/node/src/dashboard/server.ts index 18103897..d56eb31a 100644 --- a/sdks/node/src/dashboard/server.ts +++ b/sdks/node/src/dashboard/server.ts @@ -116,7 +116,7 @@ async function dispatch( // the URL so it can't leak via history or the Referer header. const queryToken = url.searchParams.get("token"); if (auth && req.method === "GET" && queryToken && tokenMatches(auth.token, queryToken)) { - setTokenCookie(res, auth.token); + setTokenCookie(res, auth.token, options.secureCookies !== false); url.searchParams.delete("token"); redirect(res, `${url.pathname}${url.search}`); return; From 299543c581a1a1733311a31944c186b983464bc2 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:15:13 +0530 Subject: [PATCH 11/12] test(node): restore metrics token env after probe test --- sdks/node/test/dashboard/sessionAuth.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/sdks/node/test/dashboard/sessionAuth.test.ts b/sdks/node/test/dashboard/sessionAuth.test.ts index 41322786..f0615f14 100644 --- a/sdks/node/test/dashboard/sessionAuth.test.ts +++ b/sdks/node/test/dashboard/sessionAuth.test.ts @@ -238,6 +238,7 @@ describe("probes", () => { }); it("accepts the metrics bearer token alongside sessions", async () => { + const previous = process.env.TASKITO_DASHBOARD_METRICS_TOKEN; process.env.TASKITO_DASHBOARD_METRICS_TOKEN = "scrape-secret"; try { const ok = await fetch(`${base}/readiness`, { @@ -248,7 +249,11 @@ describe("probes", () => { (await fetch(`${base}/readiness`, { headers: { authorization: "Bearer wrong" } })).status, ).toBe(401); } finally { - delete process.env.TASKITO_DASHBOARD_METRICS_TOKEN; + if (previous === undefined) { + delete process.env.TASKITO_DASHBOARD_METRICS_TOKEN; + } else { + process.env.TASKITO_DASHBOARD_METRICS_TOKEN = previous; + } } }); }); From f8ca6700e218160f6b7f3e204299013c6addae21 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:15:17 +0530 Subject: [PATCH 12/12] docs: correct token bootstrap and setup route claims --- docs/content/docs/java/guides/operations/dashboard.mdx | 9 +++++---- docs/content/docs/node/guides/operations/dashboard.mdx | 8 ++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/content/docs/java/guides/operations/dashboard.mdx b/docs/content/docs/java/guides/operations/dashboard.mdx index b6231015..aa85cc35 100644 --- a/docs/content/docs/java/guides/operations/dashboard.mdx +++ b/docs/content/docs/java/guides/operations/dashboard.mdx @@ -100,7 +100,7 @@ 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 + `/api/auth/providers`, `/health`) 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 @@ -169,9 +169,10 @@ DashboardServer.start(taskito, 8080, System.getenv("DASH_TOKEN")); API requests authenticate via `Authorization: Bearer `, an `X-Taskito-Token` header, or the `taskito_token` cookie (compared in constant time). Opening `/?token=` once sets the httpOnly cookie and redirects -with the token stripped from the URL, so the SPA works for the rest of the -session without the secret leaking into browser history, `Referer` headers, or -access logs — a `?token=` query is never accepted on `/api/*` calls. OAuth has +with the token stripped from the URL, keeping the secret out of subsequent +browser history and `Referer` propagation — a `?token=` query is never +accepted on `/api/*` calls. The bootstrap request itself still reaches server +and proxy access logs, so redact query strings there. OAuth has no login UI in this mode, so it's disabled automatically — `start(queue, port, token, ...)` never builds an OAuth flow when `token` is non-null. diff --git a/docs/content/docs/node/guides/operations/dashboard.mdx b/docs/content/docs/node/guides/operations/dashboard.mdx index f78f36a0..fcae1bfe 100644 --- a/docs/content/docs/node/guides/operations/dashboard.mdx +++ b/docs/content/docs/node/guides/operations/dashboard.mdx @@ -61,10 +61,10 @@ serveDashboard(queue, { port: 8787, auth: { token: process.env.DASH_TOKEN! } }); API requests authenticate via `Authorization: Bearer `, an `X-Taskito-Token` header, or the `taskito_token` cookie — a `?token=` query is never accepted on `/api/*` calls. Opening `/?token=` once sets the -httpOnly cookie and redirects with the token stripped from the URL, so the SPA -works for the rest of the session without the secret leaking into browser -history, `Referer` headers, or access logs. The token is compared in constant -time. You can also mount the dashboard inside an existing app behind +httpOnly cookie and redirects with the token stripped from the URL, keeping +the secret out of subsequent browser history and `Referer` propagation; the +bootstrap request itself still reaches server and proxy access logs, so redact +query strings there. The token is compared in 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.