Skip to content

feat(auth): multi-organization SSO with per-org site scoping - #21

Open
lamminpaa wants to merge 3 commits into
mainfrom
feat/multi-org
Open

feat(auth): multi-organization SSO with per-org site scoping#21
lamminpaa wants to merge 3 commits into
mainfrom
feat/multi-org

Conversation

@lamminpaa

Copy link
Copy Markdown
Contributor

What

Adds multi-organization support to Flarelytics, modeled on helpparibotti: dashboard users sign in via the palvelureppu OIDC provider and only see the sites their active organization owns.

Built per the user's directive: best for long term, secure by default.

Worker

  • New packages/worker/src/auth/ modules (TS ports of helpparibotti's worker/lib/*, minus the subscription/billing check): crypto, oidc, session, session-refresh, middleware, sites-store, routes.
  • Endpoints: /api/auth/{login,oidc/callback,me,switch-org,logout}.
  • Signed __Secure-fl_session cookie (sub, orgs[], active_org). With COOKIE_DOMAIN it is first-party same-site across app. / api. subdomains (SameSite=Lax) — survives Safari/Chrome third-party-cookie blocking.
  • /query: session path is org-scoped by site (ADMIN_EMAILS bypass); X-API-Key keeps full programmatic access (back-compat).
  • /admin/sites: org-scoped CRUD via KV org:<id>:sites for owner/admin with a same-origin CSRF guard; legacy global allowed_origins via X-API-Key.
  • Org→site ownership is an authz layer above Analytics Engine — blob10 and org_id semantics in AE are unchanged.

Dashboard

  • SSO login replaces the API-key paste flow; single worker via PUBLIC_API_BASE (or ?worker= override); credentialed fetches; org switcher; logout. No secret stored in the browser.

Security posture

PKCE + state + nonce + full ES256 ID-token verification; IDOR-checked /switch-org; open-redirect-safe return_to; same-origin CSRF guard on mutations; least-privilege roles (member = read-only).

Tests

+53 cases (252 total green): session integrity/tamper/expiry, role/IDOR/CSRF/site-scope gates, sites-store, OIDC route helpers. Worker typechecks + bundles clean; dashboard builds clean.

External prerequisites (not code)

  1. Register flarelytics OIDC client at id.palvelureppu.fi (redirect https://api.flarelytics.dev/api/auth/oidc/callback, scopes openid email profile orgs roles offline_access).
  2. wrangler secret put SESSION_SECRET and OIDC_CLIENT_SECRET.
  3. Custom domains api.flarelytics.dev (worker) + app.flarelytics.dev (Pages); COOKIE_DOMAIN=flarelytics.dev.
  4. Backfill existing sites into the owner org (admins see all via ADMIN_EMAILS).

Note: no Playwright/e2e harness exists in the repo; auth is covered by worker integration tests through the real router.

lamminpaa added 3 commits June 3, 2026 18:15
Authenticate dashboard users via the palvelureppu OIDC provider and scope
each user to the sites their active organization owns, mirroring the
helpparibotti multi-org model.

Worker:
- New auth modules (packages/worker/src/auth): crypto, oidc, session,
  session-refresh, middleware, sites-store, routes — TS ports of the
  helpparibotti libs, minus the subscription/billing check.
- Endpoints: /api/auth/{login,oidc/callback,me,switch-org,logout}.
- Signed __Secure-fl_session cookie carrying sub/orgs/active_org. With
  COOKIE_DOMAIN it is first-party same-site across app./api. subdomains
  (SameSite=Lax), surviving Safari/Chrome third-party-cookie blocking.
- /query: session path is org-scoped by site (ADMIN_EMAILS bypass);
  X-API-Key keeps full programmatic access (back-compat).
- /admin/sites: org-scoped CRUD via KV (org:<id>:sites) for owner/admin
  with a same-origin CSRF guard; legacy global allowed_origins via X-API-Key.
- Org->site ownership is an authz layer above Analytics Engine; blob10 and
  org_id semantics in AE are unchanged.

Dashboard:
- Replace the API-key paste login with SSO; single worker via PUBLIC_API_BASE
  (or ?worker= override); credentialed fetches; org switcher; logout. No
  secret stored in the browser.

Tests: +53 cases covering session integrity, role/IDOR/CSRF/site-scope gates,
sites-store, and the OIDC route helpers. Docs and wrangler.toml.example updated.
Code review found a cross-tenant data leak: any signed-in user is owner of
their personal org, so they could POST /admin/sites {hostname:"victim.com"},
land it in their own org's site list, and read another tenant's analytics via
/query?site=victim.com — site ownership was only checked within an org.

Close it with proof-of-control:
- Add a global ownership ledger (site_owner:<hostname>) and a DNS-TXT claim
  flow. POST /admin/sites returns a pending claim with a TXT record; the new
  POST /admin/sites/verify confirms it via DNS-over-HTTPS before granting
  exclusive ownership. Cross-tenant claims now 409; access requires verification.
- /query and assertSiteAccess unchanged — they read the org's verified list.

Additional review hardening:
- dashboardOrigins no longer trusts http://localhost:4321 in production
  (was granting credentialed CORS + passing the CSRF guard for localhost).
- handleLogout only deletes the stored refresh token on a same-origin request
  (blocks <img src=.../logout> denial-of-session CSRF).
- normalizeHostname rejects leading/trailing dots and bare labels.
- Dashboard: claim→verify UI in both add-site forms; removeSite surfaces
  failures instead of silently reloading; loop.astro ?site= precedence fix.

Tests: rewrite sites-store tests for the verification flow (DoH mocked), add
worker integration tests for pending claim / verify / 409 conflict / 422
failure, and add oidc.test.ts (verifyEs256 + alg-confusion/alg:none guards,
PKCE/state/nonce). 273 worker tests pass.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant