Project

Roadmap & status

Multi-phase hardening programme, gap-analysis re-evaluation and upcoming work for auth-service.

Based on the full codebase audit vs the specifications. Status legend: ✅ done · 🟡 partial · ⬜ planned · 🚫 accepted / won't fix.

Programme — security hardening, BetterAuth 1.7 & quality

PhaseScopeStatus
0Lint / format / coverage / CI guardrails✅
1Urgent security fixes (fail-closed OAuth guard, admin hierarchy, env booleans, token revocation, M2M verification)✅
2BetterAuth 1.6.23 → 1.7.7 (schema, protected resources, M2M E2E)✅
3Design-level hardening✅ (M-02 / L-08 accepted, L-10 won't-fix)
4Refactoring to AGENTS.md rules (core)✅ (remainder → Phase 6)
5New features + email delivery hardening (optional Redis, DPoP, back-channel logout…)🟡 5a done · 5b partial
6Quality: types, file split, coverage ≥80 %, specs sync⬜
7Loose ends (social providers, role cast, health probe, Dependabot)⬜

The LiteLLM gateway and shared credit wallet programme is tracked separately in the LiteLLM gateway & credit wallet plan.

The per-product Stripe subscription programme (paid plans with fixed and metered features, delegated billing pages) is tracked in the Stripe billing programme plan.

A phase is considered done only when pnpm build:server, pnpm test, pnpm test:integration and pnpm lint all pass.

Re-evaluation of the previous audit

Every item from the earlier "missing features / broken / sprint plan" audit is now classified. Resolved = implemented; won't fix = deliberate decision.

#Previous findingStatusEvidence / decision
1Dashboard sessions field mismatch (list vs sessions)✅ Resolvedsrc/routes/admin/sessions.ts returns sessions; DashboardView.vue reads res.sessions
2Integration suite missing (vitest.integration.config.ts)✅ ResolvedPhase 0 — integration suite (src/tests/*.integration.test.ts, PostgreSQL 17)
3ESLint / Prettier / husky / lint-staged missing✅ ResolvedPhase 0 (flat config, pre-commit, CI)
4Per-user isMfaRequired not enforced✅ ResolveduserMustSetupMfa() blocks OAuth token issuance (BetterAuth FORBIDDEN error raised in src/auth.ts; the AUTH_004 registry code is not emitted on this path)
5Stripe webhook handler missing✅ Resolvedsrc/routes/stripe-webhook.ts + idempotency ledger
6Last-superadmin guard on deletion✅ ResolvedGuard existed on delete; Phase 3 added demotion guard (atomic)
7/admin/applications/new dedicated route🚫 Won't fixModal-based create is the accepted UX
8User detail shows plan UUID instead of name✅ ResolvedLeft join on subscriptionPlans.name in users.ts
9LinkedIn / Microsoft / Apple providers not wired⬜ Phase 7config.ts accepts them but auth.ts only wires Google/GitHub
10Bootstrap role cast "superadmin" as "admin"⬜ Phase 7Harmless today, fragile on BetterAuth upgrades
11Passkeys as a true second factor🚫 Won't fixBetterAuth limitation; TOTP/YubiKey-OATH covers 2FA
12Rate limiting on auth endpoints✅ ResolvedGlobal 600 req/min per IP (@fastify/rate-limit) plus two strict auth/email buckets in server.ts (RATE_001/MAIL_003), in-memory or Redis-backed
13Structured logging✅ Resolvedpino via Fastify + shared src/logger.ts
14Security audit (PKCE, rotation, CORS)✅ ResolvedPhases 1–3 (PKCE enforced, JWKS rotation, CORS scoped, helmet)
15Health check DB connectivity probe⬜ Phase 7/health is static today
16Monitoring / alerting🚫 Won't fixOperational concern, out of repo scope
17Coverage ≥80 % on routes/** + services/**⬜ Phase 6Measurable now, threshold not yet met
18BetterAuth plugin-hook integration tests🟡 PartialPhase 6 — security-hardening + M2M tests cover key hooks
19any usages in auth/consumption✅ ResolvedPhase 1 removed both
20docker-compose.dev.yml redundant/broken✅ ResolvedMerged into a single docker-compose.yml (prebuilt image by default)

Deliberately accepted (documented, not defects)

  • M-02 cross-subdomain session cookie — kept opt-in via SESSION_DOMAIN; sharing the IdP cookie across subdomains is a deliberate trade-off.
  • L-08 login IP/UA on OAuth issuance — BetterAuth's token-issuance callback exposes no request headers; the parent session row carries them.
  • L-10 pin GitHub Actions by SHA — deferred to Dependabot (Phase 7) instead of hand-pinned SHAs.

Phase details

Phase 0 — Guardrails (✅)

  • ESLint flat config — no-explicit-any / consistent-type-imports errors, no-console warn.
  • Prettier + pnpm format / pnpm format:check; husky + lint-staged pre-commit.
  • @vitest/coverage-v8 + pnpm test:coverage (thresholds declared; the ≥80 % gate lands in Phase 6).
  • CI workflow (build, lint, format:check, unit, integration on PostgreSQL 17) on PR + main.
  • Removed tracked build artefacts from frontend/.

Phase 1 — Urgent fixes (✅)

IDItem
H-04Strict env booleans (z.stringbool)
H-05/api/admin/services no longer returns provider secrets
C-01clientPrivileges admin-only; guard fail-closed on unknown client
H-01Hierarchy hook on native /admin/* endpoints
H-02isMfaRequired, lastLoginAt not user-writable
H-07Banned users refused; OAuth tokens revoked on ban / access revocation / secret rotation
M-06applications.isActive enforced; oauth_client.disabled synced
M-10/api/consumption Bearer verified via JWKS/DB, bound to azp, fail-closed

Plus: magic-link storeToken: "hashed"; internal infrastructure references removed.

Phase 2 — BetterAuth upgrade (✅, 1.6.23 → 1.7.7)

latest (1.7.7) installed for better-auth, @better-auth/oauth-provider, @better-auth/passkey, @better-auth/drizzle-adapter — no fallback needed.

  • Schema: src/db/auth-schema.ts hand-aligned with the 1.7 schema (jwks.alg/crv, new oauth_client columns, new tables oauth_resource, oauth_client_resource, oauth_client_assertion); consolidated 0000_initial_schema.sql regenerated per the pre-production exception; migrate.ts reduced to a plain migrate() call (with a clear "recreate the database" error when the schema already exists).
  • Code: validAudiences replaced by RFC 8707 protected resources (oauth_resource rows keyed by applications.url, linked per client via oauth_client_resource); 1.7 client metadata written on application create; M2M verification rewritten on verifyJwsAccessToken with a local JWKS read from the jwks table, azp-bound and fail-closed; JWKS rotation enabled (30-day rotation, 30-day grace).
  • Tests: end-to-end client_credentials → /api/consumption (success + cross-application rejection). Deferred: ID-token claims via a full authorization-code flow, end-session E2E (Phase 5).
Breaking release — recreate the database (docker compose down -v && docker compose pull && docker compose up -d), then re-register downstream clients. Set TRUST_PROXY_HOPS=1 when running behind a reverse proxy.

Post-review fixes: 2FA guard covers /sign-in/social; Stripe handlers filter by (user_id, application_id, plan_id); stripe_events purged after 30 days; requireAdmin emits AUTH_011; last-superadmin demotion is a single conditional UPDATE; bootstrap rejects the shipped .env.example password pattern.

Phase 3 — Design-level hardening (✅)

IDItem
H-03Passwordless/social sign-in blocked for 2FA-enabled accounts (top-level after hook, fail-closed)
H-06select-org.html XSS fixed (textContent, escaped hidden input)
M-01Credentialed CORS scoped to dashboard origins; app origins only on /api/auth/*
M-03disableSettingJwtHeader (no more set-auth-jwt)
M-04Request logs strip query strings (tokens)
M-05allowRegister defaults to false (DB + API + pages)
M-07TRUST_PROXY_HOPS defaults to 0
M-08@fastify/helmet (HSTS, nosniff, frame-ancestors, CSP)
M-09Stripe webhook idempotency (stripe_events claim + release on error)
L-01Slug validated with ^[a-z0-9-]+$ before template path resolution
L-03Last superadmin cannot be demoted via PATCH (atomic)
L-04Weak/default bootstrap password refused in production
L-05Dockerfile runs as USER node

Phase 4 — Refactoring (✅ core)

  • Shared src/middleware.ts (requireAdmin, requireSession, requireFullSession, getCallerRole) — 9 duplicated copies removed.
  • Shared src/logger.ts (pino); console.* removed from server code.
  • AUTH_011 (403) added; ZodError → 400 and BetterAuth APIError mapping in the global error handler.
  • Fail-fast bootstrap; user_subscriptions upsert on (user_id, application_id).

Phase 5 — New features & email delivery hardening (🟡 5a done · 5b partial)

5a — Email delivery hardening (from the delivery audit) — ✅ done

The audit found that an account created by an admin receives no verification email, because BetterAuth's native /admin/create-user endpoint never calls sendVerificationEmail (only /sign-up/email consults sendOnSignUp). A configured SMTP therefore still produced no mail. Shipped:

  • POST /api/admin/users sends the verification email after creation (best-effort and detached; a transport failure does not roll back the account, and it is skipped when the no-op transport is active).
  • No duplicate activation mail: sendOnSignIn is intentionally off (it re-sent on every failed sign-in attempt). The login page now detects EMAIL_NOT_VERIFIED and offers an explicit, rate-limited "resend" action instead.
  • Verification links land on /profile (BetterAuth's default callbackURL=/ dropped regular users on the admin-only dashboard → 403). The SPA defaults / to /profile and shows a Dashboard entry in the user menu only for admins.
  • Admin UI: POST /api/admin/users/:id/send-verification reverts the account to unverified and re-sends (buttons in the user list action menu and the user detail page); POST /api/admin/users/:id/verify-email marks it verified without the link (superadmin-only for admin/superadmin targets).
  • verify-email.html "Resend" now collects the address and POSTs it (it previously sent an empty body).
  • Fail loud instead of silent: the no-op transport is no longer reported as sent; sendEmail() logs a DROPPED warning instead.
  • Rate-limit the added mail senders (/change-email, /organization/invite-member) in the email bucket. /sign-up/email stays in the auth bucket only: its verification mail is a side effect of account creation, and the extra 5/min cap breaks legitimate provisioning.
  • docker-compose.yml: mail-catcher (mailpit) behind the mail profile and MAIL_REPLY_TO passthrough.
  • Transactional email design pass across the built-in .eml templates (seven in templates/default/emails/ today): a shared branded shell (table layout, inline styles), the instance logo embedded as a CID attachment (APP_LOGO_URL resolved to bytes, no external image request), and tightened copy. Bundled templates are wrapped; tenant/external overrides are left verbatim. Logo resolution is SSRF-guarded (private/link-local hosts and redirects are rejected), size-capped (256 KB), deduplicated, and warmed in the background so it never blocks a send.

Covered by src/tests/email-admin-created.integration.test.ts and src/tests/email-verify.integration.test.ts.

5b — New features (partial)

Shipped:

  • Shared rate-limit store (src/services/rate-limit-store.ts): in-memory by default, Redis-backed when REDIS_URL is set (lazy ioredis, atomic INCR/PEXPIRE; on a Redis error it falls back to per-instance limits rather than failing open or blocking all auth). Compose redis service behind a redis profile.
  • Opt-in BetterAuth plugins, each gated by env: haveIBeenPwned (HAVEIBEENPWNED_ENABLED, default off), captcha (explicit CAPTCHA_ENABLED, default off, plus CAPTCHA_PROVIDER/CAPTCHA_SECRET_KEY/ CAPTCHA_SITE_KEY/CAPTCHA_ENDPOINTS), lastLoginMethod (LAST_LOGIN_METHOD_ENABLED, default on: it is a cookie hint with no schema impact).
  • Device authorization grant (DEVICE_AUTHORIZATION_ENABLED): plugin, the device_code table inlined into the consolidated 0000_initial_schema.sql baseline (pre-production: no delta migration), and the /device approval page (Vue SPA view + server-rendered template).

Remaining in 5b:

  • Captcha client side: the plugin rejects /sign-in/email, /sign-up/email and /request-password-reset unless the request carries an x-captcha-response header. Neither the Vue SPA nor the server templates render a widget yet, and the site key is not exposed to clients, so leaving CAPTCHA_ENABLED=false is mandatory until the widget ships.
  • Back-channel logout (backchannelLogoutUri, backchannelLogoutSessionRequired) is exposed in the admin application create/update schema, the admin UI and the integration guide; delivery, token revocation and single-use semantics are covered by src/tests/backchannel-logout.integration.test.ts. Still to expose: DPoP (dpopBoundAccessTokens) — the schema column exists in src/db/auth-schema.ts, but neither the admin application schema (src/routes/admin/applications.ts), the admin UI nor any test references it.
  • @better-auth/i18n: BetterAuth 1.7.7 ships no i18n package, so this needs a dedicated design pass (message catalog + locale negotiation) rather than a config toggle.
  • Evaluate CIMD/DCR and auth.api.adminCreateOAuthClient vs. the hand-written client insertion.

Phase 6 — Quality & coverage (⬜)

  • src/types.ts centralisation; split applications.ts (<1000 lines); rename adminConsumption.ts → kebab-case.
  • Explicit .select({…}) / .returning({…}) sweep; transaction audit.
  • Raise routes/** + services/** coverage to ≥80 % and make it gate CI (the thresholds: { lines: 80 } is declared in vitest.config.ts); add unit tests for user.ts, stripe-webhook.ts (webhook dispatch is covered by src/tests/billing-webhook.integration.test.ts), bootstrap.ts, migrate.ts.
  • Email coverage: no-op transport, every OTP type, contact-email and org-invitation senders, and a test that runs the email flags at their production defaults (today tests/setup-env.ts forces them on, hiding gaps). Admin-created-user send + resend + mark-verified are already covered by email-admin-created.integration.test.ts.
  • Error-code sync between the specifications and src/errors.ts.

Phase 7 — Loose ends (⬜)

  • Wire LinkedIn / Microsoft / Apple in auth.ts (or drop them from config.ts to avoid a silent no-op).
  • Align the template expiresIn* values with BetterAuth's real token lifetimes (the OTP wording is now type-aware).
  • Replace the createUser role cast in bootstrap.ts with a typed path.
  • Add a DB connectivity probe to GET /health.
  • Add Dependabot (npm + GitHub Actions), which also resolves L-10.

Status at a glance

AreaState
Email/password, verification, password reset, change-email✅
Magic-link / email-OTP (opt-in), passwordless 2FA guard✅
Email delivery hardening (admin send, resend, SMTP guard, mail catcher)✅ Phase 5a
Transactional email branding (embedded CID logo, shared layout)✅ Phase 5a
Login/verification UX (no duplicate mail, /profile callback, admin resend UI)✅ Phase 5a
Optional plugins (haveIBeenPwned, captcha, lastLoginMethod)✅ Phase 5b (opt-in)
Bulk admin actions (CSV user import, bulk app access)✅
Device authorization grant (RFC 8628, opt-in)✅ Phase 5b
Back-channel logout✅ Phase 5b (native; admin schema, admin UI and tests in place)
DPoP (dpopBoundAccessTokens) admin surface⬜ Phase 5b (schema column only; no admin schema, UI or test)
TOTP 2FA + backup codes, admin force-MFA✅
Passkeys🟡 Server plugin registered; no UI (profile shows "Coming soon"); YubiKey supported via OATH-TOTP
OAuth 2.1 / OIDC (PKCE, consent, refresh, discovery, JWKS, rotation)✅
Protected resources (RFC 8707) + per-client linking✅
Machine-to-machine (client_credentials) + consumption API✅
Orgs, per-app roles/permissions, subscriptions + Stripe webhook✅
Admin dashboard (users, apps, plans, roles, sessions, consumption)✅
Guardrails (ESLint/Prettier/husky, CI, integration tests PG17)✅
Coverage ≥80 % gate⬜ Phase 6
Rate-limit shared storage (multi-instance)✅ Phase 5b (Redis opt-in)

Recently shipped

  • Stripe subscriptions and billing: per-product paid plans with fixed and metered prices, Checkout / Billing Portal, webhook-driven entitlements, invoice cache, application billing API, delegated billing pages and pnpm billing:reconcile. Lots 0–7 are done and Lot 8 is partial (repository-wide coverage gate not met); see the Stripe billing programme plan.
  • Admin real-time channel — widened event vocabulary (WP3 of #60): the channel now carries five more event types beside login.recorded — session.created, session.revoked, user.changed, application.changed and organization.changed. Session events come from the BetterAuth databaseHooks.session.create.after / delete.after hooks, so every session creation and removal passes through them whatever the caller (native /api/auth/* endpoints included); user.changed comes from both the core user hooks and the direct Drizzle writes the adapter does not see; application.changed from the writes in src/routes/admin/applications.ts and application-machine-scopes.ts and the startup bootstrap; organization.changed from the organization plugin's hooks plus the direct organization/member writes. Each type's emitting call sites are listed in the specifications (§7.8), and the frame is unchanged: an event name and a timestamp, never a payload. Covered by src/services/event-bus.test.ts and src/tests/events.integration.test.ts.
  • Admin real-time channel — dashboard consumption (WP2 of #60): the admin dashboard now subscribes to GET /api/admin/events through frontend/src/composables/useAdminEvents.ts and refreshes its logins series and online counter on login.recorded, coalescing a burst of events into one round of requests inside a 1 s window. Only the dashboard subscribed at the time of WP2; the composable is inert under VITE_USE_MOCK, closes the stream on unmount and leaves reconnection to EventSource. The 30 s active-users poll is unchanged and remains the safety net. The composable has since been extended: wallet.changed and subscription.changed are also handled, and the application detail, wallets and accounting views subscribe too. No frontend test runner is introduced — the frontend evidence is observational.
  • Admin real-time channel (SSE, WP1 of #60): GET /api/admin/events streams Server-Sent Events to the admin dashboard, guarded by the same requireAdmin preHandler as the other /api/admin/* routes (401 / 403 / 200). A frame names an event type (login.recorded in WP1; the vocabulary has since grown to eight types, see below) and carries at most a timestamp; no payload, no identifier, no count ever travels on the stream, and the client re-fetches through the REST endpoints it already calls. The event bus is in-process by default and fans out over Redis pub/sub when REDIS_URL is set, mirroring the rate-limit store. Keep-alive comment frames hold an idle stream open and every subscriber is unsubscribed on connection close, so EventSource reconnections cannot accumulate. Delivery is best effort: the dashboard's 30 s active-users poll remains the safety net. Covered by src/routes/admin/events.test.ts, src/services/event-bus.test.ts and src/tests/events.integration.test.ts.
  • Admin password reset: POST /api/admin/users/:id/set-password sets a new password for a strictly lower-ranked user and, in the same operation, revokes the target's sessions and OAuth refresh tokens — the credential write and the revocation are one gesture, so a reset on a compromised account actually takes the session away. The native /api/auth/admin/set-user-password endpoint is withheld from every role (set-password removed from the admin and superadmin permission lists) because it writes the credential and revokes nothing; the custom route is the only path. A distinct canManageRole gate (strict rank) drives both the server check and the admin UI action, so the UI never offers a reset the server would refuse. Admin UI: "Set Password" action on the user detail page reusing the WP2 password-strength meter.
  • Phase 5a/5b: admin-created users now receive a verification email (plus admin resend / mark-verified), no-op transport no longer logs a false sent, mail senders rate-limited, mailpit catcher; opt-in haveIBeenPwned / captcha / lastLoginMethod plugins, RFC 8628 device authorization with /device page, and a shared rate-limit store (in-memory or Redis via REDIS_URL).
  • Email follow-up: removed the sign-in re-send that duplicated activation mails, verification links now land on /profile (with an admin-only Dashboard menu entry), admin UI exposes resend / mark-verified, and the built-in transactional templates share a branded layout with the instance logo embedded as a CID attachment.
  • Admin UX: verification actions are gated by SMTP/verification readiness and caller rights; Ban/Delete are hidden for self and higher-ranked targets; the OAuth/Integration code block matches the details block height (scrollable + copy button); bulk CSV user import and bulk application access by organization; tenant templates (mcp-central, vandal-app) converted to Eta and completed with select-org/device pages and transactional emails.
  • Compose / packaging: single functional docker-compose.yml; docs prioritise the published latest/nightly images; production install fixed (husky || true under --prod).
  • Phase 3: 2FA enforced for passwordless/social sign-in, select-org XSS fixed, credentialed CORS scoped to the dashboard, request logs token-free, allowRegister/TRUST_PROXY_HOPS secure defaults, helmet headers, Stripe webhook idempotency, last-superadmin protection, non-root container.
  • Phase 4: shared middleware.ts and logger.ts, AUTH_011 403 + Zod/BetterAuth error mapping, fail-fast bootstrap, user_subscriptions upsert fix.
  • Phase 2: BetterAuth 1.7.7, regenerated baseline, protected resources, client metadata, native revokeSession, local-JWKS M2M verification with E2E test, JWKS rotation.
  • Phase 1: OAuth client management admin-only, fail-closed access guard, admin hierarchy hook, strict boolean env parsing, provider secrets no longer exposed, OAuth token revocation on ban/revocation/rotation.
  • Phase 0: ESLint + Prettier + husky/lint-staged, coverage tooling, CI on PR, integration tests on PostgreSQL 17, tracked build artefacts removed.

Infrastructure

  • Single docker-compose.yml — postgres + auth-service (prebuilt image or --build), healthcheck on /health, postgres exposed on 5433 for host-based dev.
  • Images — published to ghcr.io/circle-rd/auth-service as :nightly (develop), :latest (release) and version tags.
  • Dockerfile — three-stage (frontend → backend → runtime), runs as USER node.
  • Migrations — runMigrations() at startup; until the first production release the consolidated 0000 baseline is regenerated from the schema and no delta migration is shipped (see AGENTS.md §8), so a reinitialised or older database must be recreated (docker compose down -v).
Copyright © 2026