Watchtower-Course-Project

Authentication Workflow (Clerk) — Prototype 3

This document describes how authentication works for the WatchTower dashboard in the Prototype 3 flow. We use Clerk for all real authentication. WatchTower keeps its own UI shell (landing, login, signup) and delegates identity, sessions, sign-in, sign-up, password reset, and sign-out to Clerk.

Decision record: see docs/adr/ADR-0006.md.


End-to-end flow

Landing Page (public)
   src/frontend/landing/index.html
        │  "Get Started" / Login
        ▼
Login / Signup (our shell + Clerk component)
   src/frontend/auth/login.html   → #clerk-sign-in
   src/frontend/auth/signup.html  → #clerk-sign-up
        │  Clerk authenticates and starts a session
        ▼
Protected WatchTower UI
   src/frontend/dashboard/index.html   (guarded by auth-guard.js)
        │  Logout
        ▼
Back to Landing Page (public)

Page-by-page responsibilities

1. Public landing page — src/frontend/landing/index.html

2. Login / signup pages — src/frontend/auth/

3. Protected dashboard — src/frontend/dashboard/index.html


Per-user data scoping

WatchTower keeps Clerk as the only authentication provider and uses Supabase purely as the application database — there is no Supabase Auth and no password is ever stored.

Concern Where it lives
Identity / sessions / sign-in Clerk
Application users Supabase public.app_users (keyed by clerk_user_id)
Telemetry events Supabase public.prototype3_events, scoped by user_id

How a user is recognized

  1. After Clerk confirms a session, auth-guard.js calls POST /api/users/sync with { clerkUserId, email, displayName } and an X-Clerk-User-Id header.
  2. The server calls eventStore.syncUser(...), which upserts a row into app_users (clerk_user_id, email, display_name, last_seen_at). No password or credential is stored — Clerk owns those.

How data is scoped

Monitored ShopDemo bridge (/demo/)

External GitHub Pages test app ownership (temporary)

Resulting behavior

Trust model & token verification

The backend resolves the current user id with this preference order (resolveCurrentUserId in server.js):

  1. Verified Clerk session JWT — the browser sends Authorization: Bearer <Clerk.session.getToken()>. The server verifies the signature against Clerk’s public JWKS (<issuer>/.well-known/jwks.json) and the iss claim, then takes the user id from the signed sub claim. This is cryptographically authoritative and cannot be spoofed.
  2. X-Clerk-User-Id header fallback — used only when token verification is not configured (no real Clerk key, e.g. CI / local memory-store runs) or when WATCHTOWER_TRUST_USER_HEADER=true is explicitly set.

The Clerk issuer (Frontend API origin, e.g. https://your-app.clerk.accounts.dev) is derived from CLERK_PUBLISHABLE_KEY (base64-encoded inside the key) or set explicitly via CLERK_JWT_ISSUER. No Clerk secret key is needed — JWKS verification uses only public keys.

When a real Clerk instance is configured, the dashboard routes require a valid token: a request with only a (forged) X-Clerk-User-Id header and no valid token is rejected with 401.


Logout behavior


Clerk vs WatchTower responsibilities

Concern Owner
Sign-in / sign-up UI components Clerk (mounted into our shell)
Password handling & storage Clerk only — WatchTower stores nothing
Sessions & tokens Clerk
Password reset / email verification Clerk
Page branding & layout shell WatchTower
Routing between landing/login/dashboard WatchTower
Event ingestion + storage WatchTower (unrelated to user auth)

Why WatchTower does not store passwords

Storing passwords means owning hashing, salting, breach response, reset flows, and compliance. Clerk is purpose-built for this. By delegating, WatchTower never receives a password, so there is nothing sensitive to leak from our database or backend. The dashboard stores telemetry events only — no credentials.


Protection model

Remaining hardening (optional, defense-in-depth)


Dashboard user auth vs SDK event ingestion auth (important distinction)

These are two different authentication problems and must not be conflated:

  Dashboard user auth SDK event ingestion auth
Who authenticates A human operator viewing dashboards A monitored application sending events
Mechanism Clerk login/signup/session A future per-app/project key or token
Requires a user login? Yes No — must work headless/server-side
Implemented today Clerk (this work) Not yet — ingestion is currently open

The browser SDK (src/sdk/watchtower.js) and the ingestion endpoint should never require a normal Clerk user login. A separate app/project key or signed token is the correct future mechanism so that monitored apps can send telemetry without a human signing in.


Configuration & secrets

Running via the WatchTower Node server

npm start   # runs config:clerk, then boots src/backend/server.js

The backend serves the dashboard at /dashboard, the login shell at /login/, and the landing page at /landing/. The landing, auth, and dashboard pages live under src/frontend/ and the server resolves all three from there, so the full Clerk flow works end to end through the Node server (not just the static file flow).


Manual verification steps

  1. Set CLERK_PUBLISHABLE_KEY in .env, then run npm start.
  2. Open http://localhost:3000/landing/.
  3. Click Get Started / Login.
  4. Confirm the login page loads at /login.
  5. Confirm the Clerk sign-in UI appears inside the card.
  6. Sign in through Clerk.
  7. Confirm redirect to the dashboard (/dashboard).
  8. Refresh the dashboard while signed in.
  9. Confirm you stay on the WatchTower UI (no bounce to login) and the user label shows your email/username.
  10. Click Logout.
  11. Confirm redirect to the landing (or login) page.
  12. Open the dashboard directly while signed out.
  13. Confirm you are redirected to the login page.
  14. Open the signup page.
  15. Confirm the Clerk sign-up UI appears, and signing up redirects to the Prototype 3 dashboard.

Per-user scoping verification (requires Supabase configured)

  1. Sign in as User A.
  2. In Supabase, confirm a row for User A appears in app_users (clerk_user_id = User A’s Clerk id, with last_seen_at set).
  3. Confirm User A’s dashboard shows their own stats/events (a brand-new user starts at 0).
  4. Generate events as User A (the dashboard sends X-Clerk-User-Id, so new prototype3_events rows get user_id = User A’s Clerk id).
  5. Log out, then log back in as User A.
  6. Confirm User A still sees the same saved events/stats.
  7. Sign in as a different User B.
  8. Confirm User B starts at 0 and does not see any of User A’s data.

External test app ownership verification (DEFAULT_INGEST_OWNER_USER_ID)

  1. Set DEFAULT_INGEST_OWNER_USER_ID to the demo owner’s Clerk user id in the local .env (or in Render → Environment for the hosted backend).
  2. Restart the local server, or redeploy the Render backend, so the new env var is loaded.
  3. Open the GitHub Pages test app (https://cse110-sp26-group09.github.io/Watchtower-test-app/).
  4. Trigger page view / click / error / custom events from the test app.
  5. In the browser Network tab, confirm POST /api/events → 200 OK.
  6. In Supabase, run the verification query below and confirm the newest rows now have user_id populated with DEFAULT_INGEST_OWNER_USER_ID instead of NULL.
  7. Log into the WatchTower dashboard as that Clerk user and confirm the test app events appear.
  8. Log into the dashboard as a different Clerk user and confirm those events do not appear for them (per-user scoping is preserved).
select
  id,
  user_id,
  type,
  event_name,
  route,
  app_name,
  received_at
from public.prototype3_events
order by received_at desc
limit 20;

Automated checks

npm run test:unit   # event-store + shared utils
npm run test:e2e    # Playwright
npm run docs:js     # JSDoc generation

The Playwright API specs send an X-Clerk-User-Id header so they post and read back events as a single synthetic user, validating the per-user scoping without weakening external SDK ingestion (which stays open).