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 ; } 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..8238bdf4 100644 --- a/docs/content/docs/java/guides/operations/cli.mdx +++ b/docs/content/docs/java/guides/operations/cli.mdx @@ -29,12 +29,14 @@ 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. 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 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..a21ba719 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 -`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, +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 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. 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. ![First-run setup form for the initial admin](/screenshots/dashboard/auth-setup.png) 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 + () 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..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 @@ -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) { @@ -57,6 +57,9 @@ public Map logout(RequestContext ctx) { public Map whoami(RequestContext ctx) { Session session = ctx.session(); + if (session == null) { + throw DashboardError.unauthorized("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.unauthorized("not_authenticated"); + } String oldPassword = requireField(body, "old_password"); String newPassword = requireField(body, "new_password"); User user = store.getUser(session.username()).orElse(null); 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); + } + } + } } 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 }); }); 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]