@furystack/security 7.0.9 → 8.0.0

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 (54) hide show
  1. package/CHANGELOG.md +10 -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/helpers.d.ts +20 -7
  8. package/esm/helpers.d.ts.map +1 -1
  9. package/esm/helpers.js +27 -13
  10. package/esm/helpers.js.map +1 -1
  11. package/esm/index.d.ts +3 -1
  12. package/esm/index.d.ts.map +1 -1
  13. package/esm/index.js +3 -1
  14. package/esm/index.js.map +1 -1
  15. package/esm/password-authenticator.d.ts +26 -15
  16. package/esm/password-authenticator.d.ts.map +1 -1
  17. package/esm/password-authenticator.js +73 -109
  18. package/esm/password-authenticator.js.map +1 -1
  19. package/esm/password-authenticator.spec.js +122 -212
  20. package/esm/password-authenticator.spec.js.map +1 -1
  21. package/esm/password-credential-store.d.ts +50 -0
  22. package/esm/password-credential-store.d.ts.map +1 -0
  23. package/esm/password-credential-store.js +73 -0
  24. package/esm/password-credential-store.js.map +1 -0
  25. package/esm/password-credential-store.spec.d.ts +2 -0
  26. package/esm/password-credential-store.spec.d.ts.map +1 -0
  27. package/esm/password-credential-store.spec.js +64 -0
  28. package/esm/password-credential-store.spec.js.map +1 -0
  29. package/esm/password-hasher.d.ts +21 -3
  30. package/esm/password-hasher.d.ts.map +1 -1
  31. package/esm/password-hasher.js +1 -19
  32. package/esm/password-hasher.js.map +1 -1
  33. package/esm/security-policy-manager.d.ts +46 -14
  34. package/esm/security-policy-manager.d.ts.map +1 -1
  35. package/esm/security-policy-manager.js +48 -52
  36. package/esm/security-policy-manager.js.map +1 -1
  37. package/esm/security-policy-manager.spec.js +58 -7
  38. package/esm/security-policy-manager.spec.js.map +1 -1
  39. package/esm/security-policy.d.ts +35 -9
  40. package/esm/security-policy.d.ts.map +1 -1
  41. package/esm/security-policy.js +23 -33
  42. package/esm/security-policy.js.map +1 -1
  43. package/package.json +5 -5
  44. package/src/crypto-password-hasher.ts +46 -39
  45. package/src/helpers.ts +27 -15
  46. package/src/index.ts +3 -2
  47. package/src/password-authenticator.spec.ts +134 -254
  48. package/src/password-authenticator.ts +92 -86
  49. package/src/password-credential-store.spec.ts +77 -0
  50. package/src/password-credential-store.ts +79 -0
  51. package/src/password-hasher.ts +21 -9
  52. package/src/security-policy-manager.spec.ts +63 -7
  53. package/src/security-policy-manager.ts +91 -42
  54. package/src/security-policy.ts +50 -18
@@ -1,108 +1,114 @@
1
1
  import { useSystemIdentityContext } from '@furystack/core'
2
- import type { Injector } from '@furystack/inject'
3
- import { Injectable, Injected } from '@furystack/inject'
4
- import type { DataSet } from '@furystack/repository'
5
- import { getDataSetFor } from '@furystack/repository'
2
+ import { defineService, type Token } from '@furystack/inject'
6
3
  import { UnauthenticatedError } from './errors/index.js'
7
4
  import { PasswordComplexityError } from './errors/password-complexity-error.js'
8
5
  import type { PasswordCheckResult } from './models/index.js'
9
- import { PasswordCredential, PasswordResetToken } from './models/index.js'
6
+ import { PasswordCredentialDataSet, PasswordResetTokenDataSet } from './password-credential-store.js'
10
7
  import type { PasswordHasher } from './password-hasher.js'
11
8
  import { SecurityPolicyManager } from './security-policy-manager.js'
12
9
 
13
- @Injectable({ lifetime: 'singleton' })
14
- export class PasswordAuthenticator {
15
- @Injected((injector) => useSystemIdentityContext({ injector, username: 'PasswordAuthenticator' }))
16
- declare private readonly systemInjector: Injector
17
-
18
- @Injected((injector) => getDataSetFor(injector, PasswordCredential, 'userName'))
19
- declare private readonly passwordDataSet: DataSet<PasswordCredential, 'userName'>
20
-
21
- @Injected((injector) => getDataSetFor(injector, PasswordResetToken, 'token'))
22
- declare private readonly tokenDataSet: DataSet<PasswordResetToken, 'token'>
23
-
24
- @Injected(function (this: PasswordAuthenticator, injector) {
25
- return injector.getInstance(this.policyManager.policy.hasher)
26
- })
27
- declare public readonly hasher: PasswordHasher
28
-
10
+ /**
11
+ * Authenticator surface: check stored credentials, rotate passwords and
12
+ * consume reset tokens. All writes are funneled through a system-identity
13
+ * scope so the underlying DataSets skip caller-scoped authorization.
14
+ */
15
+ export interface PasswordAuthenticator {
16
+ /** The hasher bound by the active {@link SecurityPolicy}. Exposed for use in tests and migration helpers. */
17
+ readonly hasher: PasswordHasher
18
+ /** The {@link SecurityPolicyManager} resolved at construction. */
19
+ readonly policyManager: SecurityPolicyManager
29
20
  /**
30
- * @param userName The User's unique name
31
- * @param plainPassword The plain password to check
32
- * @returns An object that contains an { isValid } value that indicates if the password is valid
21
+ * Verifies that the stored credential for `userName` matches
22
+ * `plainPassword` and honours the policy's expiration rules.
33
23
  */
34
- public async checkPasswordForUser(userName: string, plainPassword: string): Promise<PasswordCheckResult> {
35
- const entry = await this.passwordDataSet.get(this.systemInjector, userName)
36
- if (!entry) {
37
- return {
38
- isValid: false,
39
- reason: 'badUsernameOrPassword',
40
- }
41
- }
42
- const result = await this.hasher.verifyCredential(plainPassword, entry)
43
- if (result.isValid && this.policyManager.hasPasswordExpired(entry)) {
44
- return {
45
- isValid: false,
46
- reason: 'passwordExpired',
47
- }
48
- }
49
- return result
50
- }
51
-
24
+ checkPasswordForUser(userName: string, plainPassword: string): Promise<PasswordCheckResult>
52
25
  /**
53
- * Sets the password for the specific user
54
- * @param userName The user to set the password for
55
- * @param lastPassword The last user password
56
- * @param plainPassword The password as a plain string
26
+ * Replaces the stored credential for `userName` with one derived from
27
+ * `plainPassword`. Requires the current `lastPassword` to be valid and
28
+ * `plainPassword` to pass the configured complexity rules.
57
29
  */
58
- public async setPasswordForUser(userName: string, lastPassword: string, plainPassword: string): Promise<void> {
59
- const complexityResult = await this.policyManager.matchPasswordComplexityRules(plainPassword)
60
- if (!complexityResult.match) {
61
- throw new PasswordComplexityError(complexityResult.errors)
62
- }
63
-
64
- const lastResult = await this.checkPasswordForUser(userName, lastPassword)
65
- if (!lastResult.isValid) {
66
- throw new UnauthenticatedError()
67
- }
68
- const newCredential = await this.hasher.createCredential(userName, plainPassword)
69
- const existing = await this.passwordDataSet.get(this.systemInjector, userName)
70
- if (existing) {
71
- await this.passwordDataSet.remove(this.systemInjector, existing.userName)
72
- }
73
- await this.passwordDataSet.add(this.systemInjector, newCredential)
74
- }
75
-
30
+ setPasswordForUser(userName: string, lastPassword: string, plainPassword: string): Promise<void>
76
31
  /**
77
- * Resets the password with a Password Reset token entry
78
- * @param resetToken The Reset Token value
79
- * @param plainPassword The new password in plain string
32
+ * Consumes `resetToken` and rotates the associated user's password to
33
+ * one derived from `plainPassword`. Expired tokens are rejected and
34
+ * cleaned up; bad tokens raise {@link UnauthenticatedError}.
80
35
  */
81
- public async resetPasswordForUser(resetToken: string, plainPassword: string): Promise<void> {
82
- const token = await this.tokenDataSet.get(this.systemInjector, resetToken)
36
+ resetPasswordForUser(resetToken: string, plainPassword: string): Promise<void>
37
+ }
83
38
 
84
- if (!token) {
85
- throw new UnauthenticatedError()
39
+ /**
40
+ * DI token for the singleton {@link PasswordAuthenticator}.
41
+ *
42
+ * The factory creates a system-identity child scope once (at first
43
+ * resolution) and uses it for every credential mutation. The scope is
44
+ * torn down alongside the owning injector via `onDispose`.
45
+ */
46
+ export const PasswordAuthenticator: Token<PasswordAuthenticator, 'singleton'> = defineService({
47
+ name: 'furystack/security/PasswordAuthenticator',
48
+ lifetime: 'singleton',
49
+ factory: ({ inject, injector, onDispose }): PasswordAuthenticator => {
50
+ const policyManager = inject(SecurityPolicyManager)
51
+ const hasher = inject(policyManager.policy.hasher)
52
+ const passwordDataSet = inject(PasswordCredentialDataSet)
53
+ const tokenDataSet = inject(PasswordResetTokenDataSet)
54
+ const systemInjector = useSystemIdentityContext({ injector, username: 'PasswordAuthenticator' })
55
+ onDispose(() => systemInjector[Symbol.asyncDispose]())
56
+
57
+ const checkPasswordForUser = async (userName: string, plainPassword: string): Promise<PasswordCheckResult> => {
58
+ const entry = await passwordDataSet.get(systemInjector, userName)
59
+ if (!entry) {
60
+ return { isValid: false, reason: 'badUsernameOrPassword' }
61
+ }
62
+ const result = await hasher.verifyCredential(plainPassword, entry)
63
+ if (result.isValid && policyManager.hasPasswordExpired(entry)) {
64
+ return { isValid: false, reason: 'passwordExpired' }
65
+ }
66
+ return result
86
67
  }
87
68
 
88
- if (this.policyManager.hasTokenExpired(token)) {
89
- await this.tokenDataSet.remove(this.systemInjector, resetToken)
90
- throw new UnauthenticatedError()
69
+ const replaceCredential = async (userName: string, plainPassword: string): Promise<void> => {
70
+ const newCredential = await hasher.createCredential(userName, plainPassword)
71
+ const existing = await passwordDataSet.get(systemInjector, userName)
72
+ if (existing) {
73
+ await passwordDataSet.remove(systemInjector, existing.userName)
74
+ }
75
+ await passwordDataSet.add(systemInjector, newCredential)
91
76
  }
92
77
 
93
- const complexityResult = await this.policyManager.matchPasswordComplexityRules(plainPassword)
94
- if (!complexityResult.match) {
95
- throw new PasswordComplexityError(complexityResult.errors)
78
+ const setPasswordForUser = async (userName: string, lastPassword: string, plainPassword: string): Promise<void> => {
79
+ const complexityResult = await policyManager.matchPasswordComplexityRules(plainPassword)
80
+ if (!complexityResult.match) {
81
+ throw new PasswordComplexityError(complexityResult.errors)
82
+ }
83
+ const lastResult = await checkPasswordForUser(userName, lastPassword)
84
+ if (!lastResult.isValid) {
85
+ throw new UnauthenticatedError()
86
+ }
87
+ await replaceCredential(userName, plainPassword)
96
88
  }
97
89
 
98
- const newCredential = await this.hasher.createCredential(token.userName, plainPassword)
99
- const existing = await this.passwordDataSet.get(this.systemInjector, token.userName)
100
- if (existing) {
101
- await this.passwordDataSet.remove(this.systemInjector, existing.userName)
90
+ const resetPasswordForUser = async (resetToken: string, plainPassword: string): Promise<void> => {
91
+ const token = await tokenDataSet.get(systemInjector, resetToken)
92
+ if (!token) {
93
+ throw new UnauthenticatedError()
94
+ }
95
+ if (policyManager.hasTokenExpired(token)) {
96
+ await tokenDataSet.remove(systemInjector, resetToken)
97
+ throw new UnauthenticatedError()
98
+ }
99
+ const complexityResult = await policyManager.matchPasswordComplexityRules(plainPassword)
100
+ if (!complexityResult.match) {
101
+ throw new PasswordComplexityError(complexityResult.errors)
102
+ }
103
+ await replaceCredential(token.userName, plainPassword)
102
104
  }
103
- await this.passwordDataSet.add(this.systemInjector, newCredential)
104
- }
105
105
 
106
- @Injected(SecurityPolicyManager)
107
- declare public policyManager: SecurityPolicyManager
108
- }
106
+ return {
107
+ hasher,
108
+ policyManager,
109
+ checkPasswordForUser,
110
+ setPasswordForUser,
111
+ resetPasswordForUser,
112
+ }
113
+ },
114
+ })
@@ -0,0 +1,77 @@
1
+ import { InMemoryStore } from '@furystack/core'
2
+ import { createInjector } from '@furystack/inject'
3
+ import { usingAsync } from '@furystack/utils'
4
+ import { describe, expect, it } from 'vitest'
5
+ import { PasswordCredential } from './models/password-credential.js'
6
+ import { PasswordResetToken } from './models/password-reset-token.js'
7
+ import {
8
+ PasswordCredentialDataSet,
9
+ PasswordCredentialStore,
10
+ PasswordResetTokenDataSet,
11
+ PasswordResetTokenStore,
12
+ SecurityStoreNotConfiguredError,
13
+ } from './password-credential-store.js'
14
+
15
+ describe('PasswordCredentialStore', () => {
16
+ it('exposes the PasswordCredential model and userName primary key on the token', () => {
17
+ expect(PasswordCredentialStore.model).toBe(PasswordCredential)
18
+ expect(PasswordCredentialStore.primaryKey).toBe('userName')
19
+ expect(PasswordCredentialStore.lifetime).toBe('singleton')
20
+ })
21
+
22
+ it('throws SecurityStoreNotConfiguredError when resolved without an override', async () => {
23
+ await usingAsync(createInjector(), async (injector) => {
24
+ expect(() => injector.get(PasswordCredentialStore)).toThrow(SecurityStoreNotConfiguredError)
25
+ expect(() => injector.get(PasswordCredentialStore)).toThrow(/PasswordCredentialStore.*not been configured/)
26
+ })
27
+ })
28
+
29
+ it('returns the bound implementation after rebinding the token', async () => {
30
+ await usingAsync(createInjector(), async (injector) => {
31
+ const impl = new InMemoryStore({ model: PasswordCredential, primaryKey: 'userName' })
32
+ injector.bind(PasswordCredentialStore, () => impl)
33
+ expect(injector.get(PasswordCredentialStore)).toBe(impl)
34
+ })
35
+ })
36
+ })
37
+
38
+ describe('PasswordResetTokenStore', () => {
39
+ it('exposes the PasswordResetToken model and token primary key on the token', () => {
40
+ expect(PasswordResetTokenStore.model).toBe(PasswordResetToken)
41
+ expect(PasswordResetTokenStore.primaryKey).toBe('token')
42
+ expect(PasswordResetTokenStore.lifetime).toBe('singleton')
43
+ })
44
+
45
+ it('throws SecurityStoreNotConfiguredError when resolved without an override', async () => {
46
+ await usingAsync(createInjector(), async (injector) => {
47
+ expect(() => injector.get(PasswordResetTokenStore)).toThrow(SecurityStoreNotConfiguredError)
48
+ expect(() => injector.get(PasswordResetTokenStore)).toThrow(/PasswordResetTokenStore.*not been configured/)
49
+ })
50
+ })
51
+
52
+ it('returns the bound implementation after rebinding the token', async () => {
53
+ await usingAsync(createInjector(), async (injector) => {
54
+ const impl = new InMemoryStore({ model: PasswordResetToken, primaryKey: 'token' })
55
+ injector.bind(PasswordResetTokenStore, () => impl)
56
+ expect(injector.get(PasswordResetTokenStore)).toBe(impl)
57
+ })
58
+ })
59
+ })
60
+
61
+ describe('PasswordCredentialDataSet / PasswordResetTokenDataSet', () => {
62
+ it('mirror the backing store metadata onto the data-set token', () => {
63
+ expect(PasswordCredentialDataSet.model).toBe(PasswordCredential)
64
+ expect(PasswordCredentialDataSet.primaryKey).toBe('userName')
65
+ expect(PasswordResetTokenDataSet.model).toBe(PasswordResetToken)
66
+ expect(PasswordResetTokenDataSet.primaryKey).toBe('token')
67
+ })
68
+ })
69
+
70
+ describe('SecurityStoreNotConfiguredError', () => {
71
+ it('embeds the store name in the message and sets the error name', () => {
72
+ const error = new SecurityStoreNotConfiguredError('SomeStore')
73
+ expect(error.name).toBe('SecurityStoreNotConfiguredError')
74
+ expect(error.message).toContain('SomeStore')
75
+ expect(error).toBeInstanceOf(Error)
76
+ })
77
+ })
@@ -0,0 +1,79 @@
1
+ import { defineStore, type StoreToken } from '@furystack/core'
2
+ import { defineDataSet, type DataSetToken } from '@furystack/repository'
3
+ import { PasswordCredential } from './models/password-credential.js'
4
+ import { PasswordResetToken } from './models/password-reset-token.js'
5
+
6
+ /**
7
+ * Error thrown by the default factories of {@link PasswordCredentialStore}
8
+ * and {@link PasswordResetTokenStore} when they are resolved without a
9
+ * caller-provided override.
10
+ *
11
+ * Password credentials and reset tokens **must** survive process restarts,
12
+ * so security refuses to fall back to an in-memory store. Applications
13
+ * must bind a persistent implementation before resolving the
14
+ * {@link PasswordAuthenticator}.
15
+ */
16
+ export class SecurityStoreNotConfiguredError extends Error {
17
+ constructor(storeName: string) {
18
+ super(
19
+ `Security store '${storeName}' has not been configured. Bind a persistent PhysicalStore for ${storeName} before resolving the PasswordAuthenticator.`,
20
+ )
21
+ this.name = 'SecurityStoreNotConfiguredError'
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Store token for persisted {@link PasswordCredential} records. The default
27
+ * factory deliberately throws — applications are expected to rebind this
28
+ * token on their root injector with a persistent backing store before
29
+ * resolving any service that depends on it.
30
+ *
31
+ * @example
32
+ * ```ts
33
+ * injector.bind(PasswordCredentialStore, () => myPersistentCredentialStore)
34
+ * ```
35
+ */
36
+ export const PasswordCredentialStore: StoreToken<PasswordCredential, 'userName'> = defineStore({
37
+ name: 'furystack/security/PasswordCredentialStore',
38
+ model: PasswordCredential,
39
+ primaryKey: 'userName',
40
+ factory: () => {
41
+ throw new SecurityStoreNotConfiguredError('PasswordCredentialStore')
42
+ },
43
+ })
44
+
45
+ /**
46
+ * Store token for persisted {@link PasswordResetToken} records. Shares the
47
+ * throw-by-default contract with {@link PasswordCredentialStore}.
48
+ *
49
+ * @example
50
+ * ```ts
51
+ * injector.bind(PasswordResetTokenStore, () => myPersistentTokenStore)
52
+ * ```
53
+ */
54
+ export const PasswordResetTokenStore: StoreToken<PasswordResetToken, 'token'> = defineStore({
55
+ name: 'furystack/security/PasswordResetTokenStore',
56
+ model: PasswordResetToken,
57
+ primaryKey: 'token',
58
+ factory: () => {
59
+ throw new SecurityStoreNotConfiguredError('PasswordResetTokenStore')
60
+ },
61
+ })
62
+
63
+ /**
64
+ * Data set token over {@link PasswordCredentialStore}. The security services
65
+ * route all credential I/O through this dataset so authorization, hooks and
66
+ * change events can be layered on top of the raw store.
67
+ */
68
+ export const PasswordCredentialDataSet: DataSetToken<PasswordCredential, 'userName'> = defineDataSet({
69
+ name: 'furystack/security/PasswordCredentialDataSet',
70
+ store: PasswordCredentialStore,
71
+ })
72
+
73
+ /**
74
+ * Data set token over {@link PasswordResetTokenStore}.
75
+ */
76
+ export const PasswordResetTokenDataSet: DataSetToken<PasswordResetToken, 'token'> = defineDataSet({
77
+ name: 'furystack/security/PasswordResetTokenDataSet',
78
+ store: PasswordResetTokenStore,
79
+ })
@@ -1,13 +1,25 @@
1
- import { Injectable } from '@furystack/inject'
2
1
  import type { PasswordCheckResult, PasswordCredential } from './models/index.js'
3
2
 
4
- @Injectable({ lifetime: 'singleton' })
5
- export class PasswordHasher {
6
- public createCredential(_userName: string, _password: string): Promise<PasswordCredential> {
7
- throw Error('Set up hasher - TODO')
8
- }
3
+ /**
4
+ * Abstraction for hashing and verifying password credentials.
5
+ *
6
+ * Implementations are DI tokens that resolve to an object conforming to this
7
+ * interface. The default implementation shipped by `@furystack/security` is
8
+ * {@link CryptoPasswordHasher}; applications can swap in an alternative by
9
+ * binding the {@link SecurityPolicy.hasher} token (for example, to use a
10
+ * stronger KDF or a managed hashing service).
11
+ */
12
+ export interface PasswordHasher {
13
+ /**
14
+ * Produces a fresh credential entry for the given user name and plain
15
+ * password. The returned record is suitable for persistence in the
16
+ * credential store.
17
+ */
18
+ createCredential(userName: string, password: string): Promise<PasswordCredential>
9
19
 
10
- public verifyCredential(_password: string, _credential: PasswordCredential): Promise<PasswordCheckResult> {
11
- throw Error('Set up hasher - TODO')
12
- }
20
+ /**
21
+ * Verifies a plain password against a stored credential. Implementations
22
+ * should be constant-time relative to the password to avoid timing leaks.
23
+ */
24
+ verifyCredential(password: string, credential: PasswordCredential): Promise<PasswordCheckResult>
13
25
  }
@@ -1,17 +1,73 @@
1
- import { Injector } from '@furystack/inject'
1
+ import { createInjector } from '@furystack/inject'
2
2
  import { usingAsync } from '@furystack/utils'
3
3
  import { describe, expect, it } from 'vitest'
4
4
  import { usePasswordPolicy } from './helpers.js'
5
5
  import { SecurityPolicyManager } from './security-policy-manager.js'
6
6
 
7
7
  describe('SecurityPolicyManager', () => {
8
- it('Should return false when no token expiration has been set', async () => {
9
- await usingAsync(new Injector(), async (i) => {
8
+ it('returns a cached manager backed by the current policy', async () => {
9
+ await usingAsync(createInjector(), async (i) => {
10
+ usePasswordPolicy(i, { passwordExpirationDays: 30 })
11
+ const manager = i.get(SecurityPolicyManager)
12
+ expect(manager.policy.passwordExpirationDays).toBe(30)
13
+ })
14
+ })
15
+
16
+ it('never considers a token expired when resetTokenExpirationSeconds is 0', async () => {
17
+ await usingAsync(createInjector(), async (i) => {
10
18
  usePasswordPolicy(i, { resetTokenExpirationSeconds: 0 })
11
- const result = i
12
- .getInstance(SecurityPolicyManager)
13
- .hasTokenExpired({ createdAt: new Date().toISOString(), token: 'token', userName: 'user' })
14
- expect(result).toBeFalsy()
19
+ const expired = i
20
+ .get(SecurityPolicyManager)
21
+ .hasTokenExpired({ createdAt: new Date(0).toISOString(), token: 'token', userName: 'user' })
22
+ expect(expired).toBe(false)
23
+ })
24
+ })
25
+
26
+ it('flags a token as expired once its lifetime has elapsed', async () => {
27
+ await usingAsync(createInjector(), async (i) => {
28
+ usePasswordPolicy(i, { resetTokenExpirationSeconds: 1 })
29
+ const pastCreatedAt = new Date()
30
+ pastCreatedAt.setSeconds(pastCreatedAt.getSeconds() - 10)
31
+ const expired = i
32
+ .get(SecurityPolicyManager)
33
+ .hasTokenExpired({ createdAt: pastCreatedAt.toISOString(), token: 'token', userName: 'user' })
34
+ expect(expired).toBe(true)
35
+ })
36
+ })
37
+
38
+ it('never considers a credential expired when passwordExpirationDays is 0', async () => {
39
+ await usingAsync(createInjector(), async (i) => {
40
+ usePasswordPolicy(i, { passwordExpirationDays: 0 })
41
+ const expired = i.get(SecurityPolicyManager).hasPasswordExpired({
42
+ userName: 'user',
43
+ passwordHash: 'hash',
44
+ salt: 'salt',
45
+ creationDate: new Date(0).toISOString(),
46
+ })
47
+ expect(expired).toBe(false)
48
+ })
49
+ })
50
+
51
+ it('flags a credential as expired once its lifetime has elapsed', async () => {
52
+ await usingAsync(createInjector(), async (i) => {
53
+ usePasswordPolicy(i, { passwordExpirationDays: 1 })
54
+ const pastCreationDate = new Date()
55
+ pastCreationDate.setDate(pastCreationDate.getDate() - 5)
56
+ const expired = i.get(SecurityPolicyManager).hasPasswordExpired({
57
+ userName: 'user',
58
+ passwordHash: 'hash',
59
+ salt: 'salt',
60
+ creationDate: pastCreationDate.toISOString(),
61
+ })
62
+ expect(expired).toBe(true)
63
+ })
64
+ })
65
+
66
+ it('reports match=true when no complexity rules are configured', async () => {
67
+ await usingAsync(createInjector(), async (i) => {
68
+ usePasswordPolicy(i)
69
+ const result = await i.get(SecurityPolicyManager).matchPasswordComplexityRules('anything')
70
+ expect(result.match).toBe(true)
15
71
  })
16
72
  })
17
73
  })
@@ -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
+ })