@happyvertical/smrt-users 0.54.0 → 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.
- package/AGENTS.md +16 -0
- package/README.md +129 -3
- package/dist/chunks/{TerminalAuthService-BfxG493c.js → TerminalAuthService-Bc67JXJ5.js} +969 -155
- package/dist/chunks/TerminalAuthService-Bc67JXJ5.js.map +1 -0
- package/dist/collections/PasswordCredentialCollection.d.ts +53 -0
- package/dist/collections/PasswordCredentialCollection.d.ts.map +1 -0
- package/dist/collections/SessionCollection.d.ts +7 -2
- package/dist/collections/SessionCollection.d.ts.map +1 -1
- package/dist/collections/index.d.ts +1 -0
- package/dist/collections/index.d.ts.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +44 -4
- package/dist/index.js.map +1 -1
- package/dist/manifest.json +424 -2
- package/dist/models/PasswordCredential.d.ts +36 -0
- package/dist/models/PasswordCredential.d.ts.map +1 -0
- package/dist/models/index.d.ts +1 -0
- package/dist/models/index.d.ts.map +1 -1
- package/dist/services/DeviceCredentialService.d.ts.map +1 -1
- package/dist/services/PasswordCredentialService.d.ts +302 -0
- package/dist/services/PasswordCredentialService.d.ts.map +1 -0
- package/dist/services/SessionService.d.ts +3 -1
- package/dist/services/SessionService.d.ts.map +1 -1
- package/dist/services/credential-hash.d.ts +76 -0
- package/dist/services/credential-hash.d.ts.map +1 -0
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.d.ts.map +1 -1
- package/dist/smrt-knowledge.json +208 -8
- package/dist/sveltekit/index.d.ts +1 -0
- package/dist/sveltekit/index.d.ts.map +1 -1
- package/dist/sveltekit/password-handlers.d.ts +91 -0
- package/dist/sveltekit/password-handlers.d.ts.map +1 -0
- package/dist/sveltekit.js +237 -68
- package/dist/sveltekit.js.map +1 -1
- package/package.json +12 -12
- package/dist/chunks/TerminalAuthService-BfxG493c.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,
|
|
606
|
-
|
|
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
|
|