@happyvertical/smrt-users 0.54.0 → 0.55.2

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 (38) hide show
  1. package/AGENTS.md +16 -0
  2. package/README.md +167 -3
  3. package/agents/oidc-provisioning.md +6 -0
  4. package/dist/chunks/{TerminalAuthService-BfxG493c.js → TerminalAuthService-DdV-Rm4h.js} +969 -155
  5. package/dist/chunks/TerminalAuthService-DdV-Rm4h.js.map +1 -0
  6. package/dist/collections/PasswordCredentialCollection.d.ts +53 -0
  7. package/dist/collections/PasswordCredentialCollection.d.ts.map +1 -0
  8. package/dist/collections/SessionCollection.d.ts +7 -2
  9. package/dist/collections/SessionCollection.d.ts.map +1 -1
  10. package/dist/collections/index.d.ts +1 -0
  11. package/dist/collections/index.d.ts.map +1 -1
  12. package/dist/index.d.ts +3 -3
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +44 -4
  15. package/dist/index.js.map +1 -1
  16. package/dist/manifest.json +462 -7
  17. package/dist/models/PasswordCredential.d.ts +36 -0
  18. package/dist/models/PasswordCredential.d.ts.map +1 -0
  19. package/dist/models/index.d.ts +1 -0
  20. package/dist/models/index.d.ts.map +1 -1
  21. package/dist/services/DeviceCredentialService.d.ts.map +1 -1
  22. package/dist/services/PasswordCredentialService.d.ts +302 -0
  23. package/dist/services/PasswordCredentialService.d.ts.map +1 -0
  24. package/dist/services/SessionService.d.ts +3 -1
  25. package/dist/services/SessionService.d.ts.map +1 -1
  26. package/dist/services/credential-hash.d.ts +76 -0
  27. package/dist/services/credential-hash.d.ts.map +1 -0
  28. package/dist/services/index.d.ts +1 -0
  29. package/dist/services/index.d.ts.map +1 -1
  30. package/dist/smrt-knowledge.json +215 -10
  31. package/dist/sveltekit/index.d.ts +46 -0
  32. package/dist/sveltekit/index.d.ts.map +1 -1
  33. package/dist/sveltekit/password-handlers.d.ts +91 -0
  34. package/dist/sveltekit/password-handlers.d.ts.map +1 -0
  35. package/dist/sveltekit.js +250 -68
  36. package/dist/sveltekit.js.map +1 -1
  37. package/package.json +12 -12
  38. 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
@@ -378,6 +378,43 @@ export const GET = createOidcCallbackHandler({
378
378
  });
379
379
  ```
380
380
 
381
+ #### Register the client: redirect URL and landing URL
382
+
383
+ Register two URLs with the IdP, not one. `getOidcClientRegistration()` derives
384
+ both from the same `callbackPath`/`loginPath` options the route handlers use
385
+ (defaults `/auth/<provider>/callback` and `/auth/<provider>/login`):
386
+
387
+ ```typescript
388
+ import { getOidcClientRegistration } from '@happyvertical/smrt-users/sveltekit';
389
+
390
+ const { redirectUri, landingUrl } = getOidcClientRegistration(
391
+ 'https://app.example.com',
392
+ { provider: 'kanidm' },
393
+ );
394
+ // redirectUri: https://app.example.com/auth/kanidm/callback
395
+ // landingUrl: https://app.example.com/auth/kanidm/login
396
+ ```
397
+
398
+ For Kanidm:
399
+
400
+ ```sh
401
+ kanidm system oauth2 add-redirect-url <client> https://app.example.com/auth/kanidm/callback
402
+ kanidm system oauth2 set-landing-url <client> https://app.example.com/auth/kanidm/login
403
+ ```
404
+
405
+ The landing URL must be the login **start** route, not the app's home page.
406
+ Kanidm (verified through 1.11) holds the app's pending authorization in a
407
+ short-lived cookie. It discards that cookie whenever the user passes through its
408
+ own `/ui/login` page, for example by pressing "Return to Login" after a
409
+ mistyped password, and also after 15 minutes. After signing in, the user then
410
+ lands in Kanidm's apps panel instead of returning to the app. The app never
411
+ sees this happen, so the app tile in that panel, which links to the landing URL,
412
+ is the only way back. When the tile points at the login start route, one click
413
+ signs the user in, because their Kanidm session already exists. When it points at
414
+ the home page, the user arrives signed out and has to start sign-in again. Other
415
+ IdPs that offer an "initiate login URI" or app-launcher URL should use the same
416
+ `landingUrl`.
417
+
381
418
  The callback verifies `state`, PKCE, issuer, audience, nonce, and the provider
382
419
  JWKS-signed ID token, falling back to the OIDC UserInfo endpoint when the ID
383
420
  token omits required profile claims like `email`. Temporary transaction cookies
@@ -602,8 +639,8 @@ the current tenant in application code.
602
639
 
603
640
  ### Login rate limiting and lockout
604
641
 
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,
642
+ Every credential-based sign-in (terminal approval, device PIN, password
643
+ sign-in, and any passkey flow an app adds) draws from one shared budget,
607
644
  `LoginAttemptLimiter` (#3273). The budget lives in `users_login_attempts`, so it
608
645
  holds across every replica on Postgres; keys are hashed, so the table never
609
646
  becomes an index of emails or IPs.
@@ -775,6 +812,127 @@ Other credential kinds (a fob or badge reader, say) implement
775
812
  `DeviceCredentialVerifier` and go through `service.signIn(verifier, input)`;
776
813
  `PinVerifier` is the reference implementation.
777
814
 
815
+ ### Password sign-in
816
+
817
+ `PasswordCredentialService` (#3274) gives an app without an identity provider
818
+ email + password sign-in for **existing, active** users. It never creates a
819
+ user. The password lives in its own object, `UsersPasswordCredential`
820
+ (`users_password_credentials`, one row per person), not on `User`: it is
821
+ `sensitive`, has no generated REST/MCP/CLI surface, never enters the change
822
+ feed, and `toPublicJSON()` omits the hash. The service and the handlers below
823
+ are its only read and write paths.
824
+
825
+ **Hashing.** scrypt from `node:crypto` (no native dependency) with a random
826
+ per-hash salt; the parameters are stored in each hash
827
+ (`scrypt$N$r$p$salt$hash`) and compared with `timingSafeEqual`. Raising
828
+ `password.scrypt` never breaks existing passwords: a weaker hash still
829
+ verifies and is rehashed under the current parameters on the next successful
830
+ sign-in, by a guarded update that never overwrites a password changed in the
831
+ meantime. A sign-in for an unknown email, or a user with no password, runs
832
+ the same scrypt verification against a dummy hash, and a check against a hash
833
+ still carrying weaker parameters is padded with extra scrypt work up to the
834
+ current policy's cost, so timing does not reveal whether the account or
835
+ credential exists. Only ever raise the parameters: hashes stronger than the
836
+ policy are not rehashed downward and would verify slower than the dummy.
837
+ Passwords are NFKC-normalized.
838
+
839
+ **Sign-in.** The submitted email is normalized exactly as `User.emailKey` is
840
+ and matched against that column (legacy users need `backfillUserEmailKeys()`
841
+ first), and the attempt is reserved through the shared `LoginAttemptLimiter` *before*
842
+ any credential work: per account (the email key, whether or not it exists) and
843
+ per client address. A request missing its email or password is reserved and
844
+ failed like a wrong password. The person must be `ACTIVE` and, when a tenant is given,
845
+ hold an `ACTIVE` membership in it. A successful sign-in mints a first-class
846
+ session with `authMethod: 'password'` through `SessionService.createSession`,
847
+ re-checks that the verified password is still current (so a concurrent change
848
+ or reset cannot leave a session behind), and revokes the browser's previous
849
+ cookie session. Every refusal — unknown email, no password, wrong password,
850
+ inactive user, no membership, malformed or over-long input — is the same
851
+ `PasswordCredentialError` / 401; a rate limit is `LoginRateLimitError` / 429
852
+ with `Retry-After`, and it is reached the same way for an unknown email as for
853
+ a real one. Audit rows carry hashed keys and the tenant only: never the email,
854
+ the password, the hash, or the user id.
855
+
856
+ **Policy** (`password` option): `minLength` (default 10), `maxLength`
857
+ (default 256, hard ceiling 1024 — longer input is refused before hashing,
858
+ at sign-in too), a small built-in `DEFAULT_PASSWORD_DENYLIST` extended by
859
+ `denylist`, a refusal of the person's own email, and a `reject(password,
860
+ { userId, email })` hook for anything else (a breached-password check, shop
861
+ words). There are no composition rules. Optional `pepper`. Violations are
862
+ `PasswordPolicyError` (400) with a message fit to display.
863
+
864
+ **Lifecycle and authority.**
865
+
866
+ | Call | Who | Sessions ended |
867
+ |---|---|---|
868
+ | `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 |
869
+ | `changePassword` | the person, proving the current password (from the sign-in budget) | every other session of theirs |
870
+ | `resetPassword` | an admin with `users.password.manage`, never for themself; `mustChange` defaults to true | every session of the person |
871
+ | `clearPassword` | an admin, or the person proving the current password | the person's password sessions |
872
+
873
+ Self-service writes are guarded on the exact credential the person proved
874
+ (or, for a first password, insert-only), so a reset that lands while a change
875
+ or clear is in flight is never overwritten with a password chosen under the
876
+ old one; the slower request fails with `PasswordCredentialError`. Every write
877
+ that ends sessions ends them both before and after the credential write, so
878
+ a failure between the two leaves the person signed out rather than signed in
879
+ under a password that is gone.
880
+
881
+ A reset with `mustChange` makes the next sign-in a restricted session: it
882
+ resolves to no permissions (an empty permission ceiling) and can only call
883
+ `changePassword`, which ends it; the person then signs in with the new
884
+ password. A device PIN (layered) session never manages passwords. Like the
885
+ PIN, the password is one per person across tenants, so `users.password.manage`
886
+ reaches only people whose every active membership is in the administrator's
887
+ session tenant. It is impersonation-equivalent — grant it like an owner-level
888
+ permission. It is registered in the runtime catalog
889
+ (`ensurePasswordPermissionsRegistered()`, called by the service), so
890
+ `syncPermissionCatalog()` creates it and the default role matrix
891
+ (`seedRolePermissions()`) grants it to `owner` and `admin` (`*`), not to
892
+ `member` or `viewer`.
893
+
894
+ #### Consumer wiring (SvelteKit)
895
+
896
+ ```ts
897
+ // src/lib/server/password-auth.ts
898
+ import { createPasswordCredentialHandlers } from '@happyvertical/smrt-users/sveltekit';
899
+
900
+ export const passwordAuth = createPasswordCredentialHandlers({
901
+ db,
902
+ tenantId: () => SHOP_TENANT_ID, // value or (event) => …; omit for no tenant context
903
+ cookieName: 'sid', // the same cookie createSessionHandler reads
904
+ sessionTtl: 12 * 60 * 60, // default 7 days
905
+ loginLimiter, // optional: share one limiter with PIN/terminal
906
+ password: { minLength: 12, denylist: ['teamworks fabrication'] },
907
+ });
908
+ // POST /auth/password → passwordAuth.signIn ({ email, password }, JSON or form)
909
+ // PUT /account/password → passwordAuth.changePassword ({ currentPassword, newPassword })
910
+ // POST /account/password → passwordAuth.setPassword ({ password } or admin { userId, password })
911
+ // DELETE /account/password → passwordAuth.clearPassword ({ currentPassword } or admin { userId })
912
+ // POST /admin/users/password → passwordAuth.resetPassword ({ userId, password, mustChange? })
913
+ // sign out → destroySessionCookie(event, { db })
914
+ ```
915
+
916
+ What the app must do:
917
+
918
+ - Mount `createSessionHandler` with the same `cookieName`; the management
919
+ handlers act as the session it puts in `event.locals` and refuse (401) when
920
+ `locals.authMethod` is absent.
921
+ - Put the sign-in route on the signed-out allowlist; leave CSRF protection on
922
+ (SvelteKit's origin check covers form posts, JSON needs CORS preflight).
923
+ - On `mustChange: true` (or a 200 from `changePassword` with
924
+ `signedOut: true`), send the person to the change-password page or back to
925
+ sign-in.
926
+ - Run `syncPermissionCatalog()` (or `seedRolePermissions()`) after the
927
+ service is first constructed, and grant `users.password.manage` to any
928
+ other role that administers passwords.
929
+ - Run `smrt db:migrate` once for the new `users_password_credentials` table.
930
+ - For a form action instead of an endpoint, call
931
+ `(await passwordAuth.service()).signIn({ … })` and set the cookie with the
932
+ returned `sessionId`; keep the session id out of page data.
933
+
934
+ Passkeys/WebAuthn are a separate credential (#3275).
935
+
778
936
  ### Request-scoped database access
779
937
 
780
938
  Generated SvelteKit helpers and custom server code can read the current
@@ -1011,6 +1169,7 @@ TenantService supports three modes: `flexible` (no auto-create), `personal` (aut
1011
1169
  | `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
1170
  | `UsersLoginAttempt`, `UsersLoginAuditEvent` | Hashed-key login budget rows and durable sign-in audit events (#3273). Closed generated surface. |
1013
1171
  | `UsersPinCredential` | Per-person scrypt PIN hash for enrolled-device sign-in (#3276). Closed generated surface. |
1172
+ | `UsersPasswordCredential` | Per-person scrypt password hash for email + password sign-in (#3274). Sensitive; closed generated surface. |
1014
1173
  | `Group` | Team within a tenant. Gains permissions via GroupRole. |
1015
1174
  | `Membership` | User + Tenant + Role junction. UNIQUE(userId, tenantId). |
1016
1175
  | `MembershipOverride` | Per-user permission grant/deny on a membership. |
@@ -1028,7 +1187,7 @@ TenantService supports three modes: `flexible` (no auto-create), `personal` (aut
1028
1187
  | `MembershipOverrideCollection`, `TenantPermissionOverrideCollection` | Override management at membership and tenant levels |
1029
1188
  | `GroupCollection`, `GroupMemberCollection`, `GroupRoleCollection`, `RolePermissionCollection` | Group and role-permission junction management |
1030
1189
  | `AccessRequestCollection` | AccessRequest queries: `findByEmail()`, `findOpenByEmail()`, `findByStatus()`, `findOpen()` |
1031
- | `UsersLoginAttemptCollection`, `UsersLoginAuditEventCollection`, `UsersPinCredentialCollection` | Atomic limiter primitives, audit storage, and PIN credential rows behind the services below |
1190
+ | `UsersLoginAttemptCollection`, `UsersLoginAuditEventCollection`, `UsersPinCredentialCollection`, `UsersPasswordCredentialCollection` | Atomic limiter primitives, audit storage, and PIN and password credential rows behind the services below |
1032
1191
 
1033
1192
  ### Services
1034
1193
 
@@ -1042,6 +1201,7 @@ TenantService supports three modes: `flexible` (no auto-create), `personal` (aut
1042
1201
  | `SessionService` | High-level session management. `createSession()`, `loadSessionContext()`, `destroySession()`; tenant contexts include direct or inherited membership provenance, `authMethod`, and `parent` for layered sessions. |
1043
1202
  | `LoginAttemptLimiter` | Shared sign-in budget with exponential lockout and audit (#3273): `reserve()` → lease `.fail()`/`.succeed()`/`.release()`. `LoginRateLimitError`, `InvalidCredentialsError`, `LoginAuditSink`, `DurableLoginAuditSink`. |
1044
1203
  | `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`. |
1204
+ | `PasswordCredentialService` | Email + password sign-in for existing active users (#3274): `signIn()`, `setPassword()`, `changePassword()`, `resetPassword()`, `clearPassword()`, `hasPassword()`, `validatePassword()`. `PasswordCredentialError`, `PasswordCredentialForbiddenError`, `PasswordPolicyError`. |
1045
1205
  | `OidcLoginService` | Generic OIDC authorization-code login with PKCE for Kanidm, Dex, and other standards-compliant providers. |
1046
1206
  | `backfillLegacyUserProfiles` | Transactionally create and link canonical global Person Profiles for legacy Users; never creates OIDC identities or infers ownership. |
1047
1207
  | `backfillUserEmailKeys` | Idempotently populate durable normalized-email keys after migrating legacy Users; fails closed on duplicates. |
@@ -1065,8 +1225,10 @@ TenantService supports three modes: `flexible` (no auto-create), `personal` (aut
1065
1225
  | `switchSessionTenant` | Change tenant context for current session |
1066
1226
  | `beginOidcLogin`, `completeOidcLogin` | Low-level SvelteKit helpers for custom OIDC login routes |
1067
1227
  | `createOidcLoginHandler`, `createOidcCallbackHandler` | Ready-to-use SvelteKit route handlers for OIDC login and callback |
1228
+ | `getOidcClientRegistration` | Redirect URL and landing (login start) URL to register with the IdP for an OIDC client |
1068
1229
  | `createMobileAuthHandlers` | Mountable `/api/mobile` PKCE, bearer session, bootstrap, logout, and route-guard handlers |
1069
1230
  | `createDeviceCredentialHandlers` | Mountable PIN sign-in, sign-out, and PIN management handlers for enrolled devices |
1231
+ | `createPasswordCredentialHandlers` | Mountable password sign-in (sets the session cookie) and password set/change/reset/clear handlers |
1070
1232
  | `resolveMobileUploadDedupKey` | Resolves `clientCaptureId` with `Idempotency-Key` fallback for app-owned multipart routes |
1071
1233
  | `SessionLocals` | Type for `event.locals` (extend in `app.d.ts`) |
1072
1234
 
@@ -1089,6 +1251,8 @@ for authentication, deduplication, and status semantics.
1089
1251
  | `DEFAULT_SESSION_TTL`, `MAX_TENANT_HIERARCHY_DEPTH` | 604800 (7 days in seconds), 10 |
1090
1252
  | `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
1253
  | `DEFAULT_PIN_MANAGE_PERMISSION`, `PIN_LOGIN_KIND` | `users.pin.manage`, `pin` |
1254
+ | `DEFAULT_PASSWORD_MANAGE_PERMISSION`, `PASSWORD_LOGIN_KIND` | `users.password.manage`, `password` |
1255
+ | `DEFAULT_PASSWORD_MIN_LENGTH`, `DEFAULT_PASSWORD_MAX_LENGTH`, `PASSWORD_MAX_LENGTH_CEILING`, `DEFAULT_PASSWORD_DENYLIST` | 10, 256, 1024, built-in common-password list |
1092
1256
  | `TenantHierarchyError` | Thrown on a missing parent, a cycle, or exceeding the hierarchy depth limit (`code`) |
1093
1257
  | `TenantHierarchyMaterializationError` | Thrown by `materializeTenantHierarchy` when any tenant's parent chain is broken; lists `problems` |
1094
1258
 
@@ -12,6 +12,12 @@ OIDC handlers, or identity migrations. The canonical scenario matrix is
12
12
  `iss` against discovered metadata with exact string comparison before trusting
13
13
  a code or error. If metadata advertises issuer-response support, `iss` is
14
14
  required.
15
+ - **The login start route is the IdP landing URL (#3569).** Kanidm drops a
16
+ pending authorization after any detour through its `/ui/login` and strands the
17
+ user in its apps panel; the client landing URL is the only way back.
18
+ `getOidcClientRegistration()` derives it from `loginPath` (default
19
+ `/auth/<provider>/login`) alongside the callback URL; keep the login handler
20
+ safe to enter with no query string.
15
21
  - **Verified-email Profile reuse is fail-closed.** The typed canonical scenarios
16
22
  live in
17
23
  `packages/profiles/src/testing/oidcProvisioningDecisionMatrix.ts`; both