wirejs-resources 0.1.7-alpha → 0.1.9-alpha

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.
@@ -1,439 +0,0 @@
1
- import { scrypt, randomBytes } from 'crypto';
2
-
3
- import * as jose from 'jose';
4
-
5
- import { Resource } from '../resource.js';
6
- import { FileService } from './file.js';
7
- import { Secret } from '../resources/secret.js';
8
- import { withContext } from '../adapters/context.js';
9
- import { overrides } from '../overrides.js';
10
-
11
-
12
- /**
13
- * @param {string} password
14
- * @param {string} [salt]
15
- */
16
- function hash(password, salt) {
17
- return new Promise((resolve, reject) => {
18
- const finalSalt = salt || randomBytes(16).toString('hex');
19
- scrypt(password, finalSalt, 64, (err, key) => {
20
- if (err) {
21
- reject(err);
22
- } else {
23
- resolve(`${finalSalt}$${key.toString('hex')}`);
24
- }
25
- })
26
- });
27
- }
28
-
29
- /**
30
- * @param {string} password
31
- * @param {string} passwordHash
32
- */
33
- async function verifyHash(password, passwordHash) {
34
- const [saltPart, _hashPart] = passwordHash.split('$');
35
- const rehashed = await hash(password, saltPart);
36
- return rehashed === passwordHash;
37
- }
38
-
39
- // #region types
40
-
41
- /**
42
- * @typedef {{
43
- * id: string;
44
- * password: string;
45
- * }} User
46
- */
47
-
48
- /**
49
- * @typedef {{
50
- * label: string;
51
- * type: 'string' | 'password' | 'number' | 'boolean';
52
- * isRequired?: boolean;
53
- * }} AuthenticationInput
54
- */
55
-
56
- /**
57
- * @typedef {{
58
- * name: string;
59
- * title?: string;
60
- * description?: string;
61
- * message?: string;
62
- * inputs?: Record<string, AuthenticationInput>;
63
- * buttons?: string[];
64
- * }} Action
65
- */
66
-
67
- /**
68
- * @typedef {{
69
- * state: 'authenticated' | 'unauthenticated';
70
- * user: string | undefined;
71
- * }} AuthenticationBaseState
72
- */
73
-
74
- /**
75
- * @typedef {{
76
- * state: AuthenticationBaseState;
77
- * message?: string;
78
- * actions: Record<string, Action>;
79
- * }} AuthenticationState
80
- */
81
-
82
- /**
83
- * @typedef {{
84
- * key: string;
85
- * inputs: Record<string, string | number | boolean>;
86
- * verb: string;
87
- * }} PerformActionParameter
88
- */
89
-
90
- /**
91
- * @typedef {{
92
- * message: string;
93
- * field?: string;
94
- * }} AuthenticationError
95
- */
96
-
97
- /**
98
- * @typedef {Object} AuthenticationServiceOptions
99
- * @property {number} [duration] - The number of seconds the authentication session stays alive.
100
- * @property {boolean} [keepalive] - Whether to automatically extend (keep alive) an authentication session when used.
101
- * @property {string} [cookie] - The name of the cookie to use to store the authentication state JWT.
102
- */
103
-
104
- // #endregion
105
-
106
- const ONE_WEEK = 7 * 24 * 60 * 60; // days * hours/day * minutes/hour * seconds/minute
107
-
108
- export class AuthenticationService extends Resource {
109
- #duration;
110
- #keepalive;
111
- #cookieName;
112
-
113
- /**
114
- * @type {Secret}
115
- */
116
- #rawSigningSecret;
117
-
118
- /**
119
- * @type {Promise<Uint8Array<ArrayBufferLike>> | undefined}
120
- */
121
- #signingSecret;
122
-
123
- #users;
124
-
125
- /**
126
- *
127
- * @param {Resource | string} scope
128
- * @param {string} id
129
- * @param {AuthenticationServiceOptions} [options]
130
- */
131
- constructor(scope, id, { duration, keepalive, cookie } = {}) {
132
- super(scope, id);
133
-
134
- this.#duration = duration || ONE_WEEK;
135
- this.#keepalive = !!keepalive;
136
- this.#cookieName = cookie ?? 'identity';
137
-
138
- this.#rawSigningSecret = new (overrides.Secret || Secret)(this, 'jwt-signing-secret');
139
- const fileService = new (overrides.FileService || FileService)(this, 'files');
140
-
141
- this.#users = {
142
- id,
143
-
144
- /**
145
- *
146
- * @param {string} username
147
- */
148
- async get(username) {
149
- try {
150
- const data = await fileService.read(this.filenameFor(username));
151
- return JSON.parse(data);
152
- } catch {
153
- return undefined;
154
- }
155
- },
156
-
157
- /**
158
- * @param {string} username
159
- * @param {User} user
160
- */
161
- async set(username, details) {
162
- await fileService.write(this.filenameFor(username), JSON.stringify(details));
163
- },
164
-
165
- /**
166
- * @param {string} username
167
- */
168
- async has(username) {
169
- const user = await this.get(username);
170
- return !!user;
171
- },
172
-
173
- /**
174
- * @param {string} username
175
- * @returns
176
- */
177
- filenameFor(username) {
178
- return `${username}.json`;
179
- }
180
- }
181
- }
182
-
183
- async getSigningSecret() {
184
- const secretAsString = await this.#rawSigningSecret.read();
185
- return new TextEncoder().encode(secretAsString);
186
- }
187
-
188
- /**
189
- * @type {Promise<Uint8Array<ArrayBufferLike>>}
190
- */
191
- get signingSecret() {
192
- if (!this.#signingSecret) {
193
- this.#signingSecret = this.getSigningSecret();
194
- }
195
- return this.#signingSecret;
196
- }
197
-
198
- /**
199
- * @param {CookieJar} cookies
200
- * @returns {Promise<AuthenticationBaseState>}
201
- */
202
- async getBaseState(cookies) {
203
- let idCookie, idPayload, user;
204
-
205
- try {
206
- idCookie = cookies.get(this.#cookieName)?.value;
207
- idPayload = idCookie ? (
208
- await jose.jwtVerify(idCookie, await this.signingSecret)
209
- ) : undefined;
210
- user = idPayload ? idPayload.payload.sub : undefined;
211
- } catch (err) {
212
- // jose doesn't like our cookie.
213
- console.error(err);
214
- }
215
-
216
- if (user) {
217
- return {
218
- state: 'authenticated',
219
- user
220
- }
221
- } else {
222
- return {
223
- state: 'unauthenticated',
224
- user: undefined,
225
- }
226
- }
227
- }
228
-
229
- /**
230
- * @param {CookieJar} cookies
231
- * @returns {Promise<AuthenticationState>}
232
- */
233
- async getState(cookies) {
234
- const state = await this.getBaseState(cookies);
235
- if (state.state === 'authenticated') {
236
- if (this.#keepalive) this.setBaseState(state);
237
- return {
238
- state,
239
- actions: {
240
- changepassword: {
241
- name: "Change Password",
242
- inputs: {
243
- existingPassword: {
244
- label: 'Old Password',
245
- type: 'password',
246
- },
247
- newPassword: {
248
- label: 'New Password',
249
- type: 'password',
250
- }
251
- },
252
- buttons: ['Change Password']
253
- },
254
- signout: {
255
- name: "Sign out"
256
- },
257
- }
258
- }
259
- } else {
260
- return {
261
- state,
262
- actions: {
263
- signin: {
264
- name: "Sign In",
265
- inputs: {
266
- username: {
267
- label: 'Username',
268
- type: 'text',
269
- },
270
- password: {
271
- label: 'Password',
272
- type: 'password',
273
- },
274
- },
275
- buttons: ['Sign In']
276
- },
277
- signup: {
278
- name: "Sign Up",
279
- inputs: {
280
- username: {
281
- label: 'Username',
282
- type: 'text',
283
- },
284
- password: {
285
- label: 'Password',
286
- type: 'password',
287
- },
288
- },
289
- buttons: ['Sign Up']
290
- },
291
- }
292
- }
293
- }
294
- }
295
-
296
- /**
297
- *
298
- * @param {CookieJar} cookies
299
- * @param {string | undefined} [user]
300
- */
301
- async setBaseState(cookies, user) {
302
- if (!user) {
303
- cookies.delete(this.#cookieName);
304
- } else {
305
- const jwt = await new jose.SignJWT({})
306
- .setProtectedHeader({ alg: 'HS256' })
307
- .setIssuedAt()
308
- .setSubject(user)
309
- .setExpirationTime(`${this.#duration}s`)
310
- .sign(await this.signingSecret);
311
-
312
- cookies.set({
313
- name: this.#cookieName,
314
- value: jwt,
315
- httpOnly: true,
316
- secure: true,
317
- maxAge: this.#duration
318
- });
319
- }
320
- }
321
-
322
- /**
323
- *
324
- * @param {Record<string, string>} input
325
- * @param {string[]} fields
326
- * @returns {AuthenticationError[] | undefined}
327
- */
328
- missingFieldErrors(input, fields) {
329
- /**
330
- * @type {AuthenticationError[]}
331
- */
332
- const errors = [];
333
- for (const field of fields) {
334
- if (!input[field]) errors.push({
335
- field,
336
- message: "Field is required."
337
- });
338
- }
339
- return errors.length > 0 ? errors : undefined;
340
- }
341
-
342
- /**
343
- * @param {CookieJar} cookies
344
- * @param {PerformActionParameter} params
345
- * @returns {Promise<AuthenticationState | { errors: AuthenticationError[] }>}
346
- */
347
- async setState(cookies, { key, inputs, verb: _verb }) {
348
- if (key === 'signout') {
349
- await this.setBaseState(cookies, undefined);
350
- return this.getState(cookies);
351
- } else if (key === 'signup') {
352
- const errors = this.missingFieldErrors(inputs, ['username', 'password']);
353
- if (errors) {
354
- return { errors };
355
- } else if (await this.#users.has(inputs.username)) {
356
- return { errors:
357
- [{
358
- field: 'username',
359
- message: 'User already exists.'
360
- }]
361
- };
362
- } else {
363
- await this.#users.set(inputs.username, {
364
- id: inputs.username,
365
- password: await hash(inputs.password)
366
- });
367
- await this.setBaseState(cookies, inputs.username);
368
- return this.getState(cookies);
369
- }
370
- } else if (key === 'signin') {
371
- const user = await this.#users.get(inputs.username);
372
- if (!user) {
373
- return { errors:
374
- [{
375
- field: 'username',
376
- message: `User doesn't exist.`
377
- }]
378
- };
379
- } else if (await verifyHash(inputs.password, user.password)) {
380
- // a real authentication service will use password hashing.
381
- // this is an in-memory just-for-testing user pool.
382
- await this.setBaseState(cookies, inputs.username);
383
- return this.getState(cookies);
384
- } else {
385
- return { errors:
386
- [{
387
- field: 'password',
388
- message: "Incorrect password."
389
- }]
390
- };
391
- }
392
- } else if (key === 'changepassword') {
393
- const state = await this.getBaseState(cookies);
394
- const user = await this.#users.get(state.user);
395
- if (!user) {
396
- return { errors:
397
- [{
398
- field: 'username',
399
- message: `You're not signed in as a recognized user.`
400
- }]
401
- };
402
- } else if (await verifyHash(inputs.existingPassword, user.password)) {
403
- await this.#users.set(user.id, {
404
- ...user,
405
- password: await hash(inputs.newPassword)
406
- });
407
- return {
408
- message: "Password updated.",
409
- ...await this.getState(cookies)
410
- };
411
- } else {
412
- return { errors: [{
413
- field: 'existingPassword',
414
- message: "The provided existing password is incorrect."
415
- }]
416
- };
417
- }
418
- } else {
419
- return { errors:
420
- [{
421
- message: 'Unrecognized authentication action.'
422
- }]
423
- };
424
- }
425
- }
426
-
427
- buildApi() {
428
- return withContext(context => ({
429
- getState: () => this.getState(context.cookies),
430
-
431
- /**
432
- *
433
- * @param {Parameters<typeof this['setState']>[1]} options
434
- * @returns
435
- */
436
- setState: (options) => this.setState(context.cookies, options),
437
- }));
438
- }
439
- }
@@ -1,78 +0,0 @@
1
- import process from 'process';
2
- import fs from 'fs';
3
- import path from 'path';
4
-
5
- import { Resource } from '../resource.js';
6
-
7
- const CWD = process.cwd();
8
-
9
- const ALREADY_EXISTS_CODE = 'EEXIST';
10
-
11
- export class FileService extends Resource {
12
- /**
13
- * @param {Resource | string} scope
14
- * @param {string} id
15
- */
16
- constructor(scope, id) {
17
- super(scope, id);
18
- }
19
-
20
- /**
21
- * @param {string} filename
22
- * @returns
23
- */
24
- #fullNameFor(filename) {
25
- const sanitizedId = this.absoluteId.replace('~', '-').replace(/\.+/g, '.');
26
- const sanitizedName = filename.replace('~', '-').replace(/\.+/g, '.');
27
- return path.join(CWD, 'temp', 'wirejs-services', sanitizedId, sanitizedName);
28
- }
29
-
30
- /**
31
- * @param {string} filename
32
- * @param {BufferEncoding} [encoding]
33
- * @return {Promise<string>} file data as a string
34
- */
35
- async read(filename, encoding = 'utf8') {
36
- return fs.promises.readFile(this.#fullNameFor(filename), { encoding });
37
- }
38
-
39
- /**
40
- *
41
- * @param {string} filename
42
- * @param {string} data
43
- * @param {{
44
- * onlyIfNotExists?: boolean;
45
- * }} [options]
46
- */
47
- async write(filename, data, { onlyIfNotExists = false } = {}) {
48
- const fullname = this.#fullNameFor(filename);
49
- const flag = onlyIfNotExists ? 'wx' : 'w';
50
- await fs.promises.mkdir(path.dirname(fullname), { recursive: true });
51
- return fs.promises.writeFile(fullname, data, { flag });
52
- }
53
-
54
- /**
55
- *
56
- * @param {string} filename
57
- */
58
- async delete(filename) {
59
- return fs.promises.unlink(this.#fullNameFor(filename));
60
- }
61
-
62
- /**
63
- *
64
- * @param {{
65
- * prefix?: string
66
- * }} [options]
67
- */
68
- async * list({ prefix = '' } = {}) {
69
- const all = await fs.promises.readdir(CWD, { recursive: true });
70
- for (const name of all) {
71
- if (prefix === undefined || name.startsWith(prefix)) yield name;
72
- }
73
- }
74
-
75
- isAlreadyExistsError(error) {
76
- return error.code === ALREADY_EXISTS_CODE;
77
- }
78
- }
package/lib/types.ts DELETED
@@ -1,16 +0,0 @@
1
- import type {
2
- AuthenticationService as AuthenticationServiceBase,
3
- withContext as withContextBase,
4
- requiresContext as requiresContextBase,
5
- FileService as FileServiceBase,
6
- Context as ContextBase,
7
- Resource as ResourceBase,
8
- } from './index.js';
9
-
10
- export declare class Resource extends ResourceBase {};
11
- export declare class Context extends ContextBase {};
12
- export declare class AuthenticationService extends AuthenticationServiceBase {};
13
- export declare class FileService extends FileServiceBase {};
14
- export declare const withContext: typeof withContextBase;
15
- export declare const requiresContext: typeof requiresContextBase;
16
- export declare const overrides: any;
File without changes