@ultimabase/password 0.0.0-stage → 0.1.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # @ultimabase/password
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 0e885c4: The first release: the kernel, its adapters, transports and plugins, the testing helpers and the starter.
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies [0e885c4]
12
+ - @ultimabase/auth@0.1.0
13
+ - ultimabase@0.1.0
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
- # Temporary Holding Version
1
+ # @ultimabase/password
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A sign-in method for `@ultimabase/auth`: a user signs up and signs in with an email and a password, hashed with Argon2id.
4
+
5
+ Documentation: [Password](https://ultimabase.dev/docs/password).
package/dist/core.d.ts ADDED
@@ -0,0 +1,57 @@
1
+ import type { AuthApi } from '@ultimabase/auth';
2
+ import type { Client, ExposedModel, Logger, Row } from 'ultimabase';
3
+ import type { Attempt } from './models.ts';
4
+ /** The method `AuthAccount` links a password's email with (D-113). */
5
+ export declare const method = "password";
6
+ /** What each running application's password reads, by auth's API. */
7
+ export interface State {
8
+ readonly auth: AuthApi;
9
+ readonly users: ExposedModel;
10
+ /** The fields a sign-up other than Master's takes, `false` when closed. */
11
+ readonly signUp: ReadonlySet<string> | false;
12
+ readonly logger: Logger;
13
+ /** The sends under way, which stopping waits for (D-117). */
14
+ readonly sending: Set<Promise<void>>;
15
+ }
16
+ /** What a sign-in returns: its session, and the user read as itself. */
17
+ export interface SignedIn<U = Row> {
18
+ /**
19
+ * The session's token, there even when the cookie carries it: a mobile
20
+ * client needs it, and a function cannot tell one client from another
21
+ * (D-115).
22
+ */
23
+ readonly token: string;
24
+ readonly expiresAt: Date;
25
+ readonly user: U | null;
26
+ }
27
+ /** The attempt a client makes for `subject`; server code is not counted. */
28
+ export declare const attemptOf: (subject: string, client: Client | undefined) => Attempt;
29
+ /**
30
+ * Creates the user, its account and its hash in one transaction, as Master:
31
+ * the caller's fields were checked against the list (D-113).
32
+ */
33
+ export declare function createUser(state: State, email: string, password: string, data: object): Promise<{
34
+ readonly user: Row;
35
+ readonly stamp: string;
36
+ }>;
37
+ /** A user whose password checked, and the stamp it checked against. */
38
+ export interface Verified {
39
+ readonly userId: string;
40
+ readonly stamp: string;
41
+ }
42
+ /** The user `email` and `password` sign in as, counting the attempt. */
43
+ export declare function verified(state: State, email: string, password: string, attempt: Attempt): Promise<Verified>;
44
+ /** Checks `userId`'s current password, counting the attempt. */
45
+ export declare function checkCurrent(userId: string, password: string, attempt: Attempt): Promise<void>;
46
+ /**
47
+ * Replaces `userId`'s hash, ends every session of the user and deletes its
48
+ * pending reset tokens (D-117); the new stamp.
49
+ */
50
+ export declare function setPassword(state: State, userId: string, password: string): Promise<string>;
51
+ /**
52
+ * Opens a session for the user, with its cookie when a client is given. The
53
+ * stamp is read after: a password set since the check ends the user's
54
+ * sessions and fails, an older read means `setPassword` ends this one (D-113).
55
+ */
56
+ export declare function signedIn(state: State, { userId, stamp }: Verified, client: Client | undefined): Promise<SignedIn>;
57
+ //# sourceMappingURL=core.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"core.d.ts","sourceRoot":"","sources":["../src/core.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAA;AAC/C,OAAO,KAAK,EACX,MAAM,EAEN,YAAY,EACZ,MAAM,EAEN,GAAG,EAEH,MAAM,YAAY,CAAA;AAInB,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,aAAa,CAAA;AAK1C,sEAAsE;AACtE,eAAO,MAAM,MAAM,aAAa,CAAA;AAEhC,qEAAqE;AACrE,MAAM,WAAW,KAAK;IACrB,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;IACtB,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAA;IAC5B,2EAA2E;IAC3E,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC,MAAM,CAAC,GAAG,KAAK,CAAA;IAC5C,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,6DAA6D;IAC7D,QAAQ,CAAC,OAAO,EAAE,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAA;CACpC;AAED,wEAAwE;AACxE,MAAM,WAAW,QAAQ,CAAC,CAAC,GAAG,GAAG;IAChC;;;;OAIG;IACH,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,SAAS,EAAE,IAAI,CAAA;IACxB,QAAQ,CAAC,IAAI,EAAE,CAAC,GAAG,IAAI,CAAA;CACvB;AAKD,4EAA4E;AAC5E,eAAO,MAAM,SAAS,YACZ,MAAM,UACP,MAAM,GAAG,SAAS,KACxB,OACqE,CAAA;AAExE;;;GAGG;AACH,wBAAsB,UAAU,CAC/B,KAAK,EAAE,KAAK,EACZ,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,GACV,OAAO,CAAC;IAAE,QAAQ,CAAC,IAAI,EAAE,GAAG,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CASzD;AAyCD,uEAAuE;AACvE,MAAM,WAAW,QAAQ;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CACtB;AAED,wEAAwE;AACxE,wBAAsB,QAAQ,CAC7B,KAAK,EAAE,KAAK,EACZ,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,OAAO,GACd,OAAO,CAAC,QAAQ,CAAC,CAOnB;AAED,gEAAgE;AAChE,wBAAsB,YAAY,CACjC,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,OAAO,GACd,OAAO,CAAC,IAAI,CAAC,CAIf;AAED;;;GAGG;AACH,wBAAsB,WAAW,CAChC,KAAK,EAAE,KAAK,EACZ,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,GACd,OAAO,CAAC,MAAM,CAAC,CAmBjB;AAaD;;;;GAIG;AACH,wBAAsB,QAAQ,CAC7B,KAAK,EAAE,KAAK,EACZ,EAAE,MAAM,EAAE,KAAK,EAAE,EAAE,QAAQ,EAC3B,MAAM,EAAE,MAAM,GAAG,SAAS,GACxB,OAAO,CAAC,QAAQ,CAAC,CAQnB"}
package/dist/core.js ADDED
@@ -0,0 +1,124 @@
1
+ // What the functions and the commands share (D-113): signing a user up,
2
+ // checking a password, setting one, and the session a success opens.
3
+ import { randomUUID } from 'node:crypto';
4
+ import { master, PublicError, transaction } from 'ultimabase';
5
+ import { dummyHash, hashPassword, verifyPassword } from './hash.js';
6
+ import { normalized } from './input.js';
7
+ import { limited, PasswordHash } from './models.js';
8
+ import { PasswordToken } from './tokens.js';
9
+ const M = { as: master };
10
+ /** The method `AuthAccount` links a password's email with (D-113). */
11
+ export const method = 'password';
12
+ const invalid = () => new PublicError('INVALID_CREDENTIALS', 'Wrong email or password.');
13
+ /** The attempt a client makes for `subject`; server code is not counted. */
14
+ export const attemptOf = (subject, client) => client === undefined ? undefined : { subject, address: client.address };
15
+ /**
16
+ * Creates the user, its account and its hash in one transaction, as Master:
17
+ * the caller's fields were checked against the list (D-113).
18
+ */
19
+ export async function createUser(state, email, password, data) {
20
+ const hash = await hashPassword(password);
21
+ const stamp = randomUUID();
22
+ return transaction(async () => {
23
+ const user = await state.users.queries.create({ data }, M);
24
+ await state.auth.linkAccount(user.id, method, email);
25
+ await PasswordHash.create({ data: { userId: user.id, hash, stamp } }, M);
26
+ return { user, stamp };
27
+ });
28
+ }
29
+ /** Rehashes the password `stamp` names, unless it was set since. */
30
+ async function rehash(stamp, password) {
31
+ const hash = await hashPassword(password);
32
+ try {
33
+ await PasswordHash.update({ where: { stamp }, data: { hash } }, M);
34
+ }
35
+ catch (error) {
36
+ if (!(error instanceof PublicError && error.code === 'NOT_FOUND')) {
37
+ throw error;
38
+ }
39
+ }
40
+ }
41
+ /** No stored password is longer, so a longer one is not hashed. */
42
+ const longest = 256;
43
+ /**
44
+ * The stamp of `userId`'s password when `password` is it, rehashing a stale
45
+ * hash; `undefined` otherwise.
46
+ */
47
+ async function matches(userId, typed) {
48
+ const password = normalized(typed);
49
+ if (Array.from(password).length > longest)
50
+ return undefined;
51
+ const stored = userId === undefined
52
+ ? null
53
+ : await PasswordHash.findUnique({ where: { userId } }, M);
54
+ // An unknown user costs a hash too, so the time tells nothing (D-113).
55
+ const { valid, stale } = await verifyPassword(password, stored?.hash ?? (await dummyHash()));
56
+ if (stored === null || !valid)
57
+ return undefined;
58
+ if (stale)
59
+ await rehash(stored.stamp, password);
60
+ return stored.stamp;
61
+ }
62
+ /** The user `email` and `password` sign in as, counting the attempt. */
63
+ export async function verified(state, email, password, attempt) {
64
+ return limited(attempt, async () => {
65
+ const account = await state.auth.accountOf(method, email);
66
+ const stamp = await matches(account?.userId, password);
67
+ if (stamp === undefined || account === undefined)
68
+ throw invalid();
69
+ return { userId: account.userId, stamp };
70
+ });
71
+ }
72
+ /** Checks `userId`'s current password, counting the attempt. */
73
+ export async function checkCurrent(userId, password, attempt) {
74
+ await limited(attempt, async () => {
75
+ if ((await matches(userId, password)) === undefined)
76
+ throw invalid();
77
+ });
78
+ }
79
+ /**
80
+ * Replaces `userId`'s hash, ends every session of the user and deletes its
81
+ * pending reset tokens (D-117); the new stamp.
82
+ */
83
+ export async function setPassword(state, userId, password) {
84
+ const stored = await PasswordHash.findUnique({ where: { userId } }, M);
85
+ if (stored === null) {
86
+ throw new PublicError('NOT_FOUND', `User ${userId} has no password: sign it up with signUp.`);
87
+ }
88
+ const hash = await hashPassword(password);
89
+ const stamp = randomUUID();
90
+ await transaction(async () => {
91
+ await PasswordHash.update({ where: { userId }, data: { hash, stamp } }, M);
92
+ await PasswordToken.deleteMany({ where: { userId, kind: 'reset' } }, M);
93
+ await state.auth.revokeSessions(userId);
94
+ });
95
+ // Again once the stamp is visible: a sign-in that read the old one opened
96
+ // its session before, so this ends it (see `signedIn`).
97
+ await state.auth.revokeSessions(userId);
98
+ return stamp;
99
+ }
100
+ /** The user as it reads itself, so its own field rules apply (D-113). */
101
+ async function asItself(state, userId) {
102
+ const { queries } = state.users;
103
+ // A transport's queries are untyped; this is the user model's row.
104
+ const user = await queries.findUnique({ where: { id: userId } }, M);
105
+ if (user === null)
106
+ return null;
107
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the user model is the one the application registers (D-62, D-80).
108
+ const caller = { kind: 'user', user: user };
109
+ return queries.findUnique({ where: { id: userId } }, { as: caller });
110
+ }
111
+ /**
112
+ * Opens a session for the user, with its cookie when a client is given. The
113
+ * stamp is read after: a password set since the check ends the user's
114
+ * sessions and fails, an older read means `setPassword` ends this one (D-113).
115
+ */
116
+ export async function signedIn(state, { userId, stamp }, client) {
117
+ const { token, expiresAt } = await state.auth.signIn(userId, client);
118
+ const stored = await PasswordHash.findUnique({ where: { userId } }, M);
119
+ if (stored?.stamp !== stamp) {
120
+ await state.auth.revokeSessions(userId);
121
+ throw invalid();
122
+ }
123
+ return { token, expiresAt, user: await asItself(state, userId) };
124
+ }
@@ -0,0 +1,20 @@
1
+ import type { FunctionShape } from 'ultimabase';
2
+ import { PublicError } from 'ultimabase';
3
+ import type { State } from './core.ts';
4
+ import type { Email, EmailKind } from './tokens.ts';
5
+ /**
6
+ * Runs `work` after the response, so its time tells nothing; a failure is
7
+ * logged, and stopping the application waits for it.
8
+ */
9
+ export declare function later(state: State, work: () => Promise<void>): void;
10
+ /** The application's `sendEmail` (D-117). */
11
+ export type SendEmail = (email: Email) => Promise<void>;
12
+ /**
13
+ * Sends `userId` an email of `kind` to `to`, with a new token; past the
14
+ * account's hourly limit, the refusal instead.
15
+ */
16
+ export type Mailer = (kind: EmailKind, userId: string, to: string) => Promise<PublicError | undefined>;
17
+ export declare const mailerOf: (sendEmail: SendEmail) => Mailer;
18
+ /** The functions that send an email or redeem its token; each is public. */
19
+ export declare function emailFunctions(stateOf: () => State, mail: Mailer): readonly FunctionShape[];
20
+ //# sourceMappingURL=email-functions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"email-functions.d.ts","sourceRoot":"","sources":["../src/email-functions.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAC/C,OAAO,EAAiC,WAAW,EAAE,MAAM,YAAY,CAAA;AACvE,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,WAAW,CAAA;AAStC,OAAO,KAAK,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAGnD;;;GAGG;AACH,wBAAgB,KAAK,CAAC,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAUnE;AAED,6CAA6C;AAC7C,MAAM,MAAM,SAAS,GAAG,CAAC,KAAK,EAAE,KAAK,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;AAEvD;;;GAGG;AACH,MAAM,MAAM,MAAM,GAAG,CACpB,IAAI,EAAE,SAAS,EACf,MAAM,EAAE,MAAM,EACd,EAAE,EAAE,MAAM,KACN,OAAO,CAAC,WAAW,GAAG,SAAS,CAAC,CAAA;AAErC,eAAO,MAAM,QAAQ,cACR,SAAS,KAAG,MAOvB,CAAA;AAWF,4EAA4E;AAC5E,wBAAgB,cAAc,CAC7B,OAAO,EAAE,MAAM,KAAK,EACpB,IAAI,EAAE,MAAM,GACV,SAAS,aAAa,EAAE,CAmE1B"}
@@ -0,0 +1,107 @@
1
+ import { allow, defineFunction, master, PublicError } from 'ultimabase';
2
+ import { method, setPassword } from './core.js';
3
+ import { outputSchema, requestResetInput, resetPasswordInput, verifyEmailInput, } from './input.js';
4
+ import { requestLimited } from './models.js';
5
+ import { emailLimited, issueToken, redeemToken } from './tokens.js';
6
+ /**
7
+ * Runs `work` after the response, so its time tells nothing; a failure is
8
+ * logged, and stopping the application waits for it.
9
+ */
10
+ export function later(state, work) {
11
+ const task = Promise.resolve()
12
+ .then(work)
13
+ .catch((error) => {
14
+ state.logger.error({ err: error }, 'An email failed to send.');
15
+ })
16
+ .finally(() => {
17
+ state.sending.delete(task);
18
+ });
19
+ state.sending.add(task);
20
+ }
21
+ export const mailerOf = (sendEmail) => async (kind, userId, to) => {
22
+ const refused = await emailLimited(userId);
23
+ if (refused !== undefined)
24
+ return refused;
25
+ const token = await issueToken(userId, kind);
26
+ await sendEmail({ kind, to, token, userId });
27
+ return undefined;
28
+ };
29
+ const done = outputSchema({ type: 'boolean' });
30
+ const M = { as: master };
31
+ /** Marks `userId`'s email verified: the user received an email (D-117). */
32
+ async function verify(state, userId) {
33
+ const where = { id: userId };
34
+ await state.users.queries.update({ where, data: { emailVerified: true } }, M);
35
+ }
36
+ /** The functions that send an email or redeem its token; each is public. */
37
+ export function emailFunctions(stateOf, mail) {
38
+ const requestPasswordReset = defineFunction({
39
+ name: 'requestPasswordReset',
40
+ input: requestResetInput,
41
+ output: done,
42
+ run: async ({ input: { input }, client }) => {
43
+ const state = stateOf();
44
+ if (client !== undefined)
45
+ await requestLimited('reset', client.address);
46
+ const { email } = input;
47
+ // The same answer, and no wait, whether the email has an account.
48
+ later(state, async () => {
49
+ const account = await state.auth.accountOf(method, email);
50
+ if (account === undefined)
51
+ return;
52
+ await mail('reset', account.userId, email);
53
+ });
54
+ return true;
55
+ },
56
+ }).with({ access: allow.public });
57
+ const resetPassword = defineFunction({
58
+ name: 'resetPassword',
59
+ input: resetPasswordInput,
60
+ output: done,
61
+ run: async ({ input: { input } }) => {
62
+ const state = stateOf();
63
+ const userId = await redeemToken(input.token, 'reset');
64
+ await setPassword(state, userId, input.password);
65
+ await verify(state, userId);
66
+ return true;
67
+ },
68
+ }).with({ access: allow.public });
69
+ const verifyEmail = defineFunction({
70
+ name: 'verifyEmail',
71
+ input: verifyEmailInput,
72
+ output: done,
73
+ run: async ({ input: { input } }) => {
74
+ await verify(stateOf(), await redeemToken(input.token, 'verify'));
75
+ return true;
76
+ },
77
+ }).with({ access: allow.public });
78
+ const sendVerificationEmail = defineFunction({
79
+ name: 'sendVerificationEmail',
80
+ output: done,
81
+ run: async ({ caller, client }) => {
82
+ if (caller.kind !== 'user') {
83
+ throw new PublicError('UNAUTHENTICATED', 'sendVerificationEmail needs a signed-in user.');
84
+ }
85
+ if (client !== undefined)
86
+ await requestLimited('verify', client.address);
87
+ const { user } = caller;
88
+ // The user's row, read as Master, holds the field password adds.
89
+ if (Reflect.get(user, 'emailVerified') === true)
90
+ return true;
91
+ const to = await stateOf().auth.subjectOf(user.id, method);
92
+ // A user signed in another way has no email to verify.
93
+ if (to === undefined)
94
+ return true;
95
+ const refused = await mail('verify', user.id, to);
96
+ if (refused !== undefined)
97
+ throw refused;
98
+ return true;
99
+ },
100
+ }).with({ access: allow.public });
101
+ return [
102
+ requestPasswordReset,
103
+ resetPassword,
104
+ verifyEmail,
105
+ sendVerificationEmail,
106
+ ];
107
+ }
@@ -0,0 +1,10 @@
1
+ import type { ExposedModel, FunctionShape } from 'ultimabase';
2
+ import type { State } from './core.ts';
3
+ import type { Mailer } from './email-functions.ts';
4
+ import type { Property } from './input.ts';
5
+ /**
6
+ * The functions, `signUp` only when sign-up is open; each is public. With a
7
+ * `mailer`, `signUp` sends the verification email (D-117).
8
+ */
9
+ export declare function passwordFunctions(stateOf: () => State, users: ExposedModel, fields: ReadonlyMap<string, Property> | false, mailer: Mailer | undefined): readonly FunctionShape[];
10
+ //# sourceMappingURL=functions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"functions.d.ts","sourceRoot":"","sources":["../src/functions.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAc,MAAM,YAAY,CAAA;AAEzE,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,WAAW,CAAA;AAStC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAA;AAElD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAA;AAkB1C;;;GAGG;AACH,wBAAgB,iBAAiB,CAChC,OAAO,EAAE,MAAM,KAAK,EACpB,KAAK,EAAE,YAAY,EACnB,MAAM,EAAE,WAAW,CAAC,MAAM,EAAE,QAAQ,CAAC,GAAG,KAAK,EAC7C,MAAM,EAAE,MAAM,GAAG,SAAS,GACxB,SAAS,aAAa,EAAE,CA2D1B"}
@@ -0,0 +1,75 @@
1
+ import { allow, defineFunction, defineType, PublicError } from 'ultimabase';
2
+ import { attemptOf, checkCurrent, createUser, setPassword, signedIn, verified, } from './core.js';
3
+ import { later } from './email-functions.js';
4
+ import { requestLimited } from './models.js';
5
+ import { changePasswordInput, outputSchema, signInInput, signUpInput, } from './input.js';
6
+ /** `User.nullable()`, which `ExposedModel.model` holds but does not type. */
7
+ function nullable(model) {
8
+ const make = Reflect.get(model, 'nullable');
9
+ if (typeof make !== 'function')
10
+ throw new TypeError(`${model.name} is not a model.`);
11
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- a `defineModel` value's `nullable()` (D-109).
12
+ return Reflect.apply(make, model, []);
13
+ }
14
+ /**
15
+ * The functions, `signUp` only when sign-up is open; each is public. With a
16
+ * `mailer`, `signUp` sends the verification email (D-117).
17
+ */
18
+ export function passwordFunctions(stateOf, users, fields, mailer) {
19
+ const SignedIn = defineType({
20
+ name: 'SignedIn',
21
+ fields: {
22
+ token: outputSchema({ type: 'string' }),
23
+ expiresAt: outputSchema({ type: 'string', format: 'date-time' }),
24
+ user: nullable(users.model),
25
+ },
26
+ });
27
+ const signUp = defineFunction({
28
+ name: 'signUp',
29
+ input: signUpInput(fields === false ? new Map() : fields),
30
+ output: SignedIn,
31
+ run: async ({ input: { input }, client }) => {
32
+ const state = stateOf();
33
+ if (client !== undefined)
34
+ await requestLimited('signUp', client.address);
35
+ const { email, password, ...data } = input;
36
+ const { user, stamp } = await createUser(state, email, password, data);
37
+ if (mailer !== undefined) {
38
+ later(state, async () => {
39
+ await mailer('verify', user.id, email);
40
+ });
41
+ }
42
+ return signedIn(state, { userId: user.id, stamp }, client);
43
+ },
44
+ }).with({ access: allow.public });
45
+ const signIn = defineFunction({
46
+ name: 'signIn',
47
+ input: signInInput,
48
+ output: SignedIn,
49
+ run: async ({ input: { input }, client }) => {
50
+ const state = stateOf();
51
+ const { email, password } = input;
52
+ const attempt = attemptOf(email, client);
53
+ const checked = await verified(state, email, password, attempt);
54
+ return signedIn(state, checked, client);
55
+ },
56
+ }).with({ access: allow.public });
57
+ const changePassword = defineFunction({
58
+ name: 'changePassword',
59
+ input: changePasswordInput,
60
+ output: SignedIn,
61
+ run: async ({ input: { input }, caller, client }) => {
62
+ if (caller.kind !== 'user') {
63
+ throw new PublicError('UNAUTHENTICATED', 'changePassword needs a signed-in user.');
64
+ }
65
+ const state = stateOf();
66
+ const { id } = caller.user;
67
+ await checkCurrent(id, input.currentPassword, attemptOf(id, client));
68
+ const stamp = await setPassword(state, id, input.newPassword);
69
+ return signedIn(state, { userId: id, stamp }, client);
70
+ },
71
+ }).with({ access: allow.public });
72
+ return fields === false
73
+ ? [signIn, changePassword]
74
+ : [signUp, signIn, changePassword];
75
+ }
package/dist/hash.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ /** The computations that run at once, and how many more may wait (D-114). */
2
+ export declare const lanes: number;
3
+ export declare const waiting = 64;
4
+ /** The PHC string of `password`, with a new 16-byte salt. */
5
+ export declare function hashPassword(password: string): Promise<string>;
6
+ /**
7
+ * Whether `password` matches `stored`, and whether `stored` was made with
8
+ * other parameters, so a sign-in rehashes it.
9
+ */
10
+ export declare function verifyPassword(password: string, stored: string): Promise<{
11
+ readonly valid: boolean;
12
+ readonly stale: boolean;
13
+ }>;
14
+ /** A hash no password matches, checked for an unknown email (D-113). */
15
+ export declare function dummyHash(): Promise<string>;
16
+ //# sourceMappingURL=hash.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hash.d.ts","sourceRoot":"","sources":["../src/hash.ts"],"names":[],"mappings":"AAqBA,6EAA6E;AAC7E,eAAO,MAAM,KAAK,EAAE,MAA+B,CAAA;AACnD,eAAO,MAAM,OAAO,KAAK,CAAA;AAuDzB,6DAA6D;AAC7D,wBAAsB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAKpE;AAoBD;;;GAGG;AACH,wBAAsB,cAAc,CACnC,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,GACZ,OAAO,CAAC;IAAE,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAA;CAAE,CAAC,CAyB/D;AAGD,wEAAwE;AACxE,wBAAsB,SAAS,IAAI,OAAO,CAAC,MAAM,CAAC,CAGjD"}
package/dist/hash.js ADDED
@@ -0,0 +1,103 @@
1
+ // Password hashes (D-113): Argon2id from `node:crypto`, stored as a PHC string,
2
+ // a few at a time (D-114).
3
+ import { argon2, randomBytes, timingSafeEqual } from 'node:crypto';
4
+ import { availableParallelism } from 'node:os';
5
+ import { PublicError } from 'ultimabase';
6
+ /** OWASP's first Argon2id setting: 19 MiB, two passes, one lane. */
7
+ const current = { memory: 19456, passes: 2, parallelism: 1 };
8
+ const tagLength = 32;
9
+ const phc = /^\$argon2id\$v=19\$m=(\d+),t=(\d+),p=(\d+)\$([A-Za-z0-9+/]+)\$([A-Za-z0-9+/]+)$/;
10
+ const unpadded = (bytes) => bytes.toString('base64').replace(/=+$/, '');
11
+ /** The computations that run at once, and how many more may wait (D-114). */
12
+ export const lanes = availableParallelism();
13
+ export const waiting = 64;
14
+ let running = 0;
15
+ const queue = [];
16
+ /** Runs `run` when a lane is free; refuses it when the queue is full. */
17
+ async function queued(run) {
18
+ if (running < lanes)
19
+ running += 1;
20
+ else if (queue.length < waiting) {
21
+ await new Promise((resolve) => queue.push(resolve));
22
+ }
23
+ else {
24
+ throw new PublicError('TOO_MANY_ATTEMPTS', 'The server is busy: try again.', {
25
+ details: { retryAfter: 1 },
26
+ });
27
+ }
28
+ try {
29
+ return await run();
30
+ }
31
+ finally {
32
+ // The lane goes to the next computation waiting, or is freed.
33
+ const next = queue.shift();
34
+ if (next === undefined)
35
+ running -= 1;
36
+ else
37
+ next();
38
+ }
39
+ }
40
+ async function derive(password, nonce, parameters, length) {
41
+ return queued(async () => compute(password, nonce, parameters, length));
42
+ }
43
+ async function compute(password, nonce, parameters, length) {
44
+ return new Promise((resolve, reject) => {
45
+ argon2('argon2id', { message: password, nonce, tagLength: length, ...parameters }, (error, key) => {
46
+ if (error === null)
47
+ resolve(key);
48
+ else
49
+ reject(error);
50
+ });
51
+ });
52
+ }
53
+ /** The PHC string of `password`, with a new 16-byte salt. */
54
+ export async function hashPassword(password) {
55
+ const salt = randomBytes(16);
56
+ const key = await derive(password, salt, current, tagLength);
57
+ const { memory, passes, parallelism } = current;
58
+ return `$argon2id$v=19$m=${memory},t=${passes},p=${parallelism}$${unpadded(salt)}$${unpadded(key)}`;
59
+ }
60
+ /**
61
+ * Whether a stored hash is one this plugin could verify: up to 256 MiB, 16
62
+ * passes and 16 lanes, and a tag of 16 to 64 bytes, so a tampered row
63
+ * neither exhausts the server nor matches too easily.
64
+ */
65
+ const bounded = ({ memory, passes, parallelism }, length) => parallelism >= 1 &&
66
+ parallelism <= 16 &&
67
+ memory >= 8 * parallelism &&
68
+ memory <= 262_144 &&
69
+ passes >= 1 &&
70
+ passes <= 16 &&
71
+ length >= 16 &&
72
+ length <= 64;
73
+ /**
74
+ * Whether `password` matches `stored`, and whether `stored` was made with
75
+ * other parameters, so a sign-in rehashes it.
76
+ */
77
+ export async function verifyPassword(password, stored) {
78
+ const parts = phc.exec(stored);
79
+ if (parts === null)
80
+ throw new Error('A stored password hash is not Argon2id.');
81
+ const [, m, t, p, salt = '', hash = ''] = parts;
82
+ const parameters = {
83
+ memory: Number(m),
84
+ passes: Number(t),
85
+ parallelism: Number(p),
86
+ };
87
+ const expected = Buffer.from(hash, 'base64');
88
+ if (!bounded(parameters, expected.length)) {
89
+ throw new Error('A stored password hash has out-of-bounds parameters.');
90
+ }
91
+ const key = await derive(password, Buffer.from(salt, 'base64'), parameters, expected.length);
92
+ const stale = parameters.memory !== current.memory ||
93
+ parameters.passes !== current.passes ||
94
+ parameters.parallelism !== current.parallelism ||
95
+ expected.length !== tagLength;
96
+ return { valid: timingSafeEqual(key, expected), stale };
97
+ }
98
+ let dummy;
99
+ /** A hash no password matches, checked for an unknown email (D-113). */
100
+ export async function dummyHash() {
101
+ dummy ??= hashPassword(randomBytes(32).toString('base64'));
102
+ return dummy;
103
+ }
@@ -0,0 +1,7 @@
1
+ export type { SignedIn } from './core.ts';
2
+ export type { SendEmail } from './email-functions.ts';
3
+ export type { Credentials } from './input.ts';
4
+ export type { Password, PasswordCommands, PasswordOptions, UserField, } from './plugin.ts';
5
+ export type { Email, EmailKind } from './tokens.ts';
6
+ export { password, passwordPlugin } from './plugin.ts';
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,YAAY,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAA;AACzC,YAAY,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAA;AACrD,YAAY,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAC7C,YAAY,EACX,QAAQ,EACR,gBAAgB,EAChB,eAAe,EACf,SAAS,GACT,MAAM,aAAa,CAAA;AACpB,YAAY,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AACnD,OAAO,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { password, passwordPlugin } from './plugin.js';