@furystack/security 7.0.9 → 8.0.1

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 (90) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +9 -3
  3. package/esm/crypto-password-hasher.d.ts +10 -12
  4. package/esm/crypto-password-hasher.d.ts.map +1 -1
  5. package/esm/crypto-password-hasher.js +43 -36
  6. package/esm/crypto-password-hasher.js.map +1 -1
  7. package/esm/errors/password-complexity-error.d.ts +5 -4
  8. package/esm/errors/password-complexity-error.d.ts.map +1 -1
  9. package/esm/errors/password-complexity-error.js +5 -4
  10. package/esm/errors/password-complexity-error.js.map +1 -1
  11. package/esm/errors/unauthenticated-error.d.ts +3 -1
  12. package/esm/errors/unauthenticated-error.d.ts.map +1 -1
  13. package/esm/errors/unauthenticated-error.js +3 -1
  14. package/esm/errors/unauthenticated-error.js.map +1 -1
  15. package/esm/helpers.d.ts +20 -7
  16. package/esm/helpers.d.ts.map +1 -1
  17. package/esm/helpers.js +27 -13
  18. package/esm/helpers.js.map +1 -1
  19. package/esm/index.d.ts +3 -1
  20. package/esm/index.d.ts.map +1 -1
  21. package/esm/index.js +3 -1
  22. package/esm/index.js.map +1 -1
  23. package/esm/models/password-credential.d.ts +3 -13
  24. package/esm/models/password-credential.d.ts.map +1 -1
  25. package/esm/models/password-credential.js +3 -1
  26. package/esm/models/password-credential.js.map +1 -1
  27. package/esm/models/password-reset-token.d.ts +5 -9
  28. package/esm/models/password-reset-token.d.ts.map +1 -1
  29. package/esm/models/password-reset-token.js +5 -0
  30. package/esm/models/password-reset-token.js.map +1 -1
  31. package/esm/password-authenticator.d.ts +26 -15
  32. package/esm/password-authenticator.d.ts.map +1 -1
  33. package/esm/password-authenticator.js +73 -109
  34. package/esm/password-authenticator.js.map +1 -1
  35. package/esm/password-authenticator.spec.js +122 -212
  36. package/esm/password-authenticator.spec.js.map +1 -1
  37. package/esm/password-complexity-rules/contains-lowercase.d.ts +2 -2
  38. package/esm/password-complexity-rules/contains-lowercase.js +2 -2
  39. package/esm/password-complexity-rules/contains-uppercase.d.ts +2 -2
  40. package/esm/password-complexity-rules/contains-uppercase.js +2 -2
  41. package/esm/password-complexity-rules/max-length.d.ts +1 -4
  42. package/esm/password-complexity-rules/max-length.d.ts.map +1 -1
  43. package/esm/password-complexity-rules/max-length.js +1 -4
  44. package/esm/password-complexity-rules/max-length.js.map +1 -1
  45. package/esm/password-complexity-rules/min-length.d.ts +1 -4
  46. package/esm/password-complexity-rules/min-length.d.ts.map +1 -1
  47. package/esm/password-complexity-rules/min-length.js +1 -4
  48. package/esm/password-complexity-rules/min-length.js.map +1 -1
  49. package/esm/password-credential-store.d.ts +50 -0
  50. package/esm/password-credential-store.d.ts.map +1 -0
  51. package/esm/password-credential-store.js +73 -0
  52. package/esm/password-credential-store.js.map +1 -0
  53. package/esm/password-credential-store.spec.d.ts +2 -0
  54. package/esm/password-credential-store.spec.d.ts.map +1 -0
  55. package/esm/password-credential-store.spec.js +64 -0
  56. package/esm/password-credential-store.spec.js.map +1 -0
  57. package/esm/password-hasher.d.ts +21 -3
  58. package/esm/password-hasher.d.ts.map +1 -1
  59. package/esm/password-hasher.js +1 -19
  60. package/esm/password-hasher.js.map +1 -1
  61. package/esm/security-policy-manager.d.ts +46 -14
  62. package/esm/security-policy-manager.d.ts.map +1 -1
  63. package/esm/security-policy-manager.js +48 -52
  64. package/esm/security-policy-manager.js.map +1 -1
  65. package/esm/security-policy-manager.spec.js +58 -7
  66. package/esm/security-policy-manager.spec.js.map +1 -1
  67. package/esm/security-policy.d.ts +35 -9
  68. package/esm/security-policy.d.ts.map +1 -1
  69. package/esm/security-policy.js +23 -33
  70. package/esm/security-policy.js.map +1 -1
  71. package/package.json +7 -7
  72. package/src/crypto-password-hasher.ts +46 -39
  73. package/src/errors/password-complexity-error.ts +7 -4
  74. package/src/errors/unauthenticated-error.ts +3 -1
  75. package/src/helpers.ts +27 -15
  76. package/src/index.ts +3 -2
  77. package/src/models/password-credential.ts +5 -15
  78. package/src/models/password-reset-token.ts +5 -9
  79. package/src/password-authenticator.spec.ts +134 -254
  80. package/src/password-authenticator.ts +92 -86
  81. package/src/password-complexity-rules/contains-lowercase.ts +2 -2
  82. package/src/password-complexity-rules/contains-uppercase.ts +2 -2
  83. package/src/password-complexity-rules/max-length.ts +1 -4
  84. package/src/password-complexity-rules/min-length.ts +1 -4
  85. package/src/password-credential-store.spec.ts +77 -0
  86. package/src/password-credential-store.ts +79 -0
  87. package/src/password-hasher.ts +21 -9
  88. package/src/security-policy-manager.spec.ts +63 -7
  89. package/src/security-policy-manager.ts +91 -42
  90. package/src/security-policy.ts +50 -18
@@ -1,51 +1,100 @@
1
- import { Injectable, Injected } from '@furystack/inject'
1
+ import { defineService, type Token } from '@furystack/inject'
2
2
  import type { PasswordCredential, PasswordResetToken } from './models/index.js'
3
3
  import { SecurityPolicy } from './security-policy.js'
4
4
 
5
- @Injectable({ lifetime: 'singleton' })
6
- export class SecurityPolicyManager {
5
+ /**
6
+ * Result of a successful password complexity check — no rule reported a
7
+ * failure.
8
+ */
9
+ export type PasswordComplexityMatch = { match: true }
10
+
11
+ /**
12
+ * Result of a failed password complexity check along with the individual
13
+ * rule failures for user-facing reporting.
14
+ */
15
+ export type PasswordComplexityMismatch = {
16
+ match: false
17
+ errors: Array<{ message: string; rule: string }>
18
+ }
19
+
20
+ /**
21
+ * Runtime checker for the active {@link SecurityPolicy}. Applies complexity
22
+ * rules to candidate passwords and determines whether stored credentials
23
+ * and reset tokens have expired.
24
+ */
25
+ export interface SecurityPolicyManager {
26
+ /** The active {@link SecurityPolicy} snapshot captured at resolve time. */
27
+ readonly policy: SecurityPolicy
7
28
  /**
8
- * @param password The Plain password string
9
- * @returns whether the password matches the complexity rules
29
+ * Evaluates every complexity rule against `password` and aggregates the
30
+ * result. `match: true` means the password is acceptable.
10
31
  */
11
- public async matchPasswordComplexityRules(password: string) {
12
- const result = await Promise.all(this.policy.passwordComplexityRules.map((rule) => rule.check(password)))
13
- const failed = result.filter((r) => r.success === false)
14
- if (failed.length) {
15
- return {
16
- match: false as const,
17
- errors: failed.map((f) => f.success === false && { message: f.message, rule: f.rule }) as Array<{
18
- message: string
19
- rule: string
20
- }>,
21
- }
22
- }
32
+ matchPasswordComplexityRules(password: string): Promise<PasswordComplexityMatch | PasswordComplexityMismatch>
33
+ /**
34
+ * Returns `true` when `credential.creationDate` plus
35
+ * `policy.passwordExpirationDays` is in the past. Always `false` when the
36
+ * policy sets `passwordExpirationDays` to `0`.
37
+ */
38
+ hasPasswordExpired(credential: PasswordCredential): boolean
39
+ /**
40
+ * Returns `true` when `token.createdAt` plus
41
+ * `policy.resetTokenExpirationSeconds` is in the past. Always `false`
42
+ * when the policy sets `resetTokenExpirationSeconds` to `0`.
43
+ */
44
+ hasTokenExpired(token: PasswordResetToken): boolean
45
+ }
23
46
 
24
- return {
25
- match: true as const,
26
- }
27
- }
47
+ const addDays = (base: Date, days: number): Date => {
48
+ const result = new Date(base)
49
+ result.setDate(result.getDate() + days)
50
+ return result
51
+ }
28
52
 
29
- public hasPasswordExpired(credential: PasswordCredential) {
30
- if (!this.policy.passwordExpirationDays) {
31
- return false
32
- }
33
- const credentialExpiration = new Date(credential.creationDate)
34
- credentialExpiration.setDate(credentialExpiration.getDate() + this.policy.passwordExpirationDays)
35
- const now = new Date()
36
- return now > credentialExpiration
37
- }
53
+ const addSeconds = (base: Date, seconds: number): Date => {
54
+ const result = new Date(base)
55
+ result.setSeconds(result.getSeconds() + seconds)
56
+ return result
57
+ }
38
58
 
39
- public hasTokenExpired(token: PasswordResetToken) {
40
- if (!this.policy.resetTokenExpirationSeconds) {
41
- return false
59
+ /**
60
+ * DI token for the singleton {@link SecurityPolicyManager}. Resolves the
61
+ * current {@link SecurityPolicy} once and closes over it for the lifetime
62
+ * of the owning injector.
63
+ */
64
+ export const SecurityPolicyManager: Token<SecurityPolicyManager, 'singleton'> = defineService({
65
+ name: 'furystack/security/SecurityPolicyManager',
66
+ lifetime: 'singleton',
67
+ factory: ({ inject }): SecurityPolicyManager => {
68
+ const policy = inject(SecurityPolicy)
69
+ return {
70
+ policy,
71
+ matchPasswordComplexityRules: async (password) => {
72
+ const outcomes = await Promise.all(policy.passwordComplexityRules.map((rule) => rule.check(password)))
73
+ const failed = outcomes.filter((outcome) => outcome.success === false)
74
+ if (failed.length === 0) {
75
+ return { match: true }
76
+ }
77
+ return {
78
+ match: false,
79
+ errors: failed.map((outcome) =>
80
+ outcome.success === false ? { message: outcome.message, rule: outcome.rule } : { message: '', rule: '' },
81
+ ),
82
+ }
83
+ },
84
+ hasPasswordExpired: (credential) => {
85
+ if (!policy.passwordExpirationDays) {
86
+ return false
87
+ }
88
+ const expiration = addDays(new Date(credential.creationDate), policy.passwordExpirationDays)
89
+ return new Date() > expiration
90
+ },
91
+ hasTokenExpired: (token) => {
92
+ if (!policy.resetTokenExpirationSeconds) {
93
+ return false
94
+ }
95
+ const expiration = addSeconds(new Date(token.createdAt), policy.resetTokenExpirationSeconds)
96
+ return new Date() > expiration
97
+ },
42
98
  }
43
- const tokenExpiration = new Date(token.createdAt)
44
- tokenExpiration.setSeconds(tokenExpiration.getSeconds() + this.policy.resetTokenExpirationSeconds)
45
- const now = new Date()
46
- return now > tokenExpiration
47
- }
48
-
49
- @Injected(SecurityPolicy)
50
- declare public readonly policy: SecurityPolicy
51
- }
99
+ },
100
+ })
@@ -1,33 +1,65 @@
1
- import type { Constructable } from '@furystack/inject'
2
- import { Injectable } from '@furystack/inject'
1
+ import { defineService, type Token } from '@furystack/inject'
3
2
  import { CryptoPasswordHasher } from './crypto-password-hasher.js'
4
3
  import type { PasswordComplexityRule } from './models/password-complexity-rule.js'
5
4
  import type { PasswordHasher } from './password-hasher.js'
6
5
 
7
- @Injectable({ lifetime: 'singleton' })
8
- export class SecurityPolicy {
6
+ /**
7
+ * Application-wide policy that governs password hashing, complexity rules,
8
+ * expiration and second-factor requirements.
9
+ *
10
+ * The default policy shipped by `@furystack/security` uses
11
+ * {@link CryptoPasswordHasher}, enforces no complexity rules, disables
12
+ * password and reset-token expiration by default and does not require
13
+ * second factor authentication. Override via {@link usePasswordPolicy}.
14
+ */
15
+ export type SecurityPolicy = {
9
16
  /**
10
- * A Hasher instance for password hash generation
17
+ * Token resolving to the {@link PasswordHasher} used for generating and
18
+ * verifying password credentials.
11
19
  */
12
- hasher: Constructable<PasswordHasher> = CryptoPasswordHasher
13
-
20
+ hasher: Token<PasswordHasher, 'singleton'>
14
21
  /**
15
- * Array of password complexity rules to apply
22
+ * Ordered list of password complexity rules applied to every new or
23
+ * changed password.
16
24
  */
17
- passwordComplexityRules: PasswordComplexityRule[] = []
18
-
25
+ passwordComplexityRules: PasswordComplexityRule[]
19
26
  /**
20
- * The password expiration in days
27
+ * Number of days after which a stored password credential is considered
28
+ * expired. `0` disables expiration entirely.
21
29
  */
22
- passwordExpirationDays = 0
23
-
30
+ passwordExpirationDays: number
24
31
  /**
25
- * Defines if the login needs a second factor authentication
32
+ * Second-factor authentication policy. `'always'` forces a second factor
33
+ * on every login; `'never'` disables it. Trusted-device-aware policies
34
+ * are not yet implemented.
26
35
  */
27
- requireSecondFactor: 'never' | 'always' = 'never' // TODO - Challenge based on trusted devices
28
-
36
+ requireSecondFactor: 'never' | 'always'
29
37
  /**
30
- * The Password Reset Token expiration in seconds
38
+ * Lifetime of a password reset token in seconds.
31
39
  */
32
- resetTokenExpirationSeconds = 15
40
+ resetTokenExpirationSeconds: number
33
41
  }
42
+
43
+ /**
44
+ * Returns a fresh copy of the default {@link SecurityPolicy}. Useful as a
45
+ * starting point for `Object.assign`-style overrides or spec fixtures.
46
+ */
47
+ export const defaultSecurityPolicy = (): SecurityPolicy => ({
48
+ hasher: CryptoPasswordHasher,
49
+ passwordComplexityRules: [],
50
+ passwordExpirationDays: 0,
51
+ requireSecondFactor: 'never',
52
+ resetTokenExpirationSeconds: 15,
53
+ })
54
+
55
+ /**
56
+ * DI token that resolves the current {@link SecurityPolicy}. Rebind this
57
+ * token via {@link usePasswordPolicy} (preferred) or directly through
58
+ * {@link Injector.bind} to customise the policy per-application or
59
+ * per-scope.
60
+ */
61
+ export const SecurityPolicy: Token<SecurityPolicy, 'singleton'> = defineService({
62
+ name: 'furystack/security/SecurityPolicy',
63
+ lifetime: 'singleton',
64
+ factory: () => defaultSecurityPolicy(),
65
+ })