Skip to content

feat(dashboard): session-based auth + login UI#168

Merged
pratyush618 merged 10 commits into
masterfrom
feat/dashboard-auth
May 17, 2026
Merged

feat(dashboard): session-based auth + login UI#168
pratyush618 merged 10 commits into
masterfrom
feat/dashboard-auth

Conversation

@pratyush618
Copy link
Copy Markdown
Collaborator

Summary

Phase 1 of the dashboard customization plan: ship dashboard authentication so the higher-risk Phase 2+ surfaces (webhook URLs, runtime overrides, etc.) land behind a real auth gate.

Backend

  • py_src/taskito/dashboard/auth.pyAuthStore persists users + sessions via the existing dashboard_settings KV table (works on SQLite, Postgres, and Redis with no new tables, no new Storage trait methods).
  • Passwords hashed with stdlib hashlib.pbkdf2_hmac — SHA-256, 600,000 iterations, 16-byte random salt. OWASP 2023+ baseline, zero new dependencies.
  • py_src/taskito/dashboard/request_context.py — per-request session + CSRF state, plus a cookie parser.
  • py_src/taskito/dashboard/handlers/auth.py/api/auth/{status,setup,login,logout,whoami,change-password} handlers.
  • py_src/taskito/dashboard/server.py — full rewrite of the dispatcher: every API path is auth-gated except the public set (/api/auth/{status,login,setup}, /health, /readiness, /metrics, static SPA assets); state-changing methods require a double-submit CSRF token; Access-Control-Allow-Origin: * removed (incompatible with cookies).
  • Session cookies are HttpOnly + SameSite=Strict; CSRF cookie is readable by JS and echoed via X-CSRF-Token.
  • TASKITO_DASHBOARD_ADMIN_USER / TASKITO_DASHBOARD_ADMIN_PASSWORD env vars bootstrap the first admin idempotently on server start.
  • Until the first admin exists, every protected route returns 503 setup_required so the SPA can drive the one-time setup flow.

Frontend

  • dashboard/src/features/auth/ — typed API client, TanStack Query hooks (useLogin, useLogout, useSetup, useWhoami, useAuthStatus, useChangePassword), and components: LoginForm, SetupForm, UserMenu (header dropdown), AuthGate (wraps the AppShell).
  • dashboard/src/routes/login.tsx — public route that picks setup vs. sign-in based on /api/auth/status.
  • dashboard/src/lib/api-client.ts — auto-attaches X-CSRF-Token from the cookie on POST/PUT/DELETE; sends credentials: same-origin on every request so cookies travel.

Tests

  • tests/dashboard/test_auth.py — 34 tests covering hashing primitives, AuthStore user + session CRUD, env bootstrap, and every HTTP endpoint (setup gating, login success/failure, whoami, CSRF accept/reject, logout, change password).
  • Existing tests/dashboard/test_dashboard.py and tests/dashboard/test_dashboard_settings.py updated to use the new AuthedClient helper (transparent session + CSRF).
  • 5 new vitest cases for api-client's CSRF + credentials behaviour.
  • py_src/taskito/dashboard/_testing.py — internal helper module shipping AuthedClient + seed_admin_and_session for tests and downstream embedders.

Test plan

  • cargo check --workspace (no Rust changes; sanity only)
  • uv run python -m pytest tests/ — 725 passed, 9 skipped
  • uv run ruff check py_src/ tests/ clean
  • uv run mypy py_src/taskito/ tests/ --no-incremental clean
  • pnpm --dir dashboard typecheck clean
  • pnpm --dir dashboard lint (biome) clean
  • pnpm --dir dashboard test — 102 passed (97 → 102 with new CSRF cases)
  • pnpm --dir dashboard build succeeds

Users + sessions live in the existing dashboard_settings KV store, so
all three storage backends work without new tables. Passwords use stdlib
PBKDF2-HMAC-SHA256 (600k iters, OWASP 2023+ baseline). Session cookie is
HttpOnly + SameSite=Strict; state-changing routes require a double-submit
CSRF token. Until the first admin is created via /api/auth/setup or the
TASKITO_DASHBOARD_ADMIN_USER / TASKITO_DASHBOARD_ADMIN_PASSWORD env vars,
every protected route returns 503 setup_required.
Login page wraps setup + sign-in forms. AuthGate component bounces
unauthenticated visitors at the AppShell boundary; the /login route is
the only public client-side path. UserMenu in the header surfaces the
current user with a sign-out action. api-client now reads the
taskito_csrf cookie and forwards it as X-CSRF-Token on POST/PUT/DELETE
and uses credentials: same-origin so session cookies travel with every
request.
#169)

* feat(webhooks): persist subscriptions and add CRUD endpoints

Webhook subscriptions are stored as JSON in the dashboard_settings
table, so they survive restarts and propagate across every worker
pointed at the same backend. WebhookManager reloads its in-memory
snapshot on every CRUD write. Each subscription supports an optional
per-task filter alongside event-type filtering, configurable retry
policy, and HMAC signing with a rotatable secret. An SSRF guard rejects
loopback / RFC1918 / link-local destinations unless
TASKITO_WEBHOOKS_ALLOW_PRIVATE is set. The dashboard exposes list /
get / create / update / delete / rotate-secret / send-test endpoints
plus a public GET /api/event-types listing.

* feat(dashboard): add Webhooks page with full CRUD UI

New /webhooks route lists configured subscriptions and surfaces a
create dialog with event-type multi-select, optional per-task filter
input, and auto-generated signing secret. Each row gets a dropdown
menu for send-test, enable/disable toggle, secret rotation (with
confirmation gate), and delete. Newly minted secrets are shown once
through a reveal-and-copy card that hides the raw value on close.

* feat(webhooks): persist delivery log + replay UI (Phase 3) (#170)

* feat(webhooks): persist delivery log with replay support

Every webhook attempt now lands in a per-subscription JSON list under
``webhooks:deliveries:{sub_id}`` in the dashboard settings store, capped
at 200 entries per webhook with FIFO eviction. Records carry the final
HTTP status, response body (truncated to 2 KiB), latency, attempt count,
and any transport-level error so operators can debug failures without
leaving the dashboard.

The dispatcher gains GET /api/webhooks/{id}/deliveries (with
status/event/limit/offset filters), GET /api/webhooks/{id}/deliveries/
{delivery_id}, and POST /api/webhooks/{id}/deliveries/{delivery_id}/
replay — replay re-fires the original payload synchronously and records
the outcome as a fresh delivery.

* feat(dashboard): add deliveries route with filter + replay

New ``/webhooks/{id}/deliveries`` route surfaces the persisted delivery
log per webhook. Rows show timestamp, event, status badge, response
code, latency, and attempts; clicking a row opens a dialog with the
full payload and (truncated) response body. Each row carries a Replay
button — and so does the row-actions menu on the main webhooks list
("View deliveries"). Status filter lets operators narrow to delivered/
failed/dead quickly.

* feat(overrides): runtime task & queue overrides (Phase 4) (#171)

* feat(overrides): persist per-task and per-queue runtime overrides

Operators can now tune retry policy, concurrency, rate limit, timeout,
priority, and pause state per task — and rate limit, concurrency, pause
state per queue — without code changes. Overrides live as JSON entries
under ``overrides:task:*`` and ``overrides:queue:*`` keys in the
dashboard settings store, and merge into PyTaskConfig / queue_configs
at worker startup. Pause state additionally flips the existing
paused_queues mechanism so it takes effect on a running worker.

Queue gains ``set_task_override`` / ``clear_task_override`` /
``set_queue_override`` / ``clear_queue_override`` plus discovery APIs
``registered_tasks()`` / ``registered_queues()`` that surface
decorator defaults, overrides, and effective values for the dashboard.

* feat(dashboard): add Tasks page with override editor

New /tasks route lists every registered task with its decorator
defaults, any active runtime override, and the effective values.
Edit-button opens a side sheet with a form for rate_limit,
max_concurrent, max_retries, timeout, priority, and a paused toggle.
Empty inputs mean "inherit decorator default"; the clear-override
button removes the row entirely. Effective values that differ from
decorator defaults are highlighted in accent.

* feat(middleware): per-task middleware toggles (Phase 5) (#172)

* feat(middleware): toggle middleware per task from dashboard

TaskMiddleware now carries a stable ``name`` (defaulting to the fully-
qualified class path so it survives restarts; overridable on a subclass).
Per-task disable lists live under ``middleware:disabled:<task>`` in the
dashboard settings store, consulted by ``_get_middleware_chain`` at every
invocation — turning a middleware off for one task takes effect on the
next job, no worker restart needed.

Queue gains ``list_middleware()``, ``disable_middleware_for_task``,
``enable_middleware_for_task``, ``clear_middleware_disables``. New
endpoints: GET /api/middleware (discovery), GET/DELETE
/api/tasks/{task}/middleware, PUT /api/tasks/{task}/middleware/{mw_name}.

* feat(dashboard): add Middleware tab to task editor

The task override side-sheet now has two tabs: Overrides (existing form)
and Middleware. The Middleware tab lists every middleware that fires for
the selected task with an enabled/disabled toggle each. Toggling a
middleware off takes effect on the next job — no worker restart required.
WebhookManager.reload() unconditionally spawned a daemon thread on every
Queue construction, leaking a thread per test and exhausting the macOS
runner's thread limit (Resource temporarily unavailable / can't start
new thread) by the time the workflow suite ran.
* fix(dashboard): URL-decode regex captures and unnest deliveries route

The HTTP server captured ``[^/]+`` from request paths and used the
match verbatim as a settings key / task name. Browser clients run
``encodeURIComponent`` on names containing ``<``, ``>``, ``/`` etc., so
the per-task middleware disable list was being written under one key
(encoded) and looked up under another (decoded). The chain filter
then never matched and the toggle silently no-op'd.

Decode every captured group with ``urllib.parse.unquote`` before it
reaches a handler. Regression-tested in ``test_put_task_middleware_handles_url_encoded_name``.

Also rename ``webhooks.\$id.deliveries.tsx`` to
``webhooks_.\$id.deliveries.tsx`` so the route is a flat sibling of
``/webhooks`` instead of a nested child — without the underscore,
TanStack Router required an ``<Outlet />`` on the parent, which the
list page doesn't render, so the deliveries page never mounted.

* chore(docs): add reproducible screenshot capture pipeline

``scripts/capture_docs_screenshots.py`` seeds a fresh queue with
deterministic demo data (admin, sample tasks/queues, three webhook
subscriptions with mixed delivery outcomes, a task override, a
middleware disable), boots the dashboard on a random port, and walks
through every screen with headless Chrome via Playwright. Output lands
under ``docs/public/screenshots/dashboard/`` so the MDX side can
reference stable paths.

``scripts/optimize_screenshots.py`` follows up with Pillow-based
palette quantisation, shaving the bundle by ~71% (2.3 MB → 675 KB)
with no visible quality loss on the UI-dominated screenshots.

Playwright lives behind a new ``docs`` optional dependency
(``uv sync --extra docs``) so contributors who never touch docs don't
pay the install cost.

* docs(dashboard): customization guides + screenshots for phases 1-5

Two new pages and a rewrite of the existing webhooks guide cover every
piece of customisation that landed across PRs #168-#172:

- ``observability/dashboard-auth`` — first-run setup, env-var bootstrap,
  session cookies, CSRF model, headless usage, SSRF guard
- ``observability/task-overrides`` — per-task and per-queue runtime
  knobs, the "next restart vs. takes effect now" timing model, and the
  middleware on/off matrix
- ``extensibility/events-webhooks`` — rewritten to cover both the
  dashboard CRUD flow and the Python API, including delivery log,
  replay, secret rotation, and the SSRF guard

The existing ``observability/dashboard`` page is refreshed: the
outdated "no auth" callout is gone, the page table now lists Webhooks
and Tasks, and the walkthrough section is restructured into screen
groups so it's easy to skim.

The REST API reference (``observability/dashboard-api``) gains
sections for Auth, Webhooks, Webhook Deliveries, Tasks & Overrides,
Queue Overrides, and Middleware, plus a refreshed lead paragraph
(auth + CSRF apply to every route, no more ``Access-Control-Allow-Origin: *``).

12 screenshots under ``docs/public/screenshots/dashboard/`` illustrate
the auth flow, every customisation page, and the side-sheet editors —
all produced by the new reproducible capture pipeline.

* docs: update dashboard screenshots for task edit middleware and task list

* chore: remove unused Makefile for documentation generation
@github-actions github-actions Bot added the docs label May 17, 2026
@pratyush618 pratyush618 changed the title feat(dashboard): session-based auth + login UI (Phase 1) feat(dashboard): session-based auth + login UI May 17, 2026
…h.py) (#174)

* chore(deps): add authlib as optional 'oauth' extra

* feat(dashboard): add is_safe_redirect for same-origin OAuth next-URLs

* feat(dashboard): OAuth config, state store, and provider contract

Adds the framework-free foundation for OAuth/OIDC login:
- OAuthConfig with env-var parsing (Google, GitHub, multiple named OIDC slots)
- OAuthStateStore for short-lived state/nonce/PKCE rows (single-use, 5-min TTL)
- ProviderIdentity dataclass + OAuthProvider Protocol for upcoming providers

* feat(dashboard): map OAuth identities onto AuthStore users

User gains email and display_name fields. verify_password rejects any
hash prefixed 'oauth:' so OAuth-only users can never log in via password.
get_or_create_oauth_user applies the bootstrap rule: an explicit
TASKITO_DASHBOARD_OAUTH_ADMIN_EMAILS list takes precedence; otherwise
the first OAuth user on an empty table becomes admin. Verified email
is required for any admin path.

* feat(dashboard): oauth providers, flow, and HTTP endpoints (#175)

* feat(dashboard): oauth provider implementations, flow, and HTTP routes

Adds the complete OAuth login pipeline on top of the foundation from
PR #174:

- GoogleProvider (OIDC + email-domain allowlist)
- GitHubProvider (OAuth2 + verified primary email + org membership)
- GenericOIDCProvider (discovery-driven, multi-slot for Okta / Microsoft / Auth0)
- OAuthFlow orchestrator: state mint, code exchange, allowlist check,
  AuthStore user/session creation
- HTTP endpoints: GET /api/auth/providers, GET /api/auth/oauth/start/{slot},
  GET /api/auth/oauth/callback/{slot} — all public, single-use state,
  S256 PKCE, nonce-verified ID tokens, same cookie shape as password login

* test(dashboard): integration tests for OAuth HTTP endpoints

Spins up a real ThreadingHTTPServer with a stubbed OAuthFlow to drive
the full request -> 302 -> cookies path: provider listing, start
redirect, callback success, replayed-state rejection, allowlist denial,
and unsafe-next-URL scrubbing. 12 tests.

* feat(dashboard): oauth login UI and operator docs (#176)

* feat(dashboard): oauth provider buttons on the login screen

LoginForm fetches /api/auth/providers and renders one button per
enabled provider above the password fields. Each button is a plain
anchor to /api/auth/oauth/start/{slot} so the browser handles the 302
natively, no client-side OAuth state to manage. Password form hides
entirely when password_enabled is false (OAuth-only mode); next-URL
from the route's search params is forwarded so post-login lands on
the intended page.

* test(dashboard): cover oauthStartUrl encoding edge cases

* docs(dashboard): SSO operator setup guide

New page walks through registering OAuth clients with Google, GitHub,
and generic OIDC providers (Okta, Microsoft, Auth0, Keycloak), env-var
reference, allowlist semantics, role-bootstrap rule, OAuth-only mode,
and a troubleshooting cookbook. Wires into the observability nav after
dashboard-auth, which gains a short forward-reference.
Introduce an HttpClient Protocol so providers accept any
duck-typed session (requests.Session in prod, StubSession in tests)
without runtime cast(). This is the actual design fix — the test
suppressions were patching a missing abstraction.

For the joserfc KeySet.import_key_set call, mypy 2.x widened the stub
to accept dict-shaped JWKS while mypy 1.x still requires the explicit
KeySetSerialization TypedDict. Suppress both directions with the
documented arg-type,unused-ignore dual pattern.

Encode the test JWT helper return as an explicit str assignment so
the no-any-return check passes under both mypy versions.
uv sync --extra dev was leaving authlib/joserfc/requests uninstalled,
so the oauth test modules failed collection with ModuleNotFoundError.
The oauth deps belong in the oauth extra (taskito core users shouldn't
pull authlib + requests for free), so CI sync needs both extras.

Also pin requests explicitly in the oauth extra — authlib does not
declare it as a hard dep (only its requests_client integration uses
it), so leaving it implicit broke clean installs.
@github-actions github-actions Bot added the ci label May 17, 2026
The Dashboard pages outgrew Observability — five pages (overview,
auth, SSO, task overrides, REST API) versus three actual
observability topics. Move them into their own top-level Guides
section and drop the redundant 'Dashboard ' prefix from page titles
('Dashboard Authentication' -> 'Authentication', etc.).

Replace the broken ASCII sequence diagram on the SSO page with a
Mermaid sequenceDiagram — the ASCII version had misaligned vertical
lines once the cookie text on the last step pushed the row wider than
the others, and the auto-overflow scroll truncated arrows. The Mermaid
version renders correctly at every viewport.

All cross-section links updated; old /guides/observability/dashboard*
paths now redirect via internal references.
EncryptedSerializer.loads caught every Exception and rewrapped it as
ValueError, which:

1. Masked programmer errors (e.g. corrupted internal state, MemoryError)
   that should propagate untouched.
2. Made the two negative tests fail under any environment where
   cryptography is installed — they asserted InvalidTag, but the wrapper
   meant ValueError reached them. They were silently green only because
   the dev extra never installed cryptography. The oauth extra (joserfc)
   does, and CI surfaced the latent bug.

Narrow the catch to cryptography.exceptions.InvalidTag (the only
expected failure mode) and pre-cache it on the instance so loads avoids
a per-call import. Tests now assert the public contract — ValueError
with 'Decryption failed' — plus verify the original InvalidTag survives
in __cause__ for debugging. Adds a regression test for the short-input
path.
@pratyush618 pratyush618 merged commit 4f5d029 into master May 17, 2026
23 checks passed
@pratyush618 pratyush618 deleted the feat/dashboard-auth branch May 17, 2026 07:46
@pratyush618 pratyush618 mentioned this pull request May 17, 2026
7 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant