Auth provider abstraction: Supabase, Entra ID, and local HS256

Three commits convert Mike's hardwired Supabase auth check into a dispatching boundary. You set `AUTH_PROVIDER` to `supabase`, `entra`, or `local` and the middleware routes to the corresponding validator - no code change needed per deployment.

securityintegration

Maintained by Allen Morgan · verified on MikeWatch

The first commit (bfee639b) is the mechanical extraction. AuthPrincipal and AuthValidationResult types land in lib/auth/types.ts. The existing Supabase validation moves to lib/auth/providers/supabase.ts unchanged. The middleware dispatches on AUTH_PROVIDER env var, returning 500 for unknown values. Zero behavior change in supabase mode.

The Entra provider (f9eb061c) validates JWTs against the tenant JWKS with a 5-minute in-memory cache. It accepts both v1.0 (https://sts.windows.net/<tid>/) and v2.0 (https://login.microsoftonline.com/<tid>/v2.0) issuer shapes, and both audience shapes (<clientId> bare and api://<clientId>). The JWT verification uses Node's node:crypto - createVerify("RSA-SHA256") - with no third-party JWT library. roles.ts maps Entra group OIDs to app roles via ENTRA_ADMIN_GROUP_IDS and ENTRA_MEMBER_GROUP_IDS env vars (comma-separated lists). tenantAccess middleware checks tenant lifecycle status (active / pending / suspended) and can auto-onboard when TENANT_ONBOARDING_MODE=auto.

The local provider (ec310e59) is HS256 using node:crypto only - createHmac("sha256", secret) with timingSafeEqual for the comparison. Requires JWT_SECRET in env. routes/auth.ts adds /local-login, /providers, /logout, and the Entra OIDC code-flow endpoints (/select-provider, /login-provider/:id, /openid-callback/:id) with HMAC-signed state cookies. The router is created in this commit but not yet mounted - that wiring lands in the later server commit.

One design note on group-to-role mapping via env vars: it works for simple cases but doesn't support per-tenant configuration. If you ever need different groups to have different roles across tenants, you'd need a mapping store.

So what The Supabase-extraction refactor (`bfee639b`) is the most portable piece - small, mechanical, and gives you a clean slot to drop in a different validator without touching every route. The Entra and local providers only matter if you're targeting those deployment shapes. Worth a look if you want to add a second auth backend without forking the middleware.

View this fork on GitHub →

Spotted something wrong? Or know the PR text has fresher detail than the writeup above?

Commits in this thread

3 commits from Altien/mikeOssAzure, oldest first. Source extracted verbatim from the harvested git log.

SHA Subject Author Date
bfee639b refactor(auth): extract AuthProvider interface and Supabase provider Allen Morgan 2026-05-08 ↗ GitHub
commit body
Provider-neutral AuthPrincipal + AuthValidationResult types in
auth/types.ts. Existing inline Supabase validation extracted to
auth/providers/supabase.ts. Middleware dispatches on AUTH_PROVIDER env
var, defaulting to 'supabase'; unknown values return 500.

Zero behaviour change when AUTH_PROVIDER is unset or 'supabase'.
Downstream routes still receive res.locals.userId / userEmail / token;
res.locals.principal is added as the canonical surface.
f9eb061c feat(auth): Entra ID provider with JWKS + tenant lifecycle + roles Allen Morgan 2026-05-08 ↗ GitHub
commit body
Adds the Entra branch of the auth boundary established in the previous
refactor. Components:

  * lib/auth/providers/entra.ts  - JWT validation against the tenant's
    JWKS (5-minute cache), with both v1.0 and v2.0 issuer + audience
    shapes accepted, exp/nbf checks, and a fallback chain for the
    email and display-name claims that Entra optionally populates.

  * lib/auth/roles.ts            - Maps Entra group OIDs to app roles
    via ENTRA_ADMIN_GROUP_IDS / ENTRA_MEMBER_GROUP_IDS env vars.
    TenantAdmin includes Member by inheritance.

  * middleware/tenantAccess.ts   - Entra-only tenant lifecycle: looks
    up the principal's tenant in the tenants table, denies UNKNOWN /
    PENDING / SUSPENDED tenants, and enforces group-to-role mapping.
    Auto-onboards when TENANT_ONBOARDING_MODE=auto.  No-ops for any
    other auth provider so non-Entra deployments are unaffected.

  * middleware/requireRole.ts    - Tiny role guard that 403s when the
    principal lacks the requested role.

  * Auth middleware now dispatches "entra" alongside "supabase" and
    routes every authenticated request through tenantAccess (no-op for
    non-Entra) after upserting the user_profiles row from the IdP
    claims.  upsertUserProfile back-fills display_name only when null
    so the user's own edits aren't clobbered on every request.
ec310e59 feat(auth): local HS256 provider + login/OpenID routes Allen Morgan 2026-05-08 ↗ GitHub
commit body
Adds the third arm of the auth boundary - a local HS256 provider for
the docker-compose dev stack - plus the routes that drive provider-
flow logins:

  * lib/auth/providers/local.ts - stdlib-only HS256 verification.
    No new dependency.  When PostgREST eventually moves to JWKS, this
    becomes a thin jose wrapper and call sites stay the same.

  * routes/auth.ts - three families of routes:
      - POST /local-login        : mints an HS256 token for a stub email
                                   when AUTH_PROVIDER=local
      - GET  /providers          : lists available IdPs for the frontend
      - GET  /logout             : provider-aware sign-out (entra clears
                                   the IdP session, others bounce to /login)
      - GET  /select-provider, /login-provider/:id, /openid-callback/:id
                                 : Entra OIDC code-flow with HMAC-signed
                                   state cookie

  * Middleware now dispatches the "local" branch.

The router itself is added to the codebase but not yet mounted on the
Express app - the index.ts wiring (and the API prefix shift) lands in a
later commit so this one stays focused on the provider boundary.

Capture this thread into my fork

Download a single Markdown prompt that tells Claude how to port every commit above into your working tree — adapting paths and structure to match your repo. Run it via claude -p < capture-thread-189.md from inside the repo you want the changes in.

⬇ Download capture-thread-189.md