@ultimabase/password 0.0.0-stage → 0.1.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.
package/dist/plugin.js ADDED
@@ -0,0 +1,172 @@
1
+ import { authPlugin } from '@ultimabase/auth';
2
+ import { definePlugin, isMaster, master, PublicError, Refused, } from 'ultimabase';
3
+ import manifest from '../package.json' with { type: 'json' };
4
+ import { attemptOf, createUser, setPassword, signedIn, verified, } from './core.js';
5
+ import { emailFunctions, mailerOf } from './email-functions.js';
6
+ import { passwordFunctions } from './functions.js';
7
+ import { emailOf, fieldProperty, passwordOf, valid } from './input.js';
8
+ import { PasswordAttempt, PasswordHash, PasswordRequest } from './models.js';
9
+ import { PasswordToken } from './tokens.js';
10
+ const M = { as: master };
11
+ const states = new WeakMap();
12
+ /** The `create` of each model whose `use` lists `password()`. */
13
+ const using = new WeakSet();
14
+ function stateOf(auth) {
15
+ const state = states.get(auth);
16
+ if (state === undefined)
17
+ throw new Error('password has not started.');
18
+ return state;
19
+ }
20
+ /** The one model whose `use` lists `password()`. */
21
+ function usersOf(models) {
22
+ // oxlint-disable-next-line typescript/unbound-method -- compared by identity, never called.
23
+ const users = models.filter(({ queries }) => using.has(queries.create));
24
+ const [first, second] = users;
25
+ if (first === undefined) {
26
+ throw new Refused('passwordPlugin() needs the user model to list it: add `use: [password()]` to defineUser.');
27
+ }
28
+ if (second !== undefined) {
29
+ throw new Refused(`${first.schema.name} and ${second.schema.name} both use password(): only the user model does.`);
30
+ }
31
+ return first;
32
+ }
33
+ const reserved = new Set([
34
+ 'id',
35
+ 'createdAt',
36
+ 'updatedAt',
37
+ 'email',
38
+ 'password',
39
+ 'emailVerified',
40
+ ]);
41
+ /** The listed fields' properties; an unknown, a reserved or a missing one stops startup. */
42
+ function signUpFields(users, listed) {
43
+ const { name, fields } = users.schema;
44
+ const where = `passwordPlugin({ signUp })`;
45
+ const properties = new Map();
46
+ for (const field of listed) {
47
+ const found = fields.find((each) => each.name === field);
48
+ if (found === undefined || reserved.has(field)) {
49
+ throw new Refused(`${where}: ${name}.${field} is not a field sign-up may take.`);
50
+ }
51
+ properties.set(field, fieldProperty(found, users.required.includes(field), where, users.validators[field]));
52
+ }
53
+ const missing = users.required.filter((field) => !properties.has(field));
54
+ if (missing.length > 0) {
55
+ throw new Refused(`${where}: ${name} needs ${missing.join(', ')} to be created; list them.`);
56
+ }
57
+ return properties;
58
+ }
59
+ /** A sign-up's data, checked against the list unless the caller is Master. */
60
+ function signUpData(state, data, caller) {
61
+ if (isMaster(caller))
62
+ return data;
63
+ if (state.signUp === false) {
64
+ throw new PublicError('ACCESS_DENIED', 'Sign-up is closed.');
65
+ }
66
+ const { signUp } = state;
67
+ const issues = Object.keys(data)
68
+ .filter((key) => !signUp.has(key))
69
+ .map((key) => ({ path: ['data', key], message: 'Unknown field.' }));
70
+ if (issues.length > 0) {
71
+ throw new PublicError('VALIDATION_FAILED', 'Sign-up takes the listed fields only.', {
72
+ details: { issues },
73
+ });
74
+ }
75
+ return data;
76
+ }
77
+ /** The user model and the sign-up fields, `false` when sign-up is closed. */
78
+ function configOf(options, models) {
79
+ const users = usersOf(models);
80
+ const listed = options.signUp ?? [];
81
+ const fields = listed === false ? false : signUpFields(users, listed);
82
+ return { users, fields };
83
+ }
84
+ const callerOf = (options) => options?.as ?? { kind: 'anonymous' };
85
+ const definition = definePlugin({
86
+ name: 'password',
87
+ manifest,
88
+ requires: { auth: authPlugin },
89
+ models: [PasswordHash, PasswordAttempt, PasswordRequest, PasswordToken],
90
+ addedFields: (f) => ({
91
+ // Only Master confirms an email: rules read it (D-117).
92
+ emailVerified: f.boolean({ default: false }).with({
93
+ access: { create: () => false, update: () => false },
94
+ }),
95
+ }),
96
+ hooks: {
97
+ // The user's hash and tokens go with them (D-113, D-117).
98
+ afterDelete: async ({ record }) => {
99
+ const where = { userId: record.id };
100
+ await PasswordHash.deleteMany({ where }, M);
101
+ await PasswordToken.deleteMany({ where }, M);
102
+ const emails = { kind: 'email', subject: record.id };
103
+ await PasswordRequest.deleteMany({ where: emails }, M);
104
+ },
105
+ },
106
+ commands: ({ queries, plugins }) => {
107
+ using.add(queries.create);
108
+ return {
109
+ signUp: async ({ email, password, data }, options) => {
110
+ const state = stateOf(plugins.auth);
111
+ const checked = signUpData(state, data, callerOf(options));
112
+ const { user } = await createUser(state, valid(emailOf, email, 'email'), valid(passwordOf, password, 'password'), checked);
113
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the user model's row, created by its own queries.
114
+ return user;
115
+ },
116
+ signIn: async ({ email, password }, options) => {
117
+ const state = stateOf(plugins.auth);
118
+ const subject = valid(emailOf, email, 'email');
119
+ const client = options?.client;
120
+ const attempt = attemptOf(subject, client);
121
+ const checked = await verified(state, subject, password, attempt);
122
+ const opened = await signedIn(state, checked, client);
123
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the user model's row, read by its own queries.
124
+ return opened;
125
+ },
126
+ setPassword: async ({ id, password }, options) => {
127
+ const caller = callerOf(options);
128
+ const self = caller.kind === 'user' && caller.user.id === id;
129
+ if (!self && !isMaster(caller)) {
130
+ throw new PublicError('ACCESS_DENIED', 'User.setPassword runs as Master or as the user.');
131
+ }
132
+ const state = stateOf(plugins.auth);
133
+ await setPassword(state, id, valid(passwordOf, password, 'password'));
134
+ },
135
+ };
136
+ },
137
+ // The functions are built before the APIs (D-89): they read the state
138
+ // when they run, and `api` stores it once auth's API exists.
139
+ api: ({ options, plugins, models, logger }) => {
140
+ const { users, fields } = configOf(options, models);
141
+ const signUp = fields === false ? false : new Set(fields.keys());
142
+ const { auth } = plugins;
143
+ states.set(auth, { auth, users, signUp, logger, sending: new Set() });
144
+ },
145
+ // The emails under way still query: they end before the database closes.
146
+ stop: async ({ plugins }) => {
147
+ await Promise.all(stateOf(plugins.auth).sending);
148
+ },
149
+ functions: ({ options, plugins, models }) => {
150
+ const { users, fields } = configOf(options, models);
151
+ const state = () => stateOf(plugins.auth);
152
+ const { sendEmail } = options;
153
+ const mailer = sendEmail === undefined ? undefined : mailerOf(sendEmail);
154
+ return [
155
+ ...passwordFunctions(state, users, fields, mailer),
156
+ ...(mailer === undefined ? [] : emailFunctions(state, mailer)),
157
+ ];
158
+ },
159
+ });
160
+ /**
161
+ * The password sign-in method; it requires `authPlugin()`.
162
+ *
163
+ * Known and kept (D-115): `signUp` refuses a taken email with
164
+ * `UNIQUE_VIOLATION`, which tells that the email has an account until email
165
+ * verification can hide it; a hundred failures on one account within 15
166
+ * minutes, from any addresses, lock it for everyone, and a hundred from one
167
+ * address lock that address out of every account (D-114); `SignedIn`
168
+ * holds the token even when the cookie is set.
169
+ */
170
+ export const passwordPlugin = definition.plugin;
171
+ /** The user model's entry: its commands, and its hash deleted with it. */
172
+ export const password = definition.use;
@@ -0,0 +1,34 @@
1
+ import type { Field, ModelOf, ScalarOptions } from 'ultimabase';
2
+ import { PublicError } from 'ultimabase';
3
+ /** What an email's token lets its holder do. */
4
+ export type EmailKind = 'reset' | 'verify';
5
+ /** What `sendEmail` receives: the application writes the text and the link. */
6
+ export interface Email {
7
+ readonly kind: EmailKind;
8
+ /** The account's email, normalized. */
9
+ readonly to: string;
10
+ /** What the link carries, for `resetPassword` or `verifyEmail`. */
11
+ readonly token: string;
12
+ readonly userId: string;
13
+ }
14
+ type TokenFields = {
15
+ readonly userId: Field<string, ScalarOptions<string>>;
16
+ readonly kind: Field<string, ScalarOptions<string>>;
17
+ /** The token's SHA-256: a leaked database gives no working link. */
18
+ readonly hash: Field<string, {
19
+ readonly unique: true;
20
+ }>;
21
+ readonly expiresAt: Field<Date, ScalarOptions<Date>>;
22
+ };
23
+ export declare const PasswordToken: ModelOf<'PasswordToken', TokenFields, []>;
24
+ /** A new token of `kind` for `userId`, which replaces the older one. */
25
+ export declare function issueToken(userId: string, kind: EmailKind): Promise<string>;
26
+ /** The user a token of `kind` was issued to; a wrong, used or expired one fails. */
27
+ export declare function redeemToken(token: string, kind: EmailKind): Promise<string>;
28
+ /**
29
+ * Counts an email to `userId`; past three within one hour, the refusal
30
+ * instead, and the email is not counted.
31
+ */
32
+ export declare const emailLimited: (userId: string) => Promise<PublicError | undefined>;
33
+ export {};
34
+ //# sourceMappingURL=tokens.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../src/tokens.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAC/D,OAAO,EAAuB,WAAW,EAAE,MAAM,YAAY,CAAA;AAO7D,gDAAgD;AAChD,MAAM,MAAM,SAAS,GAAG,OAAO,GAAG,QAAQ,CAAA;AAE1C,+EAA+E;AAC/E,MAAM,WAAW,KAAK;IACrB,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAA;IACxB,uCAAuC;IACvC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,mEAAmE;IACnE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CACvB;AAOD,KAAK,WAAW,GAAG;IAClB,QAAQ,CAAC,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC,CAAA;IACrD,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC,CAAA;IACnD,oEAAoE;IACpE,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC,MAAM,EAAE;QAAE,QAAQ,CAAC,MAAM,EAAE,IAAI,CAAA;KAAE,CAAC,CAAA;IACvD,QAAQ,CAAC,SAAS,EAAE,KAAK,CAAC,IAAI,EAAE,aAAa,CAAC,IAAI,CAAC,CAAC,CAAA;CACpD,CAAA;AAED,eAAO,MAAM,aAAa,EAAE,OAAO,CAAC,eAAe,EAAE,WAAW,EAAE,EAAE,CAUjE,CAAA;AAKH,wEAAwE;AACxE,wBAAsB,UAAU,CAC/B,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,SAAS,GACb,OAAO,CAAC,MAAM,CAAC,CAcjB;AAKD,oFAAoF;AACpF,wBAAsB,WAAW,CAChC,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,SAAS,GACb,OAAO,CAAC,MAAM,CAAC,CAUjB;AAED;;;GAGG;AACH,eAAO,MAAM,YAAY,WAChB,MAAM,KACZ,OAAO,CAAC,WAAW,GAAG,SAAS,CAAoC,CAAA"}
package/dist/tokens.js ADDED
@@ -0,0 +1,52 @@
1
+ // password's emails (D-117): the tokens their links carry, used once, and
2
+ // the emails each account receives within one hour.
3
+ import { createHash, randomBytes } from 'node:crypto';
4
+ import { defineModel, master, PublicError } from 'ultimabase';
5
+ import { hour, hourly } from './models.js';
6
+ const M = { as: master };
7
+ /** The emails an account receives within one hour, of either kind. */
8
+ const emails = 3;
9
+ const lifetimes = {
10
+ reset: hour,
11
+ verify: 24 * hour,
12
+ };
13
+ export const PasswordToken = defineModel({
14
+ name: 'PasswordToken',
15
+ fields: (f) => ({
16
+ userId: f.string(),
17
+ kind: f.string(),
18
+ hash: f.string({ unique: true }),
19
+ expiresAt: f.dateTime(),
20
+ }),
21
+ indexes: [{ fields: ['userId', 'kind'] }, { fields: ['expiresAt'] }],
22
+ });
23
+ const hashOf = (token) => createHash('sha256').update(token).digest('base64url');
24
+ /** A new token of `kind` for `userId`, which replaces the older one. */
25
+ export async function issueToken(userId, kind) {
26
+ const now = Date.now();
27
+ await PasswordToken.deleteMany({ where: { expiresAt: { lte: new Date(now) } } }, M);
28
+ await PasswordToken.deleteMany({ where: { userId, kind } }, M);
29
+ const token = randomBytes(32).toString('base64url');
30
+ const expiresAt = new Date(now + lifetimes[kind]);
31
+ await PasswordToken.create({ data: { userId, kind, hash: hashOf(token), expiresAt } }, M);
32
+ return token;
33
+ }
34
+ const invalidLink = () => new PublicError('INVALID_CREDENTIALS', 'This link is invalid or has expired.');
35
+ /** The user a token of `kind` was issued to; a wrong, used or expired one fails. */
36
+ export async function redeemToken(token, kind) {
37
+ const hash = hashOf(token);
38
+ const stored = await PasswordToken.findUnique({ where: { hash } }, M);
39
+ if (stored?.kind !== kind)
40
+ throw invalidLink();
41
+ // One delete wins, so a token used twice at once works once.
42
+ const { count } = await PasswordToken.deleteMany({ where: { hash } }, M);
43
+ if (count === 0 || stored.expiresAt.getTime() <= Date.now()) {
44
+ throw invalidLink();
45
+ }
46
+ return stored.userId;
47
+ }
48
+ /**
49
+ * Counts an email to `userId`; past three within one hour, the refusal
50
+ * instead, and the email is not counted.
51
+ */
52
+ export const emailLimited = async (userId) => hourly('email', userId, emails);
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "@ultimabase/password",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "Passwords for UltimaBase: sign-up, sign-in and password changes, hashed with Argon2id.",
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "homepage": "https://ultimabase.dev/docs/password",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/MrEkinox/ultimabase.git",
11
+ "directory": "packages/password"
12
+ },
13
+ "engines": {
14
+ "node": ">=24.7.0"
15
+ },
16
+ "exports": {
17
+ ".": "./dist/index.js"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "src",
22
+ "!src/**/*.test.ts",
23
+ "!src/test"
24
+ ],
25
+ "scripts": {
26
+ "prepack": "rm -rf dist && yarn run -T tsc --project tsconfig.build.json"
27
+ },
28
+ "publishConfig": {
29
+ "exports": {
30
+ ".": "./dist/index.js"
31
+ }
32
+ },
33
+ "dependencies": {
34
+ "ipaddr.js": "2.5.0"
35
+ },
36
+ "peerDependencies": {
37
+ "@ultimabase/auth": "^0.1.1",
38
+ "ultimabase": "^0.1.1"
39
+ },
40
+ "devDependencies": {
41
+ "@types/pg": "8.23.1",
42
+ "@ultimabase/auth": "0.1.1",
43
+ "@ultimabase/graphql": "0.1.1",
44
+ "@ultimabase/http": "0.1.1",
45
+ "@ultimabase/postgres": "0.1.1",
46
+ "graphql": "17.0.2",
47
+ "pg": "8.23.1",
48
+ "ultimabase": "0.1.1",
49
+ "vite": "8.3.4",
50
+ "vitest": "5.0.5"
51
+ }
6
52
  }
package/src/core.ts ADDED
@@ -0,0 +1,206 @@
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 type { AuthApi } from '@ultimabase/auth'
5
+ import type {
6
+ Client,
7
+ Identity,
8
+ ExposedModel,
9
+ Logger,
10
+ Register,
11
+ Row,
12
+ UserOf,
13
+ } from 'ultimabase'
14
+ import { master, PublicError, transaction } from 'ultimabase'
15
+ import { dummyHash, hashPassword, verifyPassword } from './hash.ts'
16
+ import { normalized } from './input.ts'
17
+ import type { Attempt } from './models.ts'
18
+ import { limited, PasswordHash } from './models.ts'
19
+ import { PasswordToken } from './tokens.ts'
20
+
21
+ const M = { as: master }
22
+ /** The method `AuthAccount` links a password's email with (D-113). */
23
+ export const method = 'password'
24
+
25
+ /** What each running application's password reads, by auth's API. */
26
+ export interface State {
27
+ readonly auth: AuthApi
28
+ readonly users: ExposedModel
29
+ /** The fields a sign-up other than Master's takes, `false` when closed. */
30
+ readonly signUp: ReadonlySet<string> | false
31
+ readonly logger: Logger
32
+ /** The sends under way, which stopping waits for (D-117). */
33
+ readonly sending: Set<Promise<void>>
34
+ }
35
+
36
+ /** What a sign-in returns: its session, and the user read as itself. */
37
+ export interface SignedIn<U = Row> {
38
+ /**
39
+ * The session's token, there even when the cookie carries it: a mobile
40
+ * client needs it, and a function cannot tell one client from another
41
+ * (D-115).
42
+ */
43
+ readonly token: string
44
+ readonly expiresAt: Date
45
+ readonly user: U | null
46
+ }
47
+
48
+ const invalid = () =>
49
+ new PublicError('INVALID_CREDENTIALS', 'Wrong email or password.')
50
+
51
+ /** The attempt a client makes for `subject`; server code is not counted. */
52
+ export const attemptOf = (
53
+ subject: string,
54
+ client: Client | undefined,
55
+ ): Attempt =>
56
+ client === undefined ? undefined : { subject, address: client.address }
57
+
58
+ /**
59
+ * Creates the user, its account and its hash in one transaction, as Master:
60
+ * the caller's fields were checked against the list (D-113).
61
+ */
62
+ export async function createUser(
63
+ state: State,
64
+ email: string,
65
+ password: string,
66
+ data: object,
67
+ ): Promise<{ readonly user: Row; readonly stamp: string }> {
68
+ const hash = await hashPassword(password)
69
+ const stamp = randomUUID()
70
+ return transaction(async () => {
71
+ const user = await state.users.queries.create({ data }, M)
72
+ await state.auth.linkAccount(user.id, method, email)
73
+ await PasswordHash.create({ data: { userId: user.id, hash, stamp } }, M)
74
+ return { user, stamp }
75
+ })
76
+ }
77
+
78
+ /** Rehashes the password `stamp` names, unless it was set since. */
79
+ async function rehash(stamp: string, password: string): Promise<void> {
80
+ const hash = await hashPassword(password)
81
+ try {
82
+ await PasswordHash.update({ where: { stamp }, data: { hash } }, M)
83
+ } catch (error) {
84
+ if (!(error instanceof PublicError && error.code === 'NOT_FOUND')) {
85
+ throw error
86
+ }
87
+ }
88
+ }
89
+
90
+ /** No stored password is longer, so a longer one is not hashed. */
91
+ const longest = 256
92
+
93
+ /**
94
+ * The stamp of `userId`'s password when `password` is it, rehashing a stale
95
+ * hash; `undefined` otherwise.
96
+ */
97
+ async function matches(
98
+ userId: string | undefined,
99
+ typed: string,
100
+ ): Promise<string | undefined> {
101
+ const password = normalized(typed)
102
+ if (Array.from(password).length > longest) return undefined
103
+ const stored =
104
+ userId === undefined
105
+ ? null
106
+ : await PasswordHash.findUnique({ where: { userId } }, M)
107
+ // An unknown user costs a hash too, so the time tells nothing (D-113).
108
+ const { valid, stale } = await verifyPassword(
109
+ password,
110
+ stored?.hash ?? (await dummyHash()),
111
+ )
112
+ if (stored === null || !valid) return undefined
113
+ if (stale) await rehash(stored.stamp, password)
114
+ return stored.stamp
115
+ }
116
+
117
+ /** A user whose password checked, and the stamp it checked against. */
118
+ export interface Verified {
119
+ readonly userId: string
120
+ readonly stamp: string
121
+ }
122
+
123
+ /** The user `email` and `password` sign in as, counting the attempt. */
124
+ export async function verified(
125
+ state: State,
126
+ email: string,
127
+ password: string,
128
+ attempt: Attempt,
129
+ ): Promise<Verified> {
130
+ return limited(attempt, async () => {
131
+ const account = await state.auth.accountOf(method, email)
132
+ const stamp = await matches(account?.userId, password)
133
+ if (stamp === undefined || account === undefined) throw invalid()
134
+ return { userId: account.userId, stamp }
135
+ })
136
+ }
137
+
138
+ /** Checks `userId`'s current password, counting the attempt. */
139
+ export async function checkCurrent(
140
+ userId: string,
141
+ password: string,
142
+ attempt: Attempt,
143
+ ): Promise<void> {
144
+ await limited(attempt, async () => {
145
+ if ((await matches(userId, password)) === undefined) throw invalid()
146
+ })
147
+ }
148
+
149
+ /**
150
+ * Replaces `userId`'s hash, ends every session of the user and deletes its
151
+ * pending reset tokens (D-117); the new stamp.
152
+ */
153
+ export async function setPassword(
154
+ state: State,
155
+ userId: string,
156
+ password: string,
157
+ ): Promise<string> {
158
+ const stored = await PasswordHash.findUnique({ where: { userId } }, M)
159
+ if (stored === null) {
160
+ throw new PublicError(
161
+ 'NOT_FOUND',
162
+ `User ${userId} has no password: sign it up with signUp.`,
163
+ )
164
+ }
165
+ const hash = await hashPassword(password)
166
+ const stamp = randomUUID()
167
+ await transaction(async () => {
168
+ await PasswordHash.update({ where: { userId }, data: { hash, stamp } }, M)
169
+ await PasswordToken.deleteMany({ where: { userId, kind: 'reset' } }, M)
170
+ await state.auth.revokeSessions(userId)
171
+ })
172
+ // Again once the stamp is visible: a sign-in that read the old one opened
173
+ // its session before, so this ends it (see `signedIn`).
174
+ await state.auth.revokeSessions(userId)
175
+ return stamp
176
+ }
177
+
178
+ /** The user as it reads itself, so its own field rules apply (D-113). */
179
+ async function asItself(state: State, userId: string): Promise<Row | null> {
180
+ const { queries } = state.users
181
+ // A transport's queries are untyped; this is the user model's row.
182
+ const user: unknown = await queries.findUnique({ where: { id: userId } }, M)
183
+ if (user === null) return null
184
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the user model is the one the application registers (D-62, D-80).
185
+ const caller: Identity = { kind: 'user', user: user as UserOf<Register> }
186
+ return queries.findUnique({ where: { id: userId } }, { as: caller })
187
+ }
188
+
189
+ /**
190
+ * Opens a session for the user, with its cookie when a client is given. The
191
+ * stamp is read after: a password set since the check ends the user's
192
+ * sessions and fails, an older read means `setPassword` ends this one (D-113).
193
+ */
194
+ export async function signedIn(
195
+ state: State,
196
+ { userId, stamp }: Verified,
197
+ client: Client | undefined,
198
+ ): Promise<SignedIn> {
199
+ const { token, expiresAt } = await state.auth.signIn(userId, client)
200
+ const stored = await PasswordHash.findUnique({ where: { userId } }, M)
201
+ if (stored?.stamp !== stamp) {
202
+ await state.auth.revokeSessions(userId)
203
+ throw invalid()
204
+ }
205
+ return { token, expiresAt, user: await asItself(state, userId) }
206
+ }
@@ -0,0 +1,137 @@
1
+ // password's email functions (D-117): `requestPasswordReset`,
2
+ // `resetPassword`, `verifyEmail` and `sendVerificationEmail`, there when the
3
+ // application gives `sendEmail`.
4
+ import type { FunctionShape } from 'ultimabase'
5
+ import { allow, defineFunction, master, PublicError } from 'ultimabase'
6
+ import type { State } from './core.ts'
7
+ import { method, setPassword } from './core.ts'
8
+ import {
9
+ outputSchema,
10
+ requestResetInput,
11
+ resetPasswordInput,
12
+ verifyEmailInput,
13
+ } from './input.ts'
14
+ import { requestLimited } from './models.ts'
15
+ import type { Email, EmailKind } from './tokens.ts'
16
+ import { emailLimited, issueToken, redeemToken } from './tokens.ts'
17
+
18
+ /**
19
+ * Runs `work` after the response, so its time tells nothing; a failure is
20
+ * logged, and stopping the application waits for it.
21
+ */
22
+ export function later(state: State, work: () => Promise<void>): void {
23
+ const task: Promise<void> = Promise.resolve()
24
+ .then(work)
25
+ .catch((error: unknown) => {
26
+ state.logger.error({ err: error }, 'An email failed to send.')
27
+ })
28
+ .finally(() => {
29
+ state.sending.delete(task)
30
+ })
31
+ state.sending.add(task)
32
+ }
33
+
34
+ /** The application's `sendEmail` (D-117). */
35
+ export type SendEmail = (email: Email) => Promise<void>
36
+
37
+ /**
38
+ * Sends `userId` an email of `kind` to `to`, with a new token; past the
39
+ * account's hourly limit, the refusal instead.
40
+ */
41
+ export type Mailer = (
42
+ kind: EmailKind,
43
+ userId: string,
44
+ to: string,
45
+ ) => Promise<PublicError | undefined>
46
+
47
+ export const mailerOf =
48
+ (sendEmail: SendEmail): Mailer =>
49
+ async (kind, userId, to) => {
50
+ const refused = await emailLimited(userId)
51
+ if (refused !== undefined) return refused
52
+ const token = await issueToken(userId, kind)
53
+ await sendEmail({ kind, to, token, userId })
54
+ return undefined
55
+ }
56
+
57
+ const done = outputSchema<boolean>({ type: 'boolean' })
58
+ const M = { as: master }
59
+
60
+ /** Marks `userId`'s email verified: the user received an email (D-117). */
61
+ async function verify(state: State, userId: string): Promise<void> {
62
+ const where = { id: userId }
63
+ await state.users.queries.update({ where, data: { emailVerified: true } }, M)
64
+ }
65
+
66
+ /** The functions that send an email or redeem its token; each is public. */
67
+ export function emailFunctions(
68
+ stateOf: () => State,
69
+ mail: Mailer,
70
+ ): readonly FunctionShape[] {
71
+ const requestPasswordReset = defineFunction({
72
+ name: 'requestPasswordReset',
73
+ input: requestResetInput,
74
+ output: done,
75
+ run: async ({ input: { input }, client }) => {
76
+ const state = stateOf()
77
+ if (client !== undefined) await requestLimited('reset', client.address)
78
+ const { email } = input
79
+ // The same answer, and no wait, whether the email has an account.
80
+ later(state, async () => {
81
+ const account = await state.auth.accountOf(method, email)
82
+ if (account === undefined) return
83
+ await mail('reset', account.userId, email)
84
+ })
85
+ return true
86
+ },
87
+ }).with({ access: allow.public })
88
+ const resetPassword = defineFunction({
89
+ name: 'resetPassword',
90
+ input: resetPasswordInput,
91
+ output: done,
92
+ run: async ({ input: { input } }) => {
93
+ const state = stateOf()
94
+ const userId = await redeemToken(input.token, 'reset')
95
+ await setPassword(state, userId, input.password)
96
+ await verify(state, userId)
97
+ return true
98
+ },
99
+ }).with({ access: allow.public })
100
+ const verifyEmail = defineFunction({
101
+ name: 'verifyEmail',
102
+ input: verifyEmailInput,
103
+ output: done,
104
+ run: async ({ input: { input } }) => {
105
+ await verify(stateOf(), await redeemToken(input.token, 'verify'))
106
+ return true
107
+ },
108
+ }).with({ access: allow.public })
109
+ const sendVerificationEmail = defineFunction({
110
+ name: 'sendVerificationEmail',
111
+ output: done,
112
+ run: async ({ caller, client }) => {
113
+ if (caller.kind !== 'user') {
114
+ throw new PublicError(
115
+ 'UNAUTHENTICATED',
116
+ 'sendVerificationEmail needs a signed-in user.',
117
+ )
118
+ }
119
+ if (client !== undefined) await requestLimited('verify', client.address)
120
+ const { user } = caller
121
+ // The user's row, read as Master, holds the field password adds.
122
+ if (Reflect.get(user, 'emailVerified') === true) return true
123
+ const to = await stateOf().auth.subjectOf(user.id, method)
124
+ // A user signed in another way has no email to verify.
125
+ if (to === undefined) return true
126
+ const refused = await mail('verify', user.id, to)
127
+ if (refused !== undefined) throw refused
128
+ return true
129
+ },
130
+ }).with({ access: allow.public })
131
+ return [
132
+ requestPasswordReset,
133
+ resetPassword,
134
+ verifyEmail,
135
+ sendVerificationEmail,
136
+ ]
137
+ }