feat(auth): multi-organization SSO with per-org site scoping - #21
Open
lamminpaa wants to merge 3 commits into
Open
feat(auth): multi-organization SSO with per-org site scoping#21lamminpaa wants to merge 3 commits into
lamminpaa wants to merge 3 commits into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
packages/worker/src/auth/modules (TS ports of helpparibotti'sworker/lib/*, minus the subscription/billing check):crypto,oidc,session,session-refresh,middleware,sites-store,routes./api/auth/{login,oidc/callback,me,switch-org,logout}.__Secure-fl_sessioncookie (sub,orgs[],active_org). WithCOOKIE_DOMAINit is first-party same-site acrossapp./api.subdomains (SameSite=Lax) — survives Safari/Chrome third-party-cookie blocking./query: session path is org-scoped by site (ADMIN_EMAILSbypass);X-API-Keykeeps full programmatic access (back-compat)./admin/sites: org-scoped CRUD via KVorg:<id>:sitesfor owner/admin with a same-origin CSRF guard; legacy globalallowed_originsviaX-API-Key.blob10andorg_idsemantics in AE are unchanged.Dashboard
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-safereturn_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)
flarelyticsOIDC client atid.palvelureppu.fi(redirecthttps://api.flarelytics.dev/api/auth/oidc/callback, scopesopenid email profile orgs roles offline_access).wrangler secret put SESSION_SECRETandOIDC_CLIENT_SECRET.api.flarelytics.dev(worker) +app.flarelytics.dev(Pages);COOKIE_DOMAIN=flarelytics.dev.ADMIN_EMAILS).