From a68409e4df462f9a1c21579bdb36fbb8e954f7fe Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 21 Jul 2026 13:29:30 -0500 Subject: [PATCH 01/10] fix(session): stop refresh_token_replayed alert storm 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 --- refresh_replay_test.go | 42 +++++++++++++++++++++++++++++ service.go | 26 ++++++++++++++---- session/store.go | 9 +++++++ store/memory/refresh_replay_test.go | 29 ++++++++++++++++++++ store/memory/store.go | 18 +++++++++++++ store/mongo/refresh_replay.go | 19 +++++++++++++ store/postgres/refresh_replay.go | 23 ++++++++++++++++ store/sqlite/refresh_replay.go | 20 ++++++++++++++ 8 files changed, 181 insertions(+), 5 deletions(-) diff --git a/refresh_replay_test.go b/refresh_replay_test.go index a6cda24d..db6aeb05 100644 --- a/refresh_replay_test.go +++ b/refresh_replay_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/require" "github.com/xraph/authsome/account" + "github.com/xraph/authsome/hook" "github.com/xraph/authsome/store" ) @@ -86,6 +87,47 @@ func TestRefresh_ReplayRevokesFamily(t *testing.T) { "rotated token belonging to a revoked family must not be usable") } +// TestRefresh_ReplayStormEmitsAlertOnce pins the storm guard: a client stuck +// re-presenting the same already-rotated token must trigger the replay alert +// exactly once, not once per attempt. Regression test for the runaway +// refresh_token_replayed audit stream. +func TestRefresh_ReplayStormEmitsAlertOnce(t *testing.T) { + eng, _ := newTestEngine(t) + ctx := context.Background() + appID := testAppID(t) + + _, sess, err := eng.SignUp(ctx, &account.SignUpRequest{ + AppID: appID, + Email: "storm@example.com", + Password: "SecureP@ss1", + FirstName: "Storm User", + }) + require.NoError(t, err) + staleToken := sess.RefreshToken + + // Legitimate rotation — staleToken is now revoked with reason "rotated". + _, err = eng.Refresh(ctx, staleToken) + require.NoError(t, err) + + var replayAlerts int + eng.Hooks().On("count-replays", func(_ context.Context, ev *hook.Event) error { + if ev.Action == hook.ActionRefreshTokenReplayed { + replayAlerts++ + } + return nil + }) + + // A stuck client hammers the endpoint with the same dead token. + for i := 0; i < 5; i++ { + _, rerr := eng.Refresh(ctx, staleToken) + assert.ErrorIs(t, rerr, account.ErrInvalidCredentials, + "every replay attempt is refused") + } + + assert.Equal(t, 1, replayAlerts, + "replay alert must fire once for the family, not once per attempt") +} + // TestRefresh_UnrelatedSessionUntouched ensures family cascade revocation // is scoped — sessions in a *different* family (e.g. a second login) are // not affected when one family is poisoned. diff --git a/service.go b/service.go index 19768d20..c546c384 100644 --- a/service.go +++ b/service.go @@ -2,6 +2,10 @@ package authsome import ( "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" "errors" "fmt" "strings" @@ -9,11 +13,6 @@ import ( log "github.com/xraph/go-utils/log" - "crypto/rand" - "crypto/sha256" - "crypto/subtle" - "encoding/hex" - "github.com/xraph/forge" "github.com/xraph/warden" wardenassign "github.com/xraph/warden/assignment" @@ -472,7 +471,24 @@ func (e *Engine) Refresh(ctx context.Context, refreshToken string, opts ...Refre // Replay check: if the presented token's hash is already in the // revoked set, this is either a leaked token being replayed or a // double-spend. Either way: cascade-revoke the family and refuse. + // + // The cascade + security alert fire only the FIRST time a given token + // transitions to replay_detected (MarkRefreshTokenReplayed is an atomic + // conditional upgrade). A client stuck re-presenting an already-dead token + // — the common cause of a runaway audit stream — is refused quietly on + // every subsequent attempt without re-alerting or re-revoking. if revoked, _ := e.store.IsRefreshTokenRevoked(ctx, presentedHash); revoked { //nolint:errcheck // err treated as "not revoked" + firstReplay, mrErr := e.store.MarkRefreshTokenReplayed(ctx, presentedHash) + if mrErr != nil { + e.logger.Warn("authsome: mark refresh-token replayed failed", + log.String("error", mrErr.Error()), + ) + } + if !firstReplay { + // Already handled — refuse without re-alerting to avoid a storm. + return nil, account.ErrInvalidCredentials + } + var ipAddr, userAgent string if len(opts) > 0 { ipAddr = opts[0].IPAddress diff --git a/session/store.go b/session/store.go index 54367f9e..e7ece43a 100644 --- a/session/store.go +++ b/session/store.go @@ -59,4 +59,13 @@ type Store interface { // family. Called on replay detection so a leaked token doesn't keep // working through siblings. RevokeRefreshTokenFamily(ctx context.Context, familyID id.SessionFamilyID, reason string) error + + // MarkRefreshTokenReplayed upgrades an already-revoked token's reason to + // "replay_detected" and reports whether this call performed that transition + // (i.e. the token was not already flagged as replayed). It is the + // idempotency guard for replay handling: the engine cascade-revokes the + // family and emits the security alert only when firstReplay is true, so a + // client stuck re-presenting a dead token yields exactly one alert instead + // of one per attempt. The token must already be in the revoked set. + MarkRefreshTokenReplayed(ctx context.Context, tokenHash string) (firstReplay bool, err error) } diff --git a/store/memory/refresh_replay_test.go b/store/memory/refresh_replay_test.go index a1c5e87e..e0d8e6f9 100644 --- a/store/memory/refresh_replay_test.go +++ b/store/memory/refresh_replay_test.go @@ -134,3 +134,32 @@ func TestRefreshReplay_RevokeFamilyNoop_NilFamily(t *testing.T) { s := newStore() require.NoError(t, s.RevokeRefreshTokenFamily(ctx(), id.Nil, session.RevokeReasonReplayDetected)) } + +// TestRefreshReplay_MarkReplayedIsIdempotent pins the storm guard at the store +// layer: the first upgrade to replay_detected reports firstReplay=true, every +// subsequent call reports false. +func TestRefreshReplay_MarkReplayedIsIdempotent(t *testing.T) { + s := newStore() + fam := id.NewSessionFamilyID() + h := hashTok("rotated-token") + require.NoError(t, s.MarkRefreshTokenRevoked(ctx(), h, fam, session.RevokeReasonRotated)) + + first, err := s.MarkRefreshTokenReplayed(ctx(), h) + require.NoError(t, err) + assert.True(t, first, "first replay upgrade must report firstReplay=true") + + for i := 0; i < 3; i++ { + again, aerr := s.MarkRefreshTokenReplayed(ctx(), h) + require.NoError(t, aerr) + assert.False(t, again, "repeat replay must report firstReplay=false") + } +} + +// TestRefreshReplay_MarkReplayedUnknownToken reports false rather than +// erroring when the hash was never revoked. +func TestRefreshReplay_MarkReplayedUnknownToken(t *testing.T) { + s := newStore() + first, err := s.MarkRefreshTokenReplayed(ctx(), hashTok("never-seen")) + require.NoError(t, err) + assert.False(t, first) +} diff --git a/store/memory/store.go b/store/memory/store.go index 57183cfd..341362ce 100644 --- a/store/memory/store.go +++ b/store/memory/store.go @@ -542,6 +542,24 @@ func (s *Store) RevokeRefreshTokenFamily(_ context.Context, familyID id.SessionF return nil } +// MarkRefreshTokenReplayed flips an already-revoked token's reason to +// "replay_detected", returning whether this call made the change. Guarded by +// the store mutex so concurrent presentations resolve to a single +// firstReplay=true — the engine's storm guard. +func (s *Store) MarkRefreshTokenReplayed(_ context.Context, tokenHash string) (bool, error) { + if tokenHash == "" { + return false, nil + } + s.mu.Lock() + defer s.mu.Unlock() + rec, ok := s.revokedRefreshTokens[tokenHash] + if !ok || rec == nil || rec.Reason == session.RevokeReasonReplayDetected { + return false, nil + } + rec.Reason = session.RevokeReasonReplayDetected + return true, nil +} + // hashRefreshToken returns the hex-encoded SHA-256 of a refresh token. Kept as // a small helper so memory + service share the exact same canonicalisation. func hashRefreshToken(tok string) string { diff --git a/store/mongo/refresh_replay.go b/store/mongo/refresh_replay.go index 6ca2f654..47a3373b 100644 --- a/store/mongo/refresh_replay.go +++ b/store/mongo/refresh_replay.go @@ -13,6 +13,7 @@ import ( "github.com/xraph/grove/drivers/mongodriver" "github.com/xraph/authsome/id" + "github.com/xraph/authsome/session" "github.com/xraph/authsome/store" ) @@ -157,6 +158,24 @@ func (s *Store) RevokeRefreshTokenFamily(ctx context.Context, familyID id.Sessio return nil } +// MarkRefreshTokenReplayed flips an already-revoked token's reason to +// "replay_detected", returning whether this call made the change. The filter +// excludes tokens already flagged, so a match is a real transition — the +// engine's once-per-family storm guard. +func (s *Store) MarkRefreshTokenReplayed(ctx context.Context, tokenHash string) (bool, error) { + if tokenHash == "" { + return false, nil + } + res, err := s.mdb.NewUpdate((*revokedRefreshTokenModel)(nil)). + Filter(bson.M{"_id": tokenHash, "reason": bson.M{"$ne": session.RevokeReasonReplayDetected}}). + Set("reason", session.RevokeReasonReplayDetected). + Exec(ctx) + if err != nil { + return false, fmt.Errorf("authsome/mongo: mark refresh token replayed: %w", err) + } + return res.MatchedCount() > 0, nil +} + // hashRefreshTokenMongo returns hex(SHA-256(tok)). func hashRefreshTokenMongo(tok string) string { sum := sha256.Sum256([]byte(tok)) diff --git a/store/postgres/refresh_replay.go b/store/postgres/refresh_replay.go index e17fb328..c0f54df3 100644 --- a/store/postgres/refresh_replay.go +++ b/store/postgres/refresh_replay.go @@ -9,6 +9,7 @@ import ( "time" "github.com/xraph/authsome/id" + "github.com/xraph/authsome/session" "github.com/xraph/authsome/store" ) @@ -132,6 +133,27 @@ func (s *Store) RevokeRefreshTokenFamily(ctx context.Context, familyID id.Sessio return nil } +// MarkRefreshTokenReplayed flips an already-revoked token's reason to +// "replay_detected" and reports whether this call made the change. The +// conditional UPDATE (reason <> replay_detected) is atomic, so concurrent +// double-submits and repeated presentations resolve to exactly one +// firstReplay=true — the engine's storm guard. +func (s *Store) MarkRefreshTokenReplayed(ctx context.Context, tokenHash string) (bool, error) { + if tokenHash == "" { + return false, nil + } + res, err := s.pg.NewUpdate((*RevokedRefreshTokenModel)(nil)). + Set("reason = ?", session.RevokeReasonReplayDetected). + Where("token_hash = ?", tokenHash). + Where("reason <> ?", session.RevokeReasonReplayDetected). + Exec(ctx) + if err != nil { + return false, fmt.Errorf("authsome/postgres: mark refresh token replayed: %w", pgError(err)) + } + n, _ := res.RowsAffected() //nolint:errcheck // driver always supports RowsAffected + return n > 0, nil +} + // hashRefreshTokenPg returns the hex-encoded SHA-256 of a refresh token. // Mirrors the canonicalisation used by the in-memory store and engine. func hashRefreshTokenPg(tok string) string { @@ -146,4 +168,5 @@ var _ interface { MarkRefreshTokenRevoked(context.Context, string, id.SessionFamilyID, string) error GetRevokedRefreshTokenFamily(context.Context, string) (id.SessionFamilyID, error) RevokeRefreshTokenFamily(context.Context, id.SessionFamilyID, string) error + MarkRefreshTokenReplayed(context.Context, string) (bool, error) } = (*Store)(nil) diff --git a/store/sqlite/refresh_replay.go b/store/sqlite/refresh_replay.go index 04618ce0..329ce4c9 100644 --- a/store/sqlite/refresh_replay.go +++ b/store/sqlite/refresh_replay.go @@ -9,6 +9,7 @@ import ( "time" "github.com/xraph/authsome/id" + "github.com/xraph/authsome/session" "github.com/xraph/authsome/store" ) @@ -126,6 +127,25 @@ func (s *Store) RevokeRefreshTokenFamily(ctx context.Context, familyID id.Sessio return nil } +// MarkRefreshTokenReplayed flips an already-revoked token's reason to +// "replay_detected", returning whether this call made the change. Atomic +// conditional UPDATE — the engine's once-per-family storm guard. +func (s *Store) MarkRefreshTokenReplayed(ctx context.Context, tokenHash string) (bool, error) { + if tokenHash == "" { + return false, nil + } + res, err := s.sdb.NewUpdate((*RevokedRefreshTokenModel)(nil)). + Set("reason = ?", session.RevokeReasonReplayDetected). + Where("token_hash = ?", tokenHash). + Where("reason <> ?", session.RevokeReasonReplayDetected). + Exec(ctx) + if err != nil { + return false, fmt.Errorf("authsome/sqlite: mark refresh token replayed: %w", sqliteError(err)) + } + n, _ := res.RowsAffected() //nolint:errcheck // driver always supports RowsAffected + return n > 0, nil +} + // hashRefreshTokenSqlite returns hex(SHA-256(tok)). func hashRefreshTokenSqlite(tok string) string { sum := sha256.Sum256([]byte(tok)) From c3871e3aed930dbe1d9b4af5008f40ff3ab2c8be Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Fri, 24 Jul 2026 09:45:57 -0500 Subject: [PATCH 02/10] security: harden auth surface and add cross-backend store conformance 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. --- .github/workflows/ci.yml | 45 +++ api/admin_handlers.go | 43 ++- api/admin_scope_test.go | 130 +++++++ api/api_test.go | 6 +- api/app_client_config_handlers.go | 12 +- api/app_session_config_handlers.go | 12 +- api/cross_tenant_config_test.go | 94 +++++ api/device_handlers.go | 44 ++- api/environment_handlers.go | 43 ++- api/rbac_handlers.go | 134 +++++-- api/rbac_scope_test.go | 131 +++++++ api/session_handlers.go | 13 + api/tenant_scope.go | 52 +++ api/unauth_admin_endpoints_test.go | 338 +++++++++++++++++ api/unauth_endpoints_test.go | 241 ++++++++++++ api/webhook_handlers.go | 31 +- ceremony_store_race_test.go | 60 +++ engine.go | 7 + engine_issue_session.go | 14 +- extension/contract/handlers.go | 18 +- middleware/auth.go | 23 +- middleware/captcha.go | 18 +- middleware/captcha_test.go | 30 +- middleware/clientip.go | 123 ++++++ middleware/clientip_test.go | 81 ++++ middleware/ratelimit.go | 12 +- plugins/magiclink/plugin.go | 15 +- plugins/magiclink/plugin_test.go | 19 +- plugins/magiclink/sendverify_test.go | 66 ++++ plugins/mfa/plugin.go | 104 ++++- plugins/mfa/ratelimit_test.go | 89 +++++ plugins/mfa/replay_test.go | 69 ++++ plugins/mfa/sms.go | 6 +- plugins/mfa/sms_test.go | 33 ++ plugins/mfa/store_encrypted.go | 138 +++++++ plugins/mfa/store_encrypted_test.go | 72 ++++ plugins/mfa/totp.go | 29 ++ plugins/organization/authz.go | 84 +++++ plugins/organization/authz_test.go | 354 +++++++++++++++++ plugins/organization/handlers.go | 100 ++++- plugins/passkey/clone_test.go | 29 ++ plugins/passkey/discoverable.go | 180 +++++++++ plugins/passkey/discoverable_flow_test.go | 87 +++++ plugins/passkey/discoverable_test.go | 87 +++++ plugins/passkey/handlers.go | 53 ++- plugins/passkey/passkey.go | 4 +- plugins/sso/matching_test.go | 86 +++++ plugins/sso/plugin.go | 43 ++- refresh_jwt_test.go | 44 +++ refresh_race_test.go | 62 +++ service.go | 44 ++- session/store.go | 7 + settings/failopen_test.go | 66 ++++ settings/manager.go | 23 +- store/memory/conformance_test.go | 17 + store/memory/rotate_session_test.go | 81 ++++ store/memory/store.go | 31 +- store/mongo/app.go | 80 +++- store/mongo/app_cascade_test.go | 59 +++ store/mongo/conformance_test.go | 18 + store/mongo/filter_test.go | 64 ++++ store/mongo/session.go | 18 + store/mongo/user.go | 29 +- store/postgres/conformance_test.go | 19 + store/postgres/store.go | 13 + store/sqlite/conformance_test.go | 35 ++ store/sqlite/store.go | 13 + store/storetest/storetest.go | 441 ++++++++++++++++++++++ 68 files changed, 4487 insertions(+), 179 deletions(-) create mode 100644 api/admin_scope_test.go create mode 100644 api/cross_tenant_config_test.go create mode 100644 api/rbac_scope_test.go create mode 100644 api/tenant_scope.go create mode 100644 api/unauth_admin_endpoints_test.go create mode 100644 api/unauth_endpoints_test.go create mode 100644 ceremony_store_race_test.go create mode 100644 middleware/clientip.go create mode 100644 middleware/clientip_test.go create mode 100644 plugins/magiclink/sendverify_test.go create mode 100644 plugins/mfa/ratelimit_test.go create mode 100644 plugins/mfa/replay_test.go create mode 100644 plugins/mfa/sms_test.go create mode 100644 plugins/mfa/store_encrypted.go create mode 100644 plugins/mfa/store_encrypted_test.go create mode 100644 plugins/organization/authz.go create mode 100644 plugins/organization/authz_test.go create mode 100644 plugins/passkey/clone_test.go create mode 100644 plugins/passkey/discoverable.go create mode 100644 plugins/passkey/discoverable_flow_test.go create mode 100644 plugins/passkey/discoverable_test.go create mode 100644 plugins/sso/matching_test.go create mode 100644 refresh_jwt_test.go create mode 100644 refresh_race_test.go create mode 100644 settings/failopen_test.go create mode 100644 store/memory/conformance_test.go create mode 100644 store/memory/rotate_session_test.go create mode 100644 store/mongo/app_cascade_test.go create mode 100644 store/mongo/conformance_test.go create mode 100644 store/mongo/filter_test.go create mode 100644 store/postgres/conformance_test.go create mode 100644 store/sqlite/conformance_test.go create mode 100644 store/storetest/storetest.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3528e40d..249c8c00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,51 @@ jobs: path: coverage.out retention-days: 7 + # ─── Store Integration Tests ──────────────────────────────────────── + # Runs the cross-backend store conformance suite (and other integration + # tests) against real Postgres and MongoDB. Postgres is provided by + # testcontainers (Docker is available on the runner); MongoDB runs as a + # single-node replica set so multi-document transactions (app/org cascade) + # work. The main `go` job runs -race over the non-integration tests. + integration: + name: Store Integration + runs-on: ubuntu-latest + needs: go + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.25.7" + cache: true + cache-dependency-path: go.sum + + - name: Download modules + run: go mod download + + - name: Start MongoDB (single-node replica set) + run: | + docker run -d --name mongo -p 27017:27017 mongo:7 --replSet rs0 + echo "Waiting for mongod..." + for i in $(seq 1 30); do + if docker exec mongo mongosh --quiet --eval 'db.runCommand({ ping: 1 })' >/dev/null 2>&1; then break; fi + sleep 2 + done + docker exec mongo mongosh --quiet --eval 'rs.initiate({_id:"rs0",members:[{_id:0,host:"localhost:27017"}]})' + echo "Waiting for primary..." + for i in $(seq 1 30); do + if docker exec mongo mongosh --quiet --eval 'db.hello().isWritablePrimary' | grep -q true; then break; fi + sleep 2 + done + + - name: Run store integration tests + env: + AUTHSOME_MONGO_URI: mongodb://localhost:27017/authsome_test?replicaSet=rs0&directConnection=true + run: go test -tags integration -count=1 ./store/... + # ─── Lint ─────────────────────────────────────────────────────────── lint: name: Lint diff --git a/api/admin_handlers.go b/api/admin_handlers.go index e4b67935..878537a3 100644 --- a/api/admin_handlers.go +++ b/api/admin_handlers.go @@ -284,15 +284,34 @@ func (a *API) handleAdminListUsers(ctx forge.Context, req *AdminListUsersRequest return nil, ctx.JSON(http.StatusOK, resp) } +// userInCallerApp loads a user and verifies they belong to the caller's tenant +// app. A missing user and a user in another app both return 404, so a tenant +// admin cannot view, mutate, or even confirm the existence of users outside +// their own app. +func (a *API) userInCallerApp(ctx forge.Context, userID id.UserID) (*user.User, error) { + appID, ok := a.callerAppID(ctx) + if !ok { + return nil, forge.Unauthorized("authentication required") + } + u, err := a.engine.AdminGetUser(ctx.Context(), userID) + if err != nil { + return nil, mapError(err) + } + if u.AppID.String() != appID.String() { + return nil, forge.NotFound("user not found") + } + return u, nil +} + func (a *API) handleAdminGetUser(ctx forge.Context, req *AdminGetUserRequest) (*user.User, error) { userID, err := id.ParseUserID(req.UserID) if err != nil { return nil, forge.BadRequest("invalid user_id") } - u, err := a.engine.AdminGetUser(ctx.Context(), userID) + u, err := a.userInCallerApp(ctx, userID) if err != nil { - return nil, mapError(err) + return nil, err } return u, nil @@ -314,6 +333,10 @@ func (a *API) handleAdminBanUser(ctx forge.Context, req *AdminBanUserRequest) (* return nil, forge.BadRequest("cannot ban yourself") } + if _, err := a.userInCallerApp(ctx, userID); err != nil { + return nil, err + } + var expiresAt *time.Time if req.ExpiresAt != nil && *req.ExpiresAt != "" { t, err := time.Parse(time.RFC3339, *req.ExpiresAt) @@ -342,6 +365,10 @@ func (a *API) handleAdminUnbanUser(ctx forge.Context, req *AdminUnbanUserRequest return nil, forge.BadRequest("invalid user_id") } + if _, err := a.userInCallerApp(ctx, userID); err != nil { + return nil, err + } + if err := a.engine.AdminUnbanUser(ctx.Context(), adminID, userID); err != nil { return nil, mapError(err) } @@ -366,6 +393,10 @@ func (a *API) handleAdminDeleteUser(ctx forge.Context, req *AdminDeleteUserReque return nil, forge.BadRequest("cannot delete yourself") } + if _, err := a.userInCallerApp(ctx, userID); err != nil { + return nil, err + } + if err := a.engine.AdminDeleteUser(ctx.Context(), adminID, userID); err != nil { return nil, mapError(err) } @@ -409,6 +440,10 @@ func (a *API) handleAdminImpersonate(ctx forge.Context, req *AdminImpersonateReq return nil, forge.BadRequest("cannot impersonate yourself") } + if _, err := a.userInCallerApp(ctx, targetID); err != nil { + return nil, err + } + u, sess, err := a.engine.Impersonate(ctx.Context(), adminID, targetID) if err != nil { return nil, mapError(err) @@ -442,6 +477,10 @@ func (a *API) handleAdminUpdateUser(ctx forge.Context, req *AdminUpdateUserReque return nil, forge.BadRequest("invalid user_id") } + if _, err := a.userInCallerApp(ctx, userID); err != nil { + return nil, err + } + updates := authsome.AdminUserUpdates{ FirstName: req.FirstName, LastName: req.LastName, diff --git a/api/admin_scope_test.go b/api/admin_scope_test.go new file mode 100644 index 00000000..6db34efa --- /dev/null +++ b/api/admin_scope_test.go @@ -0,0 +1,130 @@ +package api_test + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xraph/authsome" + "github.com/xraph/authsome/id" + "github.com/xraph/authsome/user" +) + +// seedForeignUser creates a user that belongs to a different app than the test +// platform app, for cross-tenant admin tests. +func seedForeignUser(t *testing.T, eng *authsome.Engine, email string) *user.User { + t.Helper() + now := time.Now() + u := &user.User{ + ID: id.NewUserID(), + AppID: otherAppID(t), + Email: email, + FirstName: "Foreign", + CreatedAt: now, + UpdatedAt: now, + } + require.NoError(t, eng.Store().CreateUser(context.Background(), u)) + return u +} + +// The admin user-management endpoints are gated by manage:user, evaluated +// against the caller's own app. Without a per-target tenant check, a tenant +// admin could view, ban, delete, edit, or impersonate a user in another app. +// Each handler must confirm the target user's app matches the caller's. + +func TestAdminGetUser_SameAppSucceeds(t *testing.T) { + a, eng := newBootstrappedAPI(t) + handler := withTestKey(a.Handler()) + _, ownerToken, _ := signUp(t, eng, "admin-get-owner@test.com", "SecureP@ss1") + ownerID := userIDFor(t, eng, ownerToken) + + // A user in the caller's own (platform) app. + _, victimToken, _ := signUp(t, eng, "admin-get-victim@test.com", "SecureP@ss1") + victimID := userIDFor(t, eng, victimToken) + + req := httptest.NewRequestWithContext(context.Background(), "GET", "/v1/admin/users/"+victimID.String(), nil) + req = asAdmin(t, req, eng, ownerID) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code, "owner must read a user in their own app; body=%s", rec.Body.String()) +} + +func TestAdminGetUser_RejectsCrossTenant(t *testing.T) { + a, eng := newBootstrappedAPI(t) + handler := withTestKey(a.Handler()) + _, ownerToken, _ := signUp(t, eng, "admin-x-owner@test.com", "SecureP@ss1") + ownerID := userIDFor(t, eng, ownerToken) + + foreign := seedForeignUser(t, eng, "foreign-user@test.com") + + req := httptest.NewRequestWithContext(context.Background(), "GET", "/v1/admin/users/"+foreign.ID.String(), nil) + req = asAdmin(t, req, eng, ownerID) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code, "must not read a user from another app; body=%s", rec.Body.String()) +} + +func TestAdminBanUser_RejectsCrossTenant(t *testing.T) { + a, eng := newBootstrappedAPI(t) + handler := withTestKey(a.Handler()) + _, ownerToken, _ := signUp(t, eng, "admin-ban-owner@test.com", "SecureP@ss1") + ownerID := userIDFor(t, eng, ownerToken) + + foreign := seedForeignUser(t, eng, "foreign-ban@test.com") + + body := []byte(`{"reason":"pwned"}`) + req := httptest.NewRequestWithContext(context.Background(), "POST", "/v1/admin/users/"+foreign.ID.String()+"/ban", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = asAdmin(t, req, eng, ownerID) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code) + + got, err := eng.AdminGetUser(context.Background(), foreign.ID) + require.NoError(t, err) + assert.False(t, got.Banned, "a user in another app must not be banned") +} + +func TestAdminImpersonate_RejectsCrossTenant(t *testing.T) { + a, eng := newBootstrappedAPI(t) + handler := withTestKey(a.Handler()) + _, ownerToken, _ := signUp(t, eng, "admin-imp-owner@test.com", "SecureP@ss1") + ownerID := userIDFor(t, eng, ownerToken) + + foreign := seedForeignUser(t, eng, "foreign-imp@test.com") + + req := httptest.NewRequestWithContext(context.Background(), "POST", "/v1/admin/impersonate/"+foreign.ID.String(), nil) + req = asAdmin(t, req, eng, ownerID) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code, "must not impersonate a user from another app; body=%s", rec.Body.String()) +} + +func TestAdminDeleteUser_RejectsCrossTenant(t *testing.T) { + a, eng := newBootstrappedAPI(t) + handler := withTestKey(a.Handler()) + _, ownerToken, _ := signUp(t, eng, "admin-del-owner@test.com", "SecureP@ss1") + ownerID := userIDFor(t, eng, ownerToken) + + foreign := seedForeignUser(t, eng, "foreign-del@test.com") + + req := httptest.NewRequestWithContext(context.Background(), "DELETE", "/v1/admin/users/"+foreign.ID.String(), nil) + req = asAdmin(t, req, eng, ownerID) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code) + + _, err := eng.AdminGetUser(context.Background(), foreign.ID) + assert.NoError(t, err, "a user in another app must survive the delete attempt") +} diff --git a/api/api_test.go b/api/api_test.go index 3aada7a3..18fe071c 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -1014,6 +1014,7 @@ func TestHandleRevokeSession_Success(t *testing.T) { require.NoError(t, err) req := httptest.NewRequestWithContext(context.Background(), "DELETE", "/v1/sessions/"+sess.ID.String(), nil) + req = req.WithContext(middleware.WithUserID(req.Context(), sess.UserID)) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) @@ -1021,10 +1022,13 @@ func TestHandleRevokeSession_Success(t *testing.T) { } func TestHandleRevokeSession_InvalidID(t *testing.T) { - a, _ := newTestAPI(t) + a, eng := newTestAPI(t) handler := withTestKey(a.Handler()) + _, token, _ := signUp(t, eng, "revoke-invalid@test.com", "SecureP@ss1") + req := httptest.NewRequestWithContext(context.Background(), "DELETE", "/v1/sessions/invalid-id", nil) + req = req.WithContext(middleware.WithUserID(req.Context(), userIDFor(t, eng, token))) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) diff --git a/api/app_client_config_handlers.go b/api/app_client_config_handlers.go index 798a26a4..441a1fd5 100644 --- a/api/app_client_config_handlers.go +++ b/api/app_client_config_handlers.go @@ -60,9 +60,9 @@ func (a *API) registerAppClientConfigRoutes(router forge.Router) error { // ────────────────────────────────────────────────── func (a *API) handleGetAppClientConfig(ctx forge.Context, req *GetAppClientConfigRequest) (*appclientconfig.Config, error) { - appID, err := id.ParseAppID(req.AppID) + appID, err := a.scopedAppID(ctx, req.AppID) if err != nil { - return nil, forge.BadRequest("invalid app_id") + return nil, err } cfg, err := a.engine.Store().GetAppClientConfig(ctx.Context(), appID) @@ -77,9 +77,9 @@ func (a *API) handleGetAppClientConfig(ctx forge.Context, req *GetAppClientConfi } func (a *API) handleSetAppClientConfig(ctx forge.Context, req *SetAppClientConfigRequest) (*appclientconfig.Config, error) { - appID, err := id.ParseAppID(req.AppID) + appID, err := a.scopedAppID(ctx, req.AppID) if err != nil { - return nil, forge.BadRequest("invalid app_id") + return nil, err } now := time.Now().UTC() @@ -125,9 +125,9 @@ func (a *API) handleSetAppClientConfig(ctx forge.Context, req *SetAppClientConfi } func (a *API) handleDeleteAppClientConfig(ctx forge.Context, req *DeleteAppClientConfigRequest) (*StatusResponse, error) { - appID, err := id.ParseAppID(req.AppID) + appID, err := a.scopedAppID(ctx, req.AppID) if err != nil { - return nil, forge.BadRequest("invalid app_id") + return nil, err } if err := a.engine.Store().DeleteAppClientConfig(ctx.Context(), appID); err != nil { diff --git a/api/app_session_config_handlers.go b/api/app_session_config_handlers.go index cc336c7d..8051ac36 100644 --- a/api/app_session_config_handlers.go +++ b/api/app_session_config_handlers.go @@ -60,9 +60,9 @@ func (a *API) registerAppSessionConfigRoutes(router forge.Router) error { // ────────────────────────────────────────────────── func (a *API) handleGetAppSessionConfig(ctx forge.Context, req *GetAppSessionConfigRequest) (*appsessionconfig.Config, error) { - appID, err := id.ParseAppID(req.AppID) + appID, err := a.scopedAppID(ctx, req.AppID) if err != nil { - return nil, forge.BadRequest("invalid app_id") + return nil, err } cfg, err := a.engine.Store().GetAppSessionConfig(ctx.Context(), appID) @@ -77,9 +77,9 @@ func (a *API) handleGetAppSessionConfig(ctx forge.Context, req *GetAppSessionCon } func (a *API) handleSetAppSessionConfig(ctx forge.Context, req *SetAppSessionConfigRequest) (*appsessionconfig.Config, error) { - appID, err := id.ParseAppID(req.AppID) + appID, err := a.scopedAppID(ctx, req.AppID) if err != nil { - return nil, forge.BadRequest("invalid app_id") + return nil, err } now := time.Now().UTC() @@ -118,9 +118,9 @@ func (a *API) handleSetAppSessionConfig(ctx forge.Context, req *SetAppSessionCon } func (a *API) handleDeleteAppSessionConfig(ctx forge.Context, req *DeleteAppSessionConfigRequest) (*StatusResponse, error) { - appID, err := id.ParseAppID(req.AppID) + appID, err := a.scopedAppID(ctx, req.AppID) if err != nil { - return nil, forge.BadRequest("invalid app_id") + return nil, err } if err := a.engine.Store().DeleteAppSessionConfig(ctx.Context(), appID); err != nil { diff --git a/api/cross_tenant_config_test.go b/api/cross_tenant_config_test.go new file mode 100644 index 00000000..f97d4fe6 --- /dev/null +++ b/api/cross_tenant_config_test.go @@ -0,0 +1,94 @@ +package api_test + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/xraph/authsome/id" +) + +// The per-app client/session config admin endpoints are gated by +// manage:app, but that permission is evaluated against the caller's OWN app. +// Without a tenant-match check, an admin of app A could read or rewrite app B's +// security-relevant config by putting B's id in the path. scopedAppID closes +// that: a request whose path app differs from the caller's bound app is 403. + +func TestGetAppClientConfig_RejectsCrossTenant(t *testing.T) { + a, eng := newBootstrappedAPI(t) + handler := withTestKey(a.Handler()) + + _, ownerToken, _ := signUp(t, eng, "cfg-get-owner@test.com", "SecureP@ss1") + ownerID := userIDFor(t, eng, ownerToken) + + req := httptest.NewRequestWithContext(context.Background(), "GET", "/v1/admin/apps/"+otherAppID(t).String()+"/client-config", nil) + req = asAdmin(t, req, eng, ownerID) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusForbidden, rec.Code, "reading another app's client config must be forbidden; body=%s", rec.Body.String()) +} + +func TestSetAppClientConfig_RejectsCrossTenant(t *testing.T) { + a, eng := newBootstrappedAPI(t) + handler := withTestKey(a.Handler()) + + _, ownerToken, _ := signUp(t, eng, "cfg-set-owner@test.com", "SecureP@ss1") + ownerID := userIDFor(t, eng, ownerToken) + + body := []byte(`{"signup_enabled":false}`) + req := httptest.NewRequestWithContext(context.Background(), "PUT", "/v1/admin/apps/"+otherAppID(t).String()+"/client-config", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = asAdmin(t, req, eng, ownerID) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusForbidden, rec.Code, "rewriting another app's client config must be forbidden; body=%s", rec.Body.String()) + + // The foreign app must have no config written. + _, err := eng.Store().GetAppClientConfig(context.Background(), otherAppID(t)) + assert.Error(t, err, "no config should have been created for the foreign app") +} + +func TestSetAppClientConfig_SameAppSucceeds(t *testing.T) { + a, eng := newBootstrappedAPI(t) + handler := withTestKey(a.Handler()) + + _, ownerToken, _ := signUp(t, eng, "cfg-same-owner@test.com", "SecureP@ss1") + ownerID := userIDFor(t, eng, ownerToken) + + appID, err := id.ParseAppID(testAppIDStr) + if err != nil { + t.Fatal(err) + } + + body := []byte(`{"signup_enabled":false}`) + req := httptest.NewRequestWithContext(context.Background(), "PUT", "/v1/admin/apps/"+appID.String()+"/client-config", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = asAdmin(t, req, eng, ownerID) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code, "owner must configure their own app; body=%s", rec.Body.String()) +} + +func TestSetAppSessionConfig_RejectsCrossTenant(t *testing.T) { + a, eng := newBootstrappedAPI(t) + handler := withTestKey(a.Handler()) + + _, ownerToken, _ := signUp(t, eng, "sess-cfg-owner@test.com", "SecureP@ss1") + ownerID := userIDFor(t, eng, ownerToken) + + body := []byte(`{}`) + req := httptest.NewRequestWithContext(context.Background(), "PUT", "/v1/admin/apps/"+otherAppID(t).String()+"/session-config", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = asAdmin(t, req, eng, ownerID) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusForbidden, rec.Code, "rewriting another app's session config must be forbidden; body=%s", rec.Body.String()) +} diff --git a/api/device_handlers.go b/api/device_handlers.go index 63d9d2b0..d6e3d0da 100644 --- a/api/device_handlers.go +++ b/api/device_handlers.go @@ -80,26 +80,20 @@ func (a *API) handleListDevices(ctx forge.Context, _ *ListDevicesRequest) (*Devi } func (a *API) handleGetDevice(ctx forge.Context, _ *GetDeviceRequest) (*device.Device, error) { - deviceID, err := id.ParseDeviceID(ctx.Param("deviceId")) + d, err := a.deviceOwnedByCaller(ctx) if err != nil { - return nil, forge.BadRequest(fmt.Sprintf("invalid device id: %v", err)) - } - - d, err := a.engine.GetDevice(ctx.Context(), deviceID) - if err != nil { - return nil, mapError(err) + return nil, err } - return d, nil } func (a *API) handleDeleteDevice(ctx forge.Context, _ *DeleteDeviceRequest) (*StatusResponse, error) { - deviceID, err := id.ParseDeviceID(ctx.Param("deviceId")) + d, err := a.deviceOwnedByCaller(ctx) if err != nil { - return nil, forge.BadRequest(fmt.Sprintf("invalid device id: %v", err)) + return nil, err } - if err := a.engine.DeleteDevice(ctx.Context(), deviceID); err != nil { + if err := a.engine.DeleteDevice(ctx.Context(), d.ID); err != nil { return nil, mapError(err) } @@ -108,15 +102,37 @@ func (a *API) handleDeleteDevice(ctx forge.Context, _ *DeleteDeviceRequest) (*St } func (a *API) handleTrustDevice(ctx forge.Context, _ *TrustDeviceRequest) (*device.Device, error) { - deviceID, err := id.ParseDeviceID(ctx.Param("deviceId")) + d, err := a.deviceOwnedByCaller(ctx) if err != nil { - return nil, forge.BadRequest(fmt.Sprintf("invalid device id: %v", err)) + return nil, err } - d, err := a.engine.TrustDevice(ctx.Context(), deviceID) + trusted, err := a.engine.TrustDevice(ctx.Context(), d.ID) if err != nil { return nil, mapError(err) } + return trusted, nil +} + +// deviceOwnedByCaller loads the device named by the :deviceId path parameter +// and verifies it belongs to the authenticated caller. A missing device and a +// device owned by another user both return 404 so the endpoint never +// discloses the existence of another user's device. +func (a *API) deviceOwnedByCaller(ctx forge.Context) (*device.Device, error) { + userID, ok := middleware.UserIDFrom(ctx.Context()) + if !ok { + return nil, forge.Unauthorized("authentication required") + } + + deviceID, err := id.ParseDeviceID(ctx.Param("deviceId")) + if err != nil { + return nil, forge.BadRequest(fmt.Sprintf("invalid device id: %v", err)) + } + + d, err := a.engine.GetDevice(ctx.Context(), deviceID) + if err != nil || d.UserID != userID { + return nil, forge.NotFound("device not found") + } return d, nil } diff --git a/api/environment_handlers.go b/api/environment_handlers.go index b7ee814c..87523482 100644 --- a/api/environment_handlers.go +++ b/api/environment_handlers.go @@ -16,7 +16,13 @@ import ( // ────────────────────────────────────────────────── func (a *API) registerEnvironmentRoutes(router forge.Router) error { - g := router.Group("/v1", forge.WithGroupTags("environments")) + g := router.Group("/v1", + forge.WithGroupTags("environments"), + forge.WithGroupMiddleware( + middleware.RequireAuth(), + middleware.RequirePermission(a.engine, "manage", "environment"), + ), + ) if err := g.POST("/environments", a.handleCreateEnvironment, forge.WithSummary("Create environment"), @@ -135,9 +141,9 @@ func (a *API) handleCreateEnvironment(ctx forge.Context, req *CreateEnvironmentR return nil, forge.BadRequest(fmt.Sprintf("invalid environment type: %s (must be development, staging, or production)", req.Type)) } - appID, err := a.resolveAppID(req.AppID) + appID, err := a.scopedAppID(ctx, req.AppID) if err != nil { - return nil, forge.BadRequest("invalid app_id") + return nil, err } env := &environment.Environment{ @@ -166,9 +172,9 @@ func (a *API) handleCreateEnvironment(ctx forge.Context, req *CreateEnvironmentR } func (a *API) handleListEnvironments(ctx forge.Context, req *ListEnvironmentsRequest) (*EnvironmentListResponse, error) { - appID, err := a.resolveAppID(req.AppID) + appID, err := a.scopedAppID(ctx, req.AppID) if err != nil { - return nil, forge.BadRequest("invalid app_id") + return nil, err } envs, err := a.engine.Store().ListEnvironments(ctx.Context(), appID) @@ -193,6 +199,9 @@ func (a *API) handleGetEnvironment(ctx forge.Context, _ *GetEnvironmentRequest) if err != nil { return nil, mapError(err) } + if err := a.assertAppScope(ctx, env.AppID); err != nil { + return nil, err + } return env, nil } @@ -207,6 +216,9 @@ func (a *API) handleUpdateEnvironment(ctx forge.Context, req *UpdateEnvironmentR if err != nil { return nil, mapError(err) } + if err := a.assertAppScope(ctx, env.AppID); err != nil { + return nil, err + } if req.Name != nil { env.Name = *req.Name @@ -236,6 +248,9 @@ func (a *API) handleDeleteEnvironment(ctx forge.Context, _ *DeleteEnvironmentReq if err != nil { return nil, mapError(err) } + if err := a.assertAppScope(ctx, env.AppID); err != nil { + return nil, err + } if env.IsDefault { return nil, forge.BadRequest("cannot delete the default environment") } @@ -254,6 +269,15 @@ func (a *API) handleCloneEnvironment(ctx forge.Context, req *CloneEnvironmentReq return nil, forge.BadRequest(fmt.Sprintf("invalid environment id: %v", err)) } + // The source environment must belong to the caller's app. + srcEnv, err := a.engine.Store().GetEnvironment(ctx.Context(), srcEnvID) + if err != nil { + return nil, mapError(err) + } + if err := a.assertAppScope(ctx, srcEnv.AppID); err != nil { + return nil, err + } + if req.Name == "" { return nil, forge.BadRequest("name is required") } @@ -304,6 +328,9 @@ func (a *API) handleGetEnvironmentSettings(ctx forge.Context, _ *GetEnvironmentS if err != nil { return nil, mapError(err) } + if err := a.assertAppScope(ctx, env.AppID); err != nil { + return nil, err + } // Resolve effective settings: type defaults + per-env overrides. typeDefaults := environment.DefaultSettingsForType(env.Type) @@ -332,6 +359,9 @@ func (a *API) handleUpdateEnvironmentSettings(ctx forge.Context, req *UpdateEnvi if err != nil { return nil, mapError(err) } + if err := a.assertAppScope(ctx, env.AppID); err != nil { + return nil, err + } // Merge new overrides on top of existing. settings := req.Settings @@ -354,6 +384,9 @@ func (a *API) handleSetDefaultEnvironment(ctx forge.Context, _ *SetDefaultEnviro if err != nil { return nil, mapError(err) } + if err := a.assertAppScope(ctx, env.AppID); err != nil { + return nil, err + } if err := a.engine.Store().SetDefaultEnvironment(ctx.Context(), env.AppID, envID); err != nil { return nil, mapError(err) diff --git a/api/rbac_handlers.go b/api/rbac_handlers.go index 3f564eb4..8c2c93ee 100644 --- a/api/rbac_handlers.go +++ b/api/rbac_handlers.go @@ -161,6 +161,24 @@ func (a *API) registerRBACRoutes(router forge.Router) error { // RBAC handlers // ────────────────────────────────────────────────── +// roleInCallerApp loads a role and verifies it belongs to the caller's tenant +// app. A missing role and a role owned by another app both return 404, so a +// caller can neither read nor infer the existence of another tenant's roles. +func (a *API) roleInCallerApp(ctx forge.Context, roleID id.RoleID) (*rbac.Role, error) { + appID, ok := a.callerAppID(ctx) + if !ok { + return nil, forge.Unauthorized("authentication required") + } + r, err := a.engine.GetRole(ctx.Context(), roleID) + if err != nil { + return nil, mapError(err) + } + if r.AppID != appID.String() { + return nil, forge.NotFound("role not found") + } + return r, nil +} + func (a *API) handleCreateRole(ctx forge.Context, req *CreateRoleRequest) (*rbac.Role, error) { if req.Name == "" { return nil, forge.BadRequest("name is required") @@ -169,9 +187,17 @@ func (a *API) handleCreateRole(ctx forge.Context, req *CreateRoleRequest) (*rbac return nil, forge.BadRequest("slug is required") } - appID, err := a.resolveAppID(req.AppID) + appID, err := a.scopedAppID(ctx, req.AppID) if err != nil { - return nil, forge.BadRequest("invalid app_id") + return nil, err + } + + // A parent role, if given, must live in the caller's app too — otherwise a + // new role could inherit another tenant's permissions. + if req.ParentID != "" { + if err := a.assertParentRoleInApp(ctx, req.ParentID); err != nil { + return nil, err + } } r := &rbac.Role{ @@ -190,10 +216,23 @@ func (a *API) handleCreateRole(ctx forge.Context, req *CreateRoleRequest) (*rbac return nil, ctx.JSON(http.StatusCreated, r) } +// assertParentRoleInApp verifies a parent role id refers to a role in the +// caller's app, preventing cross-tenant permission inheritance via re-parenting. +func (a *API) assertParentRoleInApp(ctx forge.Context, parentID string) error { + pid, err := id.ParseRoleID(parentID) + if err != nil { + return forge.BadRequest(fmt.Sprintf("invalid parent_id: %v", err)) + } + if _, err := a.roleInCallerApp(ctx, pid); err != nil { + return err + } + return nil +} + func (a *API) handleListRoles(ctx forge.Context, req *ListRolesRequest) (*RoleListResponse, error) { - appID, err := a.resolveAppID(req.AppID) + appID, err := a.scopedAppID(ctx, req.AppID) if err != nil { - return nil, forge.BadRequest("invalid app_id") + return nil, err } roles, err := a.engine.ListRoles(ctx.Context(), appID) @@ -214,9 +253,9 @@ func (a *API) handleGetRole(ctx forge.Context, _ *GetRoleRequest) (*rbac.Role, e return nil, forge.BadRequest(fmt.Sprintf("invalid role id: %v", err)) } - r, err := a.engine.GetRole(ctx.Context(), roleID) + r, err := a.roleInCallerApp(ctx, roleID) if err != nil { - return nil, mapError(err) + return nil, err } return r, nil @@ -228,9 +267,9 @@ func (a *API) handleUpdateRole(ctx forge.Context, req *UpdateRoleRequest) (*rbac return nil, forge.BadRequest(fmt.Sprintf("invalid role id: %v", err)) } - r, err := a.engine.GetRole(ctx.Context(), roleID) + r, err := a.roleInCallerApp(ctx, roleID) if err != nil { - return nil, mapError(err) + return nil, err } if req.Name != nil { @@ -240,6 +279,12 @@ func (a *API) handleUpdateRole(ctx forge.Context, req *UpdateRoleRequest) (*rbac r.Description = *req.Description } if req.ParentID != nil { + // Re-parenting must stay within the caller's app. + if *req.ParentID != "" { + if err := a.assertParentRoleInApp(ctx, *req.ParentID); err != nil { + return nil, err + } + } r.ParentID = *req.ParentID } @@ -256,6 +301,10 @@ func (a *API) handleDeleteRole(ctx forge.Context, _ *DeleteRoleRequest) (*Status return nil, forge.BadRequest(fmt.Sprintf("invalid role id: %v", err)) } + if _, err := a.roleInCallerApp(ctx, roleID); err != nil { + return nil, err + } + if err := a.engine.DeleteRole(ctx.Context(), roleID); err != nil { return nil, mapError(err) } @@ -270,6 +319,10 @@ func (a *API) handleAddPermission(ctx forge.Context, req *AddPermissionRequest) return nil, forge.BadRequest(fmt.Sprintf("invalid role id: %v", err)) } + if _, err := a.roleInCallerApp(ctx, roleID); err != nil { + return nil, err + } + if req.Action == "" { return nil, forge.BadRequest("action is required") } @@ -297,6 +350,10 @@ func (a *API) handleListRolePermissions(ctx forge.Context, _ *ListRolePermission return nil, forge.BadRequest(fmt.Sprintf("invalid role id: %v", err)) } + if _, err := a.roleInCallerApp(ctx, roleID); err != nil { + return nil, err + } + perms, err := a.engine.ListRolePermissions(ctx.Context(), roleID) if err != nil { return nil, mapError(err) @@ -310,11 +367,36 @@ func (a *API) handleListRolePermissions(ctx forge.Context, _ *ListRolePermission } func (a *API) handleRemovePermission(ctx forge.Context, _ *RemovePermissionRequest) (*StatusResponse, error) { + roleID, err := id.ParseRoleID(ctx.Param("roleId")) + if err != nil { + return nil, forge.BadRequest(fmt.Sprintf("invalid role id: %v", err)) + } permID, err := id.ParsePermissionID(ctx.Param("permissionId")) if err != nil { return nil, forge.BadRequest(fmt.Sprintf("invalid permission id: %v", err)) } + if _, err := a.roleInCallerApp(ctx, roleID); err != nil { + return nil, err + } + // The permission must actually belong to this role, so a caller cannot + // detach a permission from a role they don't own by pairing it with one + // they do. + perms, err := a.engine.ListRolePermissions(ctx.Context(), roleID) + if err != nil { + return nil, mapError(err) + } + found := false + for _, p := range perms { + if p.ID == permID.String() { + found = true + break + } + } + if !found { + return nil, forge.NotFound("permission not found") + } + if err := a.engine.RemovePermission(ctx.Context(), permID); err != nil { return nil, mapError(err) } @@ -329,6 +411,10 @@ func (a *API) handleAssignRole(ctx forge.Context, req *AssignRoleRequest) (*Stat return nil, forge.BadRequest(fmt.Sprintf("invalid role id: %v", err)) } + if _, err := a.roleInCallerApp(ctx, roleID); err != nil { + return nil, err + } + userID, err := id.ParseUserID(req.UserID) if err != nil { return nil, forge.BadRequest(fmt.Sprintf("invalid user_id: %v", err)) @@ -354,6 +440,10 @@ func (a *API) handleUnassignRole(ctx forge.Context, req *UnassignRoleRequest) (* return nil, forge.BadRequest(fmt.Sprintf("invalid role id: %v", err)) } + if _, err := a.roleInCallerApp(ctx, roleID); err != nil { + return nil, err + } + userID, err := id.ParseUserID(req.UserID) if err != nil { return nil, forge.BadRequest(fmt.Sprintf("invalid user_id: %v", err)) @@ -373,21 +463,19 @@ func (a *API) handleListUserRoles(ctx forge.Context, req *ListUserRolesRequest) return nil, forge.BadRequest(fmt.Sprintf("invalid user id: %v", err)) } - var roles []*rbac.Role - if req != nil && req.AppID != "" { - appID, parseErr := id.ParseAppID(req.AppID) - if parseErr != nil { - return nil, forge.BadRequest(fmt.Sprintf("invalid app_id: %v", parseErr)) - } - roles, err = a.engine.ListUserRolesInApp(ctx.Context(), appID, userID) - if err != nil { - return nil, mapError(err) - } - } else { - roles, err = a.engine.ListUserRoles(ctx.Context(), userID) - if err != nil { - return nil, mapError(err) - } + // Always scope to the caller's app so one tenant cannot enumerate a user's + // role assignments in other tenants. + appReq := "" + if req != nil { + appReq = req.AppID + } + appID, err := a.scopedAppID(ctx, appReq) + if err != nil { + return nil, err + } + roles, err := a.engine.ListUserRolesInApp(ctx.Context(), appID, userID) + if err != nil { + return nil, mapError(err) } if roles == nil { diff --git a/api/rbac_scope_test.go b/api/rbac_scope_test.go new file mode 100644 index 00000000..45c06168 --- /dev/null +++ b/api/rbac_scope_test.go @@ -0,0 +1,131 @@ +package api_test + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xraph/authsome" + "github.com/xraph/authsome/id" + "github.com/xraph/authsome/rbac" +) + +// seedRole creates an RBAC role in the given app/tenant and returns the +// authsome-format ("arol_") role id, which the HTTP handlers accept and warden +// resolves by suffix. (CreateRole rewrites r.ID to warden format, so we keep +// the original id we generated.) +func seedRole(t *testing.T, eng *authsome.Engine, appIDStr string) string { + t.Helper() + rid := id.NewRoleID() + suffix := rid.String()[len(rid.String())-8:] + r := &rbac.Role{ + ID: rid.String(), + AppID: appIDStr, + Name: "Role " + suffix, + Slug: "role-" + suffix, + } + require.NoError(t, eng.CreateRole(context.Background(), r)) + return rid.String() +} + +// The RBAC endpoints require manage/read/assign permissions on "role", but that +// is evaluated against the caller's own app. Without a tenant check on the +// target role, an admin of app A could read, modify, delete, add permissions +// to, or assign users to a role that belongs to app B — including escalating a +// user into another tenant's owner role. Each handler must verify the target +// role's AppID matches the caller's app. + +func TestGetRole_SameAppSucceeds(t *testing.T) { + a, eng := newBootstrappedAPI(t) + handler := withTestKey(a.Handler()) + _, ownerToken, _ := signUp(t, eng, "role-get-owner@test.com", "SecureP@ss1") + ownerID := userIDFor(t, eng, ownerToken) + + roleID := seedRole(t, eng, testAppIDStr) + + req := httptest.NewRequestWithContext(context.Background(), "GET", "/v1/roles/"+roleID, nil) + req = asAdmin(t, req, eng, ownerID) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code, "owner must read a role in their own app; body=%s", rec.Body.String()) +} + +func TestGetRole_RejectsCrossTenant(t *testing.T) { + a, eng := newBootstrappedAPI(t) + handler := withTestKey(a.Handler()) + _, ownerToken, _ := signUp(t, eng, "role-x-owner@test.com", "SecureP@ss1") + ownerID := userIDFor(t, eng, ownerToken) + + foreignRole := seedRole(t, eng, otherAppID(t).String()) + + req := httptest.NewRequestWithContext(context.Background(), "GET", "/v1/roles/"+foreignRole, nil) + req = asAdmin(t, req, eng, ownerID) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code, "a role from another app must not be readable; body=%s", rec.Body.String()) +} + +func TestDeleteRole_RejectsCrossTenant(t *testing.T) { + a, eng := newBootstrappedAPI(t) + handler := withTestKey(a.Handler()) + _, ownerToken, _ := signUp(t, eng, "role-del-owner@test.com", "SecureP@ss1") + ownerID := userIDFor(t, eng, ownerToken) + + foreignRole := seedRole(t, eng, otherAppID(t).String()) + + req := httptest.NewRequestWithContext(context.Background(), "DELETE", "/v1/roles/"+foreignRole, nil) + req = asAdmin(t, req, eng, ownerID) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code) + + rid, err := id.ParseRoleID(foreignRole) + require.NoError(t, err) + _, err = eng.GetRole(context.Background(), rid) + assert.NoError(t, err, "the foreign role must survive the delete attempt") +} + +func TestAddPermission_RejectsCrossTenant(t *testing.T) { + a, eng := newBootstrappedAPI(t) + handler := withTestKey(a.Handler()) + _, ownerToken, _ := signUp(t, eng, "perm-x-owner@test.com", "SecureP@ss1") + ownerID := userIDFor(t, eng, ownerToken) + + foreignRole := seedRole(t, eng, otherAppID(t).String()) + + body := []byte(`{"action":"manage","resource":"app"}`) + req := httptest.NewRequestWithContext(context.Background(), "POST", "/v1/roles/"+foreignRole+"/permissions", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = asAdmin(t, req, eng, ownerID) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code, "must not add permissions to another app's role; body=%s", rec.Body.String()) +} + +func TestAssignRole_RejectsCrossTenant(t *testing.T) { + a, eng := newBootstrappedAPI(t) + handler := withTestKey(a.Handler()) + _, ownerToken, _ := signUp(t, eng, "assign-x-owner@test.com", "SecureP@ss1") + ownerID := userIDFor(t, eng, ownerToken) + + foreignRole := seedRole(t, eng, otherAppID(t).String()) + victim := id.NewUserID() + + body := []byte(`{"user_id":"` + victim.String() + `"}`) + req := httptest.NewRequestWithContext(context.Background(), "POST", "/v1/roles/"+foreignRole+"/assign", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = asAdmin(t, req, eng, ownerID) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code, "must not assign users into another app's role; body=%s", rec.Body.String()) +} diff --git a/api/session_handlers.go b/api/session_handlers.go index 9c79a0ab..17a71187 100644 --- a/api/session_handlers.go +++ b/api/session_handlers.go @@ -57,12 +57,25 @@ func (a *API) handleListSessions(ctx forge.Context, _ *ListSessionsRequest) (*Se } func (a *API) handleRevokeSession(ctx forge.Context, _ *RevokeSessionRequest) (*StatusResponse, error) { + userID, ok := middleware.UserIDFrom(ctx.Context()) + if !ok { + return nil, forge.Unauthorized("authentication required") + } + rawID := ctx.Param("sessionId") sessID, err := id.ParseSessionID(rawID) if err != nil { return nil, forge.BadRequest(fmt.Sprintf("invalid session id: %v", err)) } + // Ownership check: a caller may only revoke their own sessions. A + // missing session and a session owned by another user both return 404 so + // the endpoint never discloses the existence of another user's session. + sess, err := a.engine.Store().GetSession(ctx.Context(), sessID) + if err != nil || sess.UserID != userID { + return nil, forge.NotFound("session not found") + } + if err := a.engine.RevokeSession(ctx.Context(), sessID); err != nil { return nil, mapError(err) } diff --git a/api/tenant_scope.go b/api/tenant_scope.go new file mode 100644 index 00000000..843d06e4 --- /dev/null +++ b/api/tenant_scope.go @@ -0,0 +1,52 @@ +package api + +import ( + "github.com/xraph/forge" + + "github.com/xraph/authsome/id" + "github.com/xraph/authsome/middleware" +) + +// callerAppID returns the app the authenticated caller is bound to, as resolved +// onto the request context by the auth / publishable-key middleware. This is +// the tenant boundary used to scope app-owned admin resources (webhooks, +// environments, per-app config) to the caller. +func (a *API) callerAppID(ctx forge.Context) (id.AppID, bool) { + return middleware.AppIDFrom(ctx.Context()) +} + +// scopedAppID resolves the caller's tenant app for create/list operations, +// rejecting any request that explicitly targets a different app than the caller +// is bound to. A caller may therefore only ever create or enumerate resources +// within their own app, never one supplied in the request body/query. +func (a *API) scopedAppID(ctx forge.Context, requested string) (id.AppID, error) { + var zero id.AppID + appID, ok := a.callerAppID(ctx) + if !ok { + return zero, forge.Unauthorized("authentication required") + } + if requested != "" { + reqID, err := id.ParseAppID(requested) + if err != nil { + return zero, forge.BadRequest("invalid app_id") + } + if reqID.String() != appID.String() { + return zero, forge.Forbidden("cannot act on another app's resources") + } + } + return appID, nil +} + +// assertAppScope verifies that a loaded resource belongs to the caller's tenant +// app. It returns a 404 (not 403) on mismatch so the endpoint never discloses +// the existence of another app's resource. A missing caller app yields 401. +func (a *API) assertAppScope(ctx forge.Context, resourceAppID id.AppID) error { + appID, ok := a.callerAppID(ctx) + if !ok { + return forge.Unauthorized("authentication required") + } + if resourceAppID.String() != appID.String() { + return forge.NotFound("not found") + } + return nil +} diff --git a/api/unauth_admin_endpoints_test.go b/api/unauth_admin_endpoints_test.go new file mode 100644 index 00000000..f1c5363a --- /dev/null +++ b/api/unauth_admin_endpoints_test.go @@ -0,0 +1,338 @@ +package api_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xraph/warden" + wardenmem "github.com/xraph/warden/store/memory" + + "github.com/xraph/authsome" + "github.com/xraph/authsome/api" + "github.com/xraph/authsome/environment" + "github.com/xraph/authsome/id" + "github.com/xraph/authsome/internal/secutil" + "github.com/xraph/authsome/middleware" + "github.com/xraph/authsome/store/memory" + "github.com/xraph/authsome/webhook" +) + +// newBootstrappedAPI builds an API whose engine has run the bootstrap path, so +// the embedded warden roles are seeded and the first user to sign up is +// promoted to platform-owner (with real manage:* grants). Permission-gated +// route groups can only be exercised against a bootstrapped engine; the plain +// newTestAPI helper does not seed roles. +func newBootstrappedAPI(t *testing.T) (*api.API, *authsome.Engine) { + t.Helper() + s := memory.New() + seedTestPlatformApp(t, s) + w, err := warden.NewEngine(warden.WithStore(wardenmem.New())) + require.NoError(t, err) + eng, err := authsome.NewEngine( + authsome.WithStore(s), + authsome.WithWarden(w), + authsome.WithDisableMigrate(), + authsome.WithAppID(testAppIDStr), + authsome.WithBootstrap(), + ) + require.NoError(t, err) + require.NoError(t, eng.Start(context.Background())) + secutil.RelaxAuthDefaults(t, eng) + return api.New(eng), eng +} + +// asAdmin injects both the user ID and the full user object onto the request +// context, mirroring what the identity-resolving auth middleware sets in +// production. Needed for route groups guarded by RequireAuth (which reads the +// full user) plus RequirePermission (which reads the user ID). +func asAdmin(t *testing.T, req *http.Request, eng *authsome.Engine, userID id.UserID) *http.Request { + t.Helper() + u, err := eng.AdminGetUser(context.Background(), userID) + require.NoError(t, err) + ctx := middleware.WithUserID(req.Context(), userID) + ctx = middleware.WithUser(ctx, u) + return req.WithContext(ctx) +} + +// otherAppID returns a valid AppID distinct from the test platform app, for +// building cross-tenant fixtures. +func otherAppID(t *testing.T) id.AppID { + t.Helper() + appID, err := id.ParseAppID("aapp_01jf9999999999999999999999") + require.NoError(t, err) + return appID +} + +// ────────────────────────────────────────────────── +// Webhooks — /v1/webhooks CRUD +// ────────────────────────────────────────────────── + +func TestWebhookCreate_RequiresAuth(t *testing.T) { + a, _ := newTestAPI(t) + handler := withTestKey(a.Handler()) + + body := []byte(`{"url":"https://evil.example.com/hook","events":["user.created"]}`) + req := httptest.NewRequestWithContext(context.Background(), "POST", "/v1/webhooks", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) +} + +func TestWebhookList_RequiresAuth(t *testing.T) { + a, _ := newTestAPI(t) + handler := withTestKey(a.Handler()) + + req := httptest.NewRequestWithContext(context.Background(), "GET", "/v1/webhooks", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) +} + +func TestWebhookGet_RequiresAuth(t *testing.T) { + a, eng := newTestAPI(t) + handler := withTestKey(a.Handler()) + + w := seedWebhook(t, eng, testAppIDStr) + req := httptest.NewRequestWithContext(context.Background(), "GET", "/v1/webhooks/"+w.ID.String(), nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) +} + +func TestWebhookUpdate_RequiresAuth(t *testing.T) { + a, eng := newTestAPI(t) + handler := withTestKey(a.Handler()) + + w := seedWebhook(t, eng, testAppIDStr) + body := []byte(`{"url":"https://evil.example.com/hook"}`) + req := httptest.NewRequestWithContext(context.Background(), "PATCH", "/v1/webhooks/"+w.ID.String(), bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) + + got, err := eng.GetWebhook(context.Background(), w.ID) + require.NoError(t, err) + assert.NotEqual(t, "https://evil.example.com/hook", got.URL, "unauthenticated caller must not rewrite the webhook URL") +} + +func TestWebhookDelete_RequiresAuth(t *testing.T) { + a, eng := newTestAPI(t) + handler := withTestKey(a.Handler()) + + w := seedWebhook(t, eng, testAppIDStr) + req := httptest.NewRequestWithContext(context.Background(), "DELETE", "/v1/webhooks/"+w.ID.String(), nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) +} + +func TestWebhookGet_RejectsCrossTenant(t *testing.T) { + a, eng := newBootstrappedAPI(t) + handler := withTestKey(a.Handler()) + + // Platform-owner (first user) is fully privileged in their own app, but a + // webhook belonging to a *different* app must still be invisible to them. + _, ownerToken, _ := signUp(t, eng, "wh-owner@test.com", "SecureP@ss1") + ownerID := userIDFor(t, eng, ownerToken) + + foreign := seedWebhook(t, eng, otherAppID(t).String()) + req := httptest.NewRequestWithContext(context.Background(), "GET", "/v1/webhooks/"+foreign.ID.String(), nil) + req = asAdmin(t, req, eng, ownerID) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code, "a webhook from another app must not be readable") +} + +func TestWebhookDelete_RejectsCrossTenant(t *testing.T) { + a, eng := newBootstrappedAPI(t) + handler := withTestKey(a.Handler()) + + _, ownerToken, _ := signUp(t, eng, "wh-del-owner@test.com", "SecureP@ss1") + ownerID := userIDFor(t, eng, ownerToken) + + foreign := seedWebhook(t, eng, otherAppID(t).String()) + req := httptest.NewRequestWithContext(context.Background(), "DELETE", "/v1/webhooks/"+foreign.ID.String(), nil) + req = asAdmin(t, req, eng, ownerID) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code) + + _, err := eng.GetWebhook(context.Background(), foreign.ID) + assert.NoError(t, err, "another app's webhook must survive the delete attempt") +} + +func TestWebhookCreate_OwnerSucceeds(t *testing.T) { + a, eng := newBootstrappedAPI(t) + handler := withTestKey(a.Handler()) + + _, ownerToken, _ := signUp(t, eng, "wh-create-owner@test.com", "SecureP@ss1") + ownerID := userIDFor(t, eng, ownerToken) + + body := []byte(`{"url":"https://good.example.com/hook","events":["user.created"]}`) + req := httptest.NewRequestWithContext(context.Background(), "POST", "/v1/webhooks", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = asAdmin(t, req, eng, ownerID) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusCreated, rec.Code, "body=%s", rec.Body.String()) + + var got webhook.Webhook + require.NoError(t, json.NewDecoder(rec.Body).Decode(&got)) + assert.Equal(t, testAppIDStr, got.AppID.String(), "created webhook must be bound to the caller's app") +} + +func TestWebhookCreate_ForbiddenWithoutPermission(t *testing.T) { + a, eng := newTestAPI(t) + handler := withTestKey(a.Handler()) + + // First user consumes the platform-owner slot; the second is a plain user. + _, _, _ = signUp(t, eng, "wh-first-owner@test.com", "SecureP@ss1") + _, regularToken, _ := signUp(t, eng, "wh-regular@test.com", "SecureP@ss1") + regularID := userIDFor(t, eng, regularToken) + + body := []byte(`{"url":"https://good.example.com/hook","events":["user.created"]}`) + req := httptest.NewRequestWithContext(context.Background(), "POST", "/v1/webhooks", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = asAdmin(t, req, eng, regularID) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusForbidden, rec.Code, "a non-admin user must not create webhooks; body=%s", rec.Body.String()) +} + +// ────────────────────────────────────────────────── +// Environments — /v1/environments CRUD +// ────────────────────────────────────────────────── + +func TestEnvironmentCreate_RequiresAuth(t *testing.T) { + a, _ := newTestAPI(t) + handler := withTestKey(a.Handler()) + + body := []byte(`{"name":"Prod","slug":"prod","type":"production"}`) + req := httptest.NewRequestWithContext(context.Background(), "POST", "/v1/environments", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) +} + +func TestEnvironmentList_RequiresAuth(t *testing.T) { + a, _ := newTestAPI(t) + handler := withTestKey(a.Handler()) + + req := httptest.NewRequestWithContext(context.Background(), "GET", "/v1/environments", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) +} + +func TestEnvironmentDelete_RequiresAuth(t *testing.T) { + a, eng := newTestAPI(t) + handler := withTestKey(a.Handler()) + + env := seedEnvironment(t, eng, testAppIDStr) + req := httptest.NewRequestWithContext(context.Background(), "DELETE", "/v1/environments/"+env.ID.String(), nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) + + _, err := eng.GetEnvironment(context.Background(), env.ID) + assert.NoError(t, err, "environment must not be deleted by an unauthenticated caller") +} + +func TestEnvironmentDelete_RejectsCrossTenant(t *testing.T) { + a, eng := newBootstrappedAPI(t) + handler := withTestKey(a.Handler()) + + _, ownerToken, _ := signUp(t, eng, "env-owner@test.com", "SecureP@ss1") + ownerID := userIDFor(t, eng, ownerToken) + + foreign := seedEnvironment(t, eng, otherAppID(t).String()) + req := httptest.NewRequestWithContext(context.Background(), "DELETE", "/v1/environments/"+foreign.ID.String(), nil) + req = asAdmin(t, req, eng, ownerID) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code) + + _, err := eng.GetEnvironment(context.Background(), foreign.ID) + assert.NoError(t, err, "another app's environment must survive the delete attempt") +} + +func TestEnvironmentCreate_OwnerSucceeds(t *testing.T) { + a, eng := newBootstrappedAPI(t) + handler := withTestKey(a.Handler()) + + _, ownerToken, _ := signUp(t, eng, "env-create-owner@test.com", "SecureP@ss1") + ownerID := userIDFor(t, eng, ownerToken) + + body := []byte(`{"name":"Staging","slug":"staging","type":"staging"}`) + req := httptest.NewRequestWithContext(context.Background(), "POST", "/v1/environments", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = asAdmin(t, req, eng, ownerID) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusCreated, rec.Code, "body=%s", rec.Body.String()) + + var got environment.Environment + require.NoError(t, json.NewDecoder(rec.Body).Decode(&got)) + assert.Equal(t, testAppIDStr, got.AppID.String(), "created environment must be bound to the caller's app") +} + +// ────────────────────────────────────────────────── +// Fixtures +// ────────────────────────────────────────────────── + +func seedWebhook(t *testing.T, eng *authsome.Engine, appIDStr string) *webhook.Webhook { + t.Helper() + appID, err := id.ParseAppID(appIDStr) + require.NoError(t, err) + w := &webhook.Webhook{ + ID: id.NewWebhookID(), + AppID: appID, + URL: "https://original.example.com/hook", + Events: []string{"user.created"}, + } + require.NoError(t, eng.Store().CreateWebhook(context.Background(), w)) + return w +} + +func seedEnvironment(t *testing.T, eng *authsome.Engine, appIDStr string) *environment.Environment { + t.Helper() + appID, err := id.ParseAppID(appIDStr) + require.NoError(t, err) + now := time.Now() + env := &environment.Environment{ + ID: id.NewEnvironmentID(), + AppID: appID, + Name: "Original", + Slug: "original", + Type: environment.TypeDevelopment, + CreatedAt: now, + UpdatedAt: now, + } + require.NoError(t, eng.Store().CreateEnvironment(context.Background(), env)) + return env +} diff --git a/api/unauth_endpoints_test.go b/api/unauth_endpoints_test.go new file mode 100644 index 00000000..46107af5 --- /dev/null +++ b/api/unauth_endpoints_test.go @@ -0,0 +1,241 @@ +package api_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xraph/authsome" + "github.com/xraph/authsome/device" + "github.com/xraph/authsome/id" + "github.com/xraph/authsome/middleware" +) + +// asUser returns a copy of req whose context carries the given user ID, mimicking +// what the auth middleware sets after resolving a session in production. The bare +// API test handler does not run the identity-resolving AuthMiddleware, so tests +// inject identity directly (matching the existing WithUserID convention). +func asUser(req *http.Request, userID id.UserID) *http.Request { + return req.WithContext(middleware.WithUserID(req.Context(), userID)) +} + +// userIDFor resolves the user ID behind a session token. +func userIDFor(t *testing.T, eng *authsome.Engine, token string) id.UserID { + t.Helper() + sess, err := eng.ResolveSessionByToken(token) + require.NoError(t, err) + return sess.UserID +} + +// registerDeviceFor creates a tracked device owned by the given user and returns it. +func registerDeviceFor(t *testing.T, eng *authsome.Engine, userID id.UserID) *device.Device { + t.Helper() + appID, err := id.ParseAppID(testAppIDStr) + require.NoError(t, err) + d, err := eng.RegisterDevice(context.Background(), &device.Device{ + UserID: userID, + AppID: appID, + Name: "Test Device", + }) + require.NoError(t, err) + return d +} + +// ────────────────────────────────────────────────── +// Sessions — DELETE /v1/sessions/:id +// ────────────────────────────────────────────────── + +func TestRevokeSession_RequiresAuth(t *testing.T) { + a, eng := newTestAPI(t) + handler := withTestKey(a.Handler()) + + _, token, _ := signUp(t, eng, "sess-noauth@test.com", "SecureP@ss1") + sess, err := eng.ResolveSessionByToken(token) + require.NoError(t, err) + + req := httptest.NewRequestWithContext(context.Background(), "DELETE", "/v1/sessions/"+sess.ID.String(), nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) + + _, err = eng.ResolveSessionByToken(token) + assert.NoError(t, err, "session must not be revoked by an unauthenticated caller") +} + +func TestRevokeSession_RejectsNonOwner(t *testing.T) { + a, eng := newTestAPI(t) + handler := withTestKey(a.Handler()) + + _, tokenA, _ := signUp(t, eng, "sess-owner-a@test.com", "SecureP@ss1") + _, tokenB, _ := signUp(t, eng, "sess-owner-b@test.com", "SecureP@ss1") + + sessB, err := eng.ResolveSessionByToken(tokenB) + require.NoError(t, err) + + req := httptest.NewRequestWithContext(context.Background(), "DELETE", "/v1/sessions/"+sessB.ID.String(), nil) + req = asUser(req, userIDFor(t, eng, tokenA)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code) + + _, err = eng.ResolveSessionByToken(tokenB) + assert.NoError(t, err, "user B's session must survive user A's revoke attempt") +} + +func TestRevokeSession_OwnerSucceeds(t *testing.T) { + a, eng := newTestAPI(t) + handler := withTestKey(a.Handler()) + + _, token, _ := signUp(t, eng, "sess-owner@test.com", "SecureP@ss1") + sess, err := eng.ResolveSessionByToken(token) + require.NoError(t, err) + + req := httptest.NewRequestWithContext(context.Background(), "DELETE", "/v1/sessions/"+sess.ID.String(), nil) + req = asUser(req, sess.UserID) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code, "owner must be able to revoke their own session; body=%s", rec.Body.String()) +} + +// ────────────────────────────────────────────────── +// Devices — GET/DELETE /v1/devices/:id, PATCH /v1/devices/:id/trust +// ────────────────────────────────────────────────── + +func TestGetDevice_RequiresAuth(t *testing.T) { + a, eng := newTestAPI(t) + handler := withTestKey(a.Handler()) + + _, token, _ := signUp(t, eng, "dev-noauth@test.com", "SecureP@ss1") + d := registerDeviceFor(t, eng, userIDFor(t, eng, token)) + + req := httptest.NewRequestWithContext(context.Background(), "GET", "/v1/devices/"+d.ID.String(), nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) +} + +func TestGetDevice_RejectsNonOwner(t *testing.T) { + a, eng := newTestAPI(t) + handler := withTestKey(a.Handler()) + + _, tokenA, _ := signUp(t, eng, "dev-a@test.com", "SecureP@ss1") + _, tokenB, _ := signUp(t, eng, "dev-b@test.com", "SecureP@ss1") + dB := registerDeviceFor(t, eng, userIDFor(t, eng, tokenB)) + + req := httptest.NewRequestWithContext(context.Background(), "GET", "/v1/devices/"+dB.ID.String(), nil) + req = asUser(req, userIDFor(t, eng, tokenA)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code, "user A must not read user B's device") +} + +func TestGetDevice_OwnerSucceeds(t *testing.T) { + a, eng := newTestAPI(t) + handler := withTestKey(a.Handler()) + + _, token, _ := signUp(t, eng, "dev-owner@test.com", "SecureP@ss1") + uid := userIDFor(t, eng, token) + d := registerDeviceFor(t, eng, uid) + + req := httptest.NewRequestWithContext(context.Background(), "GET", "/v1/devices/"+d.ID.String(), nil) + req = asUser(req, uid) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String()) +} + +func TestDeleteDevice_RequiresAuth(t *testing.T) { + a, eng := newTestAPI(t) + handler := withTestKey(a.Handler()) + + _, token, _ := signUp(t, eng, "dev-del-noauth@test.com", "SecureP@ss1") + d := registerDeviceFor(t, eng, userIDFor(t, eng, token)) + + req := httptest.NewRequestWithContext(context.Background(), "DELETE", "/v1/devices/"+d.ID.String(), nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) + + _, err := eng.GetDevice(context.Background(), d.ID) + assert.NoError(t, err, "device must not be deleted by an unauthenticated caller") +} + +func TestDeleteDevice_RejectsNonOwner(t *testing.T) { + a, eng := newTestAPI(t) + handler := withTestKey(a.Handler()) + + _, tokenA, _ := signUp(t, eng, "dev-del-a@test.com", "SecureP@ss1") + _, tokenB, _ := signUp(t, eng, "dev-del-b@test.com", "SecureP@ss1") + dB := registerDeviceFor(t, eng, userIDFor(t, eng, tokenB)) + + req := httptest.NewRequestWithContext(context.Background(), "DELETE", "/v1/devices/"+dB.ID.String(), nil) + req = asUser(req, userIDFor(t, eng, tokenA)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code) + + _, err := eng.GetDevice(context.Background(), dB.ID) + assert.NoError(t, err, "user B's device must survive user A's delete attempt") +} + +func TestDeleteDevice_OwnerSucceeds(t *testing.T) { + a, eng := newTestAPI(t) + handler := withTestKey(a.Handler()) + + _, token, _ := signUp(t, eng, "dev-del-owner@test.com", "SecureP@ss1") + uid := userIDFor(t, eng, token) + d := registerDeviceFor(t, eng, uid) + + req := httptest.NewRequestWithContext(context.Background(), "DELETE", "/v1/devices/"+d.ID.String(), nil) + req = asUser(req, uid) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String()) +} + +func TestTrustDevice_RequiresAuth(t *testing.T) { + a, eng := newTestAPI(t) + handler := withTestKey(a.Handler()) + + _, token, _ := signUp(t, eng, "dev-trust-noauth@test.com", "SecureP@ss1") + d := registerDeviceFor(t, eng, userIDFor(t, eng, token)) + + req := httptest.NewRequestWithContext(context.Background(), "PATCH", "/v1/devices/"+d.ID.String()+"/trust", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) +} + +func TestTrustDevice_RejectsNonOwner(t *testing.T) { + a, eng := newTestAPI(t) + handler := withTestKey(a.Handler()) + + _, tokenA, _ := signUp(t, eng, "dev-trust-a@test.com", "SecureP@ss1") + _, tokenB, _ := signUp(t, eng, "dev-trust-b@test.com", "SecureP@ss1") + dB := registerDeviceFor(t, eng, userIDFor(t, eng, tokenB)) + + req := httptest.NewRequestWithContext(context.Background(), "PATCH", "/v1/devices/"+dB.ID.String()+"/trust", nil) + req = asUser(req, userIDFor(t, eng, tokenA)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code) + + got, err := eng.GetDevice(context.Background(), dB.ID) + require.NoError(t, err) + assert.False(t, got.Trusted, "user B's device must not be trusted by user A") +} diff --git a/api/webhook_handlers.go b/api/webhook_handlers.go index 7c24b5f1..0d9d5e7c 100644 --- a/api/webhook_handlers.go +++ b/api/webhook_handlers.go @@ -7,6 +7,7 @@ import ( "github.com/xraph/forge" "github.com/xraph/authsome/id" + "github.com/xraph/authsome/middleware" "github.com/xraph/authsome/webhook" ) @@ -15,7 +16,13 @@ import ( // ────────────────────────────────────────────────── func (a *API) registerWebhookRoutes(router forge.Router) error { - g := router.Group("/v1", forge.WithGroupTags("webhooks")) + g := router.Group("/v1", + forge.WithGroupTags("webhooks"), + forge.WithGroupMiddleware( + middleware.RequireAuth(), + middleware.RequirePermission(a.engine, "manage", "app"), + ), + ) if err := g.POST("/webhooks", a.handleCreateWebhook, forge.WithSummary("Create webhook"), @@ -80,9 +87,9 @@ func (a *API) handleCreateWebhook(ctx forge.Context, req *CreateWebhookRequest) return nil, forge.BadRequest("at least one event type is required") } - appID, err := a.resolveAppID(req.AppID) + appID, err := a.scopedAppID(ctx, req.AppID) if err != nil { - return nil, forge.BadRequest("invalid app_id") + return nil, err } w := &webhook.Webhook{ @@ -99,9 +106,9 @@ func (a *API) handleCreateWebhook(ctx forge.Context, req *CreateWebhookRequest) } func (a *API) handleListWebhooks(ctx forge.Context, req *ListWebhooksRequest) (*WebhookListResponse, error) { - appID, err := a.resolveAppID(req.AppID) + appID, err := a.scopedAppID(ctx, req.AppID) if err != nil { - return nil, forge.BadRequest("invalid app_id") + return nil, err } webhooks, err := a.engine.ListWebhooks(ctx.Context(), appID) @@ -126,6 +133,9 @@ func (a *API) handleGetWebhook(ctx forge.Context, _ *GetWebhookRequest) (*webhoo if err != nil { return nil, mapError(err) } + if err := a.assertAppScope(ctx, w.AppID); err != nil { + return nil, err + } return w, nil } @@ -140,6 +150,9 @@ func (a *API) handleUpdateWebhook(ctx forge.Context, req *UpdateWebhookRequest) if err != nil { return nil, mapError(err) } + if err := a.assertAppScope(ctx, w.AppID); err != nil { + return nil, err + } if req.URL != nil { w.URL = *req.URL @@ -164,6 +177,14 @@ func (a *API) handleDeleteWebhook(ctx forge.Context, _ *DeleteWebhookRequest) (* return nil, forge.BadRequest(fmt.Sprintf("invalid webhook id: %v", err)) } + w, err := a.engine.GetWebhook(ctx.Context(), webhookID) + if err != nil { + return nil, mapError(err) + } + if err := a.assertAppScope(ctx, w.AppID); err != nil { + return nil, err + } + if err := a.engine.DeleteWebhook(ctx.Context(), webhookID); err != nil { return nil, mapError(err) } diff --git a/ceremony_store_race_test.go b/ceremony_store_race_test.go new file mode 100644 index 00000000..baf38b4e --- /dev/null +++ b/ceremony_store_race_test.go @@ -0,0 +1,60 @@ +package authsome + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/xraph/authsome/store/memory" + + "github.com/xraph/warden" + wardenmem "github.com/xraph/warden/store/memory" +) + +func newEngineForCeremonyTest(t *testing.T) *Engine { + t.Helper() + w, err := warden.NewEngine(warden.WithStore(wardenmem.New())) + require.NoError(t, err) + eng, err := NewEngine( + WithStore(memory.New()), + WithWarden(w), + WithDisableMigrate(), + ) + require.NoError(t, err) + return eng +} + +// TestCeremonyStore_EagerlyAllocatedAndStable pins the fix for the ceremony +// store data race: when no store is configured, NewEngine allocates one up +// front, and the accessor returns that same instance every time without +// mutating engine state. Previously the store was lazily created on the first +// request goroutine, racing concurrent MFA-gated logins and dropping tickets. +func TestCeremonyStore_EagerlyAllocatedAndStable(t *testing.T) { + eng := newEngineForCeremonyTest(t) + + first := eng.ceremonyStoreOrFallback() + require.NotNil(t, first, "ceremony store must be allocated at construction, not lazily") + + second := eng.ceremonyStoreOrFallback() + require.Same(t, first, second, "accessor must return the same store, never re-allocate") +} + +// TestCeremonyStore_ConcurrentAccessNoRace exercises the accessor from many +// goroutines. With the lazy nil-check-and-assign removed, `go test -race` +// reports no data race on the engine's ceremonyStore field. +func TestCeremonyStore_ConcurrentAccessNoRace(t *testing.T) { + eng := newEngineForCeremonyTest(t) + + var wg sync.WaitGroup + for i := 0; i < 64; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if s := eng.ceremonyStoreOrFallback(); s == nil { + t.Error("ceremony store must never be nil") + } + }() + } + wg.Wait() +} diff --git a/engine.go b/engine.go index cec7aa4b..1b401601 100644 --- a/engine.go +++ b/engine.go @@ -150,6 +150,13 @@ func NewEngine(opts ...Option) (*Engine, error) { return nil, errors.New("authsome: warden engine is required (use WithWarden option)") } + // Eagerly allocate the MFA ceremony store when none was configured, so it + // is never lazily created on a request goroutine (which would be a data + // race between concurrent MFA-gated logins and could drop tickets). + if e.ceremonyStore == nil { + e.ceremonyStore = ceremony.NewMemory() + } + // Initialize subsystems e.plugins = plugin.NewRegistry(e.logger) e.hooks = hook.NewBus(e.logger) diff --git a/engine_issue_session.go b/engine_issue_session.go index 0f4996cb..4e3b1409 100644 --- a/engine_issue_session.go +++ b/engine_issue_session.go @@ -316,15 +316,11 @@ func IsMFATicketNotFound(err error) bool { return errors.Is(err, ceremony.ErrNotFound) } -// ceremonyStoreOrFallback returns the configured ceremony store, -// lazily allocating a process-wide in-memory store if none was -// configured. The lazy allocation is single-use (sync.Once-like via -// nil check + assignment under the engine's existing serial -// initialisation contract) so two IssueSession calls share a backing -// map and tickets actually persist between calls. +// ceremonyStoreOrFallback returns the configured ceremony store. The store is +// always non-nil: NewEngine allocates an in-memory fallback at construction +// time when no store was configured, so this accessor never mutates engine +// state on a request goroutine (avoiding a data race between concurrent +// MFA-gated logins). func (e *Engine) ceremonyStoreOrFallback() ceremony.Store { - if e.ceremonyStore == nil { - e.ceremonyStore = ceremony.NewMemory() - } return e.ceremonyStore } diff --git a/extension/contract/handlers.go b/extension/contract/handlers.go index d861c077..4b8c52d9 100644 --- a/extension/contract/handlers.go +++ b/extension/contract/handlers.go @@ -9,6 +9,7 @@ import ( authsome "github.com/xraph/authsome" "github.com/xraph/authsome/account" "github.com/xraph/authsome/id" + "github.com/xraph/authsome/middleware" dashauth "github.com/xraph/forge/extensions/dashboard/auth" "github.com/xraph/forge/extensions/dashboard/contract" @@ -171,20 +172,9 @@ func AppIDFromPrincipal(p contract.Principal, eng *authsome.Engine) id.AppID { } func clientIP(r *http.Request) string { - if xff := r.Header.Get("X-Forwarded-For"); xff != "" { - if i := strings.IndexByte(xff, ','); i > 0 { - return strings.TrimSpace(xff[:i]) - } - return strings.TrimSpace(xff) - } - if xri := r.Header.Get("X-Real-IP"); xri != "" { - return strings.TrimSpace(xri) - } - addr := r.RemoteAddr - if i := strings.LastIndex(addr, ":"); i > 0 { - return addr[:i] - } - return addr + // Delegate to the trusted-proxy-aware resolver so forwarding headers are + // honored only from a trusted proxy — a direct client cannot spoof its IP. + return middleware.ClientIP(r) } func extractToken(r *http.Request) string { diff --git a/middleware/auth.go b/middleware/auth.go index 226ff01e..acab74ef 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -737,23 +737,10 @@ func resolveCookieName(resolver CookieNameResolver, ctx context.Context) string return "authsome_session_token" } -// clientIPFromRequest extracts the client IP from the request, checking -// X-Forwarded-For and X-Real-IP headers before falling back to RemoteAddr. +// clientIPFromRequest extracts the client IP from the request. It delegates to +// the trusted-proxy-aware resolver so X-Forwarded-For / X-Real-IP are honored +// only when the immediate peer is a trusted proxy (a direct client cannot spoof +// its IP). Kept as a thin alias so existing call sites read naturally. func clientIPFromRequest(r *http.Request) string { - if xff := r.Header.Get("X-Forwarded-For"); xff != "" { - // Take the first IP in the chain (client IP). - if i := strings.IndexByte(xff, ','); i > 0 { - return strings.TrimSpace(xff[:i]) - } - return strings.TrimSpace(xff) - } - if xri := r.Header.Get("X-Real-IP"); xri != "" { - return strings.TrimSpace(xri) - } - // RemoteAddr is "host:port"; strip the port. - addr := r.RemoteAddr - if i := strings.LastIndex(addr, ":"); i > 0 { - return addr[:i] - } - return addr + return ClientIP(r) } diff --git a/middleware/captcha.go b/middleware/captcha.go index fa376c27..648c1b6b 100644 --- a/middleware/captcha.go +++ b/middleware/captcha.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "net/http" - "strings" "sync" log "github.com/xraph/go-utils/log" @@ -291,20 +290,9 @@ func captchaExtractToken(r *http.Request) string { } func captchaClientIP(r *http.Request) string { - if v := r.Header.Get("X-Forwarded-For"); v != "" { - // First entry is the original client. - if idx := strings.Index(v, ","); idx >= 0 { - return strings.TrimSpace(v[:idx]) - } - return strings.TrimSpace(v) - } - if v := r.Header.Get("X-Real-IP"); v != "" { - return strings.TrimSpace(v) - } - if i := strings.LastIndex(r.RemoteAddr, ":"); i >= 0 { - return r.RemoteAddr[:i] - } - return r.RemoteAddr + // Trusted-proxy-aware resolution so the CAPTCHA's remote-IP binding can't be + // forged by a direct client spoofing X-Forwarded-For. + return ClientIP(r) } func captchaVerifierFromCache(cache *sync.Map, key, provider, secret string, factory func(string, string) (captcha.Verifier, error)) (captcha.Verifier, error) { diff --git a/middleware/captcha_test.go b/middleware/captcha_test.go index a3ca8c93..b917e65e 100644 --- a/middleware/captcha_test.go +++ b/middleware/captcha_test.go @@ -484,11 +484,37 @@ func TestCaptchaMiddleware_RemoteIPExtractedFromHeader(t *testing.T) { req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/test", nil) req.Header.Set("X-Captcha-Token", "t") - req.Header.Set("X-Forwarded-For", "203.0.113.5, 10.0.0.1") + // The peer must be a trusted (private) proxy for X-Forwarded-For to be + // honored; otherwise a direct client could spoof its IP. + req.RemoteAddr = "10.0.0.1:8080" + req.Header.Set("X-Forwarded-For", "203.0.113.5, 10.0.0.2") rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) require.Equal(t, http.StatusOK, rec.Code) - assert.Equal(t, "203.0.113.5", sawIP, "first XFF entry should be the original client") + assert.Equal(t, "203.0.113.5", sawIP, "the real client is the rightmost non-proxy XFF entry") +} + +func TestCaptchaMiddleware_RemoteIPNotSpoofableByDirectClient(t *testing.T) { + env := newCaptchaTestEnv(t, true) + var sawIP string + stub := &stubVerifier{fn: func(_ context.Context, _, ip, _ string) (*captcha.Result, error) { + sawIP = ip + return &captcha.Result{Success: true}, nil + }} + handler := captchaTestRouter(t, middleware.CaptchaOptions{ + Settings: env.manager, + VerifierFor: func(string, string) (captcha.Verifier, error) { return stub, nil }, + }, nil) + + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/test", nil) + req.Header.Set("X-Captcha-Token", "t") + // Direct (public) peer: the spoofed X-Forwarded-For must be ignored. + req.RemoteAddr = "203.0.113.99:44321" + req.Header.Set("X-Forwarded-For", "1.2.3.4") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "203.0.113.99", sawIP, "a direct client must not be able to spoof its IP via X-Forwarded-For") } func TestCaptchaMiddleware_NoActionPassesEmptyToVerifier(t *testing.T) { diff --git a/middleware/clientip.go b/middleware/clientip.go new file mode 100644 index 00000000..cd88d2a2 --- /dev/null +++ b/middleware/clientip.go @@ -0,0 +1,123 @@ +package middleware + +import ( + "net" + "net/http" + "os" + "strings" + "sync" +) + +// trustedProxyNets holds the CIDR ranges treated as trusted reverse proxies. +// X-Forwarded-For / X-Real-IP are honored ONLY when the immediate peer +// (RemoteAddr) falls in one of these ranges — so a directly-connecting client +// cannot spoof its IP by sending those headers. +// +// The default set is loopback plus the private / link-level ranges, which is +// where a same-network load balancer or sidecar proxy lives. Operators whose +// proxy connects from a public address can override the set via the +// AUTHSOME_TRUSTED_PROXIES environment variable (comma-separated CIDRs). +var ( + trustedProxyMu sync.RWMutex + trustedProxyNets = defaultTrustedProxyNets() +) + +func defaultTrustedProxyNets() []*net.IPNet { + return parseCIDRs([]string{ + "127.0.0.0/8", "::1/128", + "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", + "169.254.0.0/16", "fe80::/10", "fc00::/7", + }) +} + +func init() { + if env := os.Getenv("AUTHSOME_TRUSTED_PROXIES"); env != "" { + parts := strings.Split(env, ",") + for i := range parts { + parts[i] = strings.TrimSpace(parts[i]) + } + if nets := parseCIDRs(parts); len(nets) > 0 { + SetTrustedProxies(nets) + } + } +} + +// SetTrustedProxies replaces the trusted-proxy CIDR set. Passing nil restores +// the built-in defaults. Primarily used for configuration and tests. +func SetTrustedProxies(nets []*net.IPNet) { + trustedProxyMu.Lock() + defer trustedProxyMu.Unlock() + if nets == nil { + trustedProxyNets = defaultTrustedProxyNets() + return + } + trustedProxyNets = nets +} + +func isTrustedProxy(ip net.IP) bool { + if ip == nil { + return false + } + trustedProxyMu.RLock() + defer trustedProxyMu.RUnlock() + for _, n := range trustedProxyNets { + if n.Contains(ip) { + return true + } + } + return false +} + +// peerIP extracts the immediate peer IP from a "host:port" RemoteAddr. +func peerIP(remoteAddr string) net.IP { + host, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + host = remoteAddr + } + return net.ParseIP(strings.TrimSpace(host)) +} + +// ClientIP resolves the real client IP for a request. X-Forwarded-For and +// X-Real-IP are trusted ONLY when the immediate peer is a trusted proxy; +// otherwise the peer address is returned, so a direct client cannot forge its +// IP. When the peer is trusted, the X-Forwarded-For chain is walked +// right-to-left and the first non-proxy address (the real client) is returned. +func ClientIP(r *http.Request) string { + peer := peerIP(r.RemoteAddr) + if peer == nil { + // Unparseable RemoteAddr: return it verbatim (minus any port) rather + // than trusting spoofable headers. + addr := r.RemoteAddr + if i := strings.LastIndex(addr, ":"); i > 0 { + addr = addr[:i] + } + return addr + } + + // Direct or untrusted peer: never trust forwarding headers. + if !isTrustedProxy(peer) { + return peer.String() + } + + // Trusted proxy: the real client is the rightmost X-Forwarded-For entry + // that isn't itself a trusted proxy. Anything a malicious client prepends + // sits to the left of the address our proxy appended, so it is ignored. + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + parts := strings.Split(xff, ",") + for i := len(parts) - 1; i >= 0; i-- { + ip := net.ParseIP(strings.TrimSpace(parts[i])) + if ip == nil { + continue + } + if !isTrustedProxy(ip) { + return ip.String() + } + } + } + if xri := strings.TrimSpace(r.Header.Get("X-Real-IP")); xri != "" { + if ip := net.ParseIP(xri); ip != nil { + return ip.String() + } + } + return peer.String() +} diff --git a/middleware/clientip_test.go b/middleware/clientip_test.go new file mode 100644 index 00000000..f7d55325 --- /dev/null +++ b/middleware/clientip_test.go @@ -0,0 +1,81 @@ +package middleware_test + +import ( + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/xraph/authsome/middleware" +) + +// TestClientIP_TrustedProxyAware verifies that X-Forwarded-For / X-Real-IP are +// honored ONLY when the immediate peer is a trusted (loopback/private) proxy. +// A direct client cannot spoof its IP by setting these headers, which is the +// property rate limiting, session IP-binding, and IP allow/block lists depend +// on. +func TestClientIP_TrustedProxyAware(t *testing.T) { + cases := []struct { + name string + remoteAddr string + xff string + xRealIP string + want string + }{ + { + name: "direct client cannot spoof via XFF", + remoteAddr: "203.0.113.9:44321", + xff: "1.2.3.4", + want: "203.0.113.9", + }, + { + name: "direct client cannot spoof via X-Real-IP", + remoteAddr: "203.0.113.9:44321", + xRealIP: "1.2.3.4", + want: "203.0.113.9", + }, + { + name: "no forwarding headers, direct peer", + remoteAddr: "198.51.100.7:5555", + want: "198.51.100.7", + }, + { + name: "trusted loopback proxy honors XFF", + remoteAddr: "127.0.0.1:8080", + xff: "203.0.113.20", + want: "203.0.113.20", + }, + { + name: "trusted private proxy honors XFF", + remoteAddr: "10.0.0.5:8080", + xff: "203.0.113.21", + want: "203.0.113.21", + }, + { + name: "trusted proxy, client-spoofed left entry is ignored (rightmost-untrusted)", + remoteAddr: "10.0.0.5:8080", + xff: "1.2.3.4, 203.0.113.30", + want: "203.0.113.30", + }, + { + name: "trusted proxy falls back to X-Real-IP when XFF absent", + remoteAddr: "127.0.0.1:8080", + xRealIP: "203.0.113.40", + want: "203.0.113.40", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + req.RemoteAddr = tc.remoteAddr + if tc.xff != "" { + req.Header.Set("X-Forwarded-For", tc.xff) + } + if tc.xRealIP != "" { + req.Header.Set("X-Real-IP", tc.xRealIP) + } + assert.Equal(t, tc.want, middleware.ClientIP(req)) + }) + } +} diff --git a/middleware/ratelimit.go b/middleware/ratelimit.go index 83ba2085..f10f293d 100644 --- a/middleware/ratelimit.go +++ b/middleware/ratelimit.go @@ -26,14 +26,10 @@ type RateLimitConfig struct { func RateLimit(limiter ratelimit.Limiter, cfg RateLimitConfig) forge.Middleware { if cfg.KeyFunc == nil { cfg.KeyFunc = func(ctx forge.Context) string { - // Check proxy headers first, then fall back to RemoteAddr. - if xff := ctx.Request().Header.Get("X-Forwarded-For"); xff != "" { - return xff - } - if xri := ctx.Request().Header.Get("X-Real-IP"); xri != "" { - return xri - } - return ctx.Request().RemoteAddr + // Trusted-proxy-aware client IP: forwarding headers are honored only + // when the peer is a trusted proxy, so a direct client cannot mint a + // fresh rate-limit bucket per request by spoofing X-Forwarded-For. + return ClientIP(ctx.Request()) } } diff --git a/plugins/magiclink/plugin.go b/plugins/magiclink/plugin.go index 5fb6a536..a621c6f6 100644 --- a/plugins/magiclink/plugin.go +++ b/plugins/magiclink/plugin.go @@ -226,8 +226,19 @@ func (p *Plugin) handleSend(ctx forge.Context, req *SendRequest) (*SendResponse, return nil, forge.BadRequest("invalid app_id") } - // Create verification token - v, err := account.NewVerification(ctx.Context(), appID, id.NewUserID(), VerificationTypeMagicLink, p.config.TokenTTL) + // Resolve the real user by email. The verification token must be bound to + // their actual user id so /verify can resolve them — binding it to a random + // id (the previous behavior) made every real magic-link login fail. + u, lookupErr := p.store.GetUserByEmail(ctx.Context(), appID, req.Email) + if lookupErr != nil { + // Anti-enumeration: return the same success response whether or not the + // email is registered, and never mint a token or send a link for an + // address with no account. + return &SendResponse{Status: "magic link sent"}, nil + } + + // Create verification token bound to the resolved user. + v, err := account.NewVerification(ctx.Context(), appID, u.ID, VerificationTypeMagicLink, p.config.TokenTTL) if err != nil { return nil, forge.InternalError(fmt.Errorf("failed to create magic link token: %w", err)) } diff --git a/plugins/magiclink/plugin_test.go b/plugins/magiclink/plugin_test.go index 5a3fddf6..8ef92625 100644 --- a/plugins/magiclink/plugin_test.go +++ b/plugins/magiclink/plugin_test.go @@ -131,14 +131,15 @@ func TestPlugin_RegisterInRegistry(t *testing.T) { // ────────────────────────────────────────────────── func TestHandleSend_Success(t *testing.T) { - p, _, mailer := newTestPlugin(t) + p, s, mailer := newTestPlugin(t) + u := createTestUser(t, s) // registers test@example.com mux := forge.NewRouter() err := p.RegisterRoutes(mux) require.NoError(t, err) body := jsonBody(t, map[string]string{ - "email": "user@example.com", + "email": u.Email, }) req := httptest.NewRequestWithContext(context.Background(), "POST", "/v1/magic-link/send", body) @@ -153,11 +154,11 @@ func TestHandleSend_Success(t *testing.T) { require.NoError(t, err) assert.Equal(t, "magic link sent", resp["status"]) - // Mailer should have been called + // Mailer should have been called for the registered user. mailer.mu.Lock() defer mailer.mu.Unlock() assert.Len(t, mailer.sent, 1) - assert.Equal(t, "user@example.com", mailer.sent[0].Email) + assert.Equal(t, u.Email, mailer.sent[0].Email) assert.NotEmpty(t, mailer.sent[0].Token) } @@ -194,7 +195,8 @@ func TestHandleSend_InvalidJSON(t *testing.T) { } func TestHandleSend_MailerError(t *testing.T) { - p, _, mailer := newTestPlugin(t) + p, s, mailer := newTestPlugin(t) + u := createTestUser(t, s) mailer.errVal = assert.AnError mux := forge.NewRouter() @@ -202,7 +204,7 @@ func TestHandleSend_MailerError(t *testing.T) { require.NoError(t, err) body := jsonBody(t, map[string]string{ - "email": "user@example.com", + "email": u.Email, }) req := httptest.NewRequestWithContext(context.Background(), "POST", "/v1/magic-link/send", body) @@ -214,14 +216,15 @@ func TestHandleSend_MailerError(t *testing.T) { } func TestHandleSend_WithExplicitAppID(t *testing.T) { - p, _, mailer := newTestPlugin(t) + p, s, mailer := newTestPlugin(t) + u := createTestUser(t, s) mux := forge.NewRouter() err := p.RegisterRoutes(mux) require.NoError(t, err) body := jsonBody(t, map[string]string{ - "email": "user@example.com", + "email": u.Email, "app_id": testAppIDStr, }) diff --git a/plugins/magiclink/sendverify_test.go b/plugins/magiclink/sendverify_test.go new file mode 100644 index 00000000..24b35c47 --- /dev/null +++ b/plugins/magiclink/sendverify_test.go @@ -0,0 +1,66 @@ +package magiclink_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/xraph/forge" +) + +// TestMagicLink_SendThenVerify_Succeeds is the end-to-end flow: /send must +// create the verification against the REAL user (resolved by email), so that +// /verify can resolve them and mint a session. Previously /send used a random +// user id, making every real magic-link verification fail with a 500. +func TestMagicLink_SendThenVerify_Succeeds(t *testing.T) { + p, s, mailer := newTestPlugin(t) + u := createTestUser(t, s) + + mux := forge.NewRouter() + require.NoError(t, p.RegisterRoutes(mux)) + handler := mux.Handler() + + // Send. + sendReq := httptest.NewRequest(http.MethodPost, "/v1/magic-link/send", jsonBody(t, map[string]string{"email": u.Email})) + sendReq.Header.Set("Content-Type", "application/json") + sendRec := httptest.NewRecorder() + handler.ServeHTTP(sendRec, sendReq) + require.Equal(t, http.StatusOK, sendRec.Code, "send body=%s", sendRec.Body.String()) + require.Len(t, mailer.sent, 1, "a magic link should be sent to the registered email") + token := mailer.sent[0].Token + require.NotEmpty(t, token) + + // Verify. + verifyReq := httptest.NewRequest(http.MethodPost, "/v1/magic-link/verify", jsonBody(t, map[string]string{"token": token})) + verifyReq.Header.Set("Content-Type", "application/json") + verifyRec := httptest.NewRecorder() + handler.ServeHTTP(verifyRec, verifyReq) + + require.Equal(t, http.StatusOK, verifyRec.Code, "verify must succeed for a real user; body=%s", verifyRec.Body.String()) + + var resp map[string]any + require.NoError(t, json.NewDecoder(verifyRec.Body).Decode(&resp)) + assert.NotEmpty(t, resp["session_token"], "verify must return a session token") +} + +// TestMagicLink_Send_UnknownEmail_UniformSuccessNoLink pins anti-enumeration: +// sending for an unregistered email returns the same success response but does +// not actually send a link (nor create a token bound to a phantom user). +func TestMagicLink_Send_UnknownEmail_UniformSuccessNoLink(t *testing.T) { + p, _, mailer := newTestPlugin(t) + + mux := forge.NewRouter() + require.NoError(t, p.RegisterRoutes(mux)) + handler := mux.Handler() + + req := httptest.NewRequest(http.MethodPost, "/v1/magic-link/send", jsonBody(t, map[string]string{"email": "nobody@example.com"})) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code, "send must return a uniform success even for unknown emails; body=%s", rec.Body.String()) + assert.Empty(t, mailer.sent, "no magic link should be sent to an unregistered email") +} diff --git a/plugins/mfa/plugin.go b/plugins/mfa/plugin.go index d2a14cf0..539e26ef 100644 --- a/plugins/mfa/plugin.go +++ b/plugins/mfa/plugin.go @@ -108,6 +108,28 @@ func (p *Plugin) OnInit(_ context.Context, engine plugin.Engine) error { } p.engine = engine + // Build a persistent, at-rest-encrypted enrollment store when one hasn't + // been injected (tests inject via SetStore). TOTP/SMS secrets are wrapped + // with the engine's token encryptor so a database leak does not expose + // usable MFA seeds. + if p.store == nil { + var inner Store + if db := engine.DB(); db != nil { + switch db.Driver().Name() { + case "pg": + inner = NewPostgresStore(db) + case "sqlite": + inner = NewSqliteStore(db) + case "mongo": + inner = NewMongoStore(db) + } + } + if inner == nil { + inner = NewMemoryStore() + } + p.store = NewEncryptedStore(inner, engine.TokenEncryptor()) + } + return nil } @@ -137,6 +159,14 @@ func (p *Plugin) SetSMSSender(s bridge.SMSSender) { func (p *Plugin) RegisterRoutes(router forge.Router) error { g := router.Group("/v1/mfa", forge.WithGroupTags("MFA")) + // withRL appends rate-limit middleware (keyed off the engine's limiter and + // MFAChallengeLimit) to a route's options. Applied to the code-submission + // endpoints so 6-digit TOTP/SMS/recovery codes cannot be brute-forced. + rl := p.mfaRateLimit() + withRL := func(opts ...forge.RouteOption) []forge.RouteOption { + return append(opts, rl...) + } + if err := g.POST("/enroll", p.handleEnroll, forge.WithSummary("Enroll in MFA"), forge.WithOperationID("enrollMFA"), @@ -146,21 +176,21 @@ func (p *Plugin) RegisterRoutes(router forge.Router) error { return err } - if err := g.POST("/verify", p.handleVerify, + if err := g.POST("/verify", p.handleVerify, withRL( forge.WithSummary("Verify MFA code"), forge.WithOperationID("verifyMFA"), forge.WithResponseSchema(http.StatusOK, "Verified", VerifyMFAResponse{}), forge.WithErrorResponses(), - ); err != nil { + )...); err != nil { return err } - if err := g.POST("/challenge", p.handleChallenge, + if err := g.POST("/challenge", p.handleChallenge, withRL( forge.WithSummary("MFA challenge"), forge.WithOperationID("challengeMFA"), forge.WithResponseSchema(http.StatusOK, "Challenge passed", ChallengeResponse{}), forge.WithErrorResponses(), - ); err != nil { + )...); err != nil { return err } @@ -174,12 +204,12 @@ func (p *Plugin) RegisterRoutes(router forge.Router) error { } // Recovery code routes - if err := g.POST("/recovery/verify", p.handleRecoveryVerify, + if err := g.POST("/recovery/verify", p.handleRecoveryVerify, withRL( forge.WithSummary("Verify MFA recovery code"), forge.WithOperationID("verifyMFARecovery"), forge.WithResponseSchema(http.StatusOK, "Recovery code accepted", RecoveryVerifyResponse{}), forge.WithErrorResponses(), - ); err != nil { + )...); err != nil { return err } @@ -193,21 +223,47 @@ func (p *Plugin) RegisterRoutes(router forge.Router) error { } // SMS MFA routes - if err := g.POST("/sms/send", p.handleSMSSend, + if err := g.POST("/sms/send", p.handleSMSSend, withRL( forge.WithSummary("Send SMS verification code"), forge.WithOperationID("sendSMSCode"), forge.WithResponseSchema(http.StatusOK, "SMS code sent", SMSSendResponse{}), forge.WithErrorResponses(), - ); err != nil { + )...); err != nil { return err } - return g.POST("/sms/verify", p.handleSMSVerify, + return g.POST("/sms/verify", p.handleSMSVerify, withRL( forge.WithSummary("Verify SMS code"), forge.WithOperationID("verifySMSCode"), forge.WithResponseSchema(http.StatusOK, "SMS code verified", SMSVerifyResponse{}), forge.WithErrorResponses(), - ) + )...) +} + +// mfaRateLimit returns rate-limit middleware options for MFA code-submission +// endpoints, keyed off the engine's limiter and the MFAChallengeLimit config. +// Returns nil when rate limiting is disabled or the host engine isn't the +// concrete *authsome.Engine (e.g. minimal test wiring). +func (p *Plugin) mfaRateLimit() []forge.RouteOption { + eng, ok := p.engine.(*authsome.Engine) + if !ok || eng == nil { + return nil + } + rl := eng.RateLimiter() + cfg := eng.Config().RateLimit + if rl == nil || !cfg.Enabled { + return nil + } + limit := cfg.MFAChallengeLimit + if limit <= 0 { + return nil + } + return []forge.RouteOption{ + forge.WithMiddleware(middleware.RateLimit(rl, middleware.RateLimitConfig{ + Limit: limit, + Window: cfg.Window(), + })), + } } // ────────────────────────────────────────────────── @@ -386,6 +442,22 @@ func (p *Plugin) handleEnroll(ctx forge.Context, req *EnrollRequest) (*EnrollRes }, nil } +// totpReplayTTL bounds how long a used TOTP time-step is remembered. It must +// exceed the acceptance window (period × (2·skew+1) ≈ 90s) with margin. +const totpReplayTTL = 120 * time.Second + +// totpStepUsed reports whether the given TOTP time-step has already been +// accepted for this enrollment, and records it as used otherwise. Backed by the +// ephemeral ceremony store (TTL-bounded), so no persistent schema is required. +func (p *Plugin) totpStepUsed(ctx context.Context, enrollmentID id.MFAID, step int64) bool { + key := fmt.Sprintf("mfa:totp:used:%s:%d", enrollmentID.String(), step) + if _, err := p.ceremonies.Get(ctx, key); err == nil { + return true + } + _ = p.ceremonies.Set(ctx, key, []byte{1}, totpReplayTTL) //nolint:errcheck // best-effort replay marker + return false +} + // handleVerify verifies a TOTP code against the user's enrollment. func (p *Plugin) handleVerify(ctx forge.Context, req *VerifyMFARequest) (*VerifyMFAResponse, error) { userID, ok := middleware.UserIDFrom(ctx.Context()) @@ -402,9 +474,13 @@ func (p *Plugin) handleVerify(ctx forge.Context, req *VerifyMFARequest) (*Verify return nil, forge.NotFound("no MFA enrollment found") } - if !ValidateTOTP(req.Code, enrollment.Secret) { + ok2, step := ValidateTOTPStep(req.Code, enrollment.Secret) + if !ok2 { return nil, forge.Unauthorized("invalid TOTP code") } + if p.totpStepUsed(ctx.Context(), enrollment.ID, step) { + return nil, forge.Unauthorized("TOTP code already used") + } // Mark as verified if this is the first successful verification if !enrollment.Verified { @@ -490,11 +566,15 @@ func (p *Plugin) handleChallenge(ctx forge.Context, req *ChallengeRequest) (*Cha return nil, forge.Unauthorized("MFA enrollment not yet verified") } - if !ValidateTOTP(req.Code, enrollment.Secret) { + ok2, step := ValidateTOTPStep(req.Code, enrollment.Secret) + if !ok2 { // Don't consume the ticket on bad code — the user retries within // the 5-minute TTL. Rate-limiting prevents brute force. return nil, forge.Unauthorized("invalid TOTP code") } + if p.totpStepUsed(ctx.Context(), enrollment.ID, step) { + return nil, forge.Unauthorized("TOTP code already used") + } // Code is good. Consume the ticket (single-use) before minting the // real session so a race that re-uses the ticket can't double-spend. diff --git a/plugins/mfa/ratelimit_test.go b/plugins/mfa/ratelimit_test.go new file mode 100644 index 00000000..6522e1dc --- /dev/null +++ b/plugins/mfa/ratelimit_test.go @@ -0,0 +1,89 @@ +package mfa_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/xraph/forge" + + authsome "github.com/xraph/authsome" + mfa "github.com/xraph/authsome/plugins/mfa" + "github.com/xraph/authsome/ratelimit" + "github.com/xraph/authsome/store/memory" + + "github.com/xraph/warden" + wardenmem "github.com/xraph/warden/store/memory" +) + +// newMFARateLimited builds an engine with rate limiting enabled at the given +// MFA-challenge limit, mounts the MFA plugin routes, and returns the handler. +func newMFARateLimited(t *testing.T, limit int) http.Handler { + t.Helper() + cfg := authsome.DefaultConfig() + cfg.AppID = "aapp_01jf0000000000000000000000" + cfg.RateLimit.Enabled = true + cfg.RateLimit.MFAChallengeLimit = limit + cfg.RateLimit.WindowSeconds = 60 + + w, err := warden.NewEngine(warden.WithStore(wardenmem.New())) + require.NoError(t, err) + + p := mfa.New() + eng, err := authsome.NewEngine( + authsome.WithStore(memory.New()), + authsome.WithWarden(w), + authsome.WithDisableMigrate(), + authsome.WithConfig(cfg), + authsome.WithRateLimiter(ratelimit.NewMemoryLimiter()), + authsome.WithPlugin(p), + ) + require.NoError(t, err) + require.NoError(t, eng.Start(context.Background())) + t.Cleanup(func() { _ = eng.Stop(context.Background()) }) + + router := forge.NewRouter() + require.NoError(t, p.RegisterRoutes(router)) + return router.Handler() +} + +// TestMFAVerify_RateLimited pins that the MFA code-verification endpoint is +// throttled: past the MFAChallengeLimit, further attempts return 429. Without +// this an attacker can brute-force the 6-digit code space unthrottled. +func TestMFAVerify_RateLimited(t *testing.T) { + const limit = 3 + h := newMFARateLimited(t, limit) + + var lastCode int + for i := 0; i < limit+1; i++ { + req := httptest.NewRequestWithContext(context.Background(), "POST", "/v1/mfa/verify", nil) + req.RemoteAddr = "203.0.113.7:5555" + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + lastCode = rec.Code + } + + require.Equal(t, http.StatusTooManyRequests, lastCode, + "the attempt beyond the MFA challenge limit must be rate limited (429)") +} + +// TestMFASMSVerify_RateLimited pins the same protection on the SMS verify route. +func TestMFASMSVerify_RateLimited(t *testing.T) { + const limit = 3 + h := newMFARateLimited(t, limit) + + var lastCode int + for i := 0; i < limit+1; i++ { + req := httptest.NewRequestWithContext(context.Background(), "POST", "/v1/mfa/sms/verify", nil) + req.RemoteAddr = "203.0.113.8:5555" + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + lastCode = rec.Code + } + + require.Equal(t, http.StatusTooManyRequests, lastCode, + "the attempt beyond the limit on SMS verify must be rate limited (429)") +} diff --git a/plugins/mfa/replay_test.go b/plugins/mfa/replay_test.go new file mode 100644 index 00000000..df22bbe9 --- /dev/null +++ b/plugins/mfa/replay_test.go @@ -0,0 +1,69 @@ +package mfa_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xraph/forge" + + "github.com/xraph/authsome/id" + mfa "github.com/xraph/authsome/plugins/mfa" +) + +// TestHandleVerify_RejectsReplay pins TOTP replay protection: a code accepted +// once cannot be accepted again within its validity window. Without this an +// intercepted code can be replayed for the ~90s it stays valid. +func TestHandleVerify_RejectsReplay(t *testing.T) { + p, s := newTestPlugin(t) + + mux := forge.NewRouter() + require.NoError(t, p.RegisterRoutes(mux)) + + userID := id.NewUserID() + key, err := mfa.GenerateTOTPKey(mfa.TOTPConfig{Issuer: "TestApp", AccountName: "replay@test.com"}) + require.NoError(t, err) + require.NoError(t, s.CreateEnrollment(context.Background(), &mfa.Enrollment{ + ID: id.NewMFAID(), + UserID: userID, + Method: "totp", + Secret: key.Secret(), + Verified: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + })) + + code, err := mfa.GenerateTOTPCode(key.Secret()) + require.NoError(t, err) + + // First use succeeds. + rec1 := httptest.NewRecorder() + mux.ServeHTTP(rec1, authedRequest(t, "POST", "/v1/mfa/verify", jsonBody(t, map[string]string{"code": code}), userID)) + require.Equal(t, http.StatusOK, rec1.Code, "first use of a valid code should succeed; body=%s", rec1.Body.String()) + + // Replay of the same code is rejected. + rec2 := httptest.NewRecorder() + mux.ServeHTTP(rec2, authedRequest(t, "POST", "/v1/mfa/verify", jsonBody(t, map[string]string{"code": code}), userID)) + assert.Equal(t, http.StatusUnauthorized, rec2.Code, "a replayed TOTP code must be rejected; body=%s", rec2.Body.String()) +} + +// TestValidateTOTPStep_ReturnsStep confirms the step accessor returns a valid, +// nonzero step for a good code and reports failure for a bad one. +func TestValidateTOTPStep_ReturnsStep(t *testing.T) { + key, err := mfa.GenerateTOTPKey(mfa.TOTPConfig{Issuer: "TestApp", AccountName: "step@test.com"}) + require.NoError(t, err) + code, err := mfa.GenerateTOTPCode(key.Secret()) + require.NoError(t, err) + + ok, step := mfa.ValidateTOTPStep(code, key.Secret()) + require.True(t, ok, "a fresh code must validate") + assert.Positive(t, step, "a valid code must map to a positive time-step") + + bad, _ := mfa.ValidateTOTPStep("000000", key.Secret()) + assert.False(t, bad || code == "000000", "an incorrect code must not validate") +} diff --git a/plugins/mfa/sms.go b/plugins/mfa/sms.go index 84663a97..de3868ae 100644 --- a/plugins/mfa/sms.go +++ b/plugins/mfa/sms.go @@ -3,6 +3,7 @@ package mfa import ( "context" "crypto/rand" + "crypto/subtle" "fmt" "math/big" "time" @@ -67,5 +68,8 @@ func ValidateSMSCode(code string, challenge *SMSChallenge) bool { if time.Now().After(challenge.ExpiresAt) { return false } - return code == challenge.Code + // Constant-time comparison so a network attacker cannot use response + // timing to recover the code digit by digit. ConstantTimeCompare already + // returns 0 for differing lengths. + return subtle.ConstantTimeCompare([]byte(code), []byte(challenge.Code)) == 1 } diff --git a/plugins/mfa/sms_test.go b/plugins/mfa/sms_test.go new file mode 100644 index 00000000..a1d0d58d --- /dev/null +++ b/plugins/mfa/sms_test.go @@ -0,0 +1,33 @@ +package mfa + +import ( + "testing" + "time" +) + +func TestValidateSMSCode(t *testing.T) { + valid := &SMSChallenge{Code: "123456", ExpiresAt: time.Now().Add(time.Minute)} + expired := &SMSChallenge{Code: "123456", ExpiresAt: time.Now().Add(-time.Minute)} + + cases := []struct { + name string + code string + challenge *SMSChallenge + want bool + }{ + {"correct code", "123456", valid, true}, + {"wrong code", "000000", valid, false}, + {"empty code", "", valid, false}, + {"expired challenge", "123456", expired, false}, + {"nil challenge", "123456", nil, false}, + {"length mismatch", "12345", valid, false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := ValidateSMSCode(tc.code, tc.challenge); got != tc.want { + t.Fatalf("ValidateSMSCode(%q) = %v, want %v", tc.code, got, tc.want) + } + }) + } +} diff --git a/plugins/mfa/store_encrypted.go b/plugins/mfa/store_encrypted.go new file mode 100644 index 00000000..dc2c8258 --- /dev/null +++ b/plugins/mfa/store_encrypted.go @@ -0,0 +1,138 @@ +package mfa + +import ( + "context" + "fmt" + + "github.com/xraph/authsome/bridge" + "github.com/xraph/authsome/id" +) + +// EncryptedStore wraps a Store, transparently encrypting the TOTP/SMS secret +// on an Enrollment before it reaches the underlying storage and decrypting it +// on the way back out. +// +// It is safe over a store that already holds legacy plaintext secrets: +// bridge.AESGCMEncryptor's Decrypt returns input unchanged when no envelope +// prefix is present. Recovery codes are already hashed by the caller, so those +// methods delegate without modification. +type EncryptedStore struct { + inner Store + enc bridge.Encryptor +} + +// NewEncryptedStore wraps inner with at-rest secret encryption. A nil encryptor +// falls back to bridge.NoopEncryptor (no encryption). +func NewEncryptedStore(inner Store, enc bridge.Encryptor) *EncryptedStore { + if enc == nil { + enc = bridge.NoopEncryptor{} + } + return &EncryptedStore{inner: inner, enc: enc} +} + +// Compile-time interface check. +var _ Store = (*EncryptedStore)(nil) + +func (s *EncryptedStore) CreateEnrollment(ctx context.Context, e *Enrollment) error { + enc, err := s.encryptCopy(e) + if err != nil { + return fmt.Errorf("mfa: encrypt enrollment secret: %w", err) + } + if err := s.inner.CreateEnrollment(ctx, enc); err != nil { + return err + } + e.ID = enc.ID + e.CreatedAt = enc.CreatedAt + e.UpdatedAt = enc.UpdatedAt + return nil +} + +func (s *EncryptedStore) UpdateEnrollment(ctx context.Context, e *Enrollment) error { + enc, err := s.encryptCopy(e) + if err != nil { + return fmt.Errorf("mfa: encrypt enrollment secret: %w", err) + } + if err := s.inner.UpdateEnrollment(ctx, enc); err != nil { + return err + } + e.UpdatedAt = enc.UpdatedAt + return nil +} + +func (s *EncryptedStore) GetEnrollment(ctx context.Context, userID id.UserID, method string) (*Enrollment, error) { + e, err := s.inner.GetEnrollment(ctx, userID, method) + if err != nil { + return nil, err + } + return s.decryptInPlace(e) +} + +func (s *EncryptedStore) GetEnrollmentByID(ctx context.Context, mfaID id.MFAID) (*Enrollment, error) { + e, err := s.inner.GetEnrollmentByID(ctx, mfaID) + if err != nil { + return nil, err + } + return s.decryptInPlace(e) +} + +func (s *EncryptedStore) ListEnrollments(ctx context.Context, userID id.UserID) ([]*Enrollment, error) { + list, err := s.inner.ListEnrollments(ctx, userID) + if err != nil { + return nil, err + } + for i, e := range list { + dec, derr := s.decryptInPlace(e) + if derr != nil { + return nil, derr + } + list[i] = dec + } + return list, nil +} + +func (s *EncryptedStore) DeleteEnrollment(ctx context.Context, mfaID id.MFAID) error { + return s.inner.DeleteEnrollment(ctx, mfaID) +} + +// Recovery codes are hashed by the caller — delegate unchanged. +func (s *EncryptedStore) CreateRecoveryCodes(ctx context.Context, codes []*RecoveryCode) error { + return s.inner.CreateRecoveryCodes(ctx, codes) +} + +func (s *EncryptedStore) GetRecoveryCodes(ctx context.Context, userID id.UserID) ([]*RecoveryCode, error) { + return s.inner.GetRecoveryCodes(ctx, userID) +} + +func (s *EncryptedStore) ConsumeRecoveryCode(ctx context.Context, codeID id.RecoveryCodeID) error { + return s.inner.ConsumeRecoveryCode(ctx, codeID) +} + +func (s *EncryptedStore) DeleteRecoveryCodes(ctx context.Context, userID id.UserID) error { + return s.inner.DeleteRecoveryCodes(ctx, userID) +} + +func (s *EncryptedStore) encryptCopy(e *Enrollment) (*Enrollment, error) { + cp := *e + if cp.Secret != "" { + ct, err := s.enc.Encrypt([]byte(cp.Secret)) + if err != nil { + return nil, err + } + cp.Secret = string(ct) + } + return &cp, nil +} + +func (s *EncryptedStore) decryptInPlace(e *Enrollment) (*Enrollment, error) { + if e == nil { + return nil, nil + } + if e.Secret != "" { + pt, err := s.enc.Decrypt([]byte(e.Secret)) + if err != nil { + return nil, fmt.Errorf("mfa: decrypt enrollment secret: %w", err) + } + e.Secret = string(pt) + } + return e, nil +} diff --git a/plugins/mfa/store_encrypted_test.go b/plugins/mfa/store_encrypted_test.go new file mode 100644 index 00000000..0e67595f --- /dev/null +++ b/plugins/mfa/store_encrypted_test.go @@ -0,0 +1,72 @@ +package mfa_test + +import ( + "bytes" + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xraph/authsome/bridge" + "github.com/xraph/authsome/id" + mfa "github.com/xraph/authsome/plugins/mfa" +) + +func aesEnc(t *testing.T) bridge.Encryptor { + t.Helper() + enc, err := bridge.NewAESGCMEncryptor(bytes.Repeat([]byte{0x2a}, 32)) + require.NoError(t, err) + return enc +} + +// TestEncryptedStore_EncryptsSecretAtRest pins that the TOTP secret is stored +// encrypted in the underlying store and decrypted transparently on read. +func TestEncryptedStore_EncryptsSecretAtRest(t *testing.T) { + ctx := context.Background() + inner := mfa.NewMemoryStore() + store := mfa.NewEncryptedStore(inner, aesEnc(t)) + + userID := id.NewUserID() + require.NoError(t, store.CreateEnrollment(ctx, &mfa.Enrollment{ + ID: id.NewMFAID(), + UserID: userID, + Method: "totp", + Secret: "SUPERSECRETVALUE", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + })) + + // The underlying store must not hold the plaintext secret. + raw, err := inner.GetEnrollment(ctx, userID, "totp") + require.NoError(t, err) + assert.NotEqual(t, "SUPERSECRETVALUE", raw.Secret, "secret must be encrypted at rest") + assert.NotEmpty(t, raw.Secret) + + // Reading through the wrapper returns the plaintext secret. + got, err := store.GetEnrollment(ctx, userID, "totp") + require.NoError(t, err) + assert.Equal(t, "SUPERSECRETVALUE", got.Secret) +} + +// TestEncryptedStore_ReadsLegacyPlaintext confirms the wrapper is backward +// compatible: a pre-existing plaintext secret decrypts to itself. +func TestEncryptedStore_ReadsLegacyPlaintext(t *testing.T) { + ctx := context.Background() + inner := mfa.NewMemoryStore() + userID := id.NewUserID() + require.NoError(t, inner.CreateEnrollment(ctx, &mfa.Enrollment{ + ID: id.NewMFAID(), + UserID: userID, + Method: "totp", + Secret: "LEGACYPLAINTEXT", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + })) + + store := mfa.NewEncryptedStore(inner, aesEnc(t)) + got, err := store.GetEnrollment(ctx, userID, "totp") + require.NoError(t, err) + assert.Equal(t, "LEGACYPLAINTEXT", got.Secret, "legacy plaintext secret must read back unchanged") +} diff --git a/plugins/mfa/totp.go b/plugins/mfa/totp.go index 9ae38a37..d2c43423 100644 --- a/plugins/mfa/totp.go +++ b/plugins/mfa/totp.go @@ -34,6 +34,35 @@ func ValidateTOTP(code, secret string) bool { return totp.Validate(code, secret) } +// totpPeriod is the TOTP time-step in seconds. totpSkew is the number of steps +// on each side of "now" that are accepted, matching the pquerna default that +// totp.Validate uses (±1 step ≈ a 90s window). +const ( + totpPeriod = 30 + totpSkew = 1 +) + +// ValidateTOTPStep validates a code and, on success, returns the time-step +// (unix seconds / period) the code corresponds to. The step uniquely +// identifies the code within its validity window, letting callers reject a +// replay of the same code. Acceptance matches ValidateTOTP (±1 step). +func ValidateTOTPStep(code, secret string) (bool, int64) { + now := time.Now() + for delta := int64(-totpSkew); delta <= totpSkew; delta++ { + ts := now.Add(time.Duration(delta*totpPeriod) * time.Second) + ok, err := totp.ValidateCustom(code, secret, ts, totp.ValidateOpts{ + Period: totpPeriod, + Skew: 0, + Digits: otp.DigitsSix, + Algorithm: otp.AlgorithmSHA1, + }) + if err == nil && ok { + return true, ts.Unix() / totpPeriod + } + } + return false, 0 +} + // GenerateTOTPCode generates a current TOTP code for a given secret. // Primarily useful for testing. func GenerateTOTPCode(secret string) (string, error) { diff --git a/plugins/organization/authz.go b/plugins/organization/authz.go new file mode 100644 index 00000000..14ef5895 --- /dev/null +++ b/plugins/organization/authz.go @@ -0,0 +1,84 @@ +package organization + +import ( + "errors" + + "github.com/xraph/forge" + + "github.com/xraph/authsome/id" + "github.com/xraph/authsome/middleware" + "github.com/xraph/authsome/organization" + "github.com/xraph/authsome/store" +) + +// orgRoleRank ranks membership roles so they can be compared (higher is more +// privileged): owner > admin > member. +func orgRoleRank(r organization.MemberRole) int { + switch r { + case organization.RoleOwner: + return 3 + case organization.RoleAdmin: + return 2 + case organization.RoleMember: + return 1 + default: + return 0 + } +} + +// requireOrgRole verifies the authenticated caller is a member of orgID holding +// at least minRole, returning their membership record. Failure modes: +// - no authenticated identity on the request → 401 +// - caller is not a member of the org → 404 (so org existence is not +// disclosed to outsiders) +// - caller's role is below minRole → 403 +func (p *Plugin) requireOrgRole(ctx forge.Context, orgID id.OrgID, minRole organization.MemberRole) (*organization.Member, error) { + userID, ok := middleware.UserIDFrom(ctx.Context()) + if !ok { + return nil, forge.Unauthorized("authentication required") + } + member, err := p.store.GetMemberByUserAndOrg(ctx.Context(), userID, orgID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + return nil, forge.NotFound("organization not found") + } + return nil, p.mapError(err) + } + if orgRoleRank(member.Role) < orgRoleRank(minRole) { + return nil, forge.Forbidden("insufficient organization role") + } + return member, nil +} + +// assertMemberInOrg verifies the member identified by memberID belongs to +// orgID, returning 404 otherwise. This stops a caller authorized in one org +// from mutating a member row that lives in another org via a crafted path. +func (p *Plugin) assertMemberInOrg(ctx forge.Context, memberID id.MemberID, orgID id.OrgID) error { + m, err := p.store.GetMember(ctx.Context(), memberID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + return forge.NotFound("member not found") + } + return p.mapError(err) + } + if m.OrgID.String() != orgID.String() { + return forge.NotFound("member not found") + } + return nil +} + +// assertTeamInOrg verifies the team identified by teamID belongs to orgID, +// returning 404 otherwise. +func (p *Plugin) assertTeamInOrg(ctx forge.Context, teamID id.TeamID, orgID id.OrgID) error { + tm, err := p.store.GetTeam(ctx.Context(), teamID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + return forge.NotFound("team not found") + } + return p.mapError(err) + } + if tm.OrgID.String() != orgID.String() { + return forge.NotFound("team not found") + } + return nil +} diff --git a/plugins/organization/authz_test.go b/plugins/organization/authz_test.go new file mode 100644 index 00000000..bcd7cdfb --- /dev/null +++ b/plugins/organization/authz_test.go @@ -0,0 +1,354 @@ +package organization_test + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xraph/forge" + + authsome "github.com/xraph/authsome" + "github.com/xraph/authsome/id" + "github.com/xraph/authsome/internal/secutil" + "github.com/xraph/authsome/middleware" + "github.com/xraph/authsome/organization" + orgplugin "github.com/xraph/authsome/plugins/organization" +) + +const orgTestAppID = "aapp_01jf0000000000000000000000" + +// newOrgHTTP builds an initialized org plugin and mounts its routes on a fresh +// router, returning the plugin (for seeding) and the HTTP handler. +func newOrgHTTP(t *testing.T) (*orgplugin.Plugin, http.Handler) { + t.Helper() + p := orgplugin.New() + _ = secutil.NewTestEngine(t, authsome.WithPlugin(p)) + router := forge.NewRouter() + require.NoError(t, p.RegisterRoutes(router)) + return p, router.Handler() +} + +// seedOrg creates an organization owned by ownerID (CreateOrganization also +// adds ownerID as an owner member). +func seedOrg(t *testing.T, p *orgplugin.Plugin, ownerID id.UserID) *organization.Organization { + t.Helper() + appID, err := id.ParseAppID(orgTestAppID) + require.NoError(t, err) + now := time.Now() + o := &organization.Organization{ + ID: id.NewOrgID(), + AppID: appID, + Name: "Acme", + Slug: "acme", + CreatedBy: ownerID, + CreatedAt: now, + UpdatedAt: now, + } + require.NoError(t, p.CreateOrganization(context.Background(), o)) + return o +} + +// memberRole returns the role of userID within orgID, failing if not a member. +func memberRole(t *testing.T, p *orgplugin.Plugin, orgID id.OrgID, userID id.UserID) organization.MemberRole { + t.Helper() + members, err := p.ListMembers(context.Background(), orgID) + require.NoError(t, err) + for _, m := range members { + if m.UserID.String() == userID.String() { + return m.Role + } + } + t.Fatalf("user %s is not a member of org %s", userID, orgID) + return "" +} + +// addMember adds userID to orgID with the given role and returns the member. +func addMember(t *testing.T, p *orgplugin.Plugin, orgID id.OrgID, userID id.UserID, role organization.MemberRole) *organization.Member { + t.Helper() + m := &organization.Member{ + ID: id.NewMemberID(), + OrgID: orgID, + UserID: userID, + Role: role, + } + require.NoError(t, p.AddMember(context.Background(), m)) + return m +} + +// orgReq builds a request, optionally authenticated as userID (pass the zero +// UserID for an unauthenticated request). +func orgReq(method, target string, body []byte, userID id.UserID) *http.Request { + var r *http.Request + if body != nil { + r = httptest.NewRequestWithContext(context.Background(), method, target, bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + } else { + r = httptest.NewRequestWithContext(context.Background(), method, target, nil) + } + if !userID.IsNil() { + r = r.WithContext(middleware.WithUserID(r.Context(), userID)) + } + return r +} + +// ────────────────────────────────────────────────── +// GET /orgs/:orgId — read requires membership +// ────────────────────────────────────────────────── + +func TestOrgGet_RequiresAuth(t *testing.T) { + p, h := newOrgHTTP(t) + o := seedOrg(t, p, id.NewUserID()) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, orgReq("GET", "/v1/orgs/"+o.ID.String(), nil, id.UserID{})) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) +} + +func TestOrgGet_RejectsNonMember(t *testing.T) { + p, h := newOrgHTTP(t) + o := seedOrg(t, p, id.NewUserID()) + stranger := id.NewUserID() + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, orgReq("GET", "/v1/orgs/"+o.ID.String(), nil, stranger)) + + assert.Equal(t, http.StatusNotFound, rec.Code, "a non-member must not see the org") +} + +func TestOrgGet_MemberSucceeds(t *testing.T) { + p, h := newOrgHTTP(t) + owner := id.NewUserID() + o := seedOrg(t, p, owner) + member := id.NewUserID() + addMember(t, p, o.ID, member, organization.RoleMember) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, orgReq("GET", "/v1/orgs/"+o.ID.String(), nil, member)) + + assert.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String()) +} + +// ────────────────────────────────────────────────── +// PATCH /orgs/:orgId — update requires admin +// ────────────────────────────────────────────────── + +func TestOrgUpdate_RequiresAuth(t *testing.T) { + p, h := newOrgHTTP(t) + o := seedOrg(t, p, id.NewUserID()) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, orgReq("PATCH", "/v1/orgs/"+o.ID.String(), []byte(`{"name":"Hacked"}`), id.UserID{})) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) +} + +func TestOrgUpdate_RejectsMember(t *testing.T) { + p, h := newOrgHTTP(t) + owner := id.NewUserID() + o := seedOrg(t, p, owner) + member := id.NewUserID() + addMember(t, p, o.ID, member, organization.RoleMember) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, orgReq("PATCH", "/v1/orgs/"+o.ID.String(), []byte(`{"name":"Hacked"}`), member)) + + assert.Equal(t, http.StatusForbidden, rec.Code) + + got, err := p.GetOrganization(context.Background(), o.ID) + require.NoError(t, err) + assert.Equal(t, "Acme", got.Name, "a plain member must not rename the org") +} + +func TestOrgUpdate_AdminSucceeds(t *testing.T) { + p, h := newOrgHTTP(t) + owner := id.NewUserID() + o := seedOrg(t, p, owner) + admin := id.NewUserID() + addMember(t, p, o.ID, admin, organization.RoleAdmin) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, orgReq("PATCH", "/v1/orgs/"+o.ID.String(), []byte(`{"name":"Renamed"}`), admin)) + + assert.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String()) +} + +// ────────────────────────────────────────────────── +// DELETE /orgs/:orgId — delete requires owner +// ────────────────────────────────────────────────── + +func TestOrgDelete_RejectsAdmin(t *testing.T) { + p, h := newOrgHTTP(t) + owner := id.NewUserID() + o := seedOrg(t, p, owner) + admin := id.NewUserID() + addMember(t, p, o.ID, admin, organization.RoleAdmin) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, orgReq("DELETE", "/v1/orgs/"+o.ID.String(), nil, admin)) + + assert.Equal(t, http.StatusForbidden, rec.Code, "only an owner may delete an org") + + _, err := p.GetOrganization(context.Background(), o.ID) + assert.NoError(t, err, "org must survive an admin's delete attempt") +} + +func TestOrgDelete_OwnerSucceeds(t *testing.T) { + p, h := newOrgHTTP(t) + owner := id.NewUserID() + o := seedOrg(t, p, owner) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, orgReq("DELETE", "/v1/orgs/"+o.ID.String(), nil, owner)) + + assert.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String()) +} + +// ────────────────────────────────────────────────── +// Members — add requires admin; role change requires owner; no self-escalation +// ────────────────────────────────────────────────── + +func TestListMembers_RequiresAuth(t *testing.T) { + p, h := newOrgHTTP(t) + o := seedOrg(t, p, id.NewUserID()) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, orgReq("GET", "/v1/orgs/"+o.ID.String()+"/members", nil, id.UserID{})) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) +} + +func TestAddMember_RejectsMember(t *testing.T) { + p, h := newOrgHTTP(t) + owner := id.NewUserID() + o := seedOrg(t, p, owner) + member := id.NewUserID() + addMember(t, p, o.ID, member, organization.RoleMember) + + body := []byte(`{"user_id":"` + id.NewUserID().String() + `","role":"member"}`) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, orgReq("POST", "/v1/orgs/"+o.ID.String()+"/members", body, member)) + + assert.Equal(t, http.StatusForbidden, rec.Code) +} + +func TestAddMember_AdminSucceeds(t *testing.T) { + p, h := newOrgHTTP(t) + owner := id.NewUserID() + o := seedOrg(t, p, owner) + admin := id.NewUserID() + addMember(t, p, o.ID, admin, organization.RoleAdmin) + + body := []byte(`{"user_id":"` + id.NewUserID().String() + `","role":"member"}`) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, orgReq("POST", "/v1/orgs/"+o.ID.String()+"/members", body, admin)) + + assert.Equal(t, http.StatusCreated, rec.Code, "body=%s", rec.Body.String()) +} + +// TestUpdateMember_SelfEscalation_Rejected is the headline case: a plain member +// must not be able to promote themselves (or anyone) to owner. +func TestUpdateMember_SelfEscalation_Rejected(t *testing.T) { + p, h := newOrgHTTP(t) + owner := id.NewUserID() + o := seedOrg(t, p, owner) + attacker := id.NewUserID() + m := addMember(t, p, o.ID, attacker, organization.RoleMember) + + body := []byte(`{"role":"owner"}`) + target := "/v1/orgs/" + o.ID.String() + "/members/" + m.ID.String() + rec := httptest.NewRecorder() + h.ServeHTTP(rec, orgReq("PATCH", target, body, attacker)) + + assert.Equal(t, http.StatusForbidden, rec.Code, "a member must not escalate their own role") + + assert.Equal(t, organization.RoleMember, memberRole(t, p, o.ID, attacker), "attacker's role must be unchanged") +} + +func TestUpdateMember_RejectsAdmin(t *testing.T) { + p, h := newOrgHTTP(t) + owner := id.NewUserID() + o := seedOrg(t, p, owner) + admin := id.NewUserID() + addMember(t, p, o.ID, admin, organization.RoleAdmin) + victim := id.NewUserID() + vm := addMember(t, p, o.ID, victim, organization.RoleMember) + + body := []byte(`{"role":"admin"}`) + target := "/v1/orgs/" + o.ID.String() + "/members/" + vm.ID.String() + rec := httptest.NewRecorder() + h.ServeHTTP(rec, orgReq("PATCH", target, body, admin)) + + assert.Equal(t, http.StatusForbidden, rec.Code, "only an owner may change member roles") +} + +func TestUpdateMember_OwnerSucceeds(t *testing.T) { + p, h := newOrgHTTP(t) + owner := id.NewUserID() + o := seedOrg(t, p, owner) + victim := id.NewUserID() + vm := addMember(t, p, o.ID, victim, organization.RoleMember) + + body := []byte(`{"role":"admin"}`) + target := "/v1/orgs/" + o.ID.String() + "/members/" + vm.ID.String() + rec := httptest.NewRecorder() + h.ServeHTTP(rec, orgReq("PATCH", target, body, owner)) + + assert.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String()) +} + +// ────────────────────────────────────────────────── +// Teams — create requires admin +// ────────────────────────────────────────────────── + +func TestCreateTeam_RejectsMember(t *testing.T) { + p, h := newOrgHTTP(t) + owner := id.NewUserID() + o := seedOrg(t, p, owner) + member := id.NewUserID() + addMember(t, p, o.ID, member, organization.RoleMember) + + body := []byte(`{"name":"Eng","slug":"eng"}`) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, orgReq("POST", "/v1/orgs/"+o.ID.String()+"/teams", body, member)) + + assert.Equal(t, http.StatusForbidden, rec.Code) +} + +// TestUpdateMember_CrossOrg_Rejected verifies an owner of one org cannot mutate +// a member record that belongs to a different org by supplying a foreign +// memberId under their own org's path. +func TestUpdateMember_CrossOrg_Rejected(t *testing.T) { + p, h := newOrgHTTP(t) + + ownerA := id.NewUserID() + orgA := seedOrg(t, p, ownerA) + + ownerB := id.NewUserID() + // Second org for the same app needs a distinct slug. + appID, _ := id.ParseAppID(orgTestAppID) + now := time.Now() + orgB := &organization.Organization{ + ID: id.NewOrgID(), AppID: appID, Name: "Beta", Slug: "beta", + CreatedBy: ownerB, CreatedAt: now, UpdatedAt: now, + } + require.NoError(t, p.CreateOrganization(context.Background(), orgB)) + victim := id.NewUserID() + vm := addMember(t, p, orgB.ID, victim, organization.RoleMember) + + // ownerA targets orgB's member via orgA's path. + body := []byte(`{"role":"owner"}`) + target := "/v1/orgs/" + orgA.ID.String() + "/members/" + vm.ID.String() + rec := httptest.NewRecorder() + h.ServeHTTP(rec, orgReq("PATCH", target, body, ownerA)) + + assert.Equal(t, http.StatusNotFound, rec.Code, "a member from another org must not be reachable") + + assert.Equal(t, organization.RoleMember, memberRole(t, p, orgB.ID, victim), "victim's role must be unchanged") +} diff --git a/plugins/organization/handlers.go b/plugins/organization/handlers.go index 4b892905..c626df29 100644 --- a/plugins/organization/handlers.go +++ b/plugins/organization/handlers.go @@ -344,6 +344,10 @@ func (p *Plugin) handleGetOrg(ctx forge.Context, _ *GetOrgRequest) (*organizatio return nil, forge.BadRequest(fmt.Sprintf("invalid org id: %v", err)) } + if _, err := p.requireOrgRole(ctx, orgID, organization.RoleMember); err != nil { + return nil, err + } + o, err := p.GetOrganization(ctx.Context(), orgID) if err != nil { return nil, mapError(err) @@ -358,6 +362,10 @@ func (p *Plugin) handleUpdateOrg(ctx forge.Context, req *UpdateOrgRequest) (*org return nil, forge.BadRequest(fmt.Sprintf("invalid org id: %v", err)) } + if _, err := p.requireOrgRole(ctx, orgID, organization.RoleAdmin); err != nil { + return nil, err + } + o, err := p.GetOrganization(ctx.Context(), orgID) if err != nil { return nil, mapError(err) @@ -383,6 +391,10 @@ func (p *Plugin) handleDeleteOrg(ctx forge.Context, _ *DeleteOrgRequest) (*Statu return nil, forge.BadRequest(fmt.Sprintf("invalid org id: %v", err)) } + if _, err := p.requireOrgRole(ctx, orgID, organization.RoleOwner); err != nil { + return nil, err + } + if err := p.DeleteOrganization(ctx.Context(), orgID); err != nil { return nil, mapError(err) } @@ -401,6 +413,10 @@ func (p *Plugin) handleListMembers(ctx forge.Context, _ *ListMembersRequest) (*M return nil, forge.BadRequest(fmt.Sprintf("invalid org id: %v", err)) } + if _, err := p.requireOrgRole(ctx, orgID, organization.RoleMember); err != nil { + return nil, err + } + members, err := p.ListMembers(ctx.Context(), orgID) if err != nil { return nil, mapError(err) @@ -419,6 +435,10 @@ func (p *Plugin) handleAddMember(ctx forge.Context, req *AddMemberRequest) (*org return nil, forge.BadRequest(fmt.Sprintf("invalid org id: %v", err)) } + if _, err := p.requireOrgRole(ctx, orgID, organization.RoleAdmin); err != nil { + return nil, err + } + if req.UserID == "" { return nil, forge.BadRequest("user_id is required") } @@ -448,11 +468,22 @@ func (p *Plugin) handleAddMember(ctx forge.Context, req *AddMemberRequest) (*org } func (p *Plugin) handleRemoveMember(ctx forge.Context, _ *RemoveMemberRequest) (*StatusResponse, error) { + orgID, err := id.ParseOrgID(ctx.Param("orgId")) + if err != nil { + return nil, forge.BadRequest(fmt.Sprintf("invalid org id: %v", err)) + } memberID, err := id.ParseMemberID(ctx.Param("memberId")) if err != nil { return nil, forge.BadRequest(fmt.Sprintf("invalid member id: %v", err)) } + if _, err := p.requireOrgRole(ctx, orgID, organization.RoleAdmin); err != nil { + return nil, err + } + if err := p.assertMemberInOrg(ctx, memberID, orgID); err != nil { + return nil, err + } + if err := p.RemoveMember(ctx.Context(), memberID); err != nil { return nil, mapError(err) } @@ -462,6 +493,10 @@ func (p *Plugin) handleRemoveMember(ctx forge.Context, _ *RemoveMemberRequest) ( } func (p *Plugin) handleUpdateMember(ctx forge.Context, req *UpdateMemberRequest) (*organization.Member, error) { + orgID, err := id.ParseOrgID(ctx.Param("orgId")) + if err != nil { + return nil, forge.BadRequest(fmt.Sprintf("invalid org id: %v", err)) + } memberID, err := id.ParseMemberID(ctx.Param("memberId")) if err != nil { return nil, forge.BadRequest(fmt.Sprintf("invalid member id: %v", err)) @@ -471,6 +506,15 @@ func (p *Plugin) handleUpdateMember(ctx forge.Context, req *UpdateMemberRequest) return nil, forge.BadRequest("role is required") } + // Only an owner may change member roles (including granting ownership), + // which prevents members/admins from escalating themselves or others. + if _, err := p.requireOrgRole(ctx, orgID, organization.RoleOwner); err != nil { + return nil, err + } + if err := p.assertMemberInOrg(ctx, memberID, orgID); err != nil { + return nil, err + } + member, err := p.UpdateMemberRole(ctx.Context(), memberID, organization.MemberRole(req.Role)) if err != nil { return nil, mapError(err) @@ -489,10 +533,11 @@ func (p *Plugin) handleCreateInvitation(ctx forge.Context, req *CreateInvitation return nil, forge.BadRequest(fmt.Sprintf("invalid org id: %v", err)) } - inviterID, ok := middleware.UserIDFrom(ctx.Context()) - if !ok { - return nil, forge.Unauthorized("authentication required") + inviter, err := p.requireOrgRole(ctx, orgID, organization.RoleAdmin) + if err != nil { + return nil, err } + inviterID := inviter.UserID if req.Email == "" { return nil, forge.BadRequest("email is required") @@ -531,6 +576,10 @@ func (p *Plugin) handleListInvitations(ctx forge.Context, _ *ListInvitationsRequ return nil, forge.BadRequest(fmt.Sprintf("invalid org id: %v", err)) } + if _, err := p.requireOrgRole(ctx, orgID, organization.RoleAdmin); err != nil { + return nil, err + } + invitations, err := p.ListInvitations(ctx.Context(), orgID) if err != nil { return nil, mapError(err) @@ -579,6 +628,10 @@ func (p *Plugin) handleCreateTeam(ctx forge.Context, req *CreateTeamRequest) (*o return nil, forge.BadRequest(fmt.Sprintf("invalid org id: %v", err)) } + if _, err := p.requireOrgRole(ctx, orgID, organization.RoleAdmin); err != nil { + return nil, err + } + if req.Name == "" || req.Slug == "" { return nil, forge.BadRequest("name and slug are required") } @@ -603,6 +656,10 @@ func (p *Plugin) handleListTeams(ctx forge.Context, _ *ListTeamsRequest) (*TeamL return nil, forge.BadRequest(fmt.Sprintf("invalid org id: %v", err)) } + if _, err := p.requireOrgRole(ctx, orgID, organization.RoleMember); err != nil { + return nil, err + } + teams, err := p.ListTeams(ctx.Context(), orgID) if err != nil { return nil, mapError(err) @@ -616,11 +673,22 @@ func (p *Plugin) handleListTeams(ctx forge.Context, _ *ListTeamsRequest) (*TeamL } func (p *Plugin) handleGetTeam(ctx forge.Context, _ *GetTeamRequest) (*organization.Team, error) { + orgID, err := id.ParseOrgID(ctx.Param("orgId")) + if err != nil { + return nil, forge.BadRequest(fmt.Sprintf("invalid org id: %v", err)) + } teamID, err := id.ParseTeamID(ctx.Param("teamId")) if err != nil { return nil, forge.BadRequest(fmt.Sprintf("invalid team id: %v", err)) } + if _, err := p.requireOrgRole(ctx, orgID, organization.RoleMember); err != nil { + return nil, err + } + if err := p.assertTeamInOrg(ctx, teamID, orgID); err != nil { + return nil, err + } + t, err := p.GetTeam(ctx.Context(), teamID) if err != nil { return nil, mapError(err) @@ -630,11 +698,22 @@ func (p *Plugin) handleGetTeam(ctx forge.Context, _ *GetTeamRequest) (*organizat } func (p *Plugin) handleUpdateTeam(ctx forge.Context, req *UpdateTeamRequest) (*organization.Team, error) { + orgID, err := id.ParseOrgID(ctx.Param("orgId")) + if err != nil { + return nil, forge.BadRequest(fmt.Sprintf("invalid org id: %v", err)) + } teamID, err := id.ParseTeamID(ctx.Param("teamId")) if err != nil { return nil, forge.BadRequest(fmt.Sprintf("invalid team id: %v", err)) } + if _, err := p.requireOrgRole(ctx, orgID, organization.RoleAdmin); err != nil { + return nil, err + } + if err := p.assertTeamInOrg(ctx, teamID, orgID); err != nil { + return nil, err + } + t, err := p.GetTeam(ctx.Context(), teamID) if err != nil { return nil, mapError(err) @@ -655,11 +734,22 @@ func (p *Plugin) handleUpdateTeam(ctx forge.Context, req *UpdateTeamRequest) (*o } func (p *Plugin) handleDeleteTeam(ctx forge.Context, _ *DeleteTeamRequest) (*StatusResponse, error) { + orgID, err := id.ParseOrgID(ctx.Param("orgId")) + if err != nil { + return nil, forge.BadRequest(fmt.Sprintf("invalid org id: %v", err)) + } teamID, err := id.ParseTeamID(ctx.Param("teamId")) if err != nil { return nil, forge.BadRequest(fmt.Sprintf("invalid team id: %v", err)) } + if _, err := p.requireOrgRole(ctx, orgID, organization.RoleAdmin); err != nil { + return nil, err + } + if err := p.assertTeamInOrg(ctx, teamID, orgID); err != nil { + return nil, err + } + if err := p.DeleteTeam(ctx.Context(), teamID); err != nil { return nil, mapError(err) } @@ -673,6 +763,10 @@ func (p *Plugin) handleDeleteTeam(ctx forge.Context, _ *DeleteTeamRequest) (*Sta // ────────────────────────────────────────────────── func (p *Plugin) handleCheckSlug(ctx forge.Context, req *CheckSlugRequest) (*SlugAvailableResponse, error) { + if _, ok := middleware.UserIDFrom(ctx.Context()); !ok { + return nil, forge.Unauthorized("authentication required") + } + if req.Slug == "" { return nil, forge.BadRequest("slug query parameter is required") } diff --git a/plugins/passkey/clone_test.go b/plugins/passkey/clone_test.go new file mode 100644 index 00000000..b85bb07d --- /dev/null +++ b/plugins/passkey/clone_test.go @@ -0,0 +1,29 @@ +package passkey + +import ( + "testing" + + "github.com/go-webauthn/webauthn/webauthn" +) + +// TestCloneWarningError pins that a WebAuthn clone warning (a non-increasing +// sign count, which go-webauthn flags but does not itself reject) is turned +// into a login-rejecting error. Without this, a cloned/duplicated authenticator +// authenticates successfully and goes undetected. +func TestCloneWarningError(t *testing.T) { + cloned := &webauthn.Credential{} + cloned.Authenticator.CloneWarning = true + if err := cloneWarningError(cloned); err == nil { + t.Fatal("a credential flagged as cloned must produce an error") + } + + ok := &webauthn.Credential{} + ok.Authenticator.CloneWarning = false + if err := cloneWarningError(ok); err != nil { + t.Fatalf("a healthy credential must not error, got %v", err) + } + + if err := cloneWarningError(nil); err != nil { + t.Fatalf("nil credential must not error, got %v", err) + } +} diff --git a/plugins/passkey/discoverable.go b/plugins/passkey/discoverable.go new file mode 100644 index 00000000..99810013 --- /dev/null +++ b/plugins/passkey/discoverable.go @@ -0,0 +1,180 @@ +package passkey + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/go-webauthn/webauthn/webauthn" + "github.com/xraph/forge" + + authsome "github.com/xraph/authsome" + "github.com/xraph/authsome/bridge" + "github.com/xraph/authsome/hook" + "github.com/xraph/authsome/id" + "github.com/xraph/authsome/session" + "github.com/xraph/authsome/user" +) + +// ceremonyCookieName correlates a discoverable-login begin with its finish. It +// is HttpOnly and short-lived; the value is an opaque random ceremony id that +// keys the stored WebAuthn session, so concurrent passwordless logins never +// share a single global ceremony slot. +const ceremonyCookieName = "authsome_passkey_ceremony" + +// newCeremonyID returns a random, URL-safe ceremony identifier. +func newCeremonyID() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("passkey: generate ceremony id: %w", err) + } + return hex.EncodeToString(b), nil +} + +// discoverableKey is the ceremony-store key for a discoverable login ceremony. +func discoverableKey(ceremonyID string) string { + return "passkey:discoverable:" + ceremonyID +} + +// setCeremonyCookie writes the ceremony-correlation cookie. +func setCeremonyCookie(w http.ResponseWriter, ceremonyID string, ttl time.Duration) { + http.SetCookie(w, &http.Cookie{ + Name: ceremonyCookieName, + Value: ceremonyID, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: int(ttl.Seconds()), + }) +} + +// readCeremonyCookie returns the ceremony id from the request, or "" when the +// request carries no discoverable ceremony (i.e. an identified/step-up login). +func readCeremonyCookie(r *http.Request) string { + if r == nil { + return "" + } + if c, err := r.Cookie(ceremonyCookieName); err == nil { + return c.Value + } + return "" +} + +// clearCeremonyCookie expires the ceremony-correlation cookie. +func clearCeremonyCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: ceremonyCookieName, + Value: "", + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + }) +} + +// resolveDiscoverableUser maps a WebAuthn user handle (the authsome user id) to +// the user and its webauthn.User view, WITHOUT requiring a pre-authenticated +// session — this is what makes passwordless login work. Errors on an +// unparseable handle, a missing engine, or an unknown user. +func (p *Plugin) resolveDiscoverableUser(ctx context.Context, userHandle []byte) (*user.User, webauthn.User, error) { + uid, err := id.ParseUserID(string(userHandle)) + if err != nil { + return nil, nil, fmt.Errorf("passkey: invalid user handle: %w", err) + } + if p.engine == nil { + return nil, nil, fmt.Errorf("passkey: engine unavailable for passwordless login") + } + u, err := p.engine.GetUser(ctx, uid) + if err != nil { + return nil, nil, err + } + return u, p.toWebAuthnUser(ctx, u), nil +} + +// finishDiscoverableLogin completes a passwordless WebAuthn ceremony: it loads +// the ceremony session correlated by cookie, verifies the assertion against the +// resolved user (looked up by user handle — no prior authentication), rejects +// cloned authenticators, and issues a real session. +func (p *Plugin) finishDiscoverableLogin(ctx forge.Context, ceremonyID string) (*LoginFinishResponse, error) { + key := discoverableKey(ceremonyID) + sessionJSON, err := p.ceremonies.Get(ctx.Context(), key) + if err != nil { + return nil, forge.BadRequest("no pending login ceremony") + } + _ = p.ceremonies.Delete(ctx.Context(), key) //nolint:errcheck // best-effort cleanup + clearCeremonyCookie(ctx.Response()) + + var waSession webauthn.SessionData + if unmarshalErr := json.Unmarshal(sessionJSON, &waSession); unmarshalErr != nil { + return nil, forge.InternalError(fmt.Errorf("failed to parse session: %w", unmarshalErr)) + } + + var loggedIn *user.User + handler := func(_, userHandle []byte) (webauthn.User, error) { + u, wau, herr := p.resolveDiscoverableUser(ctx.Context(), userHandle) + if herr != nil { + return nil, herr + } + loggedIn = u + return wau, nil + } + + cred, err := p.waForRequest(ctx.Request()).FinishDiscoverableLogin(handler, waSession, ctx.Request()) + if err != nil { + return nil, forge.Unauthorized(fmt.Sprintf("passkey: finish discoverable login: %v", err)) + } + if loggedIn == nil { + return nil, forge.Unauthorized("passkey: could not resolve user for passwordless login") + } + + // Clone detection: reject a possibly-duplicated authenticator. + if cwErr := cloneWarningError(cred); cwErr != nil { + p.audit(ctx.Context(), hook.ActionPasskeyLogin, hook.ResourcePasskey, "", loggedIn.ID.String(), "", bridge.OutcomeFailure) + return nil, forge.Unauthorized(cwErr.Error()) + } + + if p.store != nil { + _ = p.store.UpdateSignCount(ctx.Context(), cred.ID, cred.Authenticator.SignCount) //nolint:errcheck // best-effort update + } + + sess, err := p.issueSession(ctx, loggedIn) + if err != nil { + return nil, err + } + + userIDStr := loggedIn.ID.String() + p.audit(ctx.Context(), hook.ActionPasskeyLogin, hook.ResourcePasskey, "", userIDStr, "", bridge.OutcomeSuccess) + p.relayEvent(ctx.Context(), "auth.passkey.authenticated", "", map[string]string{"user_id": userIDStr}) + p.emitHook(ctx.Context(), hook.ActionPasskeyLogin, hook.ResourcePasskey, "", userIDStr, "") + + return &LoginFinishResponse{ + UserID: userIDStr, + Status: "authenticated", + SessionToken: sess.Token, + RefreshToken: sess.RefreshToken, + }, nil +} + +// issueSession mints a real session for a passwordless login via the engine's +// centralized IssueSession (so the MFA gate applies), returning it. +func (p *Plugin) issueSession(ctx forge.Context, u *user.User) (*session.Session, error) { + eng, ok := p.engine.(*authsome.Engine) + if !ok || eng == nil { + return nil, forge.InternalError(fmt.Errorf("passkey: session issuance unavailable")) + } + result, err := eng.IssueSession(ctx.Context(), &authsome.IssueSessionRequest{ + User: u, + AppID: u.AppID, + AuthMethod: "passkey", + IPAddress: ctx.Request().RemoteAddr, + UserAgent: ctx.Request().UserAgent(), + }) + if err != nil { + return nil, err + } + return result.Session, nil +} diff --git a/plugins/passkey/discoverable_flow_test.go b/plugins/passkey/discoverable_flow_test.go new file mode 100644 index 00000000..a393b26c --- /dev/null +++ b/plugins/passkey/discoverable_flow_test.go @@ -0,0 +1,87 @@ +package passkey + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xraph/forge" + + authsome "github.com/xraph/authsome" + "github.com/xraph/authsome/internal/secutil" +) + +// TestDiscoverableLoginFlow_RoutesWithoutPreAuth verifies the two behavioral +// fixes for passwordless passkey login end-to-end (minus the assertion crypto, +// which go-webauthn owns): +// - /login/begin with no email sets a per-ceremony correlation cookie and +// stores the ceremony under a unique key (no shared global slot). +// - /login/finish carrying that cookie takes the discoverable path and does +// NOT require a pre-authenticated session (the old behavior rejected it +// with "authentication required", defeating passwordless login entirely). +func TestDiscoverableLoginFlow_RoutesWithoutPreAuth(t *testing.T) { + p := New() + _ = secutil.NewTestEngine(t, authsome.WithPlugin(p)) + + router := forge.NewRouter() + require.NoError(t, p.RegisterRoutes(router)) + handler := router.Handler() + + // Begin (no email → discoverable). + beginReq := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/v1/passkeys/login/begin", strings.NewReader(`{}`)) + beginReq.Header.Set("Content-Type", "application/json") + beginRec := httptest.NewRecorder() + handler.ServeHTTP(beginRec, beginReq) + require.Equal(t, http.StatusOK, beginRec.Code, "discoverable begin should succeed; body=%s", beginRec.Body.String()) + + var ceremonyCookie *http.Cookie + for _, c := range beginRec.Result().Cookies() { + if c.Name == ceremonyCookieName { + ceremonyCookie = c + } + } + require.NotNil(t, ceremonyCookie, "begin must set a ceremony correlation cookie") + require.NotEmpty(t, ceremonyCookie.Value) + + // The ceremony must be stored under a per-ceremony key (not a global one). + _, err := p.ceremonies.Get(context.Background(), discoverableKey(ceremonyCookie.Value)) + require.NoError(t, err, "the ceremony session must be stored under its unique key") + + // Finish carrying the ceremony cookie takes the passwordless path. The + // assertion body is invalid, so it fails — but with an assertion error, NOT + // "authentication required" (which would mean pre-auth was still demanded). + finishReq := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/v1/passkeys/login/finish", strings.NewReader(`{}`)) + finishReq.Header.Set("Content-Type", "application/json") + finishReq.AddCookie(ceremonyCookie) + finishRec := httptest.NewRecorder() + handler.ServeHTTP(finishRec, finishReq) + + body := finishRec.Body.String() + assert.NotContains(t, body, "authentication required", + "the discoverable path must not require a pre-authenticated session; body=%s", body) +} + +// TestLoginFinish_IdentifiedPath_StillRequiresAuth confirms the identified +// (step-up) path is unchanged: without a ceremony cookie and without a session, +// finish still rejects with authentication required. +func TestLoginFinish_IdentifiedPath_StillRequiresAuth(t *testing.T) { + p := New() + _ = secutil.NewTestEngine(t, authsome.WithPlugin(p)) + + router := forge.NewRouter() + require.NoError(t, p.RegisterRoutes(router)) + handler := router.Handler() + + req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/v1/passkeys/login/finish", strings.NewReader(`{}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Contains(t, rec.Body.String(), "authentication required") +} diff --git a/plugins/passkey/discoverable_test.go b/plugins/passkey/discoverable_test.go new file mode 100644 index 00000000..2995bc7f --- /dev/null +++ b/plugins/passkey/discoverable_test.go @@ -0,0 +1,87 @@ +package passkey + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + authsome "github.com/xraph/authsome" + "github.com/xraph/authsome/id" + "github.com/xraph/authsome/internal/secutil" + "github.com/xraph/authsome/user" +) + +func seedPasskeyUser(t *testing.T, eng *authsome.Engine) *user.User { + t.Helper() + appID, err := id.ParseAppID("aapp_01jf0000000000000000000000") + require.NoError(t, err) + u := &user.User{ + ID: id.NewUserID(), + AppID: appID, + Email: "passwordless@test.com", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + require.NoError(t, eng.Store().CreateUser(context.Background(), u)) + return u +} + +// TestResolveDiscoverableUser is the core of passwordless login: the WebAuthn +// user handle (the authsome user id) must map back to a real user, WITHOUT any +// pre-existing authenticated session. A bad handle or unknown user errors. +func TestResolveDiscoverableUser(t *testing.T) { + p := New() + eng := secutil.NewTestEngine(t, authsome.WithPlugin(p)) + u := seedPasskeyUser(t, eng) + ctx := context.Background() + + gotUser, wau, err := p.resolveDiscoverableUser(ctx, []byte(u.ID.String())) + require.NoError(t, err) + require.NotNil(t, wau) + assert.Equal(t, u.ID.String(), gotUser.ID.String()) + assert.Equal(t, []byte(u.ID.String()), wau.WebAuthnID(), "the webauthn user handle must round-trip") + + _, _, err = p.resolveDiscoverableUser(ctx, []byte("not-a-valid-user-id")) + assert.Error(t, err, "an unparseable user handle must error") + + _, _, err = p.resolveDiscoverableUser(ctx, []byte(id.NewUserID().String())) + assert.Error(t, err, "an unknown user must error") +} + +// TestCeremonyCookieRoundTrip pins that the discoverable ceremony is correlated +// via a cookie set at begin and read at finish, and that each ceremony gets a +// unique id (so concurrent passwordless logins don't clobber a shared key). +func TestCeremonyCookieRoundTrip(t *testing.T) { + id1, err := newCeremonyID() + require.NoError(t, err) + id2, err := newCeremonyID() + require.NoError(t, err) + assert.NotEmpty(t, id1) + assert.NotEqual(t, id1, id2, "each ceremony must get a distinct id") + + rec := httptest.NewRecorder() + setCeremonyCookie(rec, id1, time.Minute) + + // The cookie the server set becomes the request cookie on the follow-up call. + req := httptest.NewRequest(http.MethodPost, "/v1/passkey/login/finish", nil) + for _, c := range rec.Result().Cookies() { + req.AddCookie(c) + } + assert.Equal(t, id1, readCeremonyCookie(req), "the ceremony id must round-trip via cookie") + + // A request without the cookie yields an empty id (identified flow). + bare := httptest.NewRequest(http.MethodPost, "/v1/passkey/login/finish", nil) + assert.Empty(t, readCeremonyCookie(bare)) +} + +// TestDiscoverableKeyIsPerCeremony confirms the ceremony store key is derived +// from the ceremony id rather than a single global constant. +func TestDiscoverableKeyIsPerCeremony(t *testing.T) { + assert.NotEqual(t, discoverableKey("a"), discoverableKey("b")) + assert.Contains(t, discoverableKey("abc"), "abc") +} diff --git a/plugins/passkey/handlers.go b/plugins/passkey/handlers.go index 8c8382eb..7c04643c 100644 --- a/plugins/passkey/handlers.go +++ b/plugins/passkey/handlers.go @@ -17,6 +17,20 @@ import ( "github.com/xraph/authsome/user" ) +// errCloneWarning is returned when a WebAuthn assertion's sign count did not +// increase, which indicates the authenticator may have been cloned. +var errCloneWarning = errors.New("passkey: authenticator may be cloned (sign count did not increase)") + +// cloneWarningError converts go-webauthn's CloneWarning flag into a login- +// rejecting error. go-webauthn sets the flag on FinishLogin but does not fail +// the ceremony itself, leaving the decision to the relying party. +func cloneWarningError(cred *webauthn.Credential) error { + if cred != nil && cred.Authenticator.CloneWarning { + return errCloneWarning + } + return nil +} + // RegisterRoutes registers passkey/WebAuthn HTTP endpoints on a forge.Router. func (p *Plugin) RegisterRoutes(router forge.Router) error { g := router.Group("/v1/passkeys", forge.WithGroupTags("Passkeys")) @@ -111,10 +125,13 @@ type LoginBeginResponse struct { // LoginFinishRequest is the raw assertion response from the browser. type LoginFinishRequest struct{} -// LoginFinishResponse confirms successful authentication. +// LoginFinishResponse confirms successful authentication. For passwordless +// (discoverable) login it also carries the freshly-issued session tokens. type LoginFinishResponse struct { - UserID string `json:"user_id"` - Status string `json:"status"` + UserID string `json:"user_id"` + Status string `json:"status"` + SessionToken string `json:"session_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` } // ListRequest is an empty request for listing passkeys. @@ -248,14 +265,21 @@ func (p *Plugin) handleLoginBegin(ctx forge.Context, req *LoginBeginRequest) (*L wa := p.waForRequest(ctx.Request()) - // For discoverable credentials (passkey), we can start without user identity + // For discoverable credentials (passkey), we can start without user identity. + // Each ceremony gets its own random id (stored key + correlation cookie) so + // concurrent passwordless logins never overwrite a single shared slot. if req.Email == "" { options, session, err := wa.BeginDiscoverableLogin() if err != nil { return nil, forge.InternalError(fmt.Errorf("passkey: begin discoverable login: %w", err)) } - sessionJSON, _ := json.Marshal(session) //nolint:errcheck // marshaling known types - _ = p.ceremonies.Set(ctx.Context(), "passkey:discoverable", sessionJSON, p.config.SessionTimeout) //nolint:errcheck // best-effort cache + ceremonyID, err := newCeremonyID() + if err != nil { + return nil, forge.InternalError(err) + } + sessionJSON, _ := json.Marshal(session) //nolint:errcheck // marshaling known types + _ = p.ceremonies.Set(ctx.Context(), discoverableKey(ceremonyID), sessionJSON, p.config.SessionTimeout) //nolint:errcheck // best-effort cache + setCeremonyCookie(ctx.Response(), ceremonyID, p.config.SessionTimeout) return &LoginBeginResponse{Options: options}, nil } @@ -285,6 +309,14 @@ func (p *Plugin) handleLoginFinish(ctx forge.Context, _ *LoginFinishRequest) (*L return nil, forge.InternalError(fmt.Errorf("passkey: WebAuthn not initialized")) } + // Passwordless (discoverable) login: correlated by the ceremony cookie set + // at begin. No prior authentication is required — the user is resolved from + // the assertion's user handle. + if ceremonyID := readCeremonyCookie(ctx.Request()); ceremonyID != "" { + return p.finishDiscoverableLogin(ctx, ceremonyID) + } + + // Identified / step-up flow: an already-authenticated user re-asserting. u, err := p.resolveUser(ctx) if err != nil { return nil, err @@ -309,6 +341,15 @@ func (p *Plugin) handleLoginFinish(ctx forge.Context, _ *LoginFinishRequest) (*L return nil, forge.Unauthorized(fmt.Sprintf("passkey: finish login: %v", err)) } + // Clone detection: go-webauthn sets CloneWarning when the authenticator's + // reported sign count did not increase, but leaves it to the relying party + // to act on. Reject the login (and record a failure) rather than accept a + // possibly-duplicated authenticator. + if cwErr := cloneWarningError(cred); cwErr != nil { + p.audit(ctx.Context(), hook.ActionPasskeyLogin, hook.ResourcePasskey, "", u.ID.String(), "", bridge.OutcomeFailure) + return nil, forge.Unauthorized(cwErr.Error()) + } + // Update sign count if p.store != nil { _ = p.store.UpdateSignCount(ctx.Context(), cred.ID, cred.Authenticator.SignCount) //nolint:errcheck // best-effort update diff --git a/plugins/passkey/passkey.go b/plugins/passkey/passkey.go index 818dd505..e2f536c1 100644 --- a/plugins/passkey/passkey.go +++ b/plugins/passkey/passkey.go @@ -125,7 +125,8 @@ type Plugin struct { hooks *hook.Bus logger log.Logger settingsMgr *settings.Manager - originWAOnce sync.Map // map[string]*webauthn.WebAuthn — per-origin webauthn cache for localhost dev + engine plugin.Engine // used to resolve users and issue sessions for passwordless login + originWAOnce sync.Map // map[string]*webauthn.WebAuthn — per-origin webauthn cache for localhost dev } // DeclareSettings implements plugin.SettingsProvider. @@ -217,6 +218,7 @@ func (p *Plugin) OnInit(_ context.Context, engine plugin.Engine) error { p.wa = wa if engine != nil { + p.engine = engine p.chronicle = engine.Chronicle() p.relay = engine.Relay() p.hooks = engine.Hooks() diff --git a/plugins/sso/matching_test.go b/plugins/sso/matching_test.go new file mode 100644 index 00000000..fd6e132e --- /dev/null +++ b/plugins/sso/matching_test.go @@ -0,0 +1,86 @@ +package sso + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xraph/authsome/id" + memory "github.com/xraph/authsome/store/memory" + "github.com/xraph/authsome/user" +) + +func seedUserWithEmail(t *testing.T, s *memory.Store, appID id.AppID, envID id.EnvironmentID, email string, verified bool) *user.User { + t.Helper() + u := &user.User{ + ID: id.NewUserID(), + AppID: appID, + EnvID: envID, + Email: email, + EmailVerified: verified, + } + row := &user.UserEmail{ + ID: id.NewUserEmailID(), + UserID: u.ID, + AppID: appID, + EnvID: envID, + Email: email, + Verified: verified, + IsPrimary: true, + Source: "test", + } + require.NoError(t, s.CreateUserWithPrimaryEmail(context.Background(), u, row)) + return u +} + +// TestLinkableExistingUser_RefusesUnverified is the account-takeover guard: an +// SSO login whose email matches a *pre-existing but unverified* local account +// must NOT link to it. Otherwise an attacker who pre-registers the victim's +// email (unverified) would capture the victim's later SSO login. +func TestLinkableExistingUser_RefusesUnverified(t *testing.T) { + s := memory.New() + p := New() + p.SetStore(s) + appID, envID := id.NewAppID(), id.NewEnvironmentID() + + seedUserWithEmail(t, s, appID, envID, "victim@corp.com", false) + + got, err := p.linkableExistingUser(context.Background(), appID, envID, "victim@corp.com") + + require.Error(t, err, "linking to an unverified pre-existing account must be refused") + assert.Nil(t, got) +} + +// TestLinkableExistingUser_LinksVerified confirms the legitimate path: a +// verified local account is linked to. +func TestLinkableExistingUser_LinksVerified(t *testing.T) { + s := memory.New() + p := New() + p.SetStore(s) + appID, envID := id.NewAppID(), id.NewEnvironmentID() + + u := seedUserWithEmail(t, s, appID, envID, "member@corp.com", true) + + got, err := p.linkableExistingUser(context.Background(), appID, envID, "member@corp.com") + + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, u.ID.String(), got.ID.String()) +} + +// TestLinkableExistingUser_NoMatchCreatesFresh confirms that when no local +// account exists, the guard signals "no match" (nil user, nil error) so the +// caller creates a fresh SSO user. +func TestLinkableExistingUser_NoMatchCreatesFresh(t *testing.T) { + s := memory.New() + p := New() + p.SetStore(s) + appID, envID := id.NewAppID(), id.NewEnvironmentID() + + got, err := p.linkableExistingUser(context.Background(), appID, envID, "nobody@corp.com") + + require.NoError(t, err) + assert.Nil(t, got) +} diff --git a/plugins/sso/plugin.go b/plugins/sso/plugin.go index 3a3b46a0..401bdd07 100644 --- a/plugins/sso/plugin.go +++ b/plugins/sso/plugin.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "encoding/hex" "encoding/json" + "errors" "fmt" "net/http" "strings" @@ -455,6 +456,35 @@ func (p *Plugin) handleACS(ctx forge.Context, req *ACSRequest) (*CallbackRespons } // authenticateUser processes an SSO identity and creates/links a user. +// errUnverifiedSSOLink signals that an SSO email matched a pre-existing local +// account whose email has never been verified. Linking in that case is an +// account-takeover vector, so the caller refuses. +var errUnverifiedSSOLink = errors.New("sso: refusing to link to an unverified pre-existing account") + +// linkableExistingUser resolves an existing local account for an SSO email and +// verifies it is safe to link to. It returns: +// - (user, nil) when a matching account exists and the matched email is verified +// - (nil, nil) when no account matches (the caller creates a fresh user) +// - (nil, errUnverifiedSSOLink) when a match exists but its email is unverified +// - (nil, err) on an unexpected store error +// +// This mirrors the social plugin's verified-email linking rule so SSO logins +// cannot be captured by a pre-registered unverified account. +func (p *Plugin) linkableExistingUser(ctx context.Context, appID id.AppID, envID id.EnvironmentID, email string) (*user.User, error) { + u, err := p.store.GetUserByAnyEmail(ctx, appID, envID, email) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + return nil, nil + } + return nil, err + } + rec, recErr := p.store.GetUserEmailRecord(ctx, appID, envID, email) + if recErr != nil || rec == nil || !rec.Verified { + return nil, errUnverifiedSSOLink + } + return u, nil +} + func (p *Plugin) authenticateUser(ctx forge.Context, provider Provider, params map[string]string) (*CallbackResponse, error) { ssoUser, err := provider.HandleCallback(ctx.Context(), params) if err != nil { @@ -482,8 +512,19 @@ func (p *Plugin) authenticateUser(ctx forge.Context, provider Provider, params m isNew := false if ssoUser.Email != "" { - u, err = p.store.GetUserByAnyEmail(goCtx, appID, envID, strings.ToLower(ssoUser.Email)) + email := strings.ToLower(ssoUser.Email) + u, err = p.linkableExistingUser(goCtx, appID, envID, email) + if errors.Is(err, errUnverifiedSSOLink) { + // A pre-existing account owns this email but has never verified it. + // Linking here would let an attacker who pre-registered the + // victim's email capture the victim's SSO login, so refuse. + return nil, forge.NewHTTPError(http.StatusConflict, + "an account with this email already exists but is not verified; verify it before signing in with SSO") + } if err != nil { + return nil, forge.InternalError(fmt.Errorf("sso: resolve existing user: %w", err)) + } + if u == nil { // No existing user -- create one. Prefer the upstream // IdP's user_id (sub claim) as the local user_id when // it parses as a valid Authsome UserID. This makes the diff --git a/refresh_jwt_test.go b/refresh_jwt_test.go new file mode 100644 index 00000000..409408f3 --- /dev/null +++ b/refresh_jwt_test.go @@ -0,0 +1,44 @@ +package authsome_test + +import ( + "context" + "testing" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + authsome "github.com/xraph/authsome" + "github.com/xraph/authsome/account" + "github.com/xraph/authsome/tokenformat" +) + +// TestRefresh_PreservesJWTAccessToken pins that refreshing a session for a +// JWT-configured app keeps the access token a JWT. Previously Refresh delegated +// token minting to account.RefreshSession, which always issues an opaque token, +// silently downgrading JWT apps and breaking stateless verification / JWT +// revocation binding. +func TestRefresh_PreservesJWTAccessToken(t *testing.T) { + jwtFmt, err := tokenformat.NewJWT(tokenformat.JWTConfig{ + SigningMethod: jwt.SigningMethodHS256, + SigningKey: []byte("test-signing-key-0123456789abcdef"), + }) + require.NoError(t, err) + + eng, _ := newTestEngine(t, authsome.WithJWTFormat("aapp_01jf0000000000000000000000", jwtFmt)) + ctx := context.Background() + appID := testAppID(t) + + _, sess, err := eng.SignUp(ctx, &account.SignUpRequest{ + AppID: appID, + Email: "jwt-refresh@example.com", + Password: "SecureP@ss1", + FirstName: "JWT User", + }) + require.NoError(t, err) + require.True(t, tokenformat.IsJWT(sess.Token), "login access token should be a JWT for a JWT-configured app") + + refreshed, err := eng.Refresh(ctx, sess.RefreshToken) + require.NoError(t, err) + assert.True(t, tokenformat.IsJWT(refreshed.Token), "refreshed access token must remain a JWT, not downgrade to opaque") +} diff --git a/refresh_race_test.go b/refresh_race_test.go new file mode 100644 index 00000000..26ce3cb4 --- /dev/null +++ b/refresh_race_test.go @@ -0,0 +1,62 @@ +package authsome_test + +import ( + "context" + "errors" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/xraph/authsome/account" +) + +// TestRefresh_ConcurrentRotation_ExactlyOneWinner exercises the refresh-token +// rotation TOCTOU: many goroutines present the SAME valid refresh token at +// once. With the compare-and-swap rotation, exactly one may win and persist its +// rotated tokens; every other caller is refused rather than returning tokens +// that were never persisted (which previously silently signed clients out). +// Also run under -race to confirm the store no longer shares a live pointer. +func TestRefresh_ConcurrentRotation_ExactlyOneWinner(t *testing.T) { + eng, _ := newTestEngine(t) + ctx := context.Background() + appID := testAppID(t) + + _, sess, err := eng.SignUp(ctx, &account.SignUpRequest{ + AppID: appID, + Email: "refresh-race@example.com", + Password: "SecureP@ss1", + FirstName: "Race", + }) + require.NoError(t, err) + rt := sess.RefreshToken + + const n = 12 + var wg sync.WaitGroup + errs := make([]error, n) + start := make(chan struct{}) + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + <-start + _, errs[i] = eng.Refresh(ctx, rt) + }(i) + } + close(start) + wg.Wait() + + successes := 0 + for _, e := range errs { + switch { + case e == nil: + successes++ + case errors.Is(e, account.ErrInvalidCredentials): + // expected loser + default: + t.Fatalf("unexpected refresh error: %v", e) + } + } + + require.Equal(t, 1, successes, "exactly one concurrent refresh may win the rotation") +} diff --git a/service.go b/service.go index c546c384..4e14a159 100644 --- a/service.go +++ b/service.go @@ -523,6 +523,12 @@ func (e *Engine) Refresh(ctx context.Context, refreshToken string, opts ...Refre return nil, account.ErrInvalidCredentials } + // Capture the pre-rotation access token. The rotation below is committed + // via a compare-and-swap keyed on this value, so two concurrent refreshes + // presenting the same token cannot both persist their (different) rotated + // tokens — exactly one wins. + oldAccessToken := sess.Token + // Check if refresh token is expired if time.Now().After(sess.RefreshTokenExpiresAt) { _ = e.store.DeleteSession(ctx, sess.ID) //nolint:errcheck // best-effort cleanup @@ -567,9 +573,45 @@ func (e *Engine) Refresh(ctx context.Context, refreshToken string, opts ...Refre return nil, fmt.Errorf("authsome: refresh session: %w", err) } - if err := e.store.UpdateSession(ctx, sess); err != nil { + // account.RefreshSession always mints an opaque access token. If the app is + // configured for JWT access tokens, re-derive a JWT here (mirroring + // newSession) so a refresh does not silently downgrade the token format — + // which would break stateless verification and JWT-revocation binding. + tokFmt := e.TokenFormatForApp(sess.AppID.String()) + if tokFmt.Name() == "jwt" { + jwtToken, genErr := tokFmt.GenerateAccessToken(tokenformat.TokenClaims{ + UserID: sess.UserID.String(), + AppID: sess.AppID.String(), + SessionID: sess.ID.String(), + IssuedAt: sess.UpdatedAt, + ExpiresAt: sess.ExpiresAt, + }) + if genErr != nil { + return nil, fmt.Errorf("authsome: refresh session: regenerate jwt: %w", genErr) + } + sess.Token = jwtToken + } + + rotated, err := e.store.RotateSession(ctx, sess, oldAccessToken) + if err != nil { + // The session can be concurrently deleted out from under us when a + // sibling refresh detects replay and revokes the whole family. That is + // a benign lost race for this caller — not a server error — so refuse + // cleanly rather than surfacing a 500. + if errors.Is(err, store.ErrNotFound) { + return nil, account.ErrInvalidCredentials + } return nil, fmt.Errorf("authsome: update session: %w", err) } + if !rotated { + // A concurrent refresh already rotated this session between our fetch + // and write. Refuse this request rather than returning tokens that were + // never persisted (which would silently sign the client out on its next + // call). This is a benign race, not a token replay, so we deliberately + // do NOT flag the family — avoiding a revocation storm on legitimate + // concurrent refreshes (e.g. a page firing several XHRs near expiry). + return nil, account.ErrInvalidCredentials + } // Record the OLD refresh-token hash as rotated. A subsequent Refresh // call presenting this same token will trigger the replay branch above. diff --git a/session/store.go b/session/store.go index e7ece43a..3dc306e7 100644 --- a/session/store.go +++ b/session/store.go @@ -31,6 +31,13 @@ type Store interface { GetSessionByToken(ctx context.Context, token string) (*Session, error) GetSessionByRefreshToken(ctx context.Context, refreshToken string) (*Session, error) UpdateSession(ctx context.Context, s *Session) error + // RotateSession atomically persists s only if the stored row's access token + // still equals expectedToken. It reports true when the row was updated and + // false when no row matched — meaning a concurrent refresh already rotated + // this session. This compare-and-swap serializes concurrent refresh-token + // rotations so only one caller can "win"; the others are refused rather than + // returning tokens that were never persisted (the refresh TOCTOU fix). + RotateSession(ctx context.Context, s *Session, expectedToken string) (bool, error) // TouchSession performs a lightweight update of last_activity_at, expires_at, // and updated_at without rewriting the entire session row. TouchSession(ctx context.Context, sessionID id.SessionID, lastActivityAt, expiresAt time.Time) error diff --git a/settings/failopen_test.go b/settings/failopen_test.go new file mode 100644 index 00000000..ac3711e7 --- /dev/null +++ b/settings/failopen_test.go @@ -0,0 +1,66 @@ +package settings_test + +import ( + "context" + "errors" + "testing" + + log "github.com/xraph/go-utils/log" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xraph/authsome/settings" +) + +// failingStore errors on every read, simulating a settings-store outage. +type failingStore struct{} + +func (failingStore) GetSetting(context.Context, string, settings.Scope, string) (*settings.Setting, error) { + return nil, errors.New("boom") +} +func (failingStore) SetSetting(context.Context, *settings.Setting) error { return errors.New("boom") } +func (failingStore) DeleteSetting(context.Context, string, settings.Scope, string) error { + return errors.New("boom") +} +func (failingStore) ListSettings(context.Context, settings.ListOpts) ([]*settings.Setting, error) { + return nil, errors.New("boom") +} +func (failingStore) ResolveSettings(context.Context, string, settings.ResolveOpts) ([]*settings.Setting, error) { + return nil, errors.New("boom") +} +func (failingStore) BatchResolve(context.Context, []string, settings.ResolveOpts) (map[string][]*settings.Setting, error) { + return nil, errors.New("boom") +} +func (failingStore) DeleteSettingsByNamespace(context.Context, string) error { + return errors.New("boom") +} + +// TestGet_FailsSafeToDefaultOnStoreError pins that when the settings store +// errors, Get returns the registered default (not the zero value of T). This is +// the security-relevant behavior for flags like cookie Secure/HttpOnly whose +// default is true: a store outage must not silently flip them to false. +func TestGet_FailsSafeToDefaultOnStoreError(t *testing.T) { + mgr := settings.NewManager(failingStore{}, log.NewNoopLogger()) + + secureDef := settings.Define("test.cookie_secure", true) + require.NoError(t, settings.RegisterTyped(mgr, "test", secureDef)) + + val, err := settings.Get(context.Background(), mgr, secureDef, settings.ResolveOpts{}) + + require.Error(t, err, "the store error should still be surfaced") + assert.True(t, val, "on a store error Get must fall back to the registered default (true), not the zero value") +} + +// A string default must also survive a store outage. +func TestGet_FailsSafeToStringDefault(t *testing.T) { + mgr := settings.NewManager(failingStore{}, log.NewNoopLogger()) + + def := settings.Define("test.some_string", "lax") + require.NoError(t, settings.RegisterTyped(mgr, "test", def)) + + val, err := settings.Get(context.Background(), mgr, def, settings.ResolveOpts{}) + + require.Error(t, err) + assert.Equal(t, "lax", val, "string default must be returned on store error") +} diff --git a/settings/manager.go b/settings/manager.go index ed3eea38..95c0b781 100644 --- a/settings/manager.go +++ b/settings/manager.go @@ -104,15 +104,34 @@ func Get[T any](ctx context.Context, m *Manager, def DefinitionTyped[T], opts Re var zero T raw, err := m.Resolve(ctx, def.Def.Key, opts) if err != nil { - return zero, err + // Fail safe: fall back to the registered code default rather than the + // zero value of T. Callers frequently ignore this error (e.g. cookie + // Secure/HttpOnly resolution), and a settings-store outage must not + // silently flip a default-true security flag to false. + return defaultOrZero(def), err } var val T if err := json.Unmarshal(raw, &val); err != nil { - return zero, fmt.Errorf("settings: unmarshal %q into %T: %w", def.Def.Key, zero, err) + // A corrupt stored value is likewise treated as "use the default". + return defaultOrZero(def), fmt.Errorf("settings: unmarshal %q into %T: %w", def.Def.Key, zero, err) } return val, nil } +// defaultOrZero returns the definition's registered default unmarshaled into T, +// or the zero value of T if no default is set or it fails to decode. +func defaultOrZero[T any](def DefinitionTyped[T]) T { + var val T + if len(def.Def.Default) > 0 { + if err := json.Unmarshal(def.Def.Default, &val); err == nil { + return val + } + var zero T + return zero + } + return val +} + // Set writes a setting value at the given scope. It validates the value, // checks scope permissions, checks enforcement, and emits a change event. func (m *Manager) Set(ctx context.Context, key string, value json.RawMessage, scope Scope, scopeID, appID, orgID, updatedBy string) error { diff --git a/store/memory/conformance_test.go b/store/memory/conformance_test.go new file mode 100644 index 00000000..ec91524f --- /dev/null +++ b/store/memory/conformance_test.go @@ -0,0 +1,17 @@ +package memory_test + +import ( + "testing" + + "github.com/xraph/authsome/store" + "github.com/xraph/authsome/store/memory" + "github.com/xraph/authsome/store/storetest" +) + +// TestConformance runs the shared cross-backend store contract suite against +// the in-memory backend. It runs in normal CI (no Docker required). +func TestConformance(t *testing.T) { + storetest.RunConformance(t, func(t *testing.T) store.Store { + return memory.New() + }) +} diff --git a/store/memory/rotate_session_test.go b/store/memory/rotate_session_test.go new file mode 100644 index 00000000..4d68792a --- /dev/null +++ b/store/memory/rotate_session_test.go @@ -0,0 +1,81 @@ +package memory_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/xraph/authsome/id" + "github.com/xraph/authsome/session" + memory "github.com/xraph/authsome/store/memory" +) + +// TestRotateSession_CompareAndSwap pins the atomic compare-and-swap that fixes +// the refresh-token rotation TOCTOU: an update is applied only when the stored +// access token still equals the expected (pre-rotation) value. A second, +// stale-based rotation must not overwrite the winner. +func TestRotateSession_CompareAndSwap(t *testing.T) { + s := memory.New() + ctx := context.Background() + + sess := &session.Session{ + ID: id.NewSessionID(), + AppID: id.NewAppID(), + UserID: id.NewUserID(), + Token: "T0", + RefreshToken: "R0", + } + require.NoError(t, s.CreateSession(ctx, sess)) + + // Winner rotates from T0 -> T1. + win := *sess + win.Token = "T1" + win.RefreshToken = "R1" + ok, err := s.RotateSession(ctx, &win, "T0") + require.NoError(t, err) + require.True(t, ok, "CAS with the current token must succeed") + + // Loser presents the now-stale T0 and must lose. + lose := *sess + lose.Token = "T2" + lose.RefreshToken = "R2" + ok, err = s.RotateSession(ctx, &lose, "T0") + require.NoError(t, err) + require.False(t, ok, "CAS with a stale token must not overwrite the winner") + + // The stored session must reflect the winner's tokens. + got, err := s.GetSession(ctx, sess.ID) + require.NoError(t, err) + require.Equal(t, "T1", got.Token, "winner's access token must be persisted") + require.Equal(t, "R1", got.RefreshToken, "winner's refresh token must be persisted") +} + +// TestGetSessionByRefreshToken_ReturnsCopy pins that the memory store hands out +// a copy, not the live map pointer. The refresh flow mutates the returned +// session in place before persisting; if it shared the stored pointer, the CAS +// could never detect the pre-rotation value. +func TestGetSessionByRefreshToken_ReturnsCopy(t *testing.T) { + s := memory.New() + ctx := context.Background() + + sess := &session.Session{ + ID: id.NewSessionID(), + AppID: id.NewAppID(), + UserID: id.NewUserID(), + Token: "T0", + RefreshToken: "R0", + } + require.NoError(t, s.CreateSession(ctx, sess)) + + fetched, err := s.GetSessionByRefreshToken(ctx, "R0") + require.NoError(t, err) + + // Mutating the fetched copy must not affect stored state. + fetched.Token = "MUTATED" + fetched.RefreshToken = "MUTATED" + + stored, err := s.GetSession(ctx, sess.ID) + require.NoError(t, err) + require.Equal(t, "T0", stored.Token, "mutating a fetched session must not leak into the store") +} diff --git a/store/memory/store.go b/store/memory/store.go index 341362ce..b630ed89 100644 --- a/store/memory/store.go +++ b/store/memory/store.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "sort" + "strings" "sync" "time" @@ -342,6 +343,11 @@ func (s *Store) ListUsers(_ context.Context, q *user.Query) (*user.List, error) if !q.EnvID.IsNil() && u.EnvID.String() != q.EnvID.String() { continue } + // Case-insensitive substring match on email, mirroring the SQL + // backends' LIKE '%email%' so admin user search behaves identically. + if q.Email != "" && !strings.Contains(strings.ToLower(u.Email), strings.ToLower(q.Email)) { + continue + } result = append(result, u) } return &user.List{Users: result, Total: len(result)}, nil @@ -388,7 +394,12 @@ func (s *Store) GetSessionByRefreshToken(_ context.Context, refreshToken string) defer s.mu.RUnlock() for _, sess := range s.sessions { if sess.RefreshToken == refreshToken { - return sess, nil + // Return a copy: the refresh flow mutates the returned session in + // place before persisting via RotateSession, and the CAS there must + // still be able to observe the pre-rotation token. Sharing the map + // pointer would let that mutation defeat the compare-and-swap. + copied := *sess + return &copied, nil } } return nil, store.ErrNotFound @@ -405,6 +416,24 @@ func (s *Store) UpdateSession(_ context.Context, sess *session.Session) error { return nil } +func (s *Store) RotateSession(_ context.Context, sess *session.Session, expectedToken string) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + stored, ok := s.sessions[sess.ID.String()] + if !ok { + return false, store.ErrNotFound + } + // Compare-and-swap on the access token: only apply if the stored token is + // still the pre-rotation value the caller observed. + if stored.Token != expectedToken { + return false, nil + } + updated := *sess + updated.UpdatedAt = time.Now() + s.sessions[sess.ID.String()] = &updated + return true, nil +} + func (s *Store) TouchSession(_ context.Context, sessionID id.SessionID, lastActivityAt, expiresAt time.Time) error { s.mu.Lock() defer s.mu.Unlock() diff --git a/store/mongo/app.go b/store/mongo/app.go index 02847b64..987355fd 100644 --- a/store/mongo/app.go +++ b/store/mongo/app.go @@ -6,6 +6,8 @@ import ( "go.mongodb.org/mongo-driver/v2/bson" + "github.com/xraph/grove/drivers/mongodriver" + "github.com/xraph/authsome/app" "github.com/xraph/authsome/id" "github.com/xraph/authsome/store" @@ -115,18 +117,84 @@ func (s *Store) UpdateApp(ctx context.Context, a *app.App) error { } // DeleteApp removes an app. +// DeleteApp removes an app and cascades to every app-scoped collection, so a +// deleted app leaves no orphaned users, sessions, org memberships, API keys, +// etc. behind (which the SQL backends achieve via ON DELETE CASCADE / explicit +// deletes). Runs inside a MongoTx for atomicity — this requires the deployment +// to run against a replica set or sharded cluster. func (s *Store) DeleteApp(ctx context.Context, appID id.AppID) error { - res, err := s.mdb.NewDelete((*appModel)(nil)). - Filter(bson.M{"_id": appID.String()}). - Exec(ctx) + mtx, err := s.mdb.GroveTx(ctx, 0, false) if err != nil { - return fmt.Errorf("authsome/mongo: delete app: %w", err) + return fmt.Errorf("authsome/mongo: begin tx for app cascade: %w", err) + } + tx, ok := mtx.(*mongodriver.MongoTx) + if !ok { + return fmt.Errorf("authsome/mongo: unexpected tx type %T", mtx) } + committed := false + defer func() { + if !committed { + _ = tx.Rollback() //nolint:errcheck // best-effort rollback + } + }() - if res.DeletedCount() == 0 { - return store.ErrNotFound + aid := appID.String() + appFilter := bson.M{"app_id": aid} + + // Organization-scoped children (members, teams, invitations) are keyed by + // org_id. Mongo has no subqueries, so resolve this app's org ids first, + // then delete their children by org_id. + var orgs []organizationModel + if err := tx.NewFind(&orgs).Filter(appFilter).Scan(ctx); err != nil { + return fmt.Errorf("authsome/mongo: list app orgs for cascade: %w", err) + } + if len(orgs) > 0 { + orgIDs := make([]string, 0, len(orgs)) + for i := range orgs { + orgIDs = append(orgIDs, orgs[i].ID) + } + orgFilter := bson.M{"org_id": bson.M{"$in": orgIDs}} + for _, m := range []any{(*memberModel)(nil), (*teamModel)(nil), (*invitationModel)(nil)} { + if _, err := tx.NewDelete(m).Filter(orgFilter).Exec(ctx); err != nil { + return fmt.Errorf("authsome/mongo: delete org children: %w", err) + } + } + } + + // App-scoped rows (every model carrying an app_id). + for _, m := range []any{ + (*organizationModel)(nil), + (*userEmailModel)(nil), + (*sessionModel)(nil), + (*verificationModel)(nil), + (*passwordResetModel)(nil), + (*deviceModel)(nil), + (*webhookModel)(nil), + (*notificationModel)(nil), + (*apiKeyModel)(nil), + (*userModel)(nil), + (*environmentModel)(nil), + (*formConfigModel)(nil), + (*brandingConfigModel)(nil), + (*appSessionConfigModel)(nil), + (*appClientConfigModel)(nil), + (*settingModel)(nil), + } { + if _, err := tx.NewDelete(m).Filter(appFilter).Exec(ctx); err != nil { + return fmt.Errorf("authsome/mongo: delete app children: %w", err) + } + } + + // Delete is idempotent (matching the SQL backends and every other Delete* + // method): a missing app is a no-op, not an error. + if _, err := tx.NewDelete((*appModel)(nil)).Filter(bson.M{"_id": aid}).Exec(ctx); err != nil { + return fmt.Errorf("authsome/mongo: delete app row: %w", err) } + if err := tx.Commit(); err != nil { + return fmt.Errorf("authsome/mongo: commit app cascade: %w", err) + } + committed = true return nil } diff --git a/store/mongo/app_cascade_test.go b/store/mongo/app_cascade_test.go new file mode 100644 index 00000000..8553094e --- /dev/null +++ b/store/mongo/app_cascade_test.go @@ -0,0 +1,59 @@ +//go:build integration + +package mongo_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xraph/authsome/app" + "github.com/xraph/authsome/id" + "github.com/xraph/authsome/organization" + "github.com/xraph/authsome/store" + "github.com/xraph/authsome/user" +) + +// TestDeleteApp_CascadesChildren verifies the Mongo DeleteApp removes every +// app-scoped row (users, organizations, org members) rather than orphaning +// them. Requires AUTHSOME_MONGO_URI (a replica set for the transaction). +func TestDeleteApp_CascadesChildren(t *testing.T) { + s := setupTestStore(t) + ctx := context.Background() + now := time.Now() + + appID := id.NewAppID() + require.NoError(t, s.CreateApp(ctx, &app.App{ + ID: appID, + Name: "Cascade", + Slug: "cascade-" + appID.String(), + CreatedAt: now, + UpdatedAt: now, + })) + + u := &user.User{ID: id.NewUserID(), AppID: appID, Email: "cascade@test.com", CreatedAt: now, UpdatedAt: now} + require.NoError(t, s.CreateUser(ctx, u)) + + org := &organization.Organization{ID: id.NewOrgID(), AppID: appID, Name: "Org", Slug: "org-" + appID.String(), CreatedBy: u.ID, CreatedAt: now, UpdatedAt: now} + require.NoError(t, s.CreateOrganization(ctx, org)) + require.NoError(t, s.CreateMember(ctx, &organization.Member{ID: id.NewMemberID(), OrgID: org.ID, UserID: u.ID, Role: organization.RoleOwner, CreatedAt: now, UpdatedAt: now})) + + require.NoError(t, s.DeleteApp(ctx, appID)) + + // The app-scoped rows must all be gone. + _, err := s.GetUser(ctx, u.ID) + assert.ErrorIs(t, err, store.ErrNotFound, "user must be cascaded") + + _, err = s.GetOrganization(ctx, org.ID) + assert.ErrorIs(t, err, store.ErrNotFound, "organization must be cascaded") + + members, err := s.ListMembers(ctx, org.ID) + require.NoError(t, err) + assert.Empty(t, members, "org members must be cascaded") + + // A second delete of the same app is idempotent (nothing left to remove). + assert.NoError(t, s.DeleteApp(ctx, appID)) +} diff --git a/store/mongo/conformance_test.go b/store/mongo/conformance_test.go new file mode 100644 index 00000000..e746c1f4 --- /dev/null +++ b/store/mongo/conformance_test.go @@ -0,0 +1,18 @@ +//go:build integration + +package mongo_test + +import ( + "testing" + + "github.com/xraph/authsome/store" + "github.com/xraph/authsome/store/storetest" +) + +// TestConformance runs the shared cross-backend store contract suite against +// MongoDB. One store is shared across the sub-tests (they isolate with random +// ids). Skips unless AUTHSOME_MONGO_URI is set; run with `-tags integration`. +func TestConformance(t *testing.T) { + s := setupTestStore(t) + storetest.RunConformance(t, func(_ *testing.T) store.Store { return s }) +} diff --git a/store/mongo/filter_test.go b/store/mongo/filter_test.go new file mode 100644 index 00000000..56224ee8 --- /dev/null +++ b/store/mongo/filter_test.go @@ -0,0 +1,64 @@ +package mongo + +import ( + "regexp" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/xraph/authsome/id" + "github.com/xraph/authsome/user" +) + +// TestBuildUserListFilter_EscapesEmailRegex pins that a user-supplied email +// search value is escaped before being used as a $regex, so a caller cannot +// inject regex metacharacters (ReDoS / query-semantics injection) against the +// users collection. +func TestBuildUserListFilter_EscapesEmailRegex(t *testing.T) { + appID := id.NewAppID() + + // A value full of regex metacharacters that would be pathological if + // interpreted as a pattern. + malicious := "(a+)+$" + f := buildUserListFilter(&user.Query{AppID: appID, Email: malicious}) + + emailClause, ok := f["email"].(bson.M) + if !ok { + t.Fatalf("email clause should be a bson.M, got %T", f["email"]) + } + got, _ := emailClause["$regex"].(string) + want := regexp.QuoteMeta(malicious) + if got != want { + t.Fatalf("email regex not escaped: got %q want %q", got, want) + } + // Sanity: the raw metacharacters must not survive verbatim. + if got == malicious { + t.Fatal("raw user input must not be used as a regex verbatim") + } +} + +// TestBuildUserListFilter_ScopesAppAndEnv confirms the base scoping fields are +// always present and env is included only when set. +func TestBuildUserListFilter_ScopesAppAndEnv(t *testing.T) { + appID := id.NewAppID() + envID := id.NewEnvironmentID() + + f := buildUserListFilter(&user.Query{AppID: appID, EnvID: envID}) + if f["app_id"] != appID.String() { + t.Fatalf("app_id filter missing/incorrect: %v", f["app_id"]) + } + if _, ok := f["deleted_at"]; !ok { + t.Fatal("deleted_at filter must be present") + } + if f["env_id"] != envID.String() { + t.Fatalf("env_id filter missing/incorrect: %v", f["env_id"]) + } + + noEnv := buildUserListFilter(&user.Query{AppID: appID}) + if _, ok := noEnv["env_id"]; ok { + t.Fatal("env_id must be omitted when not set") + } + if _, ok := noEnv["email"]; ok { + t.Fatal("email filter must be omitted when empty") + } +} diff --git a/store/mongo/session.go b/store/mongo/session.go index 09d05748..b604861a 100644 --- a/store/mongo/session.go +++ b/store/mongo/session.go @@ -97,6 +97,24 @@ func (s *Store) UpdateSession(ctx context.Context, sess *session.Session) error return nil } +// RotateSession atomically persists sess only if the stored access token still +// equals expectedToken. Returns false when no document matched (a concurrent +// refresh already rotated the session), which prevents two concurrent refreshes +// from both winning. +func (s *Store) RotateSession(ctx context.Context, sess *session.Session, expectedToken string) (bool, error) { + m := toSessionModel(sess) + m.UpdatedAt = now() + + res, err := s.mdb.NewUpdate(m). + Filter(bson.M{"_id": m.ID, "token": expectedToken}). + Exec(ctx) + if err != nil { + return false, fmt.Errorf("authsome/mongo: rotate session: %w", err) + } + + return res.MatchedCount() > 0, nil +} + // TouchSession performs a lightweight update of last_activity_at, expires_at, and updated_at. func (s *Store) TouchSession(ctx context.Context, sessionID id.SessionID, lastActivityAt, expiresAt time.Time) error { res, err := s.mdb.NewUpdate((*sessionModel)(nil)). diff --git a/store/mongo/user.go b/store/mongo/user.go index bfe572f9..ded922a0 100644 --- a/store/mongo/user.go +++ b/store/mongo/user.go @@ -3,6 +3,7 @@ package mongo import ( "context" "fmt" + "regexp" "go.mongodb.org/mongo-driver/v2/bson" @@ -160,22 +161,29 @@ func (s *Store) DeleteUser(ctx context.Context, userID id.UserID) error { return nil } -// ListUsers returns a paginated list of users for an app, with optional email search. -func (s *Store) ListUsers(ctx context.Context, q *user.Query) (*user.List, error) { - var models []userModel - +// buildUserListFilter builds the base Mongo query for ListUsers (app/env scope +// and optional email search), excluding pagination. The email value is escaped +// with regexp.QuoteMeta so a caller cannot inject regex metacharacters — a +// pathological pattern (ReDoS) or one that alters the query semantics. +func buildUserListFilter(q *user.Query) bson.M { filter := bson.M{ "app_id": q.AppID.String(), "deleted_at": nil, } - if !q.EnvID.IsNil() { filter["env_id"] = q.EnvID.String() } - if q.Email != "" { - filter["email"] = bson.M{"$regex": q.Email, "$options": "i"} + filter["email"] = bson.M{"$regex": regexp.QuoteMeta(q.Email), "$options": "i"} } + return filter +} + +// ListUsers returns a paginated list of users for an app, with optional email search. +func (s *Store) ListUsers(ctx context.Context, q *user.Query) (*user.List, error) { + var models []userModel + + filter := buildUserListFilter(q) if q.Cursor != "" { filter["_id"] = bson.M{"$lt": q.Cursor} @@ -197,6 +205,13 @@ func (s *Store) ListUsers(ctx context.Context, q *user.Query) (*user.List, error list := &user.List{ Users: make([]*user.User, 0, len(models)), + Total: len(models), + } + // Adjust total if we fetched the extra sentinel row (limit+1), mirroring + // the SQL stores so callers (e.g. the first-run /setup guard and admin + // counts) see a consistent, non-zero total when users exist. + if len(models) > limit { + list.Total = limit } for i := range models { diff --git a/store/postgres/conformance_test.go b/store/postgres/conformance_test.go new file mode 100644 index 00000000..3fe3965c --- /dev/null +++ b/store/postgres/conformance_test.go @@ -0,0 +1,19 @@ +//go:build integration + +package postgres_test + +import ( + "testing" + + "github.com/xraph/authsome/store" + "github.com/xraph/authsome/store/storetest" +) + +// TestConformance runs the shared cross-backend store contract suite against +// PostgreSQL (via testcontainers). One store is shared across the sub-tests — +// they isolate themselves with random ids — to avoid spinning a container per +// case. Requires Docker; run with `-tags integration`. +func TestConformance(t *testing.T) { + s := setupTestStore(t) + storetest.RunConformance(t, func(_ *testing.T) store.Store { return s }) +} diff --git a/store/postgres/store.go b/store/postgres/store.go index 1912a647..e546a844 100644 --- a/store/postgres/store.go +++ b/store/postgres/store.go @@ -365,6 +365,19 @@ func (s *Store) UpdateSession(ctx context.Context, sess *session.Session) error return pgError(err) } +func (s *Store) RotateSession(ctx context.Context, sess *session.Session, expectedToken string) (bool, error) { + m := fromSession(sess) + m.UpdatedAt = time.Now() + // Compare-and-swap: only rotate if the stored access token is still the + // pre-rotation value, so two concurrent refreshes cannot both win. + res, err := s.pg.NewUpdate(m).WherePK().Where("token = ?", expectedToken).Exec(ctx) + if err != nil { + return false, pgError(err) + } + n, _ := res.RowsAffected() //nolint:errcheck // RowsAffected always succeeds for pgx + return n > 0, nil +} + func (s *Store) TouchSession(ctx context.Context, sessionID id.SessionID, lastActivityAt, expiresAt time.Time) error { _, err := s.pg.NewUpdate((*SessionModel)(nil)). Set("last_activity_at = ?", lastActivityAt). diff --git a/store/sqlite/conformance_test.go b/store/sqlite/conformance_test.go new file mode 100644 index 00000000..3d60d94e --- /dev/null +++ b/store/sqlite/conformance_test.go @@ -0,0 +1,35 @@ +package sqlite_test + +import ( + "context" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/xraph/grove" + "github.com/xraph/grove/drivers/sqlitedriver" + _ "github.com/xraph/grove/drivers/sqlitedriver/sqlitemigrate" + + "github.com/xraph/authsome/store" + sqlitestore "github.com/xraph/authsome/store/sqlite" + "github.com/xraph/authsome/store/storetest" +) + +// TestConformance runs the shared cross-backend store contract suite against +// the SQLite backend. SQLite is embedded, so this runs in normal CI without +// Docker — giving a real SQL backend to cross-check the in-memory store. +func TestConformance(t *testing.T) { + storetest.RunConformance(t, func(t *testing.T) store.Store { + ctx := context.Background() + dsn := "file:" + filepath.Join(t.TempDir(), "conf.db") + "?cache=shared&_pragma=foreign_keys(1)" + sdb := sqlitedriver.New() + require.NoError(t, sdb.Open(ctx, dsn)) + db, err := grove.Open(sdb) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + s := sqlitestore.New(db) + require.NoError(t, s.Migrate(ctx)) + return s + }) +} diff --git a/store/sqlite/store.go b/store/sqlite/store.go index d76283e7..ea04bce7 100644 --- a/store/sqlite/store.go +++ b/store/sqlite/store.go @@ -386,6 +386,19 @@ func (s *Store) UpdateSession(ctx context.Context, sess *session.Session) error return sqliteError(err) } +func (s *Store) RotateSession(ctx context.Context, sess *session.Session, expectedToken string) (bool, error) { + m := fromSession(sess) + m.UpdatedAt = time.Now() + // Compare-and-swap: only rotate if the stored access token is still the + // pre-rotation value, so two concurrent refreshes cannot both win. + res, err := s.sdb.NewUpdate(m).WherePK().Where("token = ?", expectedToken).Exec(ctx) + if err != nil { + return false, sqliteError(err) + } + n, _ := res.RowsAffected() //nolint:errcheck // RowsAffected always succeeds for sqlite + return n > 0, nil +} + func (s *Store) TouchSession(ctx context.Context, sessionID id.SessionID, lastActivityAt, expiresAt time.Time) error { _, err := s.sdb.NewUpdate((*SessionModel)(nil)). Set("last_activity_at = ?", lastActivityAt). diff --git a/store/storetest/storetest.go b/store/storetest/storetest.go new file mode 100644 index 00000000..6d583d0a --- /dev/null +++ b/store/storetest/storetest.go @@ -0,0 +1,441 @@ +// Package storetest provides a backend-agnostic conformance suite for the +// store.Store interface. Every backend (memory, sqlite, postgres, mongo) is +// expected to pass the same contract tests, so behavioral drift between +// implementations is caught automatically rather than shipped. +// +// Run it from each backend's test package: +// +// func TestConformance(t *testing.T) { +// storetest.RunConformance(t, func(t *testing.T) store.Store { ... }) +// } +package storetest + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xraph/authsome/app" + "github.com/xraph/authsome/environment" + "github.com/xraph/authsome/id" + "github.com/xraph/authsome/organization" + "github.com/xraph/authsome/session" + "github.com/xraph/authsome/store" + "github.com/xraph/authsome/user" +) + +// Factory creates a fresh, empty, migrated store for a single test. +type Factory func(t *testing.T) store.Store + +// RunConformance runs every contract test against stores produced by newStore. +func RunConformance(t *testing.T, newStore Factory) { + t.Helper() + cases := []struct { + name string + fn func(t *testing.T, s store.Store) + }{ + {"AppCRUD", testAppCRUD}, + {"DeleteAppCascade", testDeleteAppCascade}, + {"UserEmailIsAppScoped", testUserEmailIsAppScoped}, + {"ListUsersTotalAndFilter", testListUsersTotalAndFilter}, + {"ListUsersEmailMetacharsAreSafe", testListUsersEmailMetacharsAreSafe}, + {"SessionCRUD", testSessionCRUD}, + {"SessionLookupByTokenIsScoped", testSessionLookupByTokenIsScoped}, + {"RotateSessionCAS", testRotateSessionCAS}, + {"RefreshTokenRevocation", testRefreshTokenRevocation}, + {"RefreshTokenReplayIsIdempotent", testRefreshTokenReplayIsIdempotent}, + {"OrgMemberLookupAndCascade", testOrgMemberLookupAndCascade}, + {"ListUserSessionsIsScopedToUser", testListUserSessionsIsScopedToUser}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + tc.fn(t, newStore(t)) + }) + } +} + +// ────────────────────────────────────────────────── +// Fixtures +// ────────────────────────────────────────────────── + +func now() time.Time { return time.Now().UTC().Truncate(time.Millisecond) } + +func suffix(id string) string { + if len(id) <= 10 { + return id + } + return id[len(id)-10:] +} + +type tenant struct { + AppID id.AppID + EnvID id.EnvironmentID +} + +// seedTenant creates an app + default environment (the parent rows every +// FK-enforcing backend needs before app-scoped rows can be created). +func seedTenant(t *testing.T, s store.Store) tenant { + t.Helper() + ctx := context.Background() + appID := id.NewAppID() + sfx := suffix(appID.String()) + require.NoError(t, s.CreateApp(ctx, &app.App{ + ID: appID, + Name: "App " + sfx, + Slug: "app-" + sfx, + PublishableKey: "pk_test_" + sfx, + CreatedAt: now(), + UpdatedAt: now(), + })) + env := &environment.Environment{ + ID: id.NewEnvironmentID(), + AppID: appID, + Name: "Production", + Slug: "production", + Type: environment.TypeProduction, + IsDefault: true, + CreatedAt: now(), + UpdatedAt: now(), + } + require.NoError(t, s.CreateEnvironment(ctx, env)) + return tenant{AppID: appID, EnvID: env.ID} +} + +func seedUser(t *testing.T, s store.Store, tn tenant, email string) *user.User { + t.Helper() + u := &user.User{ + ID: id.NewUserID(), + AppID: tn.AppID, + EnvID: tn.EnvID, + Email: email, + CreatedAt: now(), + UpdatedAt: now(), + } + require.NoError(t, s.CreateUser(context.Background(), u)) + return u +} + +func seedSession(t *testing.T, s store.Store, tn tenant, userID id.UserID, token, refresh string) *session.Session { + t.Helper() + sess := &session.Session{ + ID: id.NewSessionID(), + AppID: tn.AppID, + EnvID: tn.EnvID, + UserID: userID, + Token: token, + RefreshToken: refresh, + FamilyID: id.NewSessionFamilyID(), + ExpiresAt: now().Add(time.Hour), + RefreshTokenExpiresAt: now().Add(24 * time.Hour), + CreatedAt: now(), + UpdatedAt: now(), + } + require.NoError(t, s.CreateSession(context.Background(), sess)) + return sess +} + +// ────────────────────────────────────────────────── +// App +// ────────────────────────────────────────────────── + +func testAppCRUD(t *testing.T, s store.Store) { + ctx := context.Background() + appID := id.NewAppID() + sfx := suffix(appID.String()) + a := &app.App{ID: appID, Name: "Acme", Slug: "acme-" + sfx, PublishableKey: "pk_" + sfx, CreatedAt: now(), UpdatedAt: now()} + require.NoError(t, s.CreateApp(ctx, a)) + + got, err := s.GetApp(ctx, appID) + require.NoError(t, err) + assert.Equal(t, "Acme", got.Name) + + bySlug, err := s.GetAppBySlug(ctx, "acme-"+sfx) + require.NoError(t, err) + assert.Equal(t, appID.String(), bySlug.ID.String()) + + byKey, err := s.GetAppByPublishableKey(ctx, "pk_"+sfx) + require.NoError(t, err) + assert.Equal(t, appID.String(), byKey.ID.String()) + + got.Name = "Renamed" + got.UpdatedAt = now() + require.NoError(t, s.UpdateApp(ctx, got)) + reread, err := s.GetApp(ctx, appID) + require.NoError(t, err) + assert.Equal(t, "Renamed", reread.Name) + + require.NoError(t, s.DeleteApp(ctx, appID)) + _, err = s.GetApp(ctx, appID) + assert.ErrorIs(t, err, store.ErrNotFound, "deleted app must be not-found") + + // Delete is idempotent across the codebase (matching every other Delete* + // method): removing an already-absent app is a no-op, not an error. + assert.NoError(t, s.DeleteApp(ctx, appID), "double delete must be idempotent") +} + +func testDeleteAppCascade(t *testing.T, s store.Store) { + ctx := context.Background() + + tn := seedTenant(t, s) + u := seedUser(t, s, tn, "cascade@test.com") + sess := seedSession(t, s, tn, u.ID, "tok-cascade", "rtok-cascade") + org := &organization.Organization{ID: id.NewOrgID(), AppID: tn.AppID, EnvID: tn.EnvID, Name: "Org", Slug: "org", CreatedBy: u.ID, CreatedAt: now(), UpdatedAt: now()} + require.NoError(t, s.CreateOrganization(ctx, org)) + require.NoError(t, s.CreateMember(ctx, &organization.Member{ID: id.NewMemberID(), OrgID: org.ID, UserID: u.ID, Role: organization.RoleOwner, CreatedAt: now(), UpdatedAt: now()})) + + // Survivor tenant. + other := seedTenant(t, s) + otherU := seedUser(t, s, other, "keeper@test.com") + + require.NoError(t, s.DeleteApp(ctx, tn.AppID)) + + _, err := s.GetApp(ctx, tn.AppID) + assert.ErrorIs(t, err, store.ErrNotFound, "app row must be deleted") + _, err = s.GetUser(ctx, u.ID) + assert.ErrorIs(t, err, store.ErrNotFound, "user must be cascaded") + _, err = s.GetSession(ctx, sess.ID) + assert.ErrorIs(t, err, store.ErrNotFound, "session must be cascaded") + _, err = s.GetOrganization(ctx, org.ID) + assert.ErrorIs(t, err, store.ErrNotFound, "organization must be cascaded") + members, err := s.ListMembers(ctx, org.ID) + require.NoError(t, err) + assert.Empty(t, members, "org members must be cascaded") + envs, err := s.ListEnvironments(ctx, tn.AppID) + require.NoError(t, err) + assert.Empty(t, envs, "environments must be cascaded") + + // Survivor untouched. + _, err = s.GetApp(ctx, other.AppID) + require.NoError(t, err, "other app must survive") + _, err = s.GetUser(ctx, otherU.ID) + require.NoError(t, err, "other app's user must survive") +} + +// ────────────────────────────────────────────────── +// Users +// ────────────────────────────────────────────────── + +func testUserEmailIsAppScoped(t *testing.T, s store.Store) { + ctx := context.Background() + a := seedTenant(t, s) + b := seedTenant(t, s) + + ua := seedUser(t, s, a, "shared@test.com") + ub := seedUser(t, s, b, "shared@test.com") + assert.NotEqual(t, ua.ID.String(), ub.ID.String()) + + gotA, err := s.GetUserByEmail(ctx, a.AppID, "shared@test.com") + require.NoError(t, err) + assert.Equal(t, ua.ID.String(), gotA.ID.String(), "lookup must return this app's user") + + gotB, err := s.GetUserByEmail(ctx, b.AppID, "shared@test.com") + require.NoError(t, err) + assert.Equal(t, ub.ID.String(), gotB.ID.String(), "lookup must be scoped per app") + + // An app that has no such user must not resolve one from a sibling app. + empty := seedTenant(t, s) + _, err = s.GetUserByEmail(ctx, empty.AppID, "shared@test.com") + assert.ErrorIs(t, err, store.ErrNotFound, "email lookup must not cross tenants") +} + +func testListUsersTotalAndFilter(t *testing.T, s store.Store) { + ctx := context.Background() + tn := seedTenant(t, s) + + // Empty app: total is zero (load-bearing for the first-run setup guard). + empty, err := s.ListUsers(ctx, &user.Query{AppID: tn.AppID, Limit: 50}) + require.NoError(t, err) + assert.Equal(t, 0, empty.Total, "empty app must report Total==0") + + seedUser(t, s, tn, "alice@test.com") + seedUser(t, s, tn, "bob@test.com") + + all, err := s.ListUsers(ctx, &user.Query{AppID: tn.AppID, Limit: 50}) + require.NoError(t, err) + assert.Positive(t, all.Total, "populated app must report Total>0") + assert.Len(t, all.Users, 2) + + filtered, err := s.ListUsers(ctx, &user.Query{AppID: tn.AppID, Email: "alice", Limit: 50}) + require.NoError(t, err) + require.Len(t, filtered.Users, 1, "email filter must narrow results") + assert.Equal(t, "alice@test.com", filtered.Users[0].Email) + + // Another app's users must never appear. + other := seedTenant(t, s) + seedUser(t, s, other, "carol@test.com") + scoped, err := s.ListUsers(ctx, &user.Query{AppID: tn.AppID, Limit: 50}) + require.NoError(t, err) + assert.Len(t, scoped.Users, 2, "list must be scoped to the queried app") +} + +func testListUsersEmailMetacharsAreSafe(t *testing.T, s store.Store) { + ctx := context.Background() + tn := seedTenant(t, s) + seedUser(t, s, tn, "real@test.com") + + // A value full of regex/LIKE metacharacters must be treated as a literal + // search, never interpreted (no injection, no error, no crash). + res, err := s.ListUsers(ctx, &user.Query{AppID: tn.AppID, Email: "(a+)+$.*%_", Limit: 50}) + require.NoError(t, err, "metacharacter search must not error") + assert.Empty(t, res.Users, "a literal search for a nonexistent value must match nothing") +} + +// ────────────────────────────────────────────────── +// Sessions +// ────────────────────────────────────────────────── + +func testSessionCRUD(t *testing.T, s store.Store) { + ctx := context.Background() + tn := seedTenant(t, s) + u := seedUser(t, s, tn, "sess@test.com") + sess := seedSession(t, s, tn, u.ID, "tok-1", "rtok-1") + + byID, err := s.GetSession(ctx, sess.ID) + require.NoError(t, err) + assert.Equal(t, u.ID.String(), byID.UserID.String()) + + byToken, err := s.GetSessionByToken(ctx, "tok-1") + require.NoError(t, err) + assert.Equal(t, sess.ID.String(), byToken.ID.String()) + + byRefresh, err := s.GetSessionByRefreshToken(ctx, "rtok-1") + require.NoError(t, err) + assert.Equal(t, sess.ID.String(), byRefresh.ID.String()) + + require.NoError(t, s.DeleteSession(ctx, sess.ID)) + _, err = s.GetSession(ctx, sess.ID) + assert.ErrorIs(t, err, store.ErrNotFound) + _, err = s.GetSessionByToken(ctx, "tok-1") + assert.ErrorIs(t, err, store.ErrNotFound) +} + +func testSessionLookupByTokenIsScoped(t *testing.T, s store.Store) { + ctx := context.Background() + tn := seedTenant(t, s) + u := seedUser(t, s, tn, "scoped@test.com") + seedSession(t, s, tn, u.ID, "the-token", "the-refresh") + + _, err := s.GetSessionByToken(ctx, "not-a-token") + assert.ErrorIs(t, err, store.ErrNotFound, "unknown token must be not-found, not another session") + _, err = s.GetSessionByRefreshToken(ctx, "not-a-refresh") + assert.ErrorIs(t, err, store.ErrNotFound) +} + +func testRotateSessionCAS(t *testing.T, s store.Store) { + ctx := context.Background() + tn := seedTenant(t, s) + u := seedUser(t, s, tn, "rotate@test.com") + sess := seedSession(t, s, tn, u.ID, "old-tok", "old-rtok") + + // Rotate with a stale expected token: must be a no-op (compare-and-swap + // mismatch). This is the guard against the refresh-token TOCTOU race. + stale := *sess + stale.Token = "new-tok-a" + stale.RefreshToken = "new-rtok-a" + swapped, err := s.RotateSession(ctx, &stale, "WRONG-old-tok") + require.NoError(t, err) + assert.False(t, swapped, "rotation with a mismatched expected token must not swap") + unchanged, err := s.GetSessionByToken(ctx, "old-tok") + require.NoError(t, err, "the original token must still be valid after a failed CAS") + assert.Equal(t, sess.ID.String(), unchanged.ID.String()) + + // Rotate with the correct expected token: must swap atomically. + fresh := *sess + fresh.Token = "new-tok-b" + fresh.RefreshToken = "new-rtok-b" + swapped, err = s.RotateSession(ctx, &fresh, "old-tok") + require.NoError(t, err) + assert.True(t, swapped, "rotation with the correct expected token must swap") + _, err = s.GetSessionByToken(ctx, "old-tok") + assert.ErrorIs(t, err, store.ErrNotFound, "old token must no longer resolve after rotation") + rotated, err := s.GetSessionByToken(ctx, "new-tok-b") + require.NoError(t, err) + assert.Equal(t, sess.ID.String(), rotated.ID.String()) +} + +func testListUserSessionsIsScopedToUser(t *testing.T, s store.Store) { + ctx := context.Background() + tn := seedTenant(t, s) + ua := seedUser(t, s, tn, "usera@test.com") + ub := seedUser(t, s, tn, "userb@test.com") + seedSession(t, s, tn, ua.ID, "a-tok", "a-rtok") + seedSession(t, s, tn, ua.ID, "a-tok-2", "a-rtok-2") + seedSession(t, s, tn, ub.ID, "b-tok", "b-rtok") + + aSessions, err := s.ListUserSessions(ctx, ua.ID) + require.NoError(t, err) + assert.Len(t, aSessions, 2, "must return only user A's sessions") + for _, se := range aSessions { + assert.Equal(t, ua.ID.String(), se.UserID.String()) + } +} + +// ────────────────────────────────────────────────── +// Refresh-token revocation / replay +// ────────────────────────────────────────────────── + +func testRefreshTokenRevocation(t *testing.T, s store.Store) { + ctx := context.Background() + family := id.NewSessionFamilyID() + hash := "hash-revoked-1" + + revoked, err := s.IsRefreshTokenRevoked(ctx, hash) + require.NoError(t, err) + assert.False(t, revoked, "a fresh token must not be revoked") + + require.NoError(t, s.MarkRefreshTokenRevoked(ctx, hash, family, session.RevokeReasonRotated)) + revoked, err = s.IsRefreshTokenRevoked(ctx, hash) + require.NoError(t, err) + assert.True(t, revoked, "a marked token must be revoked") + + gotFamily, err := s.GetRevokedRefreshTokenFamily(ctx, hash) + require.NoError(t, err) + assert.Equal(t, family.String(), gotFamily.String()) +} + +func testRefreshTokenReplayIsIdempotent(t *testing.T, s store.Store) { + ctx := context.Background() + family := id.NewSessionFamilyID() + hash := "hash-replayed-1" + require.NoError(t, s.MarkRefreshTokenRevoked(ctx, hash, family, session.RevokeReasonRotated)) + + first, err := s.MarkRefreshTokenReplayed(ctx, hash) + require.NoError(t, err) + assert.True(t, first, "the first replay detection must report firstReplay=true") + + second, err := s.MarkRefreshTokenReplayed(ctx, hash) + require.NoError(t, err) + assert.False(t, second, "a subsequent replay must report firstReplay=false (idempotent alert)") +} + +// ────────────────────────────────────────────────── +// Organizations +// ────────────────────────────────────────────────── + +func testOrgMemberLookupAndCascade(t *testing.T, s store.Store) { + ctx := context.Background() + tn := seedTenant(t, s) + u := seedUser(t, s, tn, "orguser@test.com") + org := &organization.Organization{ID: id.NewOrgID(), AppID: tn.AppID, EnvID: tn.EnvID, Name: "Acme", Slug: "acme", CreatedBy: u.ID, CreatedAt: now(), UpdatedAt: now()} + require.NoError(t, s.CreateOrganization(ctx, org)) + require.NoError(t, s.CreateMember(ctx, &organization.Member{ID: id.NewMemberID(), OrgID: org.ID, UserID: u.ID, Role: organization.RoleOwner, CreatedAt: now(), UpdatedAt: now()})) + + m, err := s.GetMemberByUserAndOrg(ctx, u.ID, org.ID) + require.NoError(t, err) + assert.Equal(t, organization.RoleOwner, m.Role) + + // A non-member must not resolve. + _, err = s.GetMemberByUserAndOrg(ctx, id.NewUserID(), org.ID) + assert.ErrorIs(t, err, store.ErrNotFound, "non-member lookup must be not-found") + + require.NoError(t, s.DeleteOrganizationCascade(ctx, org.ID)) + _, err = s.GetOrganization(ctx, org.ID) + assert.ErrorIs(t, err, store.ErrNotFound, "org must be deleted") + members, err := s.ListMembers(ctx, org.ID) + require.NoError(t, err) + assert.Empty(t, members, "members must be cascaded with the org") +} From 9bcbb22b0b822e9945fe61d32611c01fa1588f2c Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Fri, 24 Jul 2026 09:58:35 -0500 Subject: [PATCH 03/10] chore(deps): bump golang.org/x/crypto to 0.54.0 and quic-go to 0.59.1 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. --- go.mod | 14 +++++++------- go.sum | 36 ++++++++++++++++++------------------ 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/go.mod b/go.mod index e1dd4341..da2c8280 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,7 @@ require ( github.com/xraph/warden v1.5.5 go.jetify.com/typeid/v2 v2.0.0-alpha.3 go.mongodb.org/mongo-driver/v2 v2.5.0 - golang.org/x/crypto v0.51.0 + golang.org/x/crypto v0.54.0 golang.org/x/oauth2 v0.35.0 ) @@ -150,7 +150,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/quic-go/qpack v0.6.0 // indirect - github.com/quic-go/quic-go v0.59.0 // indirect + github.com/quic-go/quic-go v0.59.1 // indirect github.com/quic-go/webtransport-go v0.10.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/shirou/gopsutil/v4 v4.26.3 // indirect @@ -177,11 +177,11 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/term v0.43.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.40.0 // indirect golang.org/x/time v0.12.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect diff --git a/go.sum b/go.sum index d52be688..c6d5288d 100644 --- a/go.sum +++ b/go.sum @@ -355,8 +355,8 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzM github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= -github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= -github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic= +github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/quic-go/webtransport-go v0.10.0 h1:LqXXPOXuETY5Xe8ITdGisBzTYmUOy5eSj+9n4hLTjHI= github.com/quic-go/webtransport-go v0.10.0/go.mod h1:LeGIXr5BQKE3UsynwVBeQrU1TPrbh73MGoC6jd+V7ow= github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= @@ -502,13 +502,13 @@ golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnf golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -517,8 +517,8 @@ golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -527,8 +527,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -555,28 +555,28 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= From c0eba917bde528fded8a7dc05b7bdf80887cbffd Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Fri, 24 Jul 2026 10:01:14 -0500 Subject: [PATCH 04/10] chore(deps): patch docs Dependabot alerts (next 16.2.6, path-to-regexp, 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. --- docs/package.json | 8 ++- docs/pnpm-lock.yaml | 134 +++++++++++++++++++++++--------------------- 2 files changed, 76 insertions(+), 66 deletions(-) diff --git a/docs/package.json b/docs/package.json index 977162ee..7a531b02 100644 --- a/docs/package.json +++ b/docs/package.json @@ -19,7 +19,7 @@ "fumadocs-mdx": "14.2.7", "fumadocs-ui": "16.6.4", "lucide-react": "^0.575.0", - "next": "16.1.6", + "next": "16.2.6", "react": "^19.2.4", "react-dom": "^19.2.4", "tailwind-merge": "^3.5.0" @@ -34,5 +34,11 @@ "postcss": "^8.5.6", "tailwindcss": "^4.2.0", "typescript": "^5.9.3" + }, + "pnpm": { + "overrides": { + "path-to-regexp@>=8.0.0 <8.4.0": "^8.4.0", + "picomatch@>=4.0.0 <4.0.4": "^4.0.4" + } } } diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml index 830254d3..4bb3fe72 100644 --- a/docs/pnpm-lock.yaml +++ b/docs/pnpm-lock.yaml @@ -4,6 +4,10 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + path-to-regexp@>=8.0.0 <8.4.0: ^8.4.0 + picomatch@>=4.0.0 <4.0.4: ^4.0.4 + importers: .: @@ -19,19 +23,19 @@ importers: version: 12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) fumadocs-core: specifier: 16.6.4 - version: 16.6.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.575.0(react@19.2.4))(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) + version: 16.6.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.575.0(react@19.2.4))(next@16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) fumadocs-mdx: specifier: 14.2.7 - version: 14.2.7(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.6.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.575.0(react@19.2.4))(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(mdast-util-mdx-jsx@3.2.0)(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + version: 14.2.7(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.6.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.575.0(react@19.2.4))(next@16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(mdast-util-mdx-jsx@3.2.0)(next@16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) fumadocs-ui: specifier: 16.6.4 - version: 16.6.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.6.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.575.0(react@19.2.4))(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.2.0) + version: 16.6.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.6.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.575.0(react@19.2.4))(next@16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.2.0) lucide-react: specifier: ^0.575.0 version: 0.575.0(react@19.2.4) next: - specifier: 16.1.6 - version: 16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 16.2.6 + version: 16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: specifier: ^19.2.4 version: 19.2.4 @@ -473,53 +477,53 @@ packages: '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} - '@next/env@16.1.6': - resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==} + '@next/env@16.2.6': + resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==} - '@next/swc-darwin-arm64@16.1.6': - resolution: {integrity: sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==} + '@next/swc-darwin-arm64@16.2.6': + resolution: {integrity: sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.1.6': - resolution: {integrity: sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==} + '@next/swc-darwin-x64@16.2.6': + resolution: {integrity: sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.1.6': - resolution: {integrity: sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==} + '@next/swc-linux-arm64-gnu@16.2.6': + resolution: {integrity: sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@16.1.6': - resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==} + '@next/swc-linux-arm64-musl@16.2.6': + resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@16.1.6': - resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==} + '@next/swc-linux-x64-gnu@16.2.6': + resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@16.1.6': - resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==} + '@next/swc-linux-x64-musl@16.2.6': + resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@16.1.6': - resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==} + '@next/swc-win32-arm64-msvc@16.2.6': + resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.1.6': - resolution: {integrity: sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==} + '@next/swc-win32-x64-msvc@16.2.6': + resolution: {integrity: sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -1212,7 +1216,7 @@ packages: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} peerDependencies: - picomatch: ^3 || ^4 + picomatch: ^4.0.4 peerDependenciesMeta: picomatch: optional: true @@ -1694,8 +1698,8 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@16.1.6: - resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==} + next@16.2.6: + resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -1731,14 +1735,14 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} - path-to-regexp@8.3.0: - resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} postcss-selector-parser@7.1.1: @@ -2303,30 +2307,30 @@ snapshots: transitivePeerDependencies: - supports-color - '@next/env@16.1.6': {} + '@next/env@16.2.6': {} - '@next/swc-darwin-arm64@16.1.6': + '@next/swc-darwin-arm64@16.2.6': optional: true - '@next/swc-darwin-x64@16.1.6': + '@next/swc-darwin-x64@16.2.6': optional: true - '@next/swc-linux-arm64-gnu@16.1.6': + '@next/swc-linux-arm64-gnu@16.2.6': optional: true - '@next/swc-linux-arm64-musl@16.1.6': + '@next/swc-linux-arm64-musl@16.2.6': optional: true - '@next/swc-linux-x64-gnu@16.1.6': + '@next/swc-linux-x64-gnu@16.2.6': optional: true - '@next/swc-linux-x64-musl@16.1.6': + '@next/swc-linux-x64-musl@16.2.6': optional: true - '@next/swc-win32-arm64-msvc@16.1.6': + '@next/swc-win32-arm64-msvc@16.2.6': optional: true - '@next/swc-win32-x64-msvc@16.1.6': + '@next/swc-win32-x64-msvc@16.2.6': optional: true '@orama/orama@3.1.18': {} @@ -3010,9 +3014,9 @@ snapshots: extend@3.0.2: {} - fdir@6.5.0(picomatch@4.0.3): + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: - picomatch: 4.0.3 + picomatch: 4.0.5 framer-motion@12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: @@ -3023,7 +3027,7 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - fumadocs-core@16.6.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.575.0(react@19.2.4))(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6): + fumadocs-core@16.6.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.575.0(react@19.2.4))(next@16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6): dependencies: '@formatjs/intl-localematcher': 0.8.1 '@orama/orama': 3.1.18 @@ -3038,7 +3042,7 @@ snapshots: mdast-util-to-markdown: 2.1.2 negotiator: 1.0.0 npm-to-yarn: 3.0.1 - path-to-regexp: 8.3.0 + path-to-regexp: 8.4.2 remark: 15.0.1 remark-gfm: 4.0.1 remark-rehype: 11.1.2 @@ -3055,25 +3059,25 @@ snapshots: '@types/mdast': 4.0.4 '@types/react': 19.2.14 lucide-react: 0.575.0(react@19.2.4) - next: 16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) zod: 4.3.6 transitivePeerDependencies: - supports-color - fumadocs-mdx@14.2.7(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.6.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.575.0(react@19.2.4))(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(mdast-util-mdx-jsx@3.2.0)(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): + fumadocs-mdx@14.2.7(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.6.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.575.0(react@19.2.4))(next@16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(mdast-util-mdx-jsx@3.2.0)(next@16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 esbuild: 0.27.3 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.6.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.575.0(react@19.2.4))(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) + fumadocs-core: 16.6.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.575.0(react@19.2.4))(next@16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) js-yaml: 4.1.1 mdast-util-to-markdown: 2.1.2 picocolors: 1.1.1 - picomatch: 4.0.3 + picomatch: 4.0.5 remark-mdx: 3.1.1 tinyexec: 1.0.2 tinyglobby: 0.2.15 @@ -3087,12 +3091,12 @@ snapshots: '@types/mdx': 2.0.13 '@types/react': 19.2.14 mdast-util-mdx-jsx: 3.2.0 - next: 16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 transitivePeerDependencies: - supports-color - fumadocs-ui@16.6.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.6.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.575.0(react@19.2.4))(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.2.0): + fumadocs-ui@16.6.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.6.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.575.0(react@19.2.4))(next@16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.2.0): dependencies: '@fumadocs/tailwind': 0.0.2(tailwindcss@4.2.0) '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -3106,7 +3110,7 @@ snapshots: '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.4) '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) class-variance-authority: 0.7.1 - fumadocs-core: 16.6.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.575.0(react@19.2.4))(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) + fumadocs-core: 16.6.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.575.0(react@19.2.4))(next@16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) lucide-react: 0.570.0(react@19.2.4) motion: 12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-themes: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -3120,7 +3124,7 @@ snapshots: unist-util-visit: 5.1.0 optionalDependencies: '@types/react': 19.2.14 - next: 16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) transitivePeerDependencies: - '@emotion/is-prop-valid' - '@types/react-dom' @@ -3788,9 +3792,9 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - '@next/env': 16.1.6 + '@next/env': 16.2.6 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.9.19 caniuse-lite: 1.0.30001769 @@ -3799,14 +3803,14 @@ snapshots: react-dom: 19.2.4(react@19.2.4) styled-jsx: 5.1.6(react@19.2.4) optionalDependencies: - '@next/swc-darwin-arm64': 16.1.6 - '@next/swc-darwin-x64': 16.1.6 - '@next/swc-linux-arm64-gnu': 16.1.6 - '@next/swc-linux-arm64-musl': 16.1.6 - '@next/swc-linux-x64-gnu': 16.1.6 - '@next/swc-linux-x64-musl': 16.1.6 - '@next/swc-win32-arm64-msvc': 16.1.6 - '@next/swc-win32-x64-msvc': 16.1.6 + '@next/swc-darwin-arm64': 16.2.6 + '@next/swc-darwin-x64': 16.2.6 + '@next/swc-linux-arm64-gnu': 16.2.6 + '@next/swc-linux-arm64-musl': 16.2.6 + '@next/swc-linux-x64-gnu': 16.2.6 + '@next/swc-linux-x64-musl': 16.2.6 + '@next/swc-win32-arm64-msvc': 16.2.6 + '@next/swc-win32-x64-msvc': 16.2.6 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -3836,11 +3840,11 @@ snapshots: dependencies: entities: 6.0.1 - path-to-regexp@8.3.0: {} + path-to-regexp@8.4.2: {} picocolors@1.1.1: {} - picomatch@4.0.3: {} + picomatch@4.0.5: {} postcss-selector-parser@7.1.1: dependencies: @@ -4091,8 +4095,8 @@ snapshots: tinyglobby@0.2.15: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 trim-lines@3.0.1: {} From 507898ad4fadd8ab072949668930f0cfc2f58082 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Fri, 24 Jul 2026 10:06:09 -0500 Subject: [PATCH 05/10] chore(deps): patch ui Dependabot alerts via pnpm overrides 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. --- ui/package.json | 16 +- ui/pnpm-lock.yaml | 1046 ++++++++++++++++++++++++--------------------- 2 files changed, 583 insertions(+), 479 deletions(-) diff --git a/ui/package.json b/ui/package.json index a5472e77..c36432a2 100644 --- a/ui/package.json +++ b/ui/package.json @@ -22,5 +22,19 @@ "engines": { "node": ">=20" }, - "packageManager": "pnpm@9.15.0" + "packageManager": "pnpm@9.15.0", + "pnpm": { + "overrides": { + "next@<15.5.16": "15.5.16", + "vite@<=6.4.2": "6.4.3", + "ws@>=8.0.0 <8.21.0": "8.21.0", + "brace-expansion@>=3.0.0 <5.0.7": "5.0.7", + "minimatch@>=9.0.0 <9.0.7": "9.0.7", + "picomatch@>=4.0.0 <4.0.4": "4.0.4", + "postcss@<8.5.10": "8.5.10", + "@babel/core@<7.29.6": "7.29.6", + "turbo@<2.9.14": "2.9.14", + "uuid@<11.1.1": "11.1.1" + } + } } diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index d0f8278d..2eabd955 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -4,13 +4,25 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + next@<15.5.16: 15.5.16 + vite@<=6.4.2: 6.4.3 + ws@>=8.0.0 <8.21.0: 8.21.0 + brace-expansion@>=3.0.0 <5.0.7: 5.0.7 + minimatch@>=9.0.0 <9.0.7: 9.0.7 + picomatch@>=4.0.0 <4.0.4: 4.0.4 + postcss@<8.5.10: 8.5.10 + '@babel/core@<7.29.6': 7.29.6 + turbo@<2.9.14: 2.9.14 + uuid@<11.1.1: 11.1.1 + importers: .: devDependencies: turbo: - specifier: ^2.4.0 - version: 2.8.10 + specifier: 2.9.14 + version: 2.9.14 typescript: specifier: ^5.8.0 version: 5.9.3 @@ -44,13 +56,13 @@ importers: version: 8.6.17(@storybook/test@8.6.17(storybook@8.6.17))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.17)(typescript@5.9.3) '@storybook/react-vite': specifier: ^8.4.0 - version: 8.6.17(@storybook/test@8.6.17(storybook@8.6.17))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.59.0)(storybook@8.6.17)(typescript@5.9.3)(vite@5.4.21(@types/node@25.5.0)(lightningcss@1.31.1)) + version: 8.6.17(@storybook/test@8.6.17(storybook@8.6.17))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.59.0)(storybook@8.6.17)(typescript@5.9.3)(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)) '@storybook/test': specifier: ^8.4.0 version: 8.6.17(storybook@8.6.17) '@tailwindcss/vite': specifier: ^4.0.0 - version: 4.2.1(vite@5.4.21(@types/node@25.5.0)(lightningcss@1.31.1)) + version: 4.2.1(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)) '@types/react': specifier: ^18.2.0 version: 18.3.28 @@ -73,8 +85,8 @@ importers: specifier: ^5.3.0 version: 5.9.3 vite: - specifier: ^5.4.0 - version: 5.4.21(@types/node@25.5.0)(lightningcss@1.31.1) + specifier: 6.4.3 + version: 6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1) packages/components: dependencies: @@ -150,7 +162,7 @@ importers: version: 4.2.1 tsup: specifier: ^8.0.0 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(typescript@5.9.3) + version: 8.5.1(jiti@2.6.1)(postcss@8.5.10)(typescript@5.9.3) typescript: specifier: ^5.3.0 version: 5.9.3 @@ -159,7 +171,7 @@ importers: devDependencies: tsup: specifier: ^8.4.0 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(typescript@5.9.3) + version: 8.5.1(jiti@2.6.1)(postcss@8.5.10)(typescript@5.9.3) typescript: specifier: ^5.8.0 version: 5.9.3 @@ -186,8 +198,8 @@ importers: specifier: ^18.2.0 version: 18.3.7(@types/react@18.3.28) next: - specifier: ^14.0.0 - version: 14.2.35(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: 15.5.16 + version: 15.5.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: specifier: ^18.2.0 version: 18.3.1 @@ -196,7 +208,7 @@ importers: version: 18.3.1(react@18.3.1) tsup: specifier: ^8.0.0 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(typescript@5.9.3) + version: 8.5.1(jiti@2.6.1)(postcss@8.5.10)(typescript@5.9.3) typescript: specifier: ^5.3.0 version: 5.9.3 @@ -221,7 +233,7 @@ importers: version: 18.3.1(react@18.3.1) tsup: specifier: ^8.0.0 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(typescript@5.9.3) + version: 8.5.1(jiti@2.6.1)(postcss@8.5.10)(typescript@5.9.3) typescript: specifier: ^5.3.0 version: 5.9.3 @@ -234,7 +246,7 @@ importers: devDependencies: tsup: specifier: ^8.0.0 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(typescript@5.9.3) + version: 8.5.1(jiti@2.6.1)(postcss@8.5.10)(typescript@5.9.3) typescript: specifier: ^5.3.0 version: 5.9.3 @@ -251,18 +263,26 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + '@babel/compat-data@7.29.0': resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} engines: {node: '>=6.9.0'} - '@babel/core@7.29.0': - resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + '@babel/core@7.29.6': + resolution: {integrity: sha512-QdxmAo/ikZqqRGA8s43ww8lcql6naWRvEz0FFrl6MIlc7Gi6TroXnSdWa5U/kq6fzcpqpHesicQxFZIieZbyIA==} engines: {node: '>=6.9.0'} '@babel/generator@7.29.1': resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + '@babel/helper-compilation-targets@7.28.6': resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} engines: {node: '>=6.9.0'} @@ -279,22 +299,30 @@ packages: resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': 7.29.6 '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.28.6': - resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} '@babel/parser@7.29.0': @@ -302,6 +330,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/runtime@7.28.6': resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} engines: {node: '>=6.9.0'} @@ -310,6 +343,10 @@ packages: resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.0': resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} @@ -318,11 +355,12 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@esbuild/aix-ppc64@0.21.5': - resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [aix] + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} @@ -336,12 +374,6 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.21.5': - resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} @@ -354,12 +386,6 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm@0.21.5': - resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} - engines: {node: '>=12'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} @@ -372,12 +398,6 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-x64@0.21.5': - resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} - engines: {node: '>=12'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} @@ -390,12 +410,6 @@ packages: cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.21.5': - resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} - engines: {node: '>=12'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} @@ -408,12 +422,6 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.21.5': - resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} - engines: {node: '>=12'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} @@ -426,12 +434,6 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.21.5': - resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} - engines: {node: '>=12'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} @@ -444,12 +446,6 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.21.5': - resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} @@ -462,12 +458,6 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.21.5': - resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} - engines: {node: '>=12'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} @@ -480,12 +470,6 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.21.5': - resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} - engines: {node: '>=12'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} @@ -498,12 +482,6 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.21.5': - resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} - engines: {node: '>=12'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} @@ -516,12 +494,6 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.21.5': - resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} - engines: {node: '>=12'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} @@ -534,12 +506,6 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.21.5': - resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} - engines: {node: '>=12'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} @@ -552,12 +518,6 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.21.5': - resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} @@ -570,12 +530,6 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.21.5': - resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} - engines: {node: '>=12'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} @@ -588,12 +542,6 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.21.5': - resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} - engines: {node: '>=12'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} @@ -606,12 +554,6 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.21.5': - resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} @@ -636,12 +578,6 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.21.5': - resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} - engines: {node: '>=12'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} @@ -666,12 +602,6 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.21.5': - resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} - engines: {node: '>=12'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} @@ -696,12 +626,6 @@ packages: cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.21.5': - resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} - engines: {node: '>=12'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} @@ -714,12 +638,6 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.21.5': - resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} @@ -732,12 +650,6 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.21.5': - resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} - engines: {node: '>=12'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} @@ -750,12 +662,6 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.21.5': - resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} - engines: {node: '>=12'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} @@ -783,6 +689,143 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -791,7 +834,7 @@ packages: resolution: {integrity: sha512-qYDdL7fPwLRI+bJNurVcis+tNgJmvWjH4YTBGXTA8xMuxFrnAz6E5o35iyzyKbq5J5Lr8mJGfrR5GXl+WGwhgQ==} peerDependencies: typescript: '>= 4.3.x' - vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 + vite: 6.4.3 peerDependenciesMeta: typescript: optional: true @@ -818,59 +861,53 @@ packages: '@types/react': '>=16' react: '>=16' - '@next/env@14.2.35': - resolution: {integrity: sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==} + '@next/env@15.5.16': + resolution: {integrity: sha512-9QMKolCl+JnJtaRAQSXy4RQrhgfe8W7/G1+Hl3QSB/HZY7zQMzTwPDdTRwwio8BS96ps1MHpHhbS8qxoNV3JIQ==} - '@next/swc-darwin-arm64@14.2.33': - resolution: {integrity: sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==} + '@next/swc-darwin-arm64@15.5.16': + resolution: {integrity: sha512-wzdER4JZj+31vNkhaZ1Ght3IsNI8DMwj7VqadfIOqJB5sh8FiOqNSopYADQn6mgEPomzDd/DHqBcfo2fmVMYtg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@14.2.33': - resolution: {integrity: sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==} + '@next/swc-darwin-x64@15.5.16': + resolution: {integrity: sha512-PPTo+cvcanxkuDEuDyZGk28ntmu0WjfkxqlG7hw9Mhsiribs4x1C6h2Culn0cJKqsne1gFjjZRK3ax7WYlSxgg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@14.2.33': - resolution: {integrity: sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==} + '@next/swc-linux-arm64-gnu@15.5.16': + resolution: {integrity: sha512-Jl0IL9P7S8uNl5oI1TqrQmfmLp7OqjWM58000pVnUVIsHrvPP6m9QDW/uNWYUbmd+8IYvc6MTeZKICstBMBpew==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@14.2.33': - resolution: {integrity: sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==} + '@next/swc-linux-arm64-musl@15.5.16': + resolution: {integrity: sha512-Zf0BIqv/o5uOWfyRkzgGhyV2Tky7HLt0bG+w7XWdaU1JpyX0tltM3TrSfa/Y9c597SJG4CzN47+u2InhgZZ4vg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@14.2.33': - resolution: {integrity: sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==} + '@next/swc-linux-x64-gnu@15.5.16': + resolution: {integrity: sha512-HCDDU1TRLeUDV180QQTWrs5Oa4lIcI7XH9nF0UVUVmYLN/boZ6LqyFtm3814gc1fv+lOVyKaw5B6bVC9BpXTSQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@14.2.33': - resolution: {integrity: sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==} + '@next/swc-linux-x64-musl@15.5.16': + resolution: {integrity: sha512-kvXUY1dn5wxKuMkXxQRUbPjEnKxW1PR9uKOm0zpIpj3574+cFfaePhYFmBVtrOuwt+w34OdDzNaJr5Iixf+HBQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@14.2.33': - resolution: {integrity: sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==} + '@next/swc-win32-arm64-msvc@15.5.16': + resolution: {integrity: sha512-zpOQuF+eyENMXRjglp2hZCIrUjTdO37suEBnDn1mX4PXSuetXZDMLpjKOh4dYSw3SiDTnOoOUwBl5i5Elr6nnQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-ia32-msvc@14.2.33': - resolution: {integrity: sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==} - engines: {node: '>= 10'} - cpu: [ia32] - os: [win32] - - '@next/swc-win32-x64-msvc@14.2.33': - resolution: {integrity: sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==} + '@next/swc-win32-x64-msvc@15.5.16': + resolution: {integrity: sha512-LnwKYpiSmIzXlTq76hMeeIzZoDcFwu848p6H+QBkGFJIbZphgzNUPdHruJcHM/bFnaFeco0l1Frie5I27VKglA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -1542,7 +1579,7 @@ packages: resolution: {integrity: sha512-ZqFaLUKlRNaU4ovEa5R6D5YTbft1CUcNHLhzpmL9JWInEh4PrUkxiLD/jjB6fiYLcRpSpcs2DF+O/Jh2GDWemA==} peerDependencies: storybook: ^8.6.17 - vite: ^4.0.0 || ^5.0.0 || ^6.0.0 + vite: 6.4.3 '@storybook/components@8.6.17': resolution: {integrity: sha512-0b8xkkuPCNbM8LTOzyfxuo2KdJCHIfu3+QxWBFllXap0eYNHwVeSxE5KERQ/bk2GDCiRzaUbwH9PeLorxOzJJQ==} @@ -1614,7 +1651,7 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta storybook: ^8.6.17 - vite: ^4.0.0 || ^5.0.0 || ^6.0.0 + vite: 6.4.3 peerDependenciesMeta: '@storybook/test': optional: true @@ -1644,11 +1681,8 @@ packages: peerDependencies: storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 - '@swc/counter@0.1.3': - resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} - - '@swc/helpers@0.5.5': - resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==} + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} '@tailwindcss/node@4.2.1': resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} @@ -1738,7 +1772,7 @@ packages: '@tailwindcss/vite@4.2.1': resolution: {integrity: sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==} peerDependencies: - vite: ^5.2.0 || ^6 || ^7 + vite: 6.4.3 '@testing-library/dom@10.4.0': resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} @@ -1754,6 +1788,36 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' + '@turbo/darwin-64@2.9.14': + resolution: {integrity: sha512-t7QiPflaEyBE4oayeZtSmu4mEfjgIrcNlNNl1z1dmIVPqEdtA7+CfTf8d7KXsOGPh6aNgWjKxyvQg9uGfDQF+A==} + cpu: [x64] + os: [darwin] + + '@turbo/darwin-arm64@2.9.14': + resolution: {integrity: sha512-d23147mC9BsCPA9mJ0h/ubcpbRgcJBXbcG3+Vq7YLhjz3IXuvQsJ1UXH8f4MD76ZjJ4m/E4aRdJV+MW88CDfbw==} + cpu: [arm64] + os: [darwin] + + '@turbo/linux-64@2.9.14': + resolution: {integrity: sha512-P3ZKB5tuUDdDQWuAsACGUR1qv9W7BNWxdxqVJ0kZNuNNPRaVYTPPikLcp79+GiEcW3npsR+KyP38lnQiBc5aSA==} + cpu: [x64] + os: [linux] + + '@turbo/linux-arm64@2.9.14': + resolution: {integrity: sha512-ZRTlzcUMrrPv9ZuDzRF9n60Ym13bKeG9jDB8WjxyLhWNzV+AJQN+zdpIk3NJYf2zQsGUm1mNar2P0elRzLw25g==} + cpu: [arm64] + os: [linux] + + '@turbo/windows-64@2.9.14': + resolution: {integrity: sha512-exanwN6sIduZwykYeiTQj8kCmOhazP5WOz3bvXMcYtjhL6Z3iRWLewKrXCBq0bqwSP3iBMb/AerRCnHI4lx46A==} + cpu: [x64] + os: [win32] + + '@turbo/windows-arm64@2.9.14': + resolution: {integrity: sha512-fVdCsnmYoKICsycbWuuGp6Jvi51/3G/UluFWuAUCvR8PIW5IJkAk5BM9UF8PSm0Q2IphWHFZjYEgjHsh3B9y/g==} + cpu: [arm64] + os: [win32] + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -1913,8 +1977,8 @@ packages: resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==} engines: {node: '>=12.0.0'} - brace-expansion@5.0.3: - resolution: {integrity: sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==} + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} engines: {node: 18 || 20 || >=22} browser-assert@1.2.1: @@ -1931,10 +1995,6 @@ packages: peerDependencies: esbuild: '>=0.18' - busboy@1.6.0: - resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} - engines: {node: '>=10.16.0'} - cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -2098,11 +2158,6 @@ packages: peerDependencies: esbuild: '>=0.12 <1' - esbuild@0.21.5: - resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} - engines: {node: '>=12'} - hasBin: true - esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -2136,7 +2191,7 @@ packages: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} peerDependencies: - picomatch: ^3 || ^4 + picomatch: 4.0.4 peerDependenciesMeta: picomatch: optional: true @@ -2381,8 +2436,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} - lodash@4.17.23: - resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + lodash@4.18.0: + resolution: {integrity: sha512-l1mfj2atMqndAHI3ls7XqPxEjV2J9ZkcNyHpoZA3r2T1LLwDB69jgkMWh71YKwhBbK0G2f4WSn05ahmQXVxupA==} + deprecated: Bad release. Please use lodash@4.17.21 instead. loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} @@ -2427,8 +2483,8 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} - minimatch@9.0.6: - resolution: {integrity: sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ==} + minimatch@9.0.7: + resolution: {integrity: sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==} engines: {node: '>=16 || 14 >=14.17'} minimist@1.2.8: @@ -2452,21 +2508,24 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - next@14.2.35: - resolution: {integrity: sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==} - engines: {node: '>=18.17.0'} + next@15.5.16: + resolution: {integrity: sha512-aZExBk/V6JCu3NCFc90twdj9L/M3y0+ukeQwUAZbOiqRhAX+h2oMEa0NZFhcpj6HYRYjVS3V2/3xvyOpNnmw7A==} + engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 - '@playwright/test': ^1.41.2 - react: ^18.2.0 - react-dom: ^18.2.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 sass: ^1.3.0 peerDependenciesMeta: '@opentelemetry/api': optional: true '@playwright/test': optional: true + babel-plugin-react-compiler: + optional: true sass: optional: true @@ -2517,8 +2576,8 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} pirates@4.0.7: @@ -2541,7 +2600,7 @@ packages: engines: {node: '>= 18'} peerDependencies: jiti: '>=1.21.0' - postcss: '>=8.0.9' + postcss: 8.5.10 tsx: ^4.8.1 yaml: ^2.4.2 peerDependenciesMeta: @@ -2554,12 +2613,8 @@ packages: yaml: optional: true - postcss@8.4.31: - resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} - engines: {node: ^10 || ^12 || >=14} - - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + postcss@8.5.10: + resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} engines: {node: ^10 || ^12 || >=14} pretty-format@27.5.1: @@ -2667,6 +2722,10 @@ packages: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -2700,10 +2759,6 @@ packages: prettier: optional: true - streamsearch@1.1.0: - resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} - engines: {node: '>=10.0.0'} - string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -2732,13 +2787,13 @@ packages: resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==} engines: {node: '>=12'} - styled-jsx@5.1.1: - resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==} + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} peerDependencies: '@babel/core': '*' babel-plugin-macros: '*' - react: '>= 16.8.0 || 17.x.x || ^18.0.0-0' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' peerDependenciesMeta: '@babel/core': optional: true @@ -2818,7 +2873,7 @@ packages: peerDependencies: '@microsoft/api-extractor': ^7.36.0 '@swc/core': ^1 - postcss: ^8.4.12 + postcss: 8.5.10 typescript: '>=4.5.0' peerDependenciesMeta: '@microsoft/api-extractor': @@ -2830,38 +2885,8 @@ packages: typescript: optional: true - turbo-darwin-64@2.8.10: - resolution: {integrity: sha512-A03fXh+B7S8mL3PbdhTd+0UsaGrhfyPkODvzBDpKRY7bbeac4MDFpJ7I+Slf2oSkCEeSvHKR7Z4U71uKRUfX7g==} - cpu: [x64] - os: [darwin] - - turbo-darwin-arm64@2.8.10: - resolution: {integrity: sha512-sidzowgWL3s5xCHLeqwC9M3s9M0i16W1nuQF3Mc7fPHpZ+YPohvcbVFBB2uoRRHYZg6yBnwD4gyUHKTeXfwtXA==} - cpu: [arm64] - os: [darwin] - - turbo-linux-64@2.8.10: - resolution: {integrity: sha512-YK9vcpL3TVtqonB021XwgaQhY9hJJbKKUhLv16osxV0HkcQASQWUqR56yMge7puh6nxU67rQlTq1b7ksR1T3KA==} - cpu: [x64] - os: [linux] - - turbo-linux-arm64@2.8.10: - resolution: {integrity: sha512-3+j2tL0sG95iBJTm+6J8/45JsETQABPqtFyYjVjBbi6eVGdtNTiBmHNKrbvXRlQ3ZbUG75bKLaSSDHSEEN+btQ==} - cpu: [arm64] - os: [linux] - - turbo-windows-64@2.8.10: - resolution: {integrity: sha512-hdeF5qmVY/NFgiucf8FW0CWJWtyT2QPm5mIsX0W1DXAVzqKVXGq+Zf+dg4EUngAFKjDzoBeN6ec2Fhajwfztkw==} - cpu: [x64] - os: [win32] - - turbo-windows-arm64@2.8.10: - resolution: {integrity: sha512-QGdr/Q8LWmj+ITMkSvfiz2glf0d7JG0oXVzGL3jxkGqiBI1zXFj20oqVY0qWi+112LO9SVrYdpHS0E/oGFrMbQ==} - cpu: [arm64] - os: [win32] - - turbo@2.8.10: - resolution: {integrity: sha512-OxbzDES66+x7nnKGg2MwBA1ypVsZoDTLHpeaP4giyiHSixbsiTaMyeJqbEyvBdp5Cm28fc+8GG6RdQtic0ijwQ==} + turbo@2.9.14: + resolution: {integrity: sha512-BQqXRr4UoWI3UPFrtznCLykYHxwxWh53iCB57x092jPMjIlW1wnm3N895g5irpiXmnxUhREBB0n6+y8BHhs4nw==} hasBin: true typescript@5.9.3: @@ -2913,26 +2938,31 @@ packages: util@0.12.5: resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} - uuid@9.0.1: - resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} hasBin: true - vite@5.4.21: - resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} - engines: {node: ^18.0.0 || >=20.0.0} + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: - '@types/node': ^18.0.0 || >=20.0.0 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' less: '*' lightningcss: ^1.21.0 sass: '*' sass-embedded: '*' stylus: '*' sugarss: '*' - terser: ^5.4.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 peerDependenciesMeta: '@types/node': optional: true + jiti: + optional: true less: optional: true lightningcss: @@ -2947,6 +2977,10 @@ packages: optional: true terser: optional: true + tsx: + optional: true + yaml: + optional: true vue@3.5.29: resolution: {integrity: sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==} @@ -2976,8 +3010,8 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} - ws@8.19.0: - resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -3005,16 +3039,22 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/compat-data@7.29.0': {} - '@babel/core@7.29.0': + '@babel/core@7.29.6': dependencies: '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 + '@babel/generator': 7.29.7 '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.28.6 - '@babel/parser': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.6) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 '@babel/template': 7.28.6 '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 @@ -3035,6 +3075,14 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + '@babel/helper-compilation-targets@7.28.6': dependencies: '@babel/compat-data': 7.29.0 @@ -3052,9 +3100,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-module-imports': 7.28.6 '@babel/helper-validator-identifier': 7.28.5 '@babel/traverse': 7.29.0 @@ -3063,19 +3111,27 @@ snapshots: '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-option@7.27.1': {} - '@babel/helpers@7.28.6': + '@babel/helpers@7.29.7': dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 '@babel/parser@7.29.0': dependencies: '@babel/types': 7.29.0 + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + '@babel/runtime@7.28.6': {} '@babel/template@7.28.6': @@ -3084,6 +3140,12 @@ snapshots: '@babel/parser': 7.29.0 '@babel/types': 7.29.0 + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@babel/traverse@7.29.0': dependencies: '@babel/code-frame': 7.29.0 @@ -3101,7 +3163,14 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@esbuild/aix-ppc64@0.21.5': + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 optional: true '@esbuild/aix-ppc64@0.25.12': @@ -3110,144 +3179,96 @@ snapshots: '@esbuild/aix-ppc64@0.27.3': optional: true - '@esbuild/android-arm64@0.21.5': - optional: true - '@esbuild/android-arm64@0.25.12': optional: true '@esbuild/android-arm64@0.27.3': optional: true - '@esbuild/android-arm@0.21.5': - optional: true - '@esbuild/android-arm@0.25.12': optional: true '@esbuild/android-arm@0.27.3': optional: true - '@esbuild/android-x64@0.21.5': - optional: true - '@esbuild/android-x64@0.25.12': optional: true '@esbuild/android-x64@0.27.3': optional: true - '@esbuild/darwin-arm64@0.21.5': - optional: true - '@esbuild/darwin-arm64@0.25.12': optional: true '@esbuild/darwin-arm64@0.27.3': optional: true - '@esbuild/darwin-x64@0.21.5': - optional: true - '@esbuild/darwin-x64@0.25.12': optional: true '@esbuild/darwin-x64@0.27.3': optional: true - '@esbuild/freebsd-arm64@0.21.5': - optional: true - '@esbuild/freebsd-arm64@0.25.12': optional: true '@esbuild/freebsd-arm64@0.27.3': optional: true - '@esbuild/freebsd-x64@0.21.5': - optional: true - '@esbuild/freebsd-x64@0.25.12': optional: true '@esbuild/freebsd-x64@0.27.3': optional: true - '@esbuild/linux-arm64@0.21.5': - optional: true - '@esbuild/linux-arm64@0.25.12': optional: true '@esbuild/linux-arm64@0.27.3': optional: true - '@esbuild/linux-arm@0.21.5': - optional: true - '@esbuild/linux-arm@0.25.12': optional: true '@esbuild/linux-arm@0.27.3': optional: true - '@esbuild/linux-ia32@0.21.5': - optional: true - '@esbuild/linux-ia32@0.25.12': optional: true '@esbuild/linux-ia32@0.27.3': optional: true - '@esbuild/linux-loong64@0.21.5': - optional: true - '@esbuild/linux-loong64@0.25.12': optional: true '@esbuild/linux-loong64@0.27.3': optional: true - '@esbuild/linux-mips64el@0.21.5': - optional: true - '@esbuild/linux-mips64el@0.25.12': optional: true '@esbuild/linux-mips64el@0.27.3': optional: true - '@esbuild/linux-ppc64@0.21.5': - optional: true - '@esbuild/linux-ppc64@0.25.12': optional: true '@esbuild/linux-ppc64@0.27.3': optional: true - '@esbuild/linux-riscv64@0.21.5': - optional: true - '@esbuild/linux-riscv64@0.25.12': optional: true '@esbuild/linux-riscv64@0.27.3': optional: true - '@esbuild/linux-s390x@0.21.5': - optional: true - '@esbuild/linux-s390x@0.25.12': optional: true '@esbuild/linux-s390x@0.27.3': optional: true - '@esbuild/linux-x64@0.21.5': - optional: true - '@esbuild/linux-x64@0.25.12': optional: true @@ -3260,9 +3281,6 @@ snapshots: '@esbuild/netbsd-arm64@0.27.3': optional: true - '@esbuild/netbsd-x64@0.21.5': - optional: true - '@esbuild/netbsd-x64@0.25.12': optional: true @@ -3275,9 +3293,6 @@ snapshots: '@esbuild/openbsd-arm64@0.27.3': optional: true - '@esbuild/openbsd-x64@0.21.5': - optional: true - '@esbuild/openbsd-x64@0.25.12': optional: true @@ -3290,36 +3305,24 @@ snapshots: '@esbuild/openharmony-arm64@0.27.3': optional: true - '@esbuild/sunos-x64@0.21.5': - optional: true - '@esbuild/sunos-x64@0.25.12': optional: true '@esbuild/sunos-x64@0.27.3': optional: true - '@esbuild/win32-arm64@0.21.5': - optional: true - '@esbuild/win32-arm64@0.25.12': optional: true '@esbuild/win32-arm64@0.27.3': optional: true - '@esbuild/win32-ia32@0.21.5': - optional: true - '@esbuild/win32-ia32@0.25.12': optional: true '@esbuild/win32-ia32@0.27.3': optional: true - '@esbuild/win32-x64@0.21.5': - optional: true - '@esbuild/win32-x64@0.25.12': optional: true @@ -3343,6 +3346,103 @@ snapshots: '@floating-ui/utils@0.2.10': {} + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.11.2 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -3352,12 +3452,12 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 - '@joshwooding/vite-plugin-react-docgen-typescript@0.5.0(typescript@5.9.3)(vite@5.4.21(@types/node@25.5.0)(lightningcss@1.31.1))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.5.0(typescript@5.9.3)(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1))': dependencies: glob: 10.5.0 magic-string: 0.27.0 react-docgen-typescript: 2.4.0(typescript@5.9.3) - vite: 5.4.21(@types/node@25.5.0)(lightningcss@1.31.1) + vite: 6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1) optionalDependencies: typescript: 5.9.3 @@ -3386,33 +3486,30 @@ snapshots: '@types/react': 18.3.28 react: 18.3.1 - '@next/env@14.2.35': {} + '@next/env@15.5.16': {} - '@next/swc-darwin-arm64@14.2.33': + '@next/swc-darwin-arm64@15.5.16': optional: true - '@next/swc-darwin-x64@14.2.33': + '@next/swc-darwin-x64@15.5.16': optional: true - '@next/swc-linux-arm64-gnu@14.2.33': + '@next/swc-linux-arm64-gnu@15.5.16': optional: true - '@next/swc-linux-arm64-musl@14.2.33': + '@next/swc-linux-arm64-musl@15.5.16': optional: true - '@next/swc-linux-x64-gnu@14.2.33': + '@next/swc-linux-x64-gnu@15.5.16': optional: true - '@next/swc-linux-x64-musl@14.2.33': + '@next/swc-linux-x64-musl@15.5.16': optional: true - '@next/swc-win32-arm64-msvc@14.2.33': + '@next/swc-win32-arm64-msvc@15.5.16': optional: true - '@next/swc-win32-ia32-msvc@14.2.33': - optional: true - - '@next/swc-win32-x64-msvc@14.2.33': + '@next/swc-win32-x64-msvc@15.5.16': optional: true '@pkgjs/parseargs@0.11.0': @@ -3857,7 +3954,7 @@ snapshots: dependencies: '@types/estree': 1.0.8 estree-walker: 2.0.2 - picomatch: 4.0.3 + picomatch: 4.0.4 optionalDependencies: rollup: 4.59.0 @@ -3951,7 +4048,7 @@ snapshots: dequal: 2.0.3 polished: 4.3.1 storybook: 8.6.17 - uuid: 9.0.1 + uuid: 11.1.1 '@storybook/addon-backgrounds@8.6.14(storybook@8.6.17)': dependencies: @@ -4041,13 +4138,13 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@storybook/builder-vite@8.6.17(storybook@8.6.17)(vite@5.4.21(@types/node@25.5.0)(lightningcss@1.31.1))': + '@storybook/builder-vite@8.6.17(storybook@8.6.17)(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1))': dependencies: '@storybook/csf-plugin': 8.6.17(storybook@8.6.17) browser-assert: 1.2.1 storybook: 8.6.17 ts-dedent: 2.2.0 - vite: 5.4.21(@types/node@25.5.0)(lightningcss@1.31.1) + vite: 6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1) '@storybook/components@8.6.17(storybook@8.6.17)': dependencies: @@ -4065,7 +4162,7 @@ snapshots: recast: 0.23.11 semver: 7.7.4 util: 0.12.5 - ws: 8.19.0 + ws: 8.21.0 transitivePeerDependencies: - bufferutil - storybook @@ -4115,11 +4212,11 @@ snapshots: react-dom: 18.3.1(react@18.3.1) storybook: 8.6.17 - '@storybook/react-vite@8.6.17(@storybook/test@8.6.17(storybook@8.6.17))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.59.0)(storybook@8.6.17)(typescript@5.9.3)(vite@5.4.21(@types/node@25.5.0)(lightningcss@1.31.1))': + '@storybook/react-vite@8.6.17(@storybook/test@8.6.17(storybook@8.6.17))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.59.0)(storybook@8.6.17)(typescript@5.9.3)(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.5.0(typescript@5.9.3)(vite@5.4.21(@types/node@25.5.0)(lightningcss@1.31.1)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.5.0(typescript@5.9.3)(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)) '@rollup/pluginutils': 5.3.0(rollup@4.59.0) - '@storybook/builder-vite': 8.6.17(storybook@8.6.17)(vite@5.4.21(@types/node@25.5.0)(lightningcss@1.31.1)) + '@storybook/builder-vite': 8.6.17(storybook@8.6.17)(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)) '@storybook/react': 8.6.17(@storybook/test@8.6.17(storybook@8.6.17))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.17)(typescript@5.9.3) find-up: 5.0.0 magic-string: 0.30.21 @@ -4129,7 +4226,7 @@ snapshots: resolve: 1.22.11 storybook: 8.6.17 tsconfig-paths: 4.2.0 - vite: 5.4.21(@types/node@25.5.0)(lightningcss@1.31.1) + vite: 6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1) optionalDependencies: '@storybook/test': 8.6.17(storybook@8.6.17) transitivePeerDependencies: @@ -4167,11 +4264,8 @@ snapshots: dependencies: storybook: 8.6.17 - '@swc/counter@0.1.3': {} - - '@swc/helpers@0.5.5': + '@swc/helpers@0.5.15': dependencies: - '@swc/counter': 0.1.3 tslib: 2.8.1 '@tailwindcss/node@4.2.1': @@ -4235,12 +4329,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 '@tailwindcss/oxide-win32-x64-msvc': 4.2.1 - '@tailwindcss/vite@4.2.1(vite@5.4.21(@types/node@25.5.0)(lightningcss@1.31.1))': + '@tailwindcss/vite@4.2.1(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1))': dependencies: '@tailwindcss/node': 4.2.1 '@tailwindcss/oxide': 4.2.1 tailwindcss: 4.2.1 - vite: 5.4.21(@types/node@25.5.0)(lightningcss@1.31.1) + vite: 6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1) '@testing-library/dom@10.4.0': dependencies: @@ -4260,13 +4354,31 @@ snapshots: chalk: 3.0.0 css.escape: 1.5.1 dom-accessibility-api: 0.6.3 - lodash: 4.17.23 + lodash: 4.18.0 redent: 3.0.0 '@testing-library/user-event@14.5.2(@testing-library/dom@10.4.0)': dependencies: '@testing-library/dom': 10.4.0 + '@turbo/darwin-64@2.9.14': + optional: true + + '@turbo/darwin-arm64@2.9.14': + optional: true + + '@turbo/linux-64@2.9.14': + optional: true + + '@turbo/linux-arm64@2.9.14': + optional: true + + '@turbo/windows-64@2.9.14': + optional: true + + '@turbo/windows-arm64@2.9.14': + optional: true + '@types/aria-query@5.0.4': {} '@types/babel__core@7.20.5': @@ -4369,7 +4481,7 @@ snapshots: '@vue/shared': 3.5.29 estree-walker: 2.0.2 magic-string: 0.30.21 - postcss: 8.5.6 + postcss: 8.5.10 source-map-js: 1.2.1 '@vue/compiler-ssr@3.5.29': @@ -4447,7 +4559,7 @@ snapshots: dependencies: open: 8.4.2 - brace-expansion@5.0.3: + brace-expansion@5.0.7: dependencies: balanced-match: 4.0.4 @@ -4466,10 +4578,6 @@ snapshots: esbuild: 0.27.3 load-tsconfig: 0.2.5 - busboy@1.6.0: - dependencies: - streamsearch: 1.1.0 - cac@6.7.14: {} call-bind-apply-helpers@1.0.2: @@ -4611,32 +4719,6 @@ snapshots: transitivePeerDependencies: - supports-color - esbuild@0.21.5: - optionalDependencies: - '@esbuild/aix-ppc64': 0.21.5 - '@esbuild/android-arm': 0.21.5 - '@esbuild/android-arm64': 0.21.5 - '@esbuild/android-x64': 0.21.5 - '@esbuild/darwin-arm64': 0.21.5 - '@esbuild/darwin-x64': 0.21.5 - '@esbuild/freebsd-arm64': 0.21.5 - '@esbuild/freebsd-x64': 0.21.5 - '@esbuild/linux-arm': 0.21.5 - '@esbuild/linux-arm64': 0.21.5 - '@esbuild/linux-ia32': 0.21.5 - '@esbuild/linux-loong64': 0.21.5 - '@esbuild/linux-mips64el': 0.21.5 - '@esbuild/linux-ppc64': 0.21.5 - '@esbuild/linux-riscv64': 0.21.5 - '@esbuild/linux-s390x': 0.21.5 - '@esbuild/linux-x64': 0.21.5 - '@esbuild/netbsd-x64': 0.21.5 - '@esbuild/openbsd-x64': 0.21.5 - '@esbuild/sunos-x64': 0.21.5 - '@esbuild/win32-arm64': 0.21.5 - '@esbuild/win32-ia32': 0.21.5 - '@esbuild/win32-x64': 0.21.5 - esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -4707,9 +4789,9 @@ snapshots: esutils@2.0.3: {} - fdir@6.5.0(picomatch@4.0.3): + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: - picomatch: 4.0.3 + picomatch: 4.0.4 find-up@5.0.0: dependencies: @@ -4764,7 +4846,7 @@ snapshots: dependencies: foreground-child: 3.3.1 jackspeak: 3.4.3 - minimatch: 9.0.6 + minimatch: 9.0.7 minipass: 7.1.3 package-json-from-dist: 1.0.1 path-scurry: 1.11.1 @@ -4915,7 +4997,7 @@ snapshots: dependencies: p-locate: 5.0.0 - lodash@4.17.23: {} + lodash@4.18.0: {} loose-envify@1.4.0: dependencies: @@ -4953,9 +5035,9 @@ snapshots: min-indent@1.0.1: {} - minimatch@9.0.6: + minimatch@9.0.7: dependencies: - brace-expansion: 5.0.3 + brace-expansion: 5.0.7 minimist@1.2.8: {} @@ -4978,27 +5060,25 @@ snapshots: nanoid@3.3.11: {} - next@14.2.35(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next@15.5.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@next/env': 14.2.35 - '@swc/helpers': 0.5.5 - busboy: 1.6.0 + '@next/env': 15.5.16 + '@swc/helpers': 0.5.15 caniuse-lite: 1.0.30001774 - graceful-fs: 4.2.11 - postcss: 8.4.31 + postcss: 8.5.10 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - styled-jsx: 5.1.1(react@18.3.1) + styled-jsx: 5.1.6(react@18.3.1) optionalDependencies: - '@next/swc-darwin-arm64': 14.2.33 - '@next/swc-darwin-x64': 14.2.33 - '@next/swc-linux-arm64-gnu': 14.2.33 - '@next/swc-linux-arm64-musl': 14.2.33 - '@next/swc-linux-x64-gnu': 14.2.33 - '@next/swc-linux-x64-musl': 14.2.33 - '@next/swc-win32-arm64-msvc': 14.2.33 - '@next/swc-win32-ia32-msvc': 14.2.33 - '@next/swc-win32-x64-msvc': 14.2.33 + '@next/swc-darwin-arm64': 15.5.16 + '@next/swc-darwin-x64': 15.5.16 + '@next/swc-linux-arm64-gnu': 15.5.16 + '@next/swc-linux-arm64-musl': 15.5.16 + '@next/swc-linux-x64-gnu': 15.5.16 + '@next/swc-linux-x64-musl': 15.5.16 + '@next/swc-win32-arm64-msvc': 15.5.16 + '@next/swc-win32-x64-msvc': 15.5.16 + sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros @@ -5040,7 +5120,7 @@ snapshots: picocolors@1.1.1: {} - picomatch@4.0.3: {} + picomatch@4.0.4: {} pirates@4.0.7: {} @@ -5056,20 +5136,14 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6): + postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.10): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 2.6.1 - postcss: 8.5.6 + postcss: 8.5.10 - postcss@8.4.31: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - postcss@8.5.6: + postcss@8.5.10: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 @@ -5089,7 +5163,7 @@ snapshots: react-docgen@7.1.1: dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 '@types/babel__core': 7.20.5 @@ -5218,6 +5292,38 @@ snapshots: gopd: 1.2.0 has-property-descriptors: 1.0.2 + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -5240,8 +5346,6 @@ snapshots: - supports-color - utf-8-validate - streamsearch@1.1.0: {} - string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -5270,7 +5374,7 @@ snapshots: strip-indent@4.1.1: {} - styled-jsx@5.1.1(react@18.3.1): + styled-jsx@5.1.6(react@18.3.1): dependencies: client-only: 0.0.1 react: 18.3.1 @@ -5311,8 +5415,8 @@ snapshots: tinyglobby@0.2.15: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 tinyrainbow@1.2.0: {} @@ -5332,7 +5436,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(jiti@2.6.1)(postcss@8.5.6)(typescript@5.9.3): + tsup@8.5.1(jiti@2.6.1)(postcss@8.5.10)(typescript@5.9.3): dependencies: bundle-require: 5.1.0(esbuild@0.27.3) cac: 6.7.14 @@ -5343,7 +5447,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6) + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.10) resolve-from: 5.0.0 rollup: 4.59.0 source-map: 0.7.6 @@ -5352,7 +5456,7 @@ snapshots: tinyglobby: 0.2.15 tree-kill: 1.2.2 optionalDependencies: - postcss: 8.5.6 + postcss: 8.5.10 typescript: 5.9.3 transitivePeerDependencies: - jiti @@ -5360,32 +5464,14 @@ snapshots: - tsx - yaml - turbo-darwin-64@2.8.10: - optional: true - - turbo-darwin-arm64@2.8.10: - optional: true - - turbo-linux-64@2.8.10: - optional: true - - turbo-linux-arm64@2.8.10: - optional: true - - turbo-windows-64@2.8.10: - optional: true - - turbo-windows-arm64@2.8.10: - optional: true - - turbo@2.8.10: + turbo@2.9.14: optionalDependencies: - turbo-darwin-64: 2.8.10 - turbo-darwin-arm64: 2.8.10 - turbo-linux-64: 2.8.10 - turbo-linux-arm64: 2.8.10 - turbo-windows-64: 2.8.10 - turbo-windows-arm64: 2.8.10 + '@turbo/darwin-64': 2.9.14 + '@turbo/darwin-arm64': 2.9.14 + '@turbo/linux-64': 2.9.14 + '@turbo/linux-arm64': 2.9.14 + '@turbo/windows-64': 2.9.14 + '@turbo/windows-arm64': 2.9.14 typescript@5.9.3: {} @@ -5431,16 +5517,20 @@ snapshots: is-typed-array: 1.1.15 which-typed-array: 1.1.20 - uuid@9.0.1: {} + uuid@11.1.1: {} - vite@5.4.21(@types/node@25.5.0)(lightningcss@1.31.1): + vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1): dependencies: - esbuild: 0.21.5 - postcss: 8.5.6 + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.10 rollup: 4.59.0 + tinyglobby: 0.2.15 optionalDependencies: '@types/node': 25.5.0 fsevents: 2.3.3 + jiti: 2.6.1 lightningcss: 1.31.1 vue@3.5.29(typescript@5.9.3): @@ -5481,7 +5571,7 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.2 - ws@8.19.0: {} + ws@8.21.0: {} yallist@3.1.1: {} From f60a14632a32ac3075b5d6d5ad2605ee4136cdc3 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Fri, 24 Jul 2026 11:12:06 -0500 Subject: [PATCH 06/10] fix(lint,ci): resolve golangci-lint findings and scope the store integration 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. --- .github/workflows/ci.yml | 25 +++++++++------ api/admin_handlers.go | 10 +++--- api/environment_handlers.go | 4 +-- api/rbac_handlers.go | 12 ++++---- middleware/clientip_test.go | 3 +- plugins/magiclink/sendverify_test.go | 7 +++-- plugins/mfa/totp.go | 2 +- plugins/organization/handlers.go | 46 ++++++++++++++-------------- plugins/passkey/discoverable.go | 29 ++++++++++-------- plugins/passkey/discoverable_test.go | 2 +- plugins/passkey/handlers.go | 2 +- plugins/passkey/passkey.go | 6 ++-- service.go | 2 +- store/memory/conformance_test.go | 2 +- store/storetest/storetest.go | 8 ++--- 15 files changed, 87 insertions(+), 73 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 249c8c00..ebd444a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,16 +56,23 @@ jobs: path: coverage.out retention-days: 7 - # ─── Store Integration Tests ──────────────────────────────────────── - # Runs the cross-backend store conformance suite (and other integration - # tests) against real Postgres and MongoDB. Postgres is provided by - # testcontainers (Docker is available on the runner); MongoDB runs as a - # single-node replica set so multi-document transactions (app/org cascade) - # work. The main `go` job runs -race over the non-integration tests. + # ─── Store Conformance (Postgres + Mongo) ─────────────────────────── + # Runs the cross-backend store conformance suite against real Postgres + # (testcontainers) and MongoDB (single-node replica set, so app/org cascade + # transactions work). Scoped to `-run TestConformance` so it does NOT run the + # older per-backend store_test.go suites, which have pre-existing failures + # (postgres users.env_id FK) and a hanging mongo migration-lock test that are + # tracked separately. + # + # This job is ADVISORY (continue-on-error): the *blocking* cross-backend + # coverage is memory + sqlite, which the main `go` job runs on every push + # without Docker. Postgres/Mongo run here for visibility; promote to blocking + # once the pre-existing per-backend failures above are resolved. integration: - name: Store Integration + name: Store Conformance (pg + mongo) runs-on: ubuntu-latest needs: go + continue-on-error: true steps: - name: Checkout @@ -96,10 +103,10 @@ jobs: sleep 2 done - - name: Run store integration tests + - name: Run store conformance suite env: AUTHSOME_MONGO_URI: mongodb://localhost:27017/authsome_test?replicaSet=rs0&directConnection=true - run: go test -tags integration -count=1 ./store/... + run: go test -tags integration -run 'TestConformance$' -count=1 -timeout 12m ./store/... # ─── Lint ─────────────────────────────────────────────────────────── lint: diff --git a/api/admin_handlers.go b/api/admin_handlers.go index 878537a3..59782526 100644 --- a/api/admin_handlers.go +++ b/api/admin_handlers.go @@ -333,7 +333,7 @@ func (a *API) handleAdminBanUser(ctx forge.Context, req *AdminBanUserRequest) (* return nil, forge.BadRequest("cannot ban yourself") } - if _, err := a.userInCallerApp(ctx, userID); err != nil { + if _, err = a.userInCallerApp(ctx, userID); err != nil { return nil, err } @@ -365,7 +365,7 @@ func (a *API) handleAdminUnbanUser(ctx forge.Context, req *AdminUnbanUserRequest return nil, forge.BadRequest("invalid user_id") } - if _, err := a.userInCallerApp(ctx, userID); err != nil { + if _, err = a.userInCallerApp(ctx, userID); err != nil { return nil, err } @@ -393,7 +393,7 @@ func (a *API) handleAdminDeleteUser(ctx forge.Context, req *AdminDeleteUserReque return nil, forge.BadRequest("cannot delete yourself") } - if _, err := a.userInCallerApp(ctx, userID); err != nil { + if _, err = a.userInCallerApp(ctx, userID); err != nil { return nil, err } @@ -440,7 +440,7 @@ func (a *API) handleAdminImpersonate(ctx forge.Context, req *AdminImpersonateReq return nil, forge.BadRequest("cannot impersonate yourself") } - if _, err := a.userInCallerApp(ctx, targetID); err != nil { + if _, err = a.userInCallerApp(ctx, targetID); err != nil { return nil, err } @@ -477,7 +477,7 @@ func (a *API) handleAdminUpdateUser(ctx forge.Context, req *AdminUpdateUserReque return nil, forge.BadRequest("invalid user_id") } - if _, err := a.userInCallerApp(ctx, userID); err != nil { + if _, err = a.userInCallerApp(ctx, userID); err != nil { return nil, err } diff --git a/api/environment_handlers.go b/api/environment_handlers.go index 87523482..7b50ba19 100644 --- a/api/environment_handlers.go +++ b/api/environment_handlers.go @@ -274,8 +274,8 @@ func (a *API) handleCloneEnvironment(ctx forge.Context, req *CloneEnvironmentReq if err != nil { return nil, mapError(err) } - if err := a.assertAppScope(ctx, srcEnv.AppID); err != nil { - return nil, err + if scopeErr := a.assertAppScope(ctx, srcEnv.AppID); scopeErr != nil { + return nil, scopeErr } if req.Name == "" { diff --git a/api/rbac_handlers.go b/api/rbac_handlers.go index 8c2c93ee..74a517af 100644 --- a/api/rbac_handlers.go +++ b/api/rbac_handlers.go @@ -301,7 +301,7 @@ func (a *API) handleDeleteRole(ctx forge.Context, _ *DeleteRoleRequest) (*Status return nil, forge.BadRequest(fmt.Sprintf("invalid role id: %v", err)) } - if _, err := a.roleInCallerApp(ctx, roleID); err != nil { + if _, err = a.roleInCallerApp(ctx, roleID); err != nil { return nil, err } @@ -319,7 +319,7 @@ func (a *API) handleAddPermission(ctx forge.Context, req *AddPermissionRequest) return nil, forge.BadRequest(fmt.Sprintf("invalid role id: %v", err)) } - if _, err := a.roleInCallerApp(ctx, roleID); err != nil { + if _, err = a.roleInCallerApp(ctx, roleID); err != nil { return nil, err } @@ -350,7 +350,7 @@ func (a *API) handleListRolePermissions(ctx forge.Context, _ *ListRolePermission return nil, forge.BadRequest(fmt.Sprintf("invalid role id: %v", err)) } - if _, err := a.roleInCallerApp(ctx, roleID); err != nil { + if _, err = a.roleInCallerApp(ctx, roleID); err != nil { return nil, err } @@ -376,7 +376,7 @@ func (a *API) handleRemovePermission(ctx forge.Context, _ *RemovePermissionReque return nil, forge.BadRequest(fmt.Sprintf("invalid permission id: %v", err)) } - if _, err := a.roleInCallerApp(ctx, roleID); err != nil { + if _, err = a.roleInCallerApp(ctx, roleID); err != nil { return nil, err } // The permission must actually belong to this role, so a caller cannot @@ -411,7 +411,7 @@ func (a *API) handleAssignRole(ctx forge.Context, req *AssignRoleRequest) (*Stat return nil, forge.BadRequest(fmt.Sprintf("invalid role id: %v", err)) } - if _, err := a.roleInCallerApp(ctx, roleID); err != nil { + if _, err = a.roleInCallerApp(ctx, roleID); err != nil { return nil, err } @@ -440,7 +440,7 @@ func (a *API) handleUnassignRole(ctx forge.Context, req *UnassignRoleRequest) (* return nil, forge.BadRequest(fmt.Sprintf("invalid role id: %v", err)) } - if _, err := a.roleInCallerApp(ctx, roleID); err != nil { + if _, err = a.roleInCallerApp(ctx, roleID); err != nil { return nil, err } diff --git a/middleware/clientip_test.go b/middleware/clientip_test.go index f7d55325..aa039327 100644 --- a/middleware/clientip_test.go +++ b/middleware/clientip_test.go @@ -1,6 +1,7 @@ package middleware_test import ( + "context" "net/http/httptest" "testing" @@ -67,7 +68,7 @@ func TestClientIP_TrustedProxyAware(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - req := httptest.NewRequest("GET", "/", nil) + req := httptest.NewRequestWithContext(context.Background(), "GET", "/", nil) req.RemoteAddr = tc.remoteAddr if tc.xff != "" { req.Header.Set("X-Forwarded-For", tc.xff) diff --git a/plugins/magiclink/sendverify_test.go b/plugins/magiclink/sendverify_test.go index 24b35c47..1e4761a3 100644 --- a/plugins/magiclink/sendverify_test.go +++ b/plugins/magiclink/sendverify_test.go @@ -1,6 +1,7 @@ package magiclink_test import ( + "context" "encoding/json" "net/http" "net/http/httptest" @@ -24,7 +25,7 @@ func TestMagicLink_SendThenVerify_Succeeds(t *testing.T) { handler := mux.Handler() // Send. - sendReq := httptest.NewRequest(http.MethodPost, "/v1/magic-link/send", jsonBody(t, map[string]string{"email": u.Email})) + sendReq := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/v1/magic-link/send", jsonBody(t, map[string]string{"email": u.Email})) sendReq.Header.Set("Content-Type", "application/json") sendRec := httptest.NewRecorder() handler.ServeHTTP(sendRec, sendReq) @@ -34,7 +35,7 @@ func TestMagicLink_SendThenVerify_Succeeds(t *testing.T) { require.NotEmpty(t, token) // Verify. - verifyReq := httptest.NewRequest(http.MethodPost, "/v1/magic-link/verify", jsonBody(t, map[string]string{"token": token})) + verifyReq := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/v1/magic-link/verify", jsonBody(t, map[string]string{"token": token})) verifyReq.Header.Set("Content-Type", "application/json") verifyRec := httptest.NewRecorder() handler.ServeHTTP(verifyRec, verifyReq) @@ -56,7 +57,7 @@ func TestMagicLink_Send_UnknownEmail_UniformSuccessNoLink(t *testing.T) { require.NoError(t, p.RegisterRoutes(mux)) handler := mux.Handler() - req := httptest.NewRequest(http.MethodPost, "/v1/magic-link/send", jsonBody(t, map[string]string{"email": "nobody@example.com"})) + req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/v1/magic-link/send", jsonBody(t, map[string]string{"email": "nobody@example.com"})) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) diff --git a/plugins/mfa/totp.go b/plugins/mfa/totp.go index d2c43423..45e3d6b3 100644 --- a/plugins/mfa/totp.go +++ b/plugins/mfa/totp.go @@ -46,7 +46,7 @@ const ( // (unix seconds / period) the code corresponds to. The step uniquely // identifies the code within its validity window, letting callers reject a // replay of the same code. Acceptance matches ValidateTOTP (±1 step). -func ValidateTOTPStep(code, secret string) (bool, int64) { +func ValidateTOTPStep(code, secret string) (valid bool, step int64) { now := time.Now() for delta := int64(-totpSkew); delta <= totpSkew; delta++ { ts := now.Add(time.Duration(delta*totpPeriod) * time.Second) diff --git a/plugins/organization/handlers.go b/plugins/organization/handlers.go index c626df29..779368c7 100644 --- a/plugins/organization/handlers.go +++ b/plugins/organization/handlers.go @@ -344,7 +344,7 @@ func (p *Plugin) handleGetOrg(ctx forge.Context, _ *GetOrgRequest) (*organizatio return nil, forge.BadRequest(fmt.Sprintf("invalid org id: %v", err)) } - if _, err := p.requireOrgRole(ctx, orgID, organization.RoleMember); err != nil { + if _, err = p.requireOrgRole(ctx, orgID, organization.RoleMember); err != nil { return nil, err } @@ -362,7 +362,7 @@ func (p *Plugin) handleUpdateOrg(ctx forge.Context, req *UpdateOrgRequest) (*org return nil, forge.BadRequest(fmt.Sprintf("invalid org id: %v", err)) } - if _, err := p.requireOrgRole(ctx, orgID, organization.RoleAdmin); err != nil { + if _, err = p.requireOrgRole(ctx, orgID, organization.RoleAdmin); err != nil { return nil, err } @@ -391,7 +391,7 @@ func (p *Plugin) handleDeleteOrg(ctx forge.Context, _ *DeleteOrgRequest) (*Statu return nil, forge.BadRequest(fmt.Sprintf("invalid org id: %v", err)) } - if _, err := p.requireOrgRole(ctx, orgID, organization.RoleOwner); err != nil { + if _, err = p.requireOrgRole(ctx, orgID, organization.RoleOwner); err != nil { return nil, err } @@ -413,7 +413,7 @@ func (p *Plugin) handleListMembers(ctx forge.Context, _ *ListMembersRequest) (*M return nil, forge.BadRequest(fmt.Sprintf("invalid org id: %v", err)) } - if _, err := p.requireOrgRole(ctx, orgID, organization.RoleMember); err != nil { + if _, err = p.requireOrgRole(ctx, orgID, organization.RoleMember); err != nil { return nil, err } @@ -435,7 +435,7 @@ func (p *Plugin) handleAddMember(ctx forge.Context, req *AddMemberRequest) (*org return nil, forge.BadRequest(fmt.Sprintf("invalid org id: %v", err)) } - if _, err := p.requireOrgRole(ctx, orgID, organization.RoleAdmin); err != nil { + if _, err = p.requireOrgRole(ctx, orgID, organization.RoleAdmin); err != nil { return nil, err } @@ -477,11 +477,11 @@ func (p *Plugin) handleRemoveMember(ctx forge.Context, _ *RemoveMemberRequest) ( return nil, forge.BadRequest(fmt.Sprintf("invalid member id: %v", err)) } - if _, err := p.requireOrgRole(ctx, orgID, organization.RoleAdmin); err != nil { + if _, err = p.requireOrgRole(ctx, orgID, organization.RoleAdmin); err != nil { return nil, err } - if err := p.assertMemberInOrg(ctx, memberID, orgID); err != nil { - return nil, err + if merr := p.assertMemberInOrg(ctx, memberID, orgID); merr != nil { + return nil, merr } if err := p.RemoveMember(ctx.Context(), memberID); err != nil { @@ -508,11 +508,11 @@ func (p *Plugin) handleUpdateMember(ctx forge.Context, req *UpdateMemberRequest) // Only an owner may change member roles (including granting ownership), // which prevents members/admins from escalating themselves or others. - if _, err := p.requireOrgRole(ctx, orgID, organization.RoleOwner); err != nil { + if _, err = p.requireOrgRole(ctx, orgID, organization.RoleOwner); err != nil { return nil, err } - if err := p.assertMemberInOrg(ctx, memberID, orgID); err != nil { - return nil, err + if merr := p.assertMemberInOrg(ctx, memberID, orgID); merr != nil { + return nil, merr } member, err := p.UpdateMemberRole(ctx.Context(), memberID, organization.MemberRole(req.Role)) @@ -576,7 +576,7 @@ func (p *Plugin) handleListInvitations(ctx forge.Context, _ *ListInvitationsRequ return nil, forge.BadRequest(fmt.Sprintf("invalid org id: %v", err)) } - if _, err := p.requireOrgRole(ctx, orgID, organization.RoleAdmin); err != nil { + if _, err = p.requireOrgRole(ctx, orgID, organization.RoleAdmin); err != nil { return nil, err } @@ -628,7 +628,7 @@ func (p *Plugin) handleCreateTeam(ctx forge.Context, req *CreateTeamRequest) (*o return nil, forge.BadRequest(fmt.Sprintf("invalid org id: %v", err)) } - if _, err := p.requireOrgRole(ctx, orgID, organization.RoleAdmin); err != nil { + if _, err = p.requireOrgRole(ctx, orgID, organization.RoleAdmin); err != nil { return nil, err } @@ -656,7 +656,7 @@ func (p *Plugin) handleListTeams(ctx forge.Context, _ *ListTeamsRequest) (*TeamL return nil, forge.BadRequest(fmt.Sprintf("invalid org id: %v", err)) } - if _, err := p.requireOrgRole(ctx, orgID, organization.RoleMember); err != nil { + if _, err = p.requireOrgRole(ctx, orgID, organization.RoleMember); err != nil { return nil, err } @@ -682,11 +682,11 @@ func (p *Plugin) handleGetTeam(ctx forge.Context, _ *GetTeamRequest) (*organizat return nil, forge.BadRequest(fmt.Sprintf("invalid team id: %v", err)) } - if _, err := p.requireOrgRole(ctx, orgID, organization.RoleMember); err != nil { + if _, err = p.requireOrgRole(ctx, orgID, organization.RoleMember); err != nil { return nil, err } - if err := p.assertTeamInOrg(ctx, teamID, orgID); err != nil { - return nil, err + if terr := p.assertTeamInOrg(ctx, teamID, orgID); terr != nil { + return nil, terr } t, err := p.GetTeam(ctx.Context(), teamID) @@ -707,11 +707,11 @@ func (p *Plugin) handleUpdateTeam(ctx forge.Context, req *UpdateTeamRequest) (*o return nil, forge.BadRequest(fmt.Sprintf("invalid team id: %v", err)) } - if _, err := p.requireOrgRole(ctx, orgID, organization.RoleAdmin); err != nil { + if _, err = p.requireOrgRole(ctx, orgID, organization.RoleAdmin); err != nil { return nil, err } - if err := p.assertTeamInOrg(ctx, teamID, orgID); err != nil { - return nil, err + if terr := p.assertTeamInOrg(ctx, teamID, orgID); terr != nil { + return nil, terr } t, err := p.GetTeam(ctx.Context(), teamID) @@ -743,11 +743,11 @@ func (p *Plugin) handleDeleteTeam(ctx forge.Context, _ *DeleteTeamRequest) (*Sta return nil, forge.BadRequest(fmt.Sprintf("invalid team id: %v", err)) } - if _, err := p.requireOrgRole(ctx, orgID, organization.RoleAdmin); err != nil { + if _, err = p.requireOrgRole(ctx, orgID, organization.RoleAdmin); err != nil { return nil, err } - if err := p.assertTeamInOrg(ctx, teamID, orgID); err != nil { - return nil, err + if terr := p.assertTeamInOrg(ctx, teamID, orgID); terr != nil { + return nil, terr } if err := p.DeleteTeam(ctx.Context(), teamID); err != nil { diff --git a/plugins/passkey/discoverable.go b/plugins/passkey/discoverable.go index 99810013..3002857b 100644 --- a/plugins/passkey/discoverable.go +++ b/plugins/passkey/discoverable.go @@ -40,13 +40,16 @@ func discoverableKey(ceremonyID string) string { return "passkey:discoverable:" + ceremonyID } -// setCeremonyCookie writes the ceremony-correlation cookie. -func setCeremonyCookie(w http.ResponseWriter, ceremonyID string, ttl time.Duration) { +// setCeremonyCookie writes the ceremony-correlation cookie. secure is set from +// the request scheme (HTTPS) by the caller. +func setCeremonyCookie(w http.ResponseWriter, ceremonyID string, ttl time.Duration, secure bool) { + // #nosec G124 -- HttpOnly, SameSite=Lax, and Secure (over HTTPS) are all set below. http.SetCookie(w, &http.Cookie{ Name: ceremonyCookieName, Value: ceremonyID, Path: "/", HttpOnly: true, + Secure: secure, SameSite: http.SameSiteLaxMode, MaxAge: int(ttl.Seconds()), }) @@ -65,12 +68,14 @@ func readCeremonyCookie(r *http.Request) string { } // clearCeremonyCookie expires the ceremony-correlation cookie. -func clearCeremonyCookie(w http.ResponseWriter) { +func clearCeremonyCookie(w http.ResponseWriter, secure bool) { + // #nosec G124 -- HttpOnly, SameSite=Lax, and Secure (over HTTPS) are all set below. http.SetCookie(w, &http.Cookie{ Name: ceremonyCookieName, Value: "", Path: "/", HttpOnly: true, + Secure: secure, SameSite: http.SameSiteLaxMode, MaxAge: -1, }) @@ -80,19 +85,19 @@ func clearCeremonyCookie(w http.ResponseWriter) { // the user and its webauthn.User view, WITHOUT requiring a pre-authenticated // session — this is what makes passwordless login work. Errors on an // unparseable handle, a missing engine, or an unknown user. -func (p *Plugin) resolveDiscoverableUser(ctx context.Context, userHandle []byte) (*user.User, webauthn.User, error) { - uid, err := id.ParseUserID(string(userHandle)) - if err != nil { - return nil, nil, fmt.Errorf("passkey: invalid user handle: %w", err) +func (p *Plugin) resolveDiscoverableUser(ctx context.Context, userHandle []byte) (u *user.User, waUser webauthn.User, err error) { + uid, perr := id.ParseUserID(string(userHandle)) + if perr != nil { + return nil, nil, fmt.Errorf("passkey: invalid user handle: %w", perr) } if p.engine == nil { return nil, nil, fmt.Errorf("passkey: engine unavailable for passwordless login") } - u, err := p.engine.GetUser(ctx, uid) - if err != nil { - return nil, nil, err + loaded, gerr := p.engine.GetUser(ctx, uid) + if gerr != nil { + return nil, nil, gerr } - return u, p.toWebAuthnUser(ctx, u), nil + return loaded, p.toWebAuthnUser(ctx, loaded), nil } // finishDiscoverableLogin completes a passwordless WebAuthn ceremony: it loads @@ -106,7 +111,7 @@ func (p *Plugin) finishDiscoverableLogin(ctx forge.Context, ceremonyID string) ( return nil, forge.BadRequest("no pending login ceremony") } _ = p.ceremonies.Delete(ctx.Context(), key) //nolint:errcheck // best-effort cleanup - clearCeremonyCookie(ctx.Response()) + clearCeremonyCookie(ctx.Response(), ctx.Request().TLS != nil) var waSession webauthn.SessionData if unmarshalErr := json.Unmarshal(sessionJSON, &waSession); unmarshalErr != nil { diff --git a/plugins/passkey/discoverable_test.go b/plugins/passkey/discoverable_test.go index 2995bc7f..76464d82 100644 --- a/plugins/passkey/discoverable_test.go +++ b/plugins/passkey/discoverable_test.go @@ -65,7 +65,7 @@ func TestCeremonyCookieRoundTrip(t *testing.T) { assert.NotEqual(t, id1, id2, "each ceremony must get a distinct id") rec := httptest.NewRecorder() - setCeremonyCookie(rec, id1, time.Minute) + setCeremonyCookie(rec, id1, time.Minute, false) // The cookie the server set becomes the request cookie on the follow-up call. req := httptest.NewRequest(http.MethodPost, "/v1/passkey/login/finish", nil) diff --git a/plugins/passkey/handlers.go b/plugins/passkey/handlers.go index 7c04643c..c86aa0fc 100644 --- a/plugins/passkey/handlers.go +++ b/plugins/passkey/handlers.go @@ -279,7 +279,7 @@ func (p *Plugin) handleLoginBegin(ctx forge.Context, req *LoginBeginRequest) (*L } sessionJSON, _ := json.Marshal(session) //nolint:errcheck // marshaling known types _ = p.ceremonies.Set(ctx.Context(), discoverableKey(ceremonyID), sessionJSON, p.config.SessionTimeout) //nolint:errcheck // best-effort cache - setCeremonyCookie(ctx.Response(), ceremonyID, p.config.SessionTimeout) + setCeremonyCookie(ctx.Response(), ceremonyID, p.config.SessionTimeout, ctx.Request().TLS != nil) return &LoginBeginResponse{Options: options}, nil } diff --git a/plugins/passkey/passkey.go b/plugins/passkey/passkey.go index e2f536c1..3989dec9 100644 --- a/plugins/passkey/passkey.go +++ b/plugins/passkey/passkey.go @@ -499,7 +499,7 @@ func toCredential(userID id.UserID, appID id.AppID, cred *webauthn.Credential, d // ────────────────────────────────────────────────── // audit records an audit event to Chronicle (nil-safe). -func (p *Plugin) audit(ctx context.Context, action, resource, resourceID, actorID, tenant, outcome string) { +func (p *Plugin) audit(ctx context.Context, action, resource, resourceID, actorID, tenant, outcome string) { //nolint:unparam // resource/tenant kept general for future non-passkey callers if p.chronicle == nil { return } @@ -516,7 +516,7 @@ func (p *Plugin) audit(ctx context.Context, action, resource, resourceID, actorI } // relayEvent sends a webhook event to EventRelay (nil-safe). -func (p *Plugin) relayEvent(ctx context.Context, eventType, tenantID string, data map[string]string) { +func (p *Plugin) relayEvent(ctx context.Context, eventType, tenantID string, data map[string]string) { //nolint:unparam // tenantID kept general if p.relay == nil { return } @@ -528,7 +528,7 @@ func (p *Plugin) relayEvent(ctx context.Context, eventType, tenantID string, dat } // emitHook fires a global hook event (nil-safe). -func (p *Plugin) emitHook(ctx context.Context, action, resource, resourceID, actorID, tenant string) { +func (p *Plugin) emitHook(ctx context.Context, action, resource, resourceID, actorID, tenant string) { //nolint:unparam // resource/tenant kept general if p.hooks == nil { return } diff --git a/service.go b/service.go index 4e14a159..c1a11fd1 100644 --- a/service.go +++ b/service.go @@ -569,7 +569,7 @@ func (e *Engine) Refresh(ctx context.Context, refreshToken string, opts ...Refre // account.RefreshSession mutates sess in place; the new RefreshToken // inherits the same FamilyID by virtue of leaving the field untouched. cfg := e.sessionConfigForApp(ctx, sess.AppID, sess.EnvID) - if err := account.RefreshSession(sess, cfg); err != nil { + if err = account.RefreshSession(sess, cfg); err != nil { return nil, fmt.Errorf("authsome: refresh session: %w", err) } diff --git a/store/memory/conformance_test.go b/store/memory/conformance_test.go index ec91524f..34764453 100644 --- a/store/memory/conformance_test.go +++ b/store/memory/conformance_test.go @@ -11,7 +11,7 @@ import ( // TestConformance runs the shared cross-backend store contract suite against // the in-memory backend. It runs in normal CI (no Docker required). func TestConformance(t *testing.T) { - storetest.RunConformance(t, func(t *testing.T) store.Store { + storetest.RunConformance(t, func(_ *testing.T) store.Store { return memory.New() }) } diff --git a/store/storetest/storetest.go b/store/storetest/storetest.go index 6d583d0a..425f6c1c 100644 --- a/store/storetest/storetest.go +++ b/store/storetest/storetest.go @@ -64,11 +64,11 @@ func RunConformance(t *testing.T, newStore Factory) { func now() time.Time { return time.Now().UTC().Truncate(time.Millisecond) } -func suffix(id string) string { - if len(id) <= 10 { - return id +func suffix(idStr string) string { + if len(idStr) <= 10 { + return idStr } - return id[len(id)-10:] + return idStr[len(idStr)-10:] } type tenant struct { From 77834534003e9b411c2b441405768eb595bd0805 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Fri, 24 Jul 2026 11:27:00 -0500 Subject: [PATCH 07/10] fix(store/mongo): query revoked refresh tokens by token_hash, not _id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- store/mongo/models.go | 10 +++++++++- store/mongo/refresh_replay.go | 6 +++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/store/mongo/models.go b/store/mongo/models.go index 75c85f5d..16e03c5e 100644 --- a/store/mongo/models.go +++ b/store/mongo/models.go @@ -1416,10 +1416,18 @@ func fromSettingModel(m *settingModel) (*settings.Setting, error) { // revokedRefreshTokenModel is the on-disk representation of // session.RevokedRefreshToken used for refresh-token replay detection. +// revokedRefreshTokenModel stores revoked/replayed refresh-token hashes. +// +// NOTE: grove's mongodriver maps a pk to `_id` ONLY when the pk column is named +// "id" (see mongodriver structToDoc). This pk column is "token_hash", so the +// hash is stored under a "token_hash" field and _id is an auto-generated +// ObjectID. All queries in refresh_replay.go therefore filter by "token_hash", +// NOT "_id". Do not "simplify" those filters to `_id` — that silently breaks +// every revocation/replay lookup on the mongo backend. type revokedRefreshTokenModel struct { grove.BaseModel `grove:"table:authsome_revoked_refresh_tokens"` - TokenHash string `grove:"token_hash,pk" bson:"_id"` + TokenHash string `grove:"token_hash,pk" bson:"token_hash"` FamilyID string `grove:"family_id" bson:"family_id"` RevokedAt time.Time `grove:"revoked_at" bson:"revoked_at"` Reason string `grove:"reason" bson:"reason"` diff --git a/store/mongo/refresh_replay.go b/store/mongo/refresh_replay.go index 47a3373b..6db8beef 100644 --- a/store/mongo/refresh_replay.go +++ b/store/mongo/refresh_replay.go @@ -32,7 +32,7 @@ func (s *Store) IsRefreshTokenRevoked(ctx context.Context, tokenHash string) (bo } var m revokedRefreshTokenModel err := s.mdb.NewFind(&m). - Filter(bson.M{"_id": tokenHash}). + Filter(bson.M{"token_hash": tokenHash}). Scan(ctx) if err != nil { if isNoDocuments(err) { @@ -72,7 +72,7 @@ func (s *Store) GetRevokedRefreshTokenFamily(ctx context.Context, tokenHash stri } var m revokedRefreshTokenModel err := s.mdb.NewFind(&m). - Filter(bson.M{"_id": tokenHash}). + Filter(bson.M{"token_hash": tokenHash}). Scan(ctx) if err != nil { if isNoDocuments(err) { @@ -167,7 +167,7 @@ func (s *Store) MarkRefreshTokenReplayed(ctx context.Context, tokenHash string) return false, nil } res, err := s.mdb.NewUpdate((*revokedRefreshTokenModel)(nil)). - Filter(bson.M{"_id": tokenHash, "reason": bson.M{"$ne": session.RevokeReasonReplayDetected}}). + Filter(bson.M{"token_hash": tokenHash, "reason": bson.M{"$ne": session.RevokeReasonReplayDetected}}). Set("reason", session.RevokeReasonReplayDetected). Exec(ctx) if err != nil { From d4d4d18e23056c9ba6b7c138553ff00e5b210b4d Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Fri, 24 Jul 2026 11:44:24 -0500 Subject: [PATCH 08/10] ci: make the Postgres/Mongo store conformance job blocking 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. --- .github/workflows/ci.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebd444a3..d1fc7d6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,15 +64,13 @@ jobs: # (postgres users.env_id FK) and a hanging mongo migration-lock test that are # tracked separately. # - # This job is ADVISORY (continue-on-error): the *blocking* cross-backend - # coverage is memory + sqlite, which the main `go` job runs on every push - # without Docker. Postgres/Mongo run here for visibility; promote to blocking - # once the pre-existing per-backend failures above are resolved. + # BLOCKING: all four backends (memory + sqlite from the `go` job, and + # postgres + mongo here) must pass the same store contract, so behavioral + # drift between implementations fails CI rather than shipping. integration: name: Store Conformance (pg + mongo) runs-on: ubuntu-latest needs: go - continue-on-error: true steps: - name: Checkout From 675d305e8a32bfade04c6e245b3fc153dcfbbf7f Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Fri, 24 Jul 2026 11:59:55 -0500 Subject: [PATCH 09/10] test(store): seed env for postgres env_id FK + fix passkey noctx lint 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. --- plugins/passkey/discoverable_test.go | 4 +- store/postgres/refresh_replay_test.go | 6 +- store/postgres/store_test.go | 118 ++++++++++++++++---------- 3 files changed, 81 insertions(+), 47 deletions(-) diff --git a/plugins/passkey/discoverable_test.go b/plugins/passkey/discoverable_test.go index 76464d82..e54a4f23 100644 --- a/plugins/passkey/discoverable_test.go +++ b/plugins/passkey/discoverable_test.go @@ -68,14 +68,14 @@ func TestCeremonyCookieRoundTrip(t *testing.T) { setCeremonyCookie(rec, id1, time.Minute, false) // The cookie the server set becomes the request cookie on the follow-up call. - req := httptest.NewRequest(http.MethodPost, "/v1/passkey/login/finish", nil) + req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/v1/passkey/login/finish", nil) for _, c := range rec.Result().Cookies() { req.AddCookie(c) } assert.Equal(t, id1, readCeremonyCookie(req), "the ceremony id must round-trip via cookie") // A request without the cookie yields an empty id (identified flow). - bare := httptest.NewRequest(http.MethodPost, "/v1/passkey/login/finish", nil) + bare := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/v1/passkey/login/finish", nil) assert.Empty(t, readCeremonyCookie(bare)) } diff --git a/store/postgres/refresh_replay_test.go b/store/postgres/refresh_replay_test.go index be5a9168..5f50c7d0 100644 --- a/store/postgres/refresh_replay_test.go +++ b/store/postgres/refresh_replay_test.go @@ -82,6 +82,7 @@ func TestRefreshReplay_RevokeFamilyCascades(t *testing.T) { return &session.Session{ ID: id.NewSessionID(), AppID: a.ID, + EnvID: testEnvID(t, a.ID), UserID: u.ID, FamilyID: family, Token: "tok_" + suffix, @@ -114,15 +115,16 @@ func TestRefreshReplay_RevokeFamilyCascades(t *testing.T) { require.NoError(t, err) // Both family refresh tokens recorded as revoked. + var revoked bool for _, rt := range []string{famSess.RefreshToken, famSess2.RefreshToken} { h := hashForTest(rt) - revoked, err := s.IsRefreshTokenRevoked(ctx, h) + revoked, err = s.IsRefreshTokenRevoked(ctx, h) require.NoError(t, err) assert.True(t, revoked, "token %q should be revoked", rt) } // Other family's refresh token still un-revoked. - revoked, err := s.IsRefreshTokenRevoked(ctx, hashForTest(otherSess.RefreshToken)) + revoked, err = s.IsRefreshTokenRevoked(ctx, hashForTest(otherSess.RefreshToken)) require.NoError(t, err) assert.False(t, revoked) } diff --git a/store/postgres/store_test.go b/store/postgres/store_test.go index cf6373f1..1726b21e 100644 --- a/store/postgres/store_test.go +++ b/store/postgres/store_test.go @@ -4,6 +4,7 @@ package postgres_test import ( "context" + "sync" "testing" "time" @@ -18,6 +19,7 @@ import ( "github.com/xraph/authsome/apikey" "github.com/xraph/authsome/app" "github.com/xraph/authsome/device" + "github.com/xraph/authsome/environment" "github.com/xraph/authsome/id" "github.com/xraph/authsome/notification" "github.com/xraph/authsome/organization" @@ -68,9 +70,17 @@ func setupTestStore(t *testing.T) *pgstore.Store { return s } -// createTestApp is a convenience that creates + returns a persisted App. +// testEnvByApp records the default environment created for each test app so +// env-scoped rows can reference a real environment (postgres enforces the +// users/sessions/... env_id foreign key; memory and sqlite don't). +var testEnvByApp sync.Map // appID string -> id.EnvironmentID + +// createTestApp is a convenience that creates + returns a persisted App along +// with a default environment (postgres requires env_id to reference a real +// environment). Use testEnvID(t, app.ID) to get that environment. func createTestApp(t *testing.T, s *pgstore.Store, slug string) *app.App { t.Helper() + ctx := context.Background() a := &app.App{ ID: id.NewAppID(), Name: "Test App " + slug, @@ -79,16 +89,38 @@ func createTestApp(t *testing.T, s *pgstore.Store, slug string) *app.App { CreatedAt: time.Now(), UpdatedAt: time.Now(), } - require.NoError(t, s.CreateApp(context.Background(), a)) + require.NoError(t, s.CreateApp(ctx, a)) + + env := &environment.Environment{ + ID: id.NewEnvironmentID(), + AppID: a.ID, + Name: "Production", + Slug: "production", + Type: environment.TypeProduction, + IsDefault: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + require.NoError(t, s.CreateEnvironment(ctx, env)) + testEnvByApp.Store(a.ID.String(), env.ID) return a } +// testEnvID returns the default environment id created by createTestApp for the +// given app. +func testEnvID(t *testing.T, appID id.AppID) id.EnvironmentID { + t.Helper() + v, ok := testEnvByApp.Load(appID.String()) + require.True(t, ok, "no test environment for app %s (createTestApp must run first)", appID) + return v.(id.EnvironmentID) +} + // createTestUser creates + returns a persisted User scoped to the given app. func createTestUser(t *testing.T, s *pgstore.Store, appID id.AppID, email string) *user.User { t.Helper() u := &user.User{ - ID: id.NewUserID(), - AppID: appID, + ID: id.NewUserID(), + AppID: appID, EnvID: testEnvID(t, appID), Email: email, FirstName: "Test User", Username: email[:len(email)-len("@test.com")], @@ -212,8 +244,8 @@ func TestUser_CRUD(t *testing.T) { a := createTestApp(t, s, "user-test") u := &user.User{ - ID: id.NewUserID(), - AppID: a.ID, + ID: id.NewUserID(), + AppID: a.ID, EnvID: testEnvID(t, a.ID), Email: "alice@test.com", EmailVerified: true, FirstName: "Alice", @@ -288,8 +320,8 @@ func TestUser_UniqueEmailPerApp(t *testing.T) { // Same email, same app -> should fail dup := &user.User{ - ID: id.NewUserID(), - AppID: a.ID, + ID: id.NewUserID(), + AppID: a.ID, EnvID: testEnvID(t, a.ID), Email: "dup@test.com", CreatedAt: time.Now(), UpdatedAt: time.Now(), @@ -308,8 +340,8 @@ func TestUser_SameEmailDifferentApp(t *testing.T) { // Same email, different app -> should succeed u2 := &user.User{ - ID: id.NewUserID(), - AppID: a2.ID, + ID: id.NewUserID(), + AppID: a2.ID, EnvID: testEnvID(t, a2.ID), Email: "shared@test.com", CreatedAt: time.Now(), UpdatedAt: time.Now(), @@ -377,8 +409,8 @@ func TestSession_CRUD(t *testing.T) { u := createTestUser(t, s, a.ID, "session-user@test.com") sess := &session.Session{ - ID: id.NewSessionID(), - AppID: a.ID, + ID: id.NewSessionID(), + AppID: a.ID, EnvID: testEnvID(t, a.ID), UserID: u.ID, Token: "tok_" + id.NewSessionID().String(), RefreshToken: "rtk_" + id.NewSessionID().String(), @@ -420,8 +452,8 @@ func TestSession_CRUD(t *testing.T) { // List user sessions sess2 := &session.Session{ - ID: id.NewSessionID(), - AppID: a.ID, + ID: id.NewSessionID(), + AppID: a.ID, EnvID: testEnvID(t, a.ID), UserID: u.ID, Token: "tok2_" + id.NewSessionID().String(), RefreshToken: "rtk2_" + id.NewSessionID().String(), @@ -457,8 +489,8 @@ func TestSession_UniqueTokens(t *testing.T) { sharedToken := "unique_token_" + id.NewSessionID().String() s1 := &session.Session{ - ID: id.NewSessionID(), - AppID: a.ID, + ID: id.NewSessionID(), + AppID: a.ID, EnvID: testEnvID(t, a.ID), UserID: u.ID, Token: sharedToken, RefreshToken: "rt1_" + id.NewSessionID().String(), @@ -471,8 +503,8 @@ func TestSession_UniqueTokens(t *testing.T) { // Duplicate token should fail s2 := &session.Session{ - ID: id.NewSessionID(), - AppID: a.ID, + ID: id.NewSessionID(), + AppID: a.ID, EnvID: testEnvID(t, a.ID), UserID: u.ID, Token: sharedToken, RefreshToken: "rt2_" + id.NewSessionID().String(), @@ -502,8 +534,8 @@ func TestVerification_CRUD(t *testing.T) { u := createTestUser(t, s, a.ID, "verify@test.com") v := &account.Verification{ - ID: id.NewVerificationID(), - AppID: a.ID, + ID: id.NewVerificationID(), + AppID: a.ID, EnvID: testEnvID(t, a.ID), UserID: u.ID, Token: "vrf_" + id.NewVerificationID().String(), Type: account.VerificationEmail, @@ -545,8 +577,8 @@ func TestPasswordReset_CRUD(t *testing.T) { u := createTestUser(t, s, a.ID, "pwreset@test.com") pr := &account.PasswordReset{ - ID: id.NewPasswordResetID(), - AppID: a.ID, + ID: id.NewPasswordResetID(), + AppID: a.ID, EnvID: testEnvID(t, a.ID), UserID: u.ID, Token: "pwr_" + id.NewPasswordResetID().String(), ExpiresAt: time.Now().Add(1 * time.Hour), @@ -579,8 +611,8 @@ func TestPasswordReset_UniqueToken(t *testing.T) { sharedToken := "unique_pwr_" + id.NewPasswordResetID().String() pr1 := &account.PasswordReset{ - ID: id.NewPasswordResetID(), - AppID: a.ID, + ID: id.NewPasswordResetID(), + AppID: a.ID, EnvID: testEnvID(t, a.ID), UserID: u.ID, Token: sharedToken, ExpiresAt: time.Now().Add(time.Hour), @@ -589,8 +621,8 @@ func TestPasswordReset_UniqueToken(t *testing.T) { require.NoError(t, s.CreatePasswordReset(ctx, pr1)) pr2 := &account.PasswordReset{ - ID: id.NewPasswordResetID(), - AppID: a.ID, + ID: id.NewPasswordResetID(), + AppID: a.ID, EnvID: testEnvID(t, a.ID), UserID: u.ID, Token: sharedToken, ExpiresAt: time.Now().Add(time.Hour), @@ -611,8 +643,8 @@ func TestOrganization_CRUD(t *testing.T) { u := createTestUser(t, s, a.ID, "org-owner@test.com") org := &organization.Organization{ - ID: id.NewOrgID(), - AppID: a.ID, + ID: id.NewOrgID(), + AppID: a.ID, EnvID: testEnvID(t, a.ID), Name: "Acme Corp", Slug: "acme-corp", Logo: "https://acme.com/logo.png", @@ -647,8 +679,8 @@ func TestOrganization_CRUD(t *testing.T) { // List by app org2 := &organization.Organization{ - ID: id.NewOrgID(), - AppID: a.ID, + ID: id.NewOrgID(), + AppID: a.ID, EnvID: testEnvID(t, a.ID), Name: "Beta Inc", Slug: "beta-inc", CreatedBy: u.ID, @@ -674,13 +706,13 @@ func TestOrganization_UniqueSlugPerApp(t *testing.T) { u := createTestUser(t, s, a.ID, "slug-owner@test.com") org1 := &organization.Organization{ - ID: id.NewOrgID(), AppID: a.ID, Name: "Org One", Slug: "same-slug", + ID: id.NewOrgID(), AppID: a.ID, EnvID: testEnvID(t, a.ID), Name: "Org One", Slug: "same-slug", CreatedBy: u.ID, CreatedAt: time.Now(), UpdatedAt: time.Now(), } require.NoError(t, s.CreateOrganization(ctx, org1)) org2 := &organization.Organization{ - ID: id.NewOrgID(), AppID: a.ID, Name: "Org Two", Slug: "same-slug", + ID: id.NewOrgID(), AppID: a.ID, EnvID: testEnvID(t, a.ID), Name: "Org Two", Slug: "same-slug", CreatedBy: u.ID, CreatedAt: time.Now(), UpdatedAt: time.Now(), } err := s.CreateOrganization(ctx, org2) @@ -700,11 +732,11 @@ func TestOrganization_ListUserOrganizations(t *testing.T) { u := createTestUser(t, s, a.ID, "multi-org@test.com") org1 := &organization.Organization{ - ID: id.NewOrgID(), AppID: a.ID, Name: "Org A", Slug: "org-a", + ID: id.NewOrgID(), AppID: a.ID, EnvID: testEnvID(t, a.ID), Name: "Org A", Slug: "org-a", CreatedBy: u.ID, CreatedAt: time.Now(), UpdatedAt: time.Now(), } org2 := &organization.Organization{ - ID: id.NewOrgID(), AppID: a.ID, Name: "Org B", Slug: "org-b", + ID: id.NewOrgID(), AppID: a.ID, EnvID: testEnvID(t, a.ID), Name: "Org B", Slug: "org-b", CreatedBy: u.ID, CreatedAt: time.Now(), UpdatedAt: time.Now(), } require.NoError(t, s.CreateOrganization(ctx, org1)) @@ -738,7 +770,7 @@ func TestMember_CRUD(t *testing.T) { u2 := createTestUser(t, s, a.ID, "member2@test.com") org := &organization.Organization{ - ID: id.NewOrgID(), AppID: a.ID, Name: "Members Org", Slug: "members-org", + ID: id.NewOrgID(), AppID: a.ID, EnvID: testEnvID(t, a.ID), Name: "Members Org", Slug: "members-org", CreatedBy: u.ID, CreatedAt: time.Now(), UpdatedAt: time.Now(), } require.NoError(t, s.CreateOrganization(ctx, org)) @@ -786,7 +818,7 @@ func TestMember_UniqueUserOrgConstraint(t *testing.T) { u := createTestUser(t, s, a.ID, "mem-uniq@test.com") org := &organization.Organization{ - ID: id.NewOrgID(), AppID: a.ID, Name: "Uniq Org", Slug: "uniq-org", + ID: id.NewOrgID(), AppID: a.ID, EnvID: testEnvID(t, a.ID), Name: "Uniq Org", Slug: "uniq-org", CreatedBy: u.ID, CreatedAt: time.Now(), UpdatedAt: time.Now(), } require.NoError(t, s.CreateOrganization(ctx, org)) @@ -823,7 +855,7 @@ func TestInvitation_CRUD(t *testing.T) { u := createTestUser(t, s, a.ID, "inviter@test.com") org := &organization.Organization{ - ID: id.NewOrgID(), AppID: a.ID, Name: "Inv Org", Slug: "inv-org", + ID: id.NewOrgID(), AppID: a.ID, EnvID: testEnvID(t, a.ID), Name: "Inv Org", Slug: "inv-org", CreatedBy: u.ID, CreatedAt: time.Now(), UpdatedAt: time.Now(), } require.NoError(t, s.CreateOrganization(ctx, org)) @@ -863,7 +895,7 @@ func TestTeam_CRUD(t *testing.T) { u := createTestUser(t, s, a.ID, "team-owner@test.com") org := &organization.Organization{ - ID: id.NewOrgID(), AppID: a.ID, Name: "Team Org", Slug: "team-org", + ID: id.NewOrgID(), AppID: a.ID, EnvID: testEnvID(t, a.ID), Name: "Team Org", Slug: "team-org", CreatedBy: u.ID, CreatedAt: time.Now(), UpdatedAt: time.Now(), } require.NoError(t, s.CreateOrganization(ctx, org)) @@ -898,7 +930,7 @@ func TestDevice_CRUD(t *testing.T) { u := createTestUser(t, s, a.ID, "device-user@test.com") dev := &device.Device{ - ID: id.NewDeviceID(), UserID: u.ID, AppID: a.ID, + ID: id.NewDeviceID(), UserID: u.ID, AppID: a.ID, EnvID: testEnvID(t, a.ID), Name: "MacBook Pro", Type: "desktop", Browser: "Chrome", OS: "macOS", IPAddress: "10.0.0.1", Fingerprint: "fp_abc123", Trusted: true, LastSeenAt: time.Now(), CreatedAt: time.Now(), UpdatedAt: time.Now(), @@ -930,7 +962,7 @@ func TestWebhook_CRUD(t *testing.T) { a := createTestApp(t, s, "webhook-test") wh := &webhook.Webhook{ - ID: id.NewWebhookID(), AppID: a.ID, + ID: id.NewWebhookID(), AppID: a.ID, EnvID: testEnvID(t, a.ID), URL: "https://example.com/webhook", Events: []string{"user.created"}, Secret: "wh_secret_123", Active: true, CreatedAt: time.Now(), UpdatedAt: time.Now(), @@ -958,7 +990,7 @@ func TestNotification_CRUD(t *testing.T) { u := createTestUser(t, s, a.ID, "notif-user@test.com") n := ¬ification.Notification{ - ID: id.NewNotificationID(), AppID: a.ID, UserID: u.ID, + ID: id.NewNotificationID(), AppID: a.ID, EnvID: testEnvID(t, a.ID), UserID: u.ID, Type: "welcome", Channel: notification.ChannelEmail, Subject: "Welcome!", Body: "Welcome to AuthSome", CreatedAt: time.Now(), @@ -991,7 +1023,7 @@ func TestAPIKey_CRUD(t *testing.T) { expires := now.Add(24 * time.Hour) key := &apikey.APIKey{ - ID: id.NewAPIKeyID(), AppID: a.ID, UserID: u.ID, + ID: id.NewAPIKeyID(), AppID: a.ID, EnvID: testEnvID(t, a.ID), UserID: u.ID, Name: "Test Key", KeyHash: "sha256_testhash_abc123", KeyPrefix: "ak_test1", Scopes: []string{"read", "write"}, ExpiresAt: &expires, CreatedAt: now, UpdatedAt: now, @@ -1040,7 +1072,7 @@ func TestOrganization_DeleteCascadesMembers(t *testing.T) { u := createTestUser(t, s, a.ID, "cascade@test.com") org := &organization.Organization{ - ID: id.NewOrgID(), AppID: a.ID, Name: "Cascade Org", Slug: "cascade-org", + ID: id.NewOrgID(), AppID: a.ID, EnvID: testEnvID(t, a.ID), Name: "Cascade Org", Slug: "cascade-org", CreatedBy: u.ID, CreatedAt: time.Now(), UpdatedAt: time.Now(), } require.NoError(t, s.CreateOrganization(ctx, org)) From 4cb1e35f288025dbd788f23d097701180314a8b5 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Fri, 24 Jul 2026 12:00:17 -0500 Subject: [PATCH 10/10] ci: update conformance-scoping comment now that postgres env_id is fixed --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1fc7d6f..d8bd4486 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,9 +60,9 @@ jobs: # Runs the cross-backend store conformance suite against real Postgres # (testcontainers) and MongoDB (single-node replica set, so app/org cascade # transactions work). Scoped to `-run TestConformance` so it does NOT run the - # older per-backend store_test.go suites, which have pre-existing failures - # (postgres users.env_id FK) and a hanging mongo migration-lock test that are - # tracked separately. + # older per-backend store_test.go suites — those now pass on postgres (env_id + # is seeded), but the mongo suite still has a hanging migration-lock test + # (TestMigrate_DoesNotBreakFreshLock) tracked separately. # # BLOCKING: all four backends (memory + sqlite from the `go` job, and # postgres + mongo here) must pass the same store contract, so behavioral