Skip to content

security: harden auth surface and add cross-backend store conformance suite - #39

Merged
juicycleff merged 10 commits into
mainfrom
security-hardening-and-store-conformance
Jul 24, 2026
Merged

security: harden auth surface and add cross-backend store conformance suite#39
juicycleff merged 10 commits into
mainfrom
security-hardening-and-store-conformance

Conversation

@juicycleff

@juicycleff juicycleff commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Fixes the findings from a full security review (five parallel review agents across the engine, stores, HTTP surface, auth strategies, and multi-tenancy) plus adds test infrastructure to prevent backend divergence going forward. Every change is test-first; go vet ./..., -race, and the memory+sqlite conformance suite pass locally, with postgres+mongo conformance running in CI.

Access control

  • Add authentication + ownership/tenant scoping to previously unauthenticated endpoint groups: sessions, devices, webhooks, environments, and the organization plugin routes (membership/role authz with self-escalation and cross-org guards).
  • Fix the confused-deputy class in the config, RBAC, and admin/impersonation handlers — each now verifies the target resource belongs to the caller's tenant (scopedAppID / roleInCallerApp / userInCallerApp).

Sessions & tokens

  • Refresh no longer downgrades a JWT access token to opaque.
  • Refresh rotation uses a compare-and-swap (RotateSession) to close a TOCTOU race; a concurrent family-revoke that deletes the session is treated as a benign lost race, not a 500.
  • MFA ceremony store allocated eagerly to remove a request-time data race.
  • settings.Get falls back to the registered default (not the zero value) on a store error, so cookie Secure/HttpOnly can't silently flip off.

Auth strategies

  • MFA verify endpoints rate-limited; TOTP codes single-use within their window; TOTP/SMS secrets encrypted at rest; SMS OTP compare constant-time.
  • WebAuthn clone detection (CloneWarning) now rejects the login.
  • Passwordless (discoverable) passkey login implemented: per-ceremony cookie correlation, FinishDiscoverableLogin with a user-handle resolver, real session issuance.
  • SSO refuses to link to a pre-existing unverified local account.
  • Magic-link resolves the real user by email (was bound to a random id, so every verification 500'd); unknown emails get a uniform response.

Network

  • Trusted-proxy-aware client IP: X-Forwarded-For/X-Real-IP honored only when the peer is a trusted proxy — closing spoofing of rate-limit keys, session IP-binding, and IP allow/block lists.

Storage

  • Mongo DeleteApp cascades to all app-scoped collections in a MongoTx; ListUsers sets Total; email filter escaped (regexp.QuoteMeta) to stop regex/ReDoS injection.
  • memory.ListUsers now applies the email filter (previously ignored → admin search returned all users); DeleteApp idempotency unified across backends.

Store conformance suite (new)

  • store/storetest: a backend-agnostic contract suite (CRUD, delete cascade, tenant scoping, ListUsers total/filter, RotateSession CAS, refresh replay). Runs against memory + sqlite in normal CI (no Docker) and postgres + mongo under -tags integration.
  • CI now runs the integration tests against real Postgres (testcontainers) and a single-node Mongo replica set — previously they never ran. The suite immediately caught the memory.ListUsers filter bug and a DeleteApp idempotency divergence, both fixed here.

68 files changed, +4,487 / −179.

The refresh replay branch re-ran the family cascade and re-emitted the
refresh_token_replayed audit + hook event on every presentation of a revoked
token, with no idempotency. A client stuck re-presenting an already-rotated
token (e.g. a frontend that never persists the rotated token) produced one
alert per attempt -> a runaway audit stream.

Add an atomic store method MarkRefreshTokenReplayed(hash) that conditionally
upgrades a revoked token's reason rotated->replay_detected and reports whether
it made the transition. The engine now cascade-revokes the family and emits
the security alert only on that first transition; repeat presentations are
refused quietly. The conditional UPDATE is race-safe, so concurrent
double-submits also resolve to a single alert.

- session.Store: +MarkRefreshTokenReplayed, implemented in postgres, sqlite,
  mongo, memory
- engine Refresh: alert + cascade gated on firstReplay
- tests: engine "alert fires once across 5 replays" + store idempotency
… suite

Fixes the findings from a full security review plus adds test infrastructure
to prevent backend divergence going forward. All changes are test-first and
the full suite passes (go vet, -race, and memory+sqlite conformance locally;
postgres+mongo conformance in CI).

Access control
- Add authentication + ownership/tenant scoping to previously unauthenticated
  endpoint groups: sessions, devices, webhooks, environments, and the
  organization plugin routes (membership/role authz + self-escalation and
  cross-org guards).
- Fix the confused-deputy class in the config, RBAC, and admin/impersonation
  handlers: every handler now verifies the target resource belongs to the
  caller's tenant (scopedAppID / roleInCallerApp / userInCallerApp).

Sessions & tokens
- Refresh no longer downgrades a JWT access token to opaque.
- Refresh rotation uses a compare-and-swap (RotateSession) to close a TOCTOU
  race; a concurrent family-revoke that deletes the session is treated as a
  benign lost race rather than a 500.
- Allocate the MFA ceremony store eagerly to remove a request-time data race.
- settings.Get falls back to the registered default (not the zero value) on a
  store error, so cookie Secure/HttpOnly can't silently flip off.

Auth strategies
- MFA verification endpoints are rate limited; TOTP codes are single-use
  within their window; TOTP/SMS secrets are encrypted at rest; SMS OTP compare
  is constant-time.
- WebAuthn clone detection (CloneWarning) now rejects the login.
- Implement passwordless (discoverable) passkey login: per-ceremony cookie
  correlation, FinishDiscoverableLogin with a user-handle resolver, and real
  session issuance.
- SSO refuses to link a login to a pre-existing unverified local account.
- Magic-link resolves the real user by email (was bound to a random id, so
  every verification 500'd); unknown emails get a uniform response.

Network
- Trusted-proxy-aware client IP resolution: X-Forwarded-For / X-Real-IP are
  honored only when the peer is a trusted proxy, closing spoofing of rate-limit
  keys, session IP-binding, and IP allow/block lists.

Storage
- Mongo: DeleteApp now cascades to all app-scoped collections in a MongoTx;
  ListUsers sets Total; the email filter is escaped (regexp.QuoteMeta) to stop
  regex/ReDoS injection.
- memory: ListUsers now applies the email filter (previously ignored, so admin
  search returned all users); DeleteApp idempotency unified across backends.

Store conformance suite
- Add store/storetest: a backend-agnostic contract suite (app/user/session/org
  CRUD, delete cascade, tenant scoping, ListUsers total/filter, RotateSession
  CAS, refresh replay). Runs against memory + sqlite in normal CI (no Docker)
  and postgres + mongo under -tags integration.
- CI now runs the integration tests against real Postgres (testcontainers) and
  a single-node Mongo replica set; previously integration tests never ran.
@vercel

vercel Bot commented Jul 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
authsome Ready Ready Preview, Comment Jul 24, 2026 5:02pm

Request Review

Comment thread plugins/passkey/discoverable.go Fixed
Comment thread plugins/passkey/discoverable.go Fixed
Clears all 14 Go Dependabot alerts (7 critical + 2 high + 4 medium on
golang.org/x/crypto <0.52.0, and one medium on quic-go <=0.59.0). Build and
the crypto-dependent test paths (account/apikey/tokenformat/passkey/mfa) pass.
…p, picomatch)

Bumps docs/ next 16.1.6 -> 16.2.6 and pins path-to-regexp>=8.4.0 / picomatch>=4.0.4
via pnpm overrides. docs build passes.
Pins the vulnerable transitive deps in the ui monorepo to their patched
versions: next 15.5.16, vite 6.4.3, ws 8.21.0, brace-expansion 5.0.7,
minimatch 9.0.7, picomatch 4.0.4, postcss 8.5.10, @babel/core 7.29.6,
turbo 2.9.14, uuid 11.1.1. lodash resolves to 4.18.0 (dev-only, via
@storybook/test). esbuild left to vite's own bump (low severity). ui build
and typecheck pass.
…gration job

Lint (32 issues from the new security/conformance code):
- name multi-result returns (ValidateTOTPStep, resolveDiscoverableUser)
- set Secure on the passkey ceremony cookies (from request scheme) + #nosec G124
- rename shadowing/reassigned err vars flagged by govet shadow + gocritic
  sloppyReassign across api/plugins/service
- use httptest.NewRequestWithContext (noctx); drop unused runner param;
  avoid the id-package import shadow in storetest; //nolint:unparam on the
  general passkey audit/relay/hook helpers

CI:
- Scope the Postgres/Mongo conformance job to `-run TestConformance` so it no
  longer runs the older per-backend store_test.go suites (pre-existing
  postgres users.env_id FK failures) or the hanging mongo migration-lock test.
- Mark that job advisory (continue-on-error): the blocking cross-backend
  coverage is memory + sqlite in the main `go` job (no Docker); pg + mongo run
  here for visibility until the pre-existing per-backend issues are resolved.
The cross-backend conformance suite caught a real mongo bug: refresh-token
revocation/replay lookups always failed on the mongo backend.

Root cause: grove's mongodriver builds documents from grove column names and
maps a primary key to `_id` ONLY when the column is named "id" (see
mongodriver structToDoc / jsonschema). revokedRefreshTokenModel's pk column is
"token_hash", so on insert the hash is stored under a "token_hash" field while
MongoDB auto-generates an ObjectID `_id`. But IsRefreshTokenRevoked,
GetRevokedRefreshTokenFamily, and MarkRefreshTokenReplayed all filtered by
`{_id: tokenHash}` — which never matched. Result: IsRefreshTokenRevoked always
returned false and replay detection never fired on mongo.

Fix: filter by "token_hash" (where the value is actually stored) and align the
bson decode tag. This also makes any pre-existing (previously unqueryable)
revocation rows work. Documented the grove pk→_id quirk on the model so the
filters aren't "simplified" back to _id. Memory + sqlite conformance unchanged.
All four backends (memory + sqlite in the go job, postgres + mongo here) now
pass the conformance suite against real databases in CI (verified on run
30109099743: ok store/mongo, ok store/postgres). Remove continue-on-error so
cross-backend drift fails the build.
The per-backend postgres store_test.go suite created users/sessions/orgs
and other env-scoped rows without an env_id, violating the NOT NULL + FK
constraint added in migration 7 (fk_authsome_users_env et al.). Memory and
sqlite don't enforce this, so the failures were postgres-only and pre-existing.

createTestApp now also creates a default Production environment and records
its id (testEnvByApp / testEnvID helper); every env-scoped literal in
store_test.go and refresh_replay_test.go now sets EnvID so the rows reference
a real environment. Also collapses a shadowed err in refresh_replay_test.go.

Separately, fixes two noctx lint failures in plugins/passkey/discoverable_test.go
by switching httptest.NewRequest to NewRequestWithContext.
@juicycleff
juicycleff merged commit fb1d3af into main Jul 24, 2026
13 checks passed
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.

2 participants