Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 18 additions & 17 deletions docs/content/docs/java/guides/operations/dashboard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -166,31 +166,32 @@ 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=<token>`; opening `/?token=<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 <token>`, an
`X-Taskito-Token` header, or the `taskito_token` cookie (compared in constant
time). Opening `/?token=<token>` once sets the httpOnly cookie and redirects
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.

<Callout type="warning">
`?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.
</Callout>

## 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 <token>` 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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## REST API

Expand Down
6 changes: 3 additions & 3 deletions docs/content/docs/java/guides/operations/sso.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions docs/content/docs/node/guides/operations/dashboard-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>`, 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 <token>`, 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`.

<Callout type="warning">
Open mode means anyone who can reach the port has full control. Don't expose it
Expand Down
11 changes: 7 additions & 4 deletions docs/content/docs/node/guides/operations/dashboard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,13 @@ overrides `authEnabled`.
serveDashboard(queue, { port: 8787, auth: { token: process.env.DASH_TOKEN! } });
```

Requests authenticate via `Authorization: Bearer <token>`, an `X-Taskito-Token`
header, or `?token=<token>`. Opening `/?token=<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 <token>`, an
`X-Taskito-Token` header, or the `taskito_token` cookie — a `?token=` query is
never accepted on `/api/*` calls. Opening `/?token=<token>` once sets the
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.
Expand Down
9 changes: 5 additions & 4 deletions docs/content/docs/python/guides/dashboard/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>` 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 <token>`. Without auth they stay
public unless that token is set.

## Headless requests

Expand Down
6 changes: 3 additions & 3 deletions docs/content/docs/python/guides/dashboard/sso.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions docs/content/docs/python/guides/operations/security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>` 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).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -282,15 +282,11 @@ private void handleOpenMode(HttpExchange exchange, String path, String method, M

private void handleTokenMode(HttpExchange exchange, String path, String method, Map<String, String> 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;
}
Expand Down Expand Up @@ -426,15 +422,15 @@ 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;
}
Http.respondJson(exchange, 200, ops.readiness());
}

private void serveMetrics(HttpExchange exchange) throws IOException {
if (!metricsTokenOk(exchange)) {
if (!probeAuthorized(exchange)) {
Http.respondError(exchange, 401, "unauthorized");
return;
}
Expand All @@ -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;
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object> row = new LinkedHashMap<>();
row.put("password_hash", PasswordHasher.oauthSentinel(slot));
Expand All @@ -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<String> 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<String> 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 ----------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@
* back-compat with {@code --token}; the session flow is the default.
*
* <p>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;
Expand All @@ -23,7 +26,7 @@ public TokenAuth(String token) {
this.token = token;
}

public String presented(HttpExchange exchange, Map<String, String> query) {
public String presented(HttpExchange exchange) {
String authorization = exchange.getRequestHeaders().getFirst("Authorization");
if (authorization != null && authorization.startsWith("Bearer ")) {
return authorization.substring("Bearer ".length()).trim();
Expand All @@ -32,10 +35,6 @@ public String presented(HttpExchange exchange, Map<String, String> 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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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. */
Expand Down
Loading