@maestro-js/password 1.0.0-alpha.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.
@@ -0,0 +1,63 @@
1
+ import { CustomErrors } from '@maestro-js/custom-errors';
2
+ import { Iso } from 'iso-fns2';
3
+ import { Hash } from '@maestro-js/hash';
4
+
5
+ declare function create<Id, Credentials extends {
6
+ password: string;
7
+ }>(config: Password.Config<Id, Credentials>): Password.Service<Id, Credentials>;
8
+ declare const Password: {
9
+ create: typeof create;
10
+ errors: {
11
+ ThrottledError: CustomErrors.CustomErrorConstructor<undefined, {}, "tooManyRequests">;
12
+ InvalidTokenError: CustomErrors.CustomErrorConstructor<undefined, {}, "unprocessableContent">;
13
+ ExpiredTokenError: CustomErrors.CustomErrorConstructor<undefined, {}, "unprocessableContent">;
14
+ };
15
+ };
16
+ declare namespace Password {
17
+ interface Service<Id, Credentials extends {
18
+ password: string;
19
+ }> {
20
+ verifyCredentials(credentials: Credentials): Promise<Password.Authenticatable<Id> | null>;
21
+ createPasswordResetToken: (authenticatableId: Id) => Promise<string>;
22
+ resetPassword: (options: {
23
+ authenticatableId: Id;
24
+ token: string;
25
+ newPassword: string;
26
+ }) => Promise<void>;
27
+ }
28
+ interface Config<Id, Credentials extends {
29
+ password: string;
30
+ }> {
31
+ getAuthenticatableByCredentials(credentials: Omit<Credentials, 'password'>): Promise<(Password.Authenticatable<Id> & {
32
+ passwordHash: string;
33
+ }) | null>;
34
+ updatePasswordHash(id: Id, passwordHash: string): Promise<void>;
35
+ updateCredentialsInvalidBefore(id: Id, date: Iso.Instant): Promise<void>;
36
+ /** Used for password hashing and verification. `Hash.drivers.bcrypt()` is recommended. */
37
+ hash: Hash.HashService;
38
+ passwordResetOptions?: Password.ResetOptions<Id>;
39
+ }
40
+ interface Authenticatable<Id> {
41
+ id: Id;
42
+ credentialsInvalidBefore: Iso.Instant | null;
43
+ }
44
+ interface ResetTokenRecord<Id> {
45
+ authenticatableId: Id;
46
+ selector: string;
47
+ verifierHash: string;
48
+ expirationDate: Iso.Instant;
49
+ createdDate: Iso.Instant;
50
+ }
51
+ interface ResetOptions<Id> {
52
+ create(record: Password.ResetTokenRecord<Id>): Promise<void>;
53
+ getBySelector(selector: string): Promise<Password.ResetTokenRecord<Id> | null>;
54
+ getByAuthenticatableId(authenticatableId: Id): Promise<Password.ResetTokenRecord<Id> | null>;
55
+ deleteByAuthenticatableId(authenticatableId: Id): Promise<void>;
56
+ /** Token lifetime in minutes. Default: 60 */
57
+ tokenExpirationMinutes?: number;
58
+ /** Minimum seconds between createToken calls for the same authenticatable. Default: 60 */
59
+ throttleSeconds?: number;
60
+ }
61
+ }
62
+
63
+ export { Password };
package/dist/index.js ADDED
@@ -0,0 +1,118 @@
1
+ // src/index.ts
2
+ import crypto from "crypto";
3
+ import { CustomErrors } from "@maestro-js/custom-errors";
4
+ import { instantFns } from "iso-fns2";
5
+ function create(config) {
6
+ const hashService = config.hash;
7
+ const passwordResetOptions = config.passwordResetOptions;
8
+ async function verifyCredentials(credentials) {
9
+ const { password, ...input } = credentials;
10
+ const authenticatable = await config.getAuthenticatableByCredentials(input);
11
+ if (!authenticatable) {
12
+ await hashService.verify({ hash: await hashService.hash("password"), plaintext: "password" });
13
+ return null;
14
+ }
15
+ const isValid = await hashService.verify({ plaintext: password, hash: authenticatable.passwordHash });
16
+ if (!isValid) {
17
+ return null;
18
+ }
19
+ const needsRehash = await hashService.needsRehash(authenticatable.passwordHash);
20
+ if (needsRehash) {
21
+ const newHash = await hashService.hash(password);
22
+ await config.updatePasswordHash(authenticatable.id, newHash);
23
+ }
24
+ return { id: authenticatable.id, credentialsInvalidBefore: authenticatable.credentialsInvalidBefore };
25
+ }
26
+ async function createPasswordResetToken(authenticatableId) {
27
+ if (!passwordResetOptions) {
28
+ throw new Error("Password reset is not configured. Provide `passwordResetOptions` when creating the password service.");
29
+ }
30
+ const existing = await passwordResetOptions.getByAuthenticatableId(authenticatableId);
31
+ if (existing) {
32
+ const throttleExpiry = instantFns.add(existing.createdDate, { seconds: passwordResetOptions.throttleSeconds ?? 60 });
33
+ if (instantFns.now() < throttleExpiry) {
34
+ throw new PasswordResetThrottledError();
35
+ }
36
+ }
37
+ const selector = crypto.randomBytes(16).toString("base64url");
38
+ const verifier = crypto.randomBytes(32).toString("base64url");
39
+ const verifierHash = await config.hash.hash(verifier);
40
+ const now = instantFns.now();
41
+ const expirationDate = instantFns.add(now, { minutes: passwordResetOptions.tokenExpirationMinutes ?? 60 });
42
+ await passwordResetOptions.create({
43
+ authenticatableId,
44
+ selector,
45
+ verifierHash,
46
+ expirationDate,
47
+ createdDate: now
48
+ });
49
+ return `${selector}|${verifier}`;
50
+ }
51
+ async function resetPassword({
52
+ authenticatableId,
53
+ token,
54
+ newPassword
55
+ }) {
56
+ if (!passwordResetOptions) {
57
+ throw new Error("Password reset is not configured. Provide `passwordResetOptions` when creating the password service.");
58
+ }
59
+ const decoded = decodePasswordResetToken(token);
60
+ if (!decoded) {
61
+ throw new PasswordResetInvalidTokenError();
62
+ }
63
+ const record = await passwordResetOptions.getBySelector(decoded.selector);
64
+ if (!record) {
65
+ throw new PasswordResetInvalidTokenError();
66
+ }
67
+ const isValid = await config.hash.verify({ plaintext: decoded.verifier, hash: record.verifierHash });
68
+ if (!isValid) {
69
+ throw new PasswordResetInvalidTokenError();
70
+ }
71
+ if (record.expirationDate < instantFns.now()) {
72
+ throw new PasswordResetExpiredTokenError();
73
+ }
74
+ if (record.authenticatableId !== authenticatableId) {
75
+ throw new PasswordResetInvalidTokenError();
76
+ }
77
+ const passwordHash = await config.hash.hash(newPassword);
78
+ await config.updatePasswordHash(authenticatableId, passwordHash);
79
+ await config.updateCredentialsInvalidBefore(authenticatableId, instantFns.now());
80
+ await passwordResetOptions.deleteByAuthenticatableId(authenticatableId);
81
+ }
82
+ return {
83
+ verifyCredentials,
84
+ createPasswordResetToken,
85
+ resetPassword
86
+ };
87
+ }
88
+ var PasswordResetThrottledError = CustomErrors.create({
89
+ name: "PasswordResetThrottledError",
90
+ message: "Password reset requests are being throttled. Please try again later.",
91
+ httpStatusCode: "tooManyRequests"
92
+ });
93
+ var PasswordResetInvalidTokenError = CustomErrors.create({
94
+ name: "PasswordResetInvalidTokenError",
95
+ message: "The password reset token is invalid.",
96
+ httpStatusCode: "unprocessableContent"
97
+ });
98
+ var PasswordResetExpiredTokenError = CustomErrors.create({
99
+ name: "PasswordResetExpiredTokenError",
100
+ message: "The password reset token has expired.",
101
+ httpStatusCode: "unprocessableContent"
102
+ });
103
+ var Password = {
104
+ create,
105
+ errors: {
106
+ ThrottledError: PasswordResetThrottledError,
107
+ InvalidTokenError: PasswordResetInvalidTokenError,
108
+ ExpiredTokenError: PasswordResetExpiredTokenError
109
+ }
110
+ };
111
+ function decodePasswordResetToken(token) {
112
+ const [selector, verifier] = token.split("|");
113
+ if (!selector || !verifier) return null;
114
+ return { selector, verifier };
115
+ }
116
+ export {
117
+ Password
118
+ };
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@maestro-js/password",
3
+ "type": "module",
4
+ "exports": {
5
+ ".": {
6
+ "types": "./dist/index.d.ts",
7
+ "default": "./dist/index.js"
8
+ }
9
+ },
10
+ "version": "1.0.0-alpha.0",
11
+ "publishConfig": {
12
+ "access": "restricted"
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "dependencies": {
18
+ "iso-fns2": "npm:iso-fns@2.0.0-alpha.26",
19
+ "@maestro-js/custom-errors": "1.0.0-alpha.0",
20
+ "@maestro-js/hash": "1.0.0-alpha.0"
21
+ },
22
+ "devDependencies": {
23
+ "@types/node": "^22.19.11"
24
+ },
25
+ "license": "UNLICENSED",
26
+ "engines": {
27
+ "node": ">=22.18.0"
28
+ },
29
+ "repository": "https://github.com/Marcato-Partners/maestro-js",
30
+ "scripts": {
31
+ "build": "tsup --config ../../tsup.config.ts",
32
+ "typecheck": "tsc --noEmit",
33
+ "test": "beartest ./tests/**/*",
34
+ "format": "prettier --write src/ tests/",
35
+ "lint": "prettier --check src/ tests/"
36
+ }
37
+ }