@bitblit/ratchet-warden-server 6.0.146-alpha → 6.0.147-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.
Files changed (26) hide show
  1. package/package.json +9 -8
  2. package/src/build/ratchet-warden-server-info.ts +19 -0
  3. package/src/server/provider/warden-default-send-magic-link-command-validator.ts +21 -0
  4. package/src/server/provider/warden-default-user-decoration-provider.ts +23 -0
  5. package/src/server/provider/warden-dynamo-storage-provider-options.ts +8 -0
  6. package/src/server/provider/warden-dynamo-storage-provider.ts +278 -0
  7. package/src/server/provider/warden-event-processing-provider.ts +10 -0
  8. package/src/server/provider/warden-mailer-and-expiring-code-ratchet-single-use-code-provider.ts +155 -0
  9. package/src/server/provider/warden-mailer-and-expiring-code-ratchet-single-user-provider-options.ts +10 -0
  10. package/src/server/provider/warden-message-sending-provider.ts +14 -0
  11. package/src/server/provider/warden-no-op-event-processing-provider.ts +12 -0
  12. package/src/server/provider/warden-s3-single-file-storage-provider-options.ts +6 -0
  13. package/src/server/provider/warden-s3-single-file-storage-provider.ts +139 -0
  14. package/src/server/provider/warden-send-magic-link-command-validator.ts +12 -0
  15. package/src/server/provider/warden-single-use-code-provider.ts +27 -0
  16. package/src/server/provider/warden-storage-provider.ts +22 -0
  17. package/src/server/provider/warden-third-party-authentication-provider.ts +18 -0
  18. package/src/server/provider/warden-twilio-verify-single-use-code-provider-options.ts +5 -0
  19. package/src/server/provider/warden-twilio-verify-single-use-code-provider.ts +38 -0
  20. package/src/server/provider/warden-user-decoration-provider.ts +10 -0
  21. package/src/server/warden-authorizer.ts +130 -0
  22. package/src/server/warden-entry-builder.ts +56 -0
  23. package/src/server/warden-service-options.ts +20 -0
  24. package/src/server/warden-service.spec.ts +102 -0
  25. package/src/server/warden-service.ts +815 -0
  26. package/src/server/warden-web-authn-export-token.ts +6 -0
@@ -0,0 +1,815 @@
1
+ import {
2
+ AuthenticationResponseJSON,
3
+ AuthenticatorTransportFuture,
4
+ generateAuthenticationOptions,
5
+ GenerateAuthenticationOptionsOpts,
6
+ generateRegistrationOptions,
7
+ PublicKeyCredentialCreationOptionsJSON,
8
+ PublicKeyCredentialRequestOptionsJSON,
9
+ RegistrationResponseJSON,
10
+ VerifiedAuthenticationResponse,
11
+ VerifiedRegistrationResponse,
12
+ verifyAuthenticationResponse,
13
+ VerifyAuthenticationResponseOpts,
14
+ verifyRegistrationResponse,
15
+ VerifyRegistrationResponseOpts
16
+ } from "@simplewebauthn/server";
17
+ import { WardenServiceOptions } from "./warden-service-options.js";
18
+ import { WardenContact } from "@bitblit/ratchet-warden-common/common/model/warden-contact";
19
+ import {
20
+ WardenCustomTemplateDescriptor
21
+ } from "@bitblit/ratchet-warden-common/common/command/warden-custom-template-descriptor";
22
+ import { WardenEntry } from "@bitblit/ratchet-warden-common/common/model/warden-entry";
23
+ import { WardenUtils } from "@bitblit/ratchet-warden-common/common/util/warden-utils";
24
+ import { WardenLoginRequest } from "@bitblit/ratchet-warden-common/common/model/warden-login-request";
25
+ import { WardenCommand } from "@bitblit/ratchet-warden-common/common/command/warden-command";
26
+ import { WardenCommandResponse } from "@bitblit/ratchet-warden-common/common/command/warden-command-response";
27
+ import { WardenLoginResults } from "@bitblit/ratchet-warden-common/common/model/warden-login-results";
28
+ import {
29
+ WardenStoreRegistrationResponse
30
+ } from "@bitblit/ratchet-warden-common/common/model/warden-store-registration-response";
31
+ import { WardenUserDecoration } from "@bitblit/ratchet-warden-common/common/model/warden-user-decoration";
32
+ import { WardenJwtToken } from "@bitblit/ratchet-warden-common/common/model/warden-jwt-token";
33
+ import {
34
+ WardenStoreRegistrationResponseType
35
+ } from "@bitblit/ratchet-warden-common/common/model/warden-store-registration-response-type";
36
+ import { WardenWebAuthnEntry } from "@bitblit/ratchet-warden-common/common/model/warden-web-authn-entry";
37
+
38
+ import { RequireRatchet } from "@bitblit/ratchet-common/lang/require-ratchet";
39
+ import { Logger } from "@bitblit/ratchet-common/logger/logger";
40
+ import { ErrorRatchet } from "@bitblit/ratchet-common/lang/error-ratchet";
41
+ import { StringRatchet } from "@bitblit/ratchet-common/lang/string-ratchet";
42
+ import { Base64Ratchet } from "@bitblit/ratchet-common/lang/base64-ratchet";
43
+ import { ExpiredJwtHandling } from "@bitblit/ratchet-common/jwt/expired-jwt-handling";
44
+
45
+ import { WardenDefaultUserDecorationProvider } from "./provider/warden-default-user-decoration-provider.js";
46
+ import { WardenNoOpEventProcessingProvider } from "./provider/warden-no-op-event-processing-provider.js";
47
+ import { WardenSingleUseCodeProvider } from "./provider/warden-single-use-code-provider.js";
48
+ import {
49
+ WardenDefaultSendMagicLinkCommandValidator
50
+ } from "./provider/warden-default-send-magic-link-command-validator.js";
51
+ import { WardenLoginRequestType } from "@bitblit/ratchet-warden-common/common/model/warden-login-request-type";
52
+ import {
53
+ WardenThirdPartyAuthentication
54
+ } from "@bitblit/ratchet-warden-common/common/model/warden-third-party-authentication";
55
+ import { WardenThirdPartyAuthenticationProvider } from "./provider/warden-third-party-authentication-provider.js";
56
+ import { WardenEntryBuilder } from "./warden-entry-builder.js";
57
+ import { WardenAuthorizer } from "./warden-authorizer.ts";
58
+ import { WardenWebAuthnExportToken } from "./warden-web-authn-export-token.ts";
59
+
60
+ export class WardenService {
61
+ private opts: WardenServiceOptions;
62
+ private cacheAuthorizer: WardenAuthorizer;
63
+
64
+ constructor(private inOptions: WardenServiceOptions) {
65
+ RequireRatchet.notNullOrUndefined(inOptions, "options");
66
+ RequireRatchet.notNullOrUndefined(inOptions.relyingPartyName, "options.relyingPartyName");
67
+ RequireRatchet.notNullUndefinedOrEmptyArray(inOptions.allowedOrigins, "options.allowedOrigins");
68
+ RequireRatchet.notNullOrUndefined(inOptions.storageProvider, "options.storageProvider");
69
+ RequireRatchet.notNullOrUndefined(inOptions.jwtRatchet, "options.jwtRatchet");
70
+ RequireRatchet.notNullUndefinedOrEmptyArray(inOptions.singleUseCodeProviders, "options.singleUseCodeProviders");
71
+
72
+ this.opts = Object.assign(
73
+ {
74
+ userTokenDataProvider: new WardenDefaultUserDecorationProvider(),
75
+ eventProcessor: new WardenNoOpEventProcessingProvider(),
76
+ sendMagicLinkCommandValidator: new WardenDefaultSendMagicLinkCommandValidator()
77
+ },
78
+ inOptions
79
+ );
80
+ this.cacheAuthorizer = new WardenAuthorizer(inOptions);
81
+ }
82
+
83
+ public get authorizer(): WardenAuthorizer {
84
+ return this.cacheAuthorizer;
85
+ }
86
+
87
+ public get options(): WardenServiceOptions {
88
+ return Object.assign({}, this.opts); // Don't allow a reader to change
89
+ }
90
+
91
+ // Passthru for very common use case
92
+ public findEntryByContact(contact: WardenContact): Promise<WardenEntry> {
93
+ return this.opts.storageProvider.findEntryByContact(contact);
94
+ }
95
+
96
+ // Passthru for very common use case
97
+ public findEntryById(userId: string): Promise<WardenEntry> {
98
+ return this.opts.storageProvider.findEntryById(userId);
99
+ }
100
+
101
+ // A helper function for bridging across GraphQL as an embedded JSON command
102
+ public async processCommandStringToString(cmdString: string, origin: string, loggedInUserId: string): Promise<string> {
103
+ let rval: string = null;
104
+ try {
105
+ const cmd: WardenCommand = JSON.parse(cmdString);
106
+ const resp: WardenCommandResponse = await this.processCommandToResponse(cmd, origin, loggedInUserId);
107
+ if (resp === null) {
108
+ Logger.warn("Response was null for %s %s %s", cmdString, origin, loggedInUserId);
109
+ } else {
110
+ rval = JSON.stringify(resp);
111
+ }
112
+ } catch (err) {
113
+ // Just cast it directly
114
+ const errString: string = ErrorRatchet.safeStringifyErr(err);
115
+ Logger.error("Failed %s : %j", errString, cmdString, err);
116
+ rval = JSON.stringify({ error: errString } as WardenCommandResponse);
117
+ }
118
+ return rval;
119
+ }
120
+
121
+ // A helper function for bridging across GraphQL as an embedded JSON command
122
+ public async processCommandToResponse(cmd: WardenCommand, origin: string, loggedInUserId: string): Promise<WardenCommandResponse> {
123
+ let rval: WardenCommandResponse = null;
124
+ if (cmd) {
125
+ Logger.info("Processing command : UserID: %s Origin: %s Command: %j", loggedInUserId, origin, cmd);
126
+
127
+ if (cmd.sendExpiringValidationToken) {
128
+ rval = { sendExpiringValidationToken: await this.sendExpiringValidationToken(cmd.sendExpiringValidationToken, origin) };
129
+ } else if (cmd.generateWebAuthnAuthenticationChallengeForUserId) {
130
+ const tmp: PublicKeyCredentialRequestOptionsJSON = await this.generateWebAuthnAuthenticationChallengeForUserId(
131
+ cmd.generateWebAuthnAuthenticationChallengeForUserId,
132
+ origin
133
+ );
134
+ rval = { generateWebAuthnAuthenticationChallengeForUserId: { dataAsJson: JSON.stringify(tmp) } };
135
+ } else if (cmd.createAccount) {
136
+ const newEntry: WardenEntry = await this.createAccount(
137
+ cmd.createAccount.contact,
138
+ origin,
139
+ cmd.createAccount.sendCode,
140
+ cmd.createAccount.label,
141
+ cmd.createAccount.tags
142
+ );
143
+ rval = {
144
+ createAccount: newEntry.userId
145
+ };
146
+ } else if (cmd.sendMagicLink) {
147
+ if (cmd?.sendMagicLink?.contactLookup && cmd?.sendMagicLink?.contact) {
148
+ throw ErrorRatchet.fErr("You may not specify both contact and contactLookup");
149
+ }
150
+ if (!cmd?.sendMagicLink?.contactLookup && !cmd?.sendMagicLink?.contact) {
151
+ throw ErrorRatchet.fErr("You must not specify either contact and contactLookup");
152
+ }
153
+ if (cmd.sendMagicLink.contactLookup) {
154
+ const entry: WardenEntry = await this.findEntryById(cmd.sendMagicLink.contactLookup.userId);
155
+ if (entry) {
156
+ if (cmd.sendMagicLink.contactLookup.contactType) {
157
+ // Use the one specified, otherwise just first one
158
+ cmd.sendMagicLink.contact = (entry.contactMethods || []).find(
159
+ (cm) => cm.type === cmd.sendMagicLink.contactLookup.contactType
160
+ );
161
+ } else {
162
+ cmd.sendMagicLink.contact = (entry.contactMethods || []).length > 0 ? entry.contactMethods[0] : null;
163
+ }
164
+ }
165
+ cmd.sendMagicLink.contactLookup = null;
166
+ }
167
+
168
+ if (!cmd.sendMagicLink.contact) {
169
+ throw ErrorRatchet.fErr("Could not find contract entry either directly or by lookup");
170
+ }
171
+ // Now run all allowance checks on the link
172
+ const loggedInUser: WardenEntry = StringRatchet.trimToNull(loggedInUserId)
173
+ ? await this.opts.storageProvider.findEntryById(loggedInUserId)
174
+ : null;
175
+
176
+ await this.opts.sendMagicLinkCommandValidator.allowMagicLinkCommand(cmd.sendMagicLink, origin, loggedInUser);
177
+
178
+ const ttlSeconds: number = cmd?.sendMagicLink?.ttlSeconds || 300;
179
+
180
+ rval = {
181
+ sendMagicLink: await this.sendMagicLink(
182
+ cmd.sendMagicLink.contact,
183
+ cmd.sendMagicLink.overrideDestinationContact,
184
+ this.opts.relyingPartyName,
185
+ cmd.sendMagicLink.landingUrl,
186
+ cmd.sendMagicLink.meta,
187
+ ttlSeconds,
188
+ cmd.sendMagicLink.customTemplate
189
+ )
190
+ };
191
+ } else if (cmd.generateWebAuthnRegistrationChallengeForLoggedInUser) {
192
+ if (!StringRatchet.trimToNull(loggedInUserId)) {
193
+ ErrorRatchet.throwFormattedErr("This requires a logged in user");
194
+ }
195
+ const tmp: PublicKeyCredentialCreationOptionsJSON = await this.generateWebAuthnRegistrationChallengeForLoggedInUser(
196
+ loggedInUserId,
197
+ origin
198
+ );
199
+ rval = { generateWebAuthnRegistrationChallengeForLoggedInUser: { dataAsJson: JSON.stringify(tmp) } };
200
+ } else if (cmd.addContactToLoggedInUser) {
201
+ if (!WardenUtils.validContact(cmd.addContactToLoggedInUser)) {
202
+ ErrorRatchet.throwFormattedErr("Cannot add, invalid contact %j", cmd.addContactToLoggedInUser);
203
+ } else {
204
+ const out: boolean = await this.addContactMethodToUser(loggedInUserId, cmd.addContactToLoggedInUser);
205
+ rval = { addContactToLoggedInUser: out };
206
+ }
207
+ } else if (cmd.addWebAuthnRegistrationToLoggedInUser) {
208
+ if (!StringRatchet.trimToNull(loggedInUserId)) {
209
+ ErrorRatchet.throwFormattedErr("This requires a logged in user");
210
+ }
211
+ const data: RegistrationResponseJSON = JSON.parse(cmd.addWebAuthnRegistrationToLoggedInUser.webAuthn.dataAsJson);
212
+ const out: WardenStoreRegistrationResponse = await this.storeAuthnRegistration(
213
+ loggedInUserId,
214
+ origin,
215
+ cmd.addWebAuthnRegistrationToLoggedInUser.applicationName,
216
+ cmd.addWebAuthnRegistrationToLoggedInUser.deviceLabel,
217
+ data
218
+ );
219
+ if (out.updatedEntry) {
220
+ rval = { addWebAuthnRegistrationToLoggedInUser: WardenUtils.stripWardenEntryToSummary(out.updatedEntry) };
221
+ } else if (out.error) {
222
+ rval = { error: out.error };
223
+ } else {
224
+ rval = { error: "Cannot happen - neither user nor error set" };
225
+ }
226
+ } else if (cmd.removeWebAuthnRegistration) {
227
+ const modified: WardenEntry = await this.removeSingleWebAuthnRegistration(
228
+ cmd.removeWebAuthnRegistration.userId,
229
+ cmd.removeWebAuthnRegistration.credentialId
230
+ );
231
+ rval = {
232
+ removeWebAuthnRegistration: WardenUtils.stripWardenEntryToSummary(modified)
233
+ };
234
+ } else if (cmd.removeWebAuthnRegistrationFromLoggedInUser) {
235
+ const modified: WardenEntry = await this.removeSingleWebAuthnRegistration(
236
+ loggedInUserId,
237
+ cmd.removeWebAuthnRegistrationFromLoggedInUser
238
+ );
239
+ rval = {
240
+ removeWebAuthnRegistrationFromLoggedInUser: WardenUtils.stripWardenEntryToSummary(modified)
241
+ };
242
+ } else if (cmd.removeContactFromLoggedInUser) {
243
+ const output: WardenEntry = await this.removeContactMethodFromUser(loggedInUserId, cmd.removeContactFromLoggedInUser);
244
+ // wardencontact
245
+ rval = {
246
+ removeContactFromLoggedInUser: WardenUtils.stripWardenEntryToSummary(output)
247
+ };
248
+ // return WardenEntrySummary
249
+ } else if (cmd.performLogin) {
250
+ const loginData: WardenLoginRequest = cmd.performLogin;
251
+ const user: WardenEntry = await this.processLogin(loginData, origin);
252
+ if (user) {
253
+ const decoration: WardenUserDecoration<any> = await this.opts.userDecorationProvider.fetchDecoration(user);
254
+ const wardenToken: WardenJwtToken<any> = {
255
+ wardenData: WardenUtils.stripWardenEntryToSummary(user),
256
+ user: decoration.userTokenData,
257
+ proxy: decoration.proxyUserTokenData,
258
+ globalRoleIds: decoration.globalRoleIds,
259
+ teamRoleMappings: decoration.teamRoleMappings
260
+ };
261
+ const jwtToken: string = await this.opts.jwtRatchet.createTokenString(wardenToken, decoration.userTokenExpirationSeconds);
262
+ const output: WardenLoginResults = {
263
+ request: loginData,
264
+ userId: user.userId,
265
+ jwtToken: jwtToken
266
+ };
267
+ rval = { performLogin: output };
268
+ } else {
269
+ rval = { error: "Login failed" };
270
+ }
271
+ } else if (cmd.refreshJwtToken) {
272
+ const parsed: WardenJwtToken<any> = await this.opts.jwtRatchet.decodeToken(cmd.refreshJwtToken, ExpiredJwtHandling.THROW_EXCEPTION);
273
+ const user: WardenEntry = await this.opts.storageProvider.findEntryById(parsed.wardenData.userId);
274
+ const decoration: WardenUserDecoration<any> = await this.opts.userDecorationProvider.fetchDecoration(user);
275
+ const wardenToken: WardenJwtToken<any> = {
276
+ wardenData: WardenUtils.stripWardenEntryToSummary(user),
277
+ user: decoration.userTokenData,
278
+ proxy: decoration.proxyUserTokenData,
279
+ globalRoleIds: decoration.globalRoleIds,
280
+ teamRoleMappings: decoration.teamRoleMappings
281
+ };
282
+
283
+ const newToken: string = await this.opts.jwtRatchet.createTokenString(wardenToken, decoration.userTokenExpirationSeconds);
284
+ // CAW : We do not use refresh token because we want any user changes to show up in the new token
285
+ //const newToken: string = await this.opts.jwtRatchet.refreshJWTString(cmd.refreshJwtToken, false, expirationSeconds);
286
+ rval = {
287
+ refreshJwtToken: newToken
288
+ };
289
+ } else if (cmd.exportWebAuthnRegistrationEntryForLoggedInUser) {
290
+ rval = {
291
+ exportWebAuthnRegistrationEntryForLoggedInUser: await this.exportWebAuthnRegistrationEntry(cmd.exportWebAuthnRegistrationEntryForLoggedInUser, loggedInUserId)
292
+ };
293
+ } else if (cmd.importWebAuthnRegistrationEntryForLoggedInUser) {
294
+ rval = {
295
+ importWebAuthnRegistrationEntryForLoggedInUser: await this.importWebAuthnRegistrationEntry(cmd.importWebAuthnRegistrationEntryForLoggedInUser, loggedInUserId)
296
+ };
297
+ }
298
+ } else {
299
+ rval = { error: "No command sent" };
300
+ }
301
+ return rval;
302
+ }
303
+
304
+ public async exportWebAuthnRegistrationEntry(origin: string, userId: string): Promise<string> {
305
+ const ent: WardenEntry = await this.opts.storageProvider.findEntryById(userId);
306
+ let rval: string = null;
307
+ if (ent) {
308
+ const webAuth: WardenWebAuthnEntry = ent.webAuthnAuthenticators.find(w => w.origin === origin);
309
+ if (webAuth) {
310
+ const oUrl: URL = new URL(origin);
311
+ const rpId: string = oUrl.hostname;
312
+ Logger.debug('Finding challenge for origin %s / RPID %s', origin, rpId);
313
+ const expectedChallenge: string = await this.opts.storageProvider.fetchCurrentUserChallenge(userId, rpId);
314
+ const token: WardenWebAuthnExportToken = {
315
+ entry: webAuth,
316
+ challenge: expectedChallenge
317
+ };
318
+
319
+ const s1: string = JSON.stringify(token);
320
+ rval = Base64Ratchet.encodeStringToBase64String(s1);
321
+ } else {
322
+ Logger.warn("Could not export webauthn - no such origin");
323
+ }
324
+ } else {
325
+ Logger.warn("Could not export webauthn - no such user id");
326
+ }
327
+
328
+ return rval;
329
+ }
330
+
331
+ public async importWebAuthnRegistrationEntry(token: string, userId: string): Promise<boolean> {
332
+ const ent: WardenEntry = await this.opts.storageProvider.findEntryById(userId);
333
+ let rval: boolean = false;
334
+ if (ent) {
335
+ const s1: string = Base64Ratchet.base64StringToString(token);
336
+ const newEntry: WardenWebAuthnExportToken = JSON.parse(s1);
337
+ const old: WardenWebAuthnEntry = ent.webAuthnAuthenticators.find(w => w.origin === newEntry.entry.origin);
338
+ if (old) {
339
+ Logger.warn("Removing existing entry %j", old);
340
+ ent.webAuthnAuthenticators = ent.webAuthnAuthenticators.filter(w => w.origin !== newEntry.entry.origin);
341
+ }
342
+ ent.webAuthnAuthenticators.push(newEntry.entry);
343
+ await this.opts.storageProvider.saveEntry(ent);
344
+ if (newEntry.challenge) {
345
+ await this.opts.storageProvider.updateUserChallenge(userId, newEntry.entry.origin, newEntry.challenge);
346
+ }
347
+ Logger.info("Wrote ok");
348
+ rval = true;
349
+ } else {
350
+ Logger.warn("Could not export webauthn - no such user id");
351
+ }
352
+ return rval;
353
+ }
354
+
355
+ public urlIsOnAllowedOrigin(url: string): boolean {
356
+ let rval: boolean = false;
357
+ if (url) {
358
+ const u: URL = new URL(url);
359
+ for (let i = 0; i < this.opts.allowedOrigins.length && !rval; i++) {
360
+ const test: URL = new URL(this.opts.allowedOrigins[i]);
361
+ rval = test.origin === u.origin && test.protocol === u.protocol && test.port === u.port;
362
+ }
363
+ }
364
+ return rval;
365
+ }
366
+
367
+ public singleUseCodeProvider(
368
+ contact: WardenContact,
369
+ requireMagicLinkSupport: boolean,
370
+ returnNullIfNoProviders?: boolean
371
+ ): WardenSingleUseCodeProvider {
372
+ const rval: WardenSingleUseCodeProvider = this.opts.singleUseCodeProviders.find(
373
+ (s) => s.handlesContactType(contact.type) && (!requireMagicLinkSupport || s.createCodeAndSendMagicLink)
374
+ );
375
+ if (!rval && !returnNullIfNoProviders) {
376
+ throw ErrorRatchet.fErr("Cannot find a single use code provider for contact type : %s", contact.type);
377
+ }
378
+ return rval;
379
+ }
380
+
381
+ public async sendMagicLink(
382
+ contact: WardenContact,
383
+ overrideDestinationContact: WardenContact,
384
+ relyingPartyName: string,
385
+ landingUrl: string,
386
+ metaIn?: Record<string, string>,
387
+ ttlSeconds?: number,
388
+ customTemplate?: WardenCustomTemplateDescriptor
389
+ ): Promise<boolean> {
390
+ let rval: boolean = false;
391
+ RequireRatchet.notNullOrUndefined(contact, "contact");
392
+ RequireRatchet.notNullUndefinedOrOnlyWhitespaceString(landingUrl, "landingUrl");
393
+ RequireRatchet.true(this.urlIsOnAllowedOrigin(landingUrl), "landingUrl is not on an allowed origin for redirect");
394
+
395
+ if (contact?.type && StringRatchet.trimToNull(contact?.value)) {
396
+ const prov: WardenSingleUseCodeProvider = this.singleUseCodeProvider(contact, true);
397
+ rval = await prov.createCodeAndSendMagicLink(
398
+ contact,
399
+ relyingPartyName,
400
+ landingUrl,
401
+ metaIn,
402
+ ttlSeconds,
403
+ overrideDestinationContact,
404
+ customTemplate
405
+ );
406
+ } else {
407
+ ErrorRatchet.throwFormattedErr("Cannot send - invalid contact %j", contact);
408
+ }
409
+ return rval;
410
+ }
411
+
412
+ public async createAccountByThirdParty(thirdParty: WardenThirdPartyAuthentication, origin: string, inLabel?: string): Promise<WardenEntry> {
413
+ let rval: WardenEntry = null;
414
+ RequireRatchet.notNullOrUndefined(thirdParty, "thirdParty");
415
+ RequireRatchet.notNullUndefinedOrOnlyWhitespaceString(thirdParty.thirdParty, "thirdParty");
416
+ RequireRatchet.notNullUndefinedOrOnlyWhitespaceString(thirdParty.thirdPartyId, "thirdPartyId");
417
+
418
+ const old: WardenEntry = await this.opts.storageProvider.findEntryByThirdPartyId(thirdParty.thirdParty, thirdParty.thirdPartyId);
419
+ if (old) {
420
+ ErrorRatchet.throwFormattedErr("Cannot create - account already exists for %j", thirdParty);
421
+ }
422
+
423
+ const label: string = inLabel || thirdParty.thirdParty + " " + thirdParty.thirdPartyId;
424
+ const newUser: WardenEntry = new WardenEntryBuilder(label).withThirdPartyAuthentication([thirdParty]).entry;
425
+ rval = await this.opts.storageProvider.saveEntry(newUser);
426
+
427
+ return rval;
428
+ }
429
+
430
+ // Creates a new account, returns the userId for that account upon success
431
+ public async createAccount(contact: WardenContact, origin: string, sendCode?: boolean, label?: string, tags?: string[]): Promise<WardenEntry> {
432
+ let rval: WardenEntry = null;
433
+ if (WardenUtils.validContact(contact)) {
434
+ const old: WardenEntry = await this.opts.storageProvider.findEntryByContact(contact);
435
+ if (old) {
436
+ ErrorRatchet.throwFormattedErr("Cannot create - account already exists for %j", contact);
437
+ }
438
+
439
+ // Defaults to email if nothing provided, usually full name
440
+ const newUser: WardenEntry = new WardenEntryBuilder(label || contact.value).withContacts([contact]).withTags(tags).entry;
441
+ rval = await this.opts.storageProvider.saveEntry(newUser);
442
+
443
+ if (sendCode) {
444
+ Logger.info("New user %j created and send requested - sending", rval);
445
+ await this.sendExpiringValidationToken(contact, origin);
446
+ }
447
+ } else {
448
+ ErrorRatchet.throwFormattedErr("Cannot create - invalid contact (missing or invalid fields)");
449
+ }
450
+ return rval;
451
+ }
452
+
453
+
454
+ public async saveNewUser(newUser: WardenEntry): Promise<WardenEntry> {
455
+ const next: WardenEntry = await this.opts.storageProvider.saveEntry(newUser);
456
+ if (this?.opts?.eventProcessor) {
457
+ await this.opts.eventProcessor.userCreated(next);
458
+ }
459
+ return next;
460
+ }
461
+
462
+ // For an existing user, add another contact method
463
+ // A given contact (eg, email address, phone number) can only associated with a single
464
+ // userId at a time
465
+ public async addContactMethodToUser(userId: string, contact: WardenContact): Promise<boolean> {
466
+ let rval: boolean = false;
467
+ if (StringRatchet.trimToNull(userId) && WardenUtils.validContact(contact)) {
468
+ const otherUser: WardenEntry = await this.opts.storageProvider.findEntryByContact(contact);
469
+ if (otherUser && otherUser.userId !== userId) {
470
+ ErrorRatchet.throwFormattedErr("Cannot add contact to this user, another user already has that contact");
471
+ }
472
+ const curUser: WardenEntry = await this.opts.storageProvider.findEntryById(userId);
473
+ if (!curUser) {
474
+ ErrorRatchet.throwFormattedErr("Cannot add contact to this user, user does not exist");
475
+ }
476
+ curUser.contactMethods.push(contact);
477
+ await this.opts.storageProvider.saveEntry(curUser);
478
+ rval = true;
479
+ } else {
480
+ ErrorRatchet.throwFormattedErr("Cannot add - invalid config : %s %j", userId, contact);
481
+ }
482
+ return rval;
483
+ }
484
+
485
+ // For an existing user, remove a contact method
486
+ public async removeContactMethodFromUser(userId: string, contact: WardenContact): Promise<WardenEntry> {
487
+ let rval: WardenEntry = null;
488
+ if (StringRatchet.trimToNull(userId) && WardenUtils.validContact(contact)) {
489
+ const curUser: WardenEntry = await this.opts.storageProvider.findEntryById(userId);
490
+ if (!curUser) {
491
+ ErrorRatchet.throwFormattedErr("Cannot remove contact from this user, user does not exist");
492
+ }
493
+ curUser.contactMethods = (curUser.contactMethods || []).filter((s) => s.type !== contact.type || s.value !== contact.value);
494
+ if (curUser.contactMethods.length === 0) {
495
+ ErrorRatchet.throwFormattedErr("Cannot remove the last contact method from a user");
496
+ }
497
+ await this.opts.storageProvider.saveEntry(curUser);
498
+ rval = await this.opts.storageProvider.findEntryById(userId);
499
+ } else {
500
+ ErrorRatchet.throwFormattedErr("Cannot add - invalid config : %s %j", userId, contact);
501
+ }
502
+ return rval;
503
+ }
504
+
505
+ // Used as the first step of adding a new WebAuthn device to an existing (logged in) user
506
+ // Server creates a challenge that the device will sign
507
+ public async generateWebAuthnRegistrationChallengeForLoggedInUser(
508
+ userId: string,
509
+ origin: string
510
+ ): Promise<PublicKeyCredentialCreationOptionsJSON> {
511
+ if (!origin || !this.opts.allowedOrigins.includes(origin)) {
512
+ throw new Error("Invalid origin : " + origin);
513
+ }
514
+ const asUrl: URL = new URL(origin);
515
+ const rpID: string = asUrl.hostname;
516
+
517
+ const entry: WardenEntry = await this.opts.storageProvider.findEntryById(userId);
518
+ if (!entry) {
519
+ throw ErrorRatchet.fErr("Cannot generateWebAuthnRegistrationChallengeForLoggedInUser - no user %s / %s", userId, origin);
520
+ }
521
+ if (!entry?.webAuthnAuthenticators?.length) {
522
+ Logger.info("Entry has no webAuthnAuthenticators");
523
+ entry.webAuthnAuthenticators = []; // Just in case
524
+ }
525
+ const options = await generateRegistrationOptions({
526
+ rpName: this.opts.relyingPartyName,
527
+ rpID: rpID,
528
+ userID: StringRatchet.stringToUint8Array(entry.userId),
529
+ userName: entry.userLabel,
530
+ // Don't prompt users for additional information about the authenticator
531
+ // (Recommended for smoother UX)
532
+ attestationType: "none",
533
+ // Prevent users from re-registering existing authenticators
534
+ excludeCredentials: entry.webAuthnAuthenticators.map((authenticator) => ({
535
+ id: authenticator.credentialPublicKeyBase64,
536
+ //type: 'public-key',
537
+ // Optional
538
+ transports: authenticator.transports as unknown as AuthenticatorTransportFuture[]
539
+ }))
540
+ });
541
+
542
+ await this.opts.storageProvider.updateUserChallenge(entry.userId, rpID, options.challenge);
543
+
544
+ return options;
545
+ }
546
+
547
+ // Given a new device's registration, add it to the specified user account as a valid login method
548
+ public async storeAuthnRegistration(
549
+ userId: string,
550
+ origin: string,
551
+ applicationName: string,
552
+ deviceLabel: string,
553
+ data: RegistrationResponseJSON
554
+ ): Promise<WardenStoreRegistrationResponse> {
555
+ Logger.info("Store authn data : %j", data);
556
+ let rval: WardenStoreRegistrationResponse = null;
557
+ try {
558
+ if (!origin || !this.opts.allowedOrigins.includes(origin)) {
559
+ throw new Error("Invalid origin : " + origin);
560
+ }
561
+ const asUrl: URL = new URL(origin);
562
+ const rpID: string = asUrl.hostname;
563
+
564
+ const user: WardenEntry = await this.opts.storageProvider.findEntryById(userId);
565
+ if (!user) {
566
+ throw ErrorRatchet.fErr("Cannot storeAuthnRegistration - no user %s / %s", userId, origin);
567
+ }
568
+
569
+ // (Pseudocode) Get `options.challenge` that was saved above
570
+ const expectedChallenge: string = await this.opts.storageProvider.fetchCurrentUserChallenge(user.userId, rpID);
571
+
572
+ const vrOpts: VerifyRegistrationResponseOpts = {
573
+ response: data,
574
+ expectedChallenge: expectedChallenge,
575
+ expectedOrigin: origin,
576
+ expectedRPID: rpID
577
+ };
578
+
579
+ Logger.info("Calling verifyRegistrationResponse: %j", vrOpts);
580
+
581
+ const verification: VerifiedRegistrationResponse = await verifyRegistrationResponse(vrOpts);
582
+ Logger.info("verifyRegistrationResponse Result : %j", verification);
583
+
584
+ rval = {
585
+ updatedEntry: null,
586
+ registrationResponseId: data.id,
587
+ result: verification.verified ? WardenStoreRegistrationResponseType.Verified : WardenStoreRegistrationResponseType.Failed
588
+ };
589
+
590
+ if (rval.result === WardenStoreRegistrationResponseType.Verified) {
591
+ Logger.info("Storing registration");
592
+ const newAuth: WardenWebAuthnEntry = {
593
+ origin: origin,
594
+ applicationName: applicationName || "Unknown Application",
595
+ deviceLabel: deviceLabel || "Unknown Device",
596
+ counter: verification.registrationInfo.credential.counter,
597
+ credentialBackedUp: verification.registrationInfo.credentialBackedUp,
598
+ credentialDeviceType: verification.registrationInfo.credentialDeviceType,
599
+ credentialIdBase64: verification.registrationInfo.credential.id,
600
+ credentialPublicKeyBase64: Base64Ratchet.uint8ArrayToBase64UrlString(verification.registrationInfo.credential.publicKey)
601
+ //transports: TBD
602
+ };
603
+
604
+ // (Pseudocode) Save the authenticator info so that we can
605
+ // get it by user ID later
606
+ user.webAuthnAuthenticators = (user.webAuthnAuthenticators || []).filter(
607
+ (wa) => wa.credentialIdBase64 !== newAuth.credentialIdBase64
608
+ );
609
+ user.webAuthnAuthenticators.push(newAuth);
610
+ const storedUser: WardenEntry = await this.opts.storageProvider.saveEntry(user);
611
+ rval.updatedEntry = storedUser;
612
+ Logger.info("Stored auth : %j", storedUser);
613
+ }
614
+ } catch (err) {
615
+ rval = {
616
+ registrationResponseId: data.id,
617
+ result: WardenStoreRegistrationResponseType.Error,
618
+ error: ErrorRatchet.safeStringifyErr(err)
619
+ };
620
+ }
621
+
622
+ return rval;
623
+ }
624
+
625
+ public async generateWebAuthnAuthenticationChallengeForUserId(
626
+ userId: string,
627
+ origin: string
628
+ ): Promise<PublicKeyCredentialRequestOptionsJSON> {
629
+ const user: WardenEntry = await this.opts.storageProvider.findEntryById(userId);
630
+ const rval: PublicKeyCredentialRequestOptionsJSON = await this.generateWebAuthnAuthenticationChallenge(user, origin);
631
+ return rval;
632
+ }
633
+
634
+ // Part of the login process - for a given user, generate the challenge that the device will have to answer
635
+ public async generateWebAuthnAuthenticationChallenge(user: WardenEntry, origin: string): Promise<PublicKeyCredentialRequestOptionsJSON> {
636
+ // (Pseudocode) Retrieve any of the user's previously-registered authenticators
637
+ const userAuthenticators: WardenWebAuthnEntry[] = user.webAuthnAuthenticators;
638
+ if (!origin || !this.opts.allowedOrigins.includes(origin)) {
639
+ throw new Error("Invalid origin : " + origin);
640
+ }
641
+ const asUrl: URL = new URL(origin);
642
+ const rpID: string = asUrl.hostname;
643
+
644
+ const out: any[] = userAuthenticators.map((authenticator) => {
645
+ const next: any = {
646
+ id: authenticator.credentialIdBase64, // Type is Base64URLString
647
+ //type: 'public-key',
648
+ // Optional
649
+ transports: authenticator.transports
650
+ };
651
+ return next;
652
+ });
653
+
654
+ const opts: GenerateAuthenticationOptionsOpts = {
655
+ // Require users to use a previously-registered authenticator
656
+ rpID: rpID,
657
+ allowCredentials: out,
658
+ userVerification: "preferred"
659
+ };
660
+
661
+ const options: PublicKeyCredentialRequestOptionsJSON = await generateAuthenticationOptions(opts);
662
+
663
+ // (Pseudocode) Remember this challenge for this user
664
+ await this.opts.storageProvider.updateUserChallenge(user.userId, rpID, options.challenge);
665
+
666
+ return options;
667
+ }
668
+
669
+ // Send a single use token to this contact
670
+ public async sendExpiringValidationToken(request: WardenContact, origin: string): Promise<boolean> {
671
+ let rval: boolean = false;
672
+ if (request?.type && StringRatchet.trimToNull(request?.value)) {
673
+ const prov: WardenSingleUseCodeProvider = this.singleUseCodeProvider(request, false);
674
+ rval = await prov.createAndSendNewCode(request, this.opts.relyingPartyName, origin);
675
+ } else {
676
+ ErrorRatchet.throwFormattedErr("Cannot send - invalid request %j", request);
677
+ }
678
+ return rval;
679
+ }
680
+
681
+ // Perform a login using one of several methods
682
+ // Delegates to functions that handle the specific methods
683
+ // Should return a valid WardenEntry if successful, or null if not
684
+ // If createUserIfMissing=true, will create a new user if one does not exist
685
+ public async processLogin(request: WardenLoginRequest, origin: string): Promise<WardenEntry | null> {
686
+ Logger.info("Processing login : %s : %j", origin, request);
687
+ let rval: WardenEntry = null;
688
+ const requestErrors: string[] = WardenUtils.loginRequestErrors(request);
689
+ if (requestErrors.length > 0) {
690
+ throw ErrorRatchet.fErr("Invalid login request : %j", requestErrors);
691
+ }
692
+ if (request.type === WardenLoginRequestType.ThirdParty) {
693
+ // ThirdParty is a bit different since token lookup typically gives us the info we'll use
694
+ // to lookup the user
695
+ const provider: WardenThirdPartyAuthenticationProvider = (this.options.thirdPartyAuthenticationProviders ?? [])
696
+ .find(s => s.handlesThirdParty(request.thirdPartyToken.thirdParty));
697
+ if (provider) {
698
+ const auth: WardenThirdPartyAuthentication = await provider.validateTokenAndReturnThirdPartyUserId(request.thirdPartyToken, origin);
699
+ if (auth) {
700
+ rval = await this.opts.storageProvider.findEntryByThirdPartyId(auth.thirdParty, auth.thirdPartyId);
701
+ if (!rval && request.createUserIfMissing) {
702
+ Logger.info("Found no existing user for %j, creating", auth);
703
+ let label: string = auth.thirdParty + " " + auth.thirdPartyId;
704
+ if (provider.extractUserLabelFromAuthentication) {
705
+ label = await provider.extractUserLabelFromAuthentication(auth);
706
+ }
707
+ rval = await this.createAccountByThirdParty(auth, origin, label);
708
+ Logger.info("Finished create, new id is %s", rval.userId);
709
+ }
710
+ } else {
711
+ Logger.warn("Authentication failed for %j", request.thirdPartyToken, request.createUserIfMissing);
712
+ }
713
+ } else {
714
+ Logger.warn("Could not find any provider to handle third party token %j", request.thirdPartyToken);
715
+ }
716
+ } else {
717
+ let user: WardenEntry = StringRatchet.trimToNull(request?.userId)
718
+ ? await this.opts.storageProvider.findEntryById(request?.userId)
719
+ : await this.opts.storageProvider.findEntryByContact(request.contact);
720
+ if (!user) {
721
+ Logger.info("User not found, and createUserIfMissing=%s / %j", request.createUserIfMissing, request.contact);
722
+ if (request.createUserIfMissing && request.contact) {
723
+ user = await this.createAccount(request.contact, origin);
724
+ Logger.info("Finished create, new id is %s", user.userId);
725
+ }
726
+ // If STILL no user...
727
+ if (!user) {
728
+ ErrorRatchet.throwFormattedErr("No user found for %j / %s", request?.contact, request?.userId);
729
+ }
730
+ }
731
+
732
+ let loginSuccess: boolean = false;
733
+ if (request.webAuthn) {
734
+ loginSuccess = await this.loginWithWebAuthnRequest(user, origin, request.webAuthn);
735
+ } else if (StringRatchet.trimToNull(request.expiringToken)) {
736
+ const prov: WardenSingleUseCodeProvider = this.singleUseCodeProvider(request.contact, false);
737
+ loginSuccess = await prov.checkCode(request.contact.value, request.expiringToken);
738
+ if (!loginSuccess) {
739
+ ErrorRatchet.throwFormattedErr("Cannot login - token is invalid for this user");
740
+ }
741
+ }
742
+ rval = loginSuccess ? user : null;
743
+ }
744
+
745
+ return rval;
746
+ }
747
+
748
+ // Perform a login using webAuthn
749
+ public async loginWithWebAuthnRequest(user: WardenEntry, origin: string, data: AuthenticationResponseJSON): Promise<boolean> {
750
+ let rval: boolean = false;
751
+ const asUrl: URL = new URL(origin);
752
+ const rpID: string = asUrl.hostname;
753
+ const expectedChallenge: string = await this.opts.storageProvider.fetchCurrentUserChallenge(user.userId, rpID);
754
+
755
+ // (Pseudocode} Retrieve an authenticator from the DB that
756
+ // should match the `id` in the returned credential
757
+ //const b64id: string = Base64Ratchet.base64UrlStringToString(data.id);
758
+ const auth: WardenWebAuthnEntry = (user.webAuthnAuthenticators || []).find((s) => s.credentialIdBase64 === data.id);
759
+
760
+ if (!auth) {
761
+ const allIds: string[] = (user.webAuthnAuthenticators || []).map((s) => s.credentialIdBase64);
762
+ throw ErrorRatchet.fErr("Could not find authenticator %s (%s) for user %s (avail were : %j)", data.id, data.id, user.userId, allIds);
763
+ }
764
+
765
+ const vrOpts: VerifyAuthenticationResponseOpts = {
766
+ response: data,
767
+ expectedChallenge,
768
+ expectedOrigin: origin,
769
+ expectedRPID: rpID,
770
+ credential: {
771
+ counter: auth.counter,
772
+ id: auth.credentialIdBase64,
773
+ publicKey: Base64Ratchet.base64UrlStringToBytes(auth.credentialPublicKeyBase64)
774
+ }
775
+ };
776
+
777
+ const verification: VerifiedAuthenticationResponse = await verifyAuthenticationResponse(vrOpts);
778
+
779
+ if (verification.verified) {
780
+ rval = true;
781
+ }
782
+ return rval;
783
+ }
784
+
785
+ // Unregisters a device from a given user account
786
+ public async removeSingleWebAuthnRegistration(userId: string, key: string): Promise<WardenEntry> {
787
+ let ent: WardenEntry = await this.opts.storageProvider.findEntryById(userId);
788
+ if (ent) {
789
+ ent.webAuthnAuthenticators = (ent.webAuthnAuthenticators || []).filter((s) => s.credentialIdBase64 !== key);
790
+ ent = await this.opts.storageProvider.saveEntry(ent);
791
+ } else {
792
+ Logger.info("Not removing - no such user as %s", userId);
793
+ }
794
+ return ent;
795
+ }
796
+
797
+ // Admin function - pass thru to the storage layer
798
+ public async removeUser(userId: string): Promise<boolean> {
799
+ let rval: boolean = false;
800
+ if (StringRatchet.trimToNull(userId)) {
801
+ const oldUser: WardenEntry = await this.opts.storageProvider.findEntryById(userId);
802
+ if (oldUser) {
803
+ await this.opts.storageProvider.removeEntry(userId);
804
+ if (this?.opts?.eventProcessor) {
805
+ await this.opts.eventProcessor.userRemoved(oldUser);
806
+ }
807
+ rval = true;
808
+ } else {
809
+ Logger.warn("Cannot remove non-existent user : %s", userId);
810
+ }
811
+ }
812
+
813
+ return rval;
814
+ }
815
+ }