@happyvertical/smrt-users 0.53.5 → 0.54.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/AGENTS.md +16 -0
  2. package/README.md +129 -3
  3. package/dist/chunks/{TerminalAuthService-BdB_0vDA.js → TerminalAuthService-Bc67JXJ5.js} +969 -155
  4. package/dist/chunks/TerminalAuthService-Bc67JXJ5.js.map +1 -0
  5. package/dist/collections/PasswordCredentialCollection.d.ts +53 -0
  6. package/dist/collections/PasswordCredentialCollection.d.ts.map +1 -0
  7. package/dist/collections/SessionCollection.d.ts +7 -2
  8. package/dist/collections/SessionCollection.d.ts.map +1 -1
  9. package/dist/collections/index.d.ts +1 -0
  10. package/dist/collections/index.d.ts.map +1 -1
  11. package/dist/index.d.ts +3 -3
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +44 -4
  14. package/dist/index.js.map +1 -1
  15. package/dist/manifest.json +424 -2
  16. package/dist/models/PasswordCredential.d.ts +36 -0
  17. package/dist/models/PasswordCredential.d.ts.map +1 -0
  18. package/dist/models/index.d.ts +1 -0
  19. package/dist/models/index.d.ts.map +1 -1
  20. package/dist/services/DeviceCredentialService.d.ts.map +1 -1
  21. package/dist/services/PasswordCredentialService.d.ts +302 -0
  22. package/dist/services/PasswordCredentialService.d.ts.map +1 -0
  23. package/dist/services/SessionService.d.ts +3 -1
  24. package/dist/services/SessionService.d.ts.map +1 -1
  25. package/dist/services/credential-hash.d.ts +76 -0
  26. package/dist/services/credential-hash.d.ts.map +1 -0
  27. package/dist/services/index.d.ts +1 -0
  28. package/dist/services/index.d.ts.map +1 -1
  29. package/dist/smrt-knowledge.json +208 -8
  30. package/dist/sveltekit/index.d.ts +1 -0
  31. package/dist/sveltekit/index.d.ts.map +1 -1
  32. package/dist/sveltekit/password-handlers.d.ts +91 -0
  33. package/dist/sveltekit/password-handlers.d.ts.map +1 -0
  34. package/dist/sveltekit.js +237 -68
  35. package/dist/sveltekit.js.map +1 -1
  36. package/package.json +12 -12
  37. package/dist/chunks/TerminalAuthService-BdB_0vDA.js.map +0 -1
package/AGENTS.md CHANGED
@@ -14,6 +14,7 @@ are not prerequisites for unrelated user-package work.
14
14
  | `src/services/MobileAuthService.ts` | mobile handshake, bearer sessions, bootstrap extension boundary | [agents/mobile-auth.md](agents/mobile-auth.md) |
15
15
  | `src/services/TerminalAuthService.ts`, `src/services/LoginAttemptLimiter.ts`, `src/collections/LoginAttemptCollection.ts` | terminal approval concurrency, shared login budget and lockout (#3273), retry-safe database fixtures | [agents/terminal-auth-testing.md](agents/terminal-auth-testing.md) |
16
16
  | `src/services/DeviceCredentialService.ts` | per-person PIN layered on a device session (#3276), verifier seam for other credential kinds | this file, "Security boundaries" |
17
+ | `src/services/PasswordCredentialService.ts`, `src/sveltekit/password-handlers.ts` | email + password sign-in and password lifecycle (#3274); shared scrypt in `credential-hash.ts` | this file, "Security boundaries"; README "Password sign-in" |
17
18
  | `src/retention.ts` | expired session/token/CLI-auth reaping and retention sweep wiring | [agents/retention.md](agents/retention.md) |
18
19
 
19
20
  ## Models and authority
@@ -90,6 +91,21 @@ are not prerequisites for unrelated user-package work.
90
91
  sign-in snapshot; `singleOccupant` revokes other children only AFTER a
91
92
  successful mint. A `mustReset` sign-in mints with an empty ceiling and
92
93
  `setPin` ends that session; do not grant it authority another way.
94
+ - `UsersPasswordCredential` is one row per person, sensitive, with no
95
+ generated surface; `PasswordCredentialService` and its handlers are its only
96
+ read/write paths. Sign-in normalizes the email like `emailKey`, reserves
97
+ before work, always runs one scrypt verify (dummy hash when absent), never
98
+ creates a user, and maps every non-rate-limit refusal to one
99
+ `PasswordCredentialError`. It re-checks the credential after the mint (a
100
+ rehash that keeps row and version is confirmed by re-verifying); the
101
+ parameter-upgrade write is a guarded UPDATE that must never replace a
102
+ rotated password, and verification against a weaker stored hash is padded
103
+ to the policy's scrypt cost. Self-service writes are guarded on the proven
104
+ credential (first set insert-only); session sweeps run before and after
105
+ each write. Self-service needs the current password except for a first
106
+ set; `users.password.manage` follows the PIN's single-tenant target
107
+ rule and never resets the actor's own. Layered sessions never manage
108
+ passwords; a `mustChange` sign-in mints an empty ceiling.
93
109
  - A layered session whose tenant differs from its parent's never loads (null
94
110
  is allowed: narrower). Limiter rows carry `retain_until`; retention must
95
111
  not fall back to a process-wide horizon.
package/README.md CHANGED
@@ -602,8 +602,8 @@ the current tenant in application code.
602
602
 
603
603
  ### Login rate limiting and lockout
604
604
 
605
- Every credential-based sign-in (terminal approval, device PIN, and any
606
- password or passkey flow an app adds) draws from one shared budget,
605
+ Every credential-based sign-in (terminal approval, device PIN, password
606
+ sign-in, and any passkey flow an app adds) draws from one shared budget,
607
607
  `LoginAttemptLimiter` (#3273). The budget lives in `users_login_attempts`, so it
608
608
  holds across every replica on Postgres; keys are hashed, so the table never
609
609
  becomes an index of emails or IPs.
@@ -775,6 +775,127 @@ Other credential kinds (a fob or badge reader, say) implement
775
775
  `DeviceCredentialVerifier` and go through `service.signIn(verifier, input)`;
776
776
  `PinVerifier` is the reference implementation.
777
777
 
778
+ ### Password sign-in
779
+
780
+ `PasswordCredentialService` (#3274) gives an app without an identity provider
781
+ email + password sign-in for **existing, active** users. It never creates a
782
+ user. The password lives in its own object, `UsersPasswordCredential`
783
+ (`users_password_credentials`, one row per person), not on `User`: it is
784
+ `sensitive`, has no generated REST/MCP/CLI surface, never enters the change
785
+ feed, and `toPublicJSON()` omits the hash. The service and the handlers below
786
+ are its only read and write paths.
787
+
788
+ **Hashing.** scrypt from `node:crypto` (no native dependency) with a random
789
+ per-hash salt; the parameters are stored in each hash
790
+ (`scrypt$N$r$p$salt$hash`) and compared with `timingSafeEqual`. Raising
791
+ `password.scrypt` never breaks existing passwords: a weaker hash still
792
+ verifies and is rehashed under the current parameters on the next successful
793
+ sign-in, by a guarded update that never overwrites a password changed in the
794
+ meantime. A sign-in for an unknown email, or a user with no password, runs
795
+ the same scrypt verification against a dummy hash, and a check against a hash
796
+ still carrying weaker parameters is padded with extra scrypt work up to the
797
+ current policy's cost, so timing does not reveal whether the account or
798
+ credential exists. Only ever raise the parameters: hashes stronger than the
799
+ policy are not rehashed downward and would verify slower than the dummy.
800
+ Passwords are NFKC-normalized.
801
+
802
+ **Sign-in.** The submitted email is normalized exactly as `User.emailKey` is
803
+ and matched against that column (legacy users need `backfillUserEmailKeys()`
804
+ first), and the attempt is reserved through the shared `LoginAttemptLimiter` *before*
805
+ any credential work: per account (the email key, whether or not it exists) and
806
+ per client address. A request missing its email or password is reserved and
807
+ failed like a wrong password. The person must be `ACTIVE` and, when a tenant is given,
808
+ hold an `ACTIVE` membership in it. A successful sign-in mints a first-class
809
+ session with `authMethod: 'password'` through `SessionService.createSession`,
810
+ re-checks that the verified password is still current (so a concurrent change
811
+ or reset cannot leave a session behind), and revokes the browser's previous
812
+ cookie session. Every refusal — unknown email, no password, wrong password,
813
+ inactive user, no membership, malformed or over-long input — is the same
814
+ `PasswordCredentialError` / 401; a rate limit is `LoginRateLimitError` / 429
815
+ with `Retry-After`, and it is reached the same way for an unknown email as for
816
+ a real one. Audit rows carry hashed keys and the tenant only: never the email,
817
+ the password, the hash, or the user id.
818
+
819
+ **Policy** (`password` option): `minLength` (default 10), `maxLength`
820
+ (default 256, hard ceiling 1024 — longer input is refused before hashing,
821
+ at sign-in too), a small built-in `DEFAULT_PASSWORD_DENYLIST` extended by
822
+ `denylist`, a refusal of the person's own email, and a `reject(password,
823
+ { userId, email })` hook for anything else (a breached-password check, shop
824
+ words). There are no composition rules. Optional `pepper`. Violations are
825
+ `PasswordPolicyError` (400) with a message fit to display.
826
+
827
+ **Lifecycle and authority.**
828
+
829
+ | Call | Who | Sessions ended |
830
+ |---|---|---|
831
+ | `setPassword` | the person, for their **first** password; or an admin with `users.password.manage` | none; all of the person's if an admin replaced an existing one |
832
+ | `changePassword` | the person, proving the current password (from the sign-in budget) | every other session of theirs |
833
+ | `resetPassword` | an admin with `users.password.manage`, never for themself; `mustChange` defaults to true | every session of the person |
834
+ | `clearPassword` | an admin, or the person proving the current password | the person's password sessions |
835
+
836
+ Self-service writes are guarded on the exact credential the person proved
837
+ (or, for a first password, insert-only), so a reset that lands while a change
838
+ or clear is in flight is never overwritten with a password chosen under the
839
+ old one; the slower request fails with `PasswordCredentialError`. Every write
840
+ that ends sessions ends them both before and after the credential write, so
841
+ a failure between the two leaves the person signed out rather than signed in
842
+ under a password that is gone.
843
+
844
+ A reset with `mustChange` makes the next sign-in a restricted session: it
845
+ resolves to no permissions (an empty permission ceiling) and can only call
846
+ `changePassword`, which ends it; the person then signs in with the new
847
+ password. A device PIN (layered) session never manages passwords. Like the
848
+ PIN, the password is one per person across tenants, so `users.password.manage`
849
+ reaches only people whose every active membership is in the administrator's
850
+ session tenant. It is impersonation-equivalent — grant it like an owner-level
851
+ permission. It is registered in the runtime catalog
852
+ (`ensurePasswordPermissionsRegistered()`, called by the service), so
853
+ `syncPermissionCatalog()` creates it and the default role matrix
854
+ (`seedRolePermissions()`) grants it to `owner` and `admin` (`*`), not to
855
+ `member` or `viewer`.
856
+
857
+ #### Consumer wiring (SvelteKit)
858
+
859
+ ```ts
860
+ // src/lib/server/password-auth.ts
861
+ import { createPasswordCredentialHandlers } from '@happyvertical/smrt-users/sveltekit';
862
+
863
+ export const passwordAuth = createPasswordCredentialHandlers({
864
+ db,
865
+ tenantId: () => SHOP_TENANT_ID, // value or (event) => …; omit for no tenant context
866
+ cookieName: 'sid', // the same cookie createSessionHandler reads
867
+ sessionTtl: 12 * 60 * 60, // default 7 days
868
+ loginLimiter, // optional: share one limiter with PIN/terminal
869
+ password: { minLength: 12, denylist: ['teamworks fabrication'] },
870
+ });
871
+ // POST /auth/password → passwordAuth.signIn ({ email, password }, JSON or form)
872
+ // PUT /account/password → passwordAuth.changePassword ({ currentPassword, newPassword })
873
+ // POST /account/password → passwordAuth.setPassword ({ password } or admin { userId, password })
874
+ // DELETE /account/password → passwordAuth.clearPassword ({ currentPassword } or admin { userId })
875
+ // POST /admin/users/password → passwordAuth.resetPassword ({ userId, password, mustChange? })
876
+ // sign out → destroySessionCookie(event, { db })
877
+ ```
878
+
879
+ What the app must do:
880
+
881
+ - Mount `createSessionHandler` with the same `cookieName`; the management
882
+ handlers act as the session it puts in `event.locals` and refuse (401) when
883
+ `locals.authMethod` is absent.
884
+ - Put the sign-in route on the signed-out allowlist; leave CSRF protection on
885
+ (SvelteKit's origin check covers form posts, JSON needs CORS preflight).
886
+ - On `mustChange: true` (or a 200 from `changePassword` with
887
+ `signedOut: true`), send the person to the change-password page or back to
888
+ sign-in.
889
+ - Run `syncPermissionCatalog()` (or `seedRolePermissions()`) after the
890
+ service is first constructed, and grant `users.password.manage` to any
891
+ other role that administers passwords.
892
+ - Run `smrt db:migrate` once for the new `users_password_credentials` table.
893
+ - For a form action instead of an endpoint, call
894
+ `(await passwordAuth.service()).signIn({ … })` and set the cookie with the
895
+ returned `sessionId`; keep the session id out of page data.
896
+
897
+ Passkeys/WebAuthn are a separate credential (#3275).
898
+
778
899
  ### Request-scoped database access
779
900
 
780
901
  Generated SvelteKit helpers and custom server code can read the current
@@ -1011,6 +1132,7 @@ TenantService supports three modes: `flexible` (no auto-create), `personal` (aut
1011
1132
  | `Session` | Server-side session. Secure UUID. TTL in seconds. `authMethod` records the channel; `parentSessionId` makes it a layered session valid only while its parent is. Reserved `data` keys (`SESSION_DATA_KEYS`) carry a permission ceiling, sliding idle timeout, and absolute expiry. |
1012
1133
  | `UsersLoginAttempt`, `UsersLoginAuditEvent` | Hashed-key login budget rows and durable sign-in audit events (#3273). Closed generated surface. |
1013
1134
  | `UsersPinCredential` | Per-person scrypt PIN hash for enrolled-device sign-in (#3276). Closed generated surface. |
1135
+ | `UsersPasswordCredential` | Per-person scrypt password hash for email + password sign-in (#3274). Sensitive; closed generated surface. |
1014
1136
  | `Group` | Team within a tenant. Gains permissions via GroupRole. |
1015
1137
  | `Membership` | User + Tenant + Role junction. UNIQUE(userId, tenantId). |
1016
1138
  | `MembershipOverride` | Per-user permission grant/deny on a membership. |
@@ -1028,7 +1150,7 @@ TenantService supports three modes: `flexible` (no auto-create), `personal` (aut
1028
1150
  | `MembershipOverrideCollection`, `TenantPermissionOverrideCollection` | Override management at membership and tenant levels |
1029
1151
  | `GroupCollection`, `GroupMemberCollection`, `GroupRoleCollection`, `RolePermissionCollection` | Group and role-permission junction management |
1030
1152
  | `AccessRequestCollection` | AccessRequest queries: `findByEmail()`, `findOpenByEmail()`, `findByStatus()`, `findOpen()` |
1031
- | `UsersLoginAttemptCollection`, `UsersLoginAuditEventCollection`, `UsersPinCredentialCollection` | Atomic limiter primitives, audit storage, and PIN credential rows behind the services below |
1153
+ | `UsersLoginAttemptCollection`, `UsersLoginAuditEventCollection`, `UsersPinCredentialCollection`, `UsersPasswordCredentialCollection` | Atomic limiter primitives, audit storage, and PIN and password credential rows behind the services below |
1032
1154
 
1033
1155
  ### Services
1034
1156
 
@@ -1042,6 +1164,7 @@ TenantService supports three modes: `flexible` (no auto-create), `personal` (aut
1042
1164
  | `SessionService` | High-level session management. `createSession()`, `loadSessionContext()`, `destroySession()`; tenant contexts include direct or inherited membership provenance, `authMethod`, and `parent` for layered sessions. |
1043
1165
  | `LoginAttemptLimiter` | Shared sign-in budget with exponential lockout and audit (#3273): `reserve()` → lease `.fail()`/`.succeed()`/`.release()`. `LoginRateLimitError`, `InvalidCredentialsError`, `LoginAuditSink`, `DurableLoginAuditSink`. |
1044
1166
  | `DeviceCredentialService` | Per-person sign-in layered on an enrolled device session (#3276): `signInWithPin()`, `signIn(verifier, input)`, `signOut()`, `setPin()`, `resetPin()`, `clearPin()`. Person authority with optional `deviceCeiling`, `singleOccupant` hand-over, `personIdleSeconds` / `personMaxSeconds`. `PinVerifier`, `DeviceCredentialVerifier`. |
1167
+ | `PasswordCredentialService` | Email + password sign-in for existing active users (#3274): `signIn()`, `setPassword()`, `changePassword()`, `resetPassword()`, `clearPassword()`, `hasPassword()`, `validatePassword()`. `PasswordCredentialError`, `PasswordCredentialForbiddenError`, `PasswordPolicyError`. |
1045
1168
  | `OidcLoginService` | Generic OIDC authorization-code login with PKCE for Kanidm, Dex, and other standards-compliant providers. |
1046
1169
  | `backfillLegacyUserProfiles` | Transactionally create and link canonical global Person Profiles for legacy Users; never creates OIDC identities or infers ownership. |
1047
1170
  | `backfillUserEmailKeys` | Idempotently populate durable normalized-email keys after migrating legacy Users; fails closed on duplicates. |
@@ -1067,6 +1190,7 @@ TenantService supports three modes: `flexible` (no auto-create), `personal` (aut
1067
1190
  | `createOidcLoginHandler`, `createOidcCallbackHandler` | Ready-to-use SvelteKit route handlers for OIDC login and callback |
1068
1191
  | `createMobileAuthHandlers` | Mountable `/api/mobile` PKCE, bearer session, bootstrap, logout, and route-guard handlers |
1069
1192
  | `createDeviceCredentialHandlers` | Mountable PIN sign-in, sign-out, and PIN management handlers for enrolled devices |
1193
+ | `createPasswordCredentialHandlers` | Mountable password sign-in (sets the session cookie) and password set/change/reset/clear handlers |
1070
1194
  | `resolveMobileUploadDedupKey` | Resolves `clientCaptureId` with `Idempotency-Key` fallback for app-owned multipart routes |
1071
1195
  | `SessionLocals` | Type for `event.locals` (extend in `app.d.ts`) |
1072
1196
 
@@ -1089,6 +1213,8 @@ for authentication, deduplication, and status semantics.
1089
1213
  | `DEFAULT_SESSION_TTL`, `MAX_TENANT_HIERARCHY_DEPTH` | 604800 (7 days in seconds), 10 |
1090
1214
  | `DEFAULT_LOGIN_MAX_ATTEMPTS`, `DEFAULT_LOGIN_ATTEMPT_WINDOW_SECONDS`, `DEFAULT_LOGIN_LOCKOUT_*` | 5 attempts per 300 s window; lockout 60 s × 2ⁿ, capped at 3600 s |
1091
1215
  | `DEFAULT_PIN_MANAGE_PERMISSION`, `PIN_LOGIN_KIND` | `users.pin.manage`, `pin` |
1216
+ | `DEFAULT_PASSWORD_MANAGE_PERMISSION`, `PASSWORD_LOGIN_KIND` | `users.password.manage`, `password` |
1217
+ | `DEFAULT_PASSWORD_MIN_LENGTH`, `DEFAULT_PASSWORD_MAX_LENGTH`, `PASSWORD_MAX_LENGTH_CEILING`, `DEFAULT_PASSWORD_DENYLIST` | 10, 256, 1024, built-in common-password list |
1092
1218
  | `TenantHierarchyError` | Thrown on a missing parent, a cycle, or exceeding the hierarchy depth limit (`code`) |
1093
1219
  | `TenantHierarchyMaterializationError` | Thrown by `materializeTenantHierarchy` when any tenant's parent chain is broken; lists `problems` |
1094
1220