@things-factory/auth-base 10.1.247 → 10.1.254

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 (35) hide show
  1. package/dist-server/controllers/account-mail.d.ts +29 -0
  2. package/dist-server/controllers/account-mail.js +28 -0
  3. package/dist-server/controllers/account-mail.js.map +1 -0
  4. package/dist-server/controllers/password-setup.js +2 -9
  5. package/dist-server/controllers/password-setup.js.map +1 -1
  6. package/dist-server/controllers/reset-password.js +2 -6
  7. package/dist-server/controllers/reset-password.js.map +1 -1
  8. package/dist-server/controllers/unlock-user.js +2 -7
  9. package/dist-server/controllers/unlock-user.js.map +1 -1
  10. package/dist-server/controllers/verification.js +2 -7
  11. package/dist-server/controllers/verification.js.map +1 -1
  12. package/dist-server/router/webauthn-router.js +18 -5
  13. package/dist-server/router/webauthn-router.js.map +1 -1
  14. package/dist-server/tsconfig.tsbuildinfo +1 -1
  15. package/package.json +5 -5
  16. package/tests/account-mail.test.ts +69 -0
  17. package/tests/mail-request-answers-db.test.ts +3 -0
  18. package/tests/passkey-signin-route-db.test.ts +139 -0
  19. package/tests/password-setup-db.test.ts +2 -0
  20. package/tests/verification-token-db.test.ts +3 -0
  21. package/translations/en.json +13 -1
  22. package/translations/es.json +13 -1
  23. package/translations/ja.json +13 -1
  24. package/translations/ko.json +13 -1
  25. package/translations/ms.json +13 -1
  26. package/translations/zh.json +13 -1
  27. package/dist-server/templates/account-unlock-email.d.ts +0 -5
  28. package/dist-server/templates/account-unlock-email.js +0 -69
  29. package/dist-server/templates/account-unlock-email.js.map +0 -1
  30. package/dist-server/templates/reset-password-email.d.ts +0 -11
  31. package/dist-server/templates/reset-password-email.js +0 -73
  32. package/dist-server/templates/reset-password-email.js.map +0 -1
  33. package/dist-server/templates/verification-email.d.ts +0 -5
  34. package/dist-server/templates/verification-email.js +0 -70
  35. package/dist-server/templates/verification-email.js.map +0 -1
@@ -0,0 +1,29 @@
1
+ import { type MailWords } from '@things-factory/email-base';
2
+ /**
3
+ * The four mails an account gets, each with one link: confirm the address it signed up with, reset a forgotten
4
+ * password, set the password an administrator left waiting (ADR-0161), and unlock after too many wrong passwords.
5
+ * They are written in the person's language and sent in the same mail every module sends (email-base `mailForm`), so
6
+ * they carry the application's brand like any notice.
7
+ */
8
+ export type AccountMailKind = 'verify-email' | 'reset-password' | 'set-password' | 'unlock-account';
9
+ export declare const ACCOUNT_MAIL_KINDS: readonly AccountMailKind[];
10
+ type Translate = (key: string, options?: Record<string, unknown>) => string;
11
+ /** What one account mail says, in the language `t` speaks. `brand` is the application's name. */
12
+ export declare function accountMailWords(t: Translate, kind: AccountMailKind, brand: string): MailWords;
13
+ /**
14
+ * The subject and HTML of an account mail to `user`. The language is the person's own, else the one their request was
15
+ * made in, else the deployment's default - the request is theirs for every mail but the setup link, which an
16
+ * administrator asks for.
17
+ */
18
+ export declare function accountMail({ kind, user, link, context }: {
19
+ kind: AccountMailKind;
20
+ user: {
21
+ locale?: string | null;
22
+ };
23
+ link: string;
24
+ context: any;
25
+ }): {
26
+ subject: string;
27
+ content: string;
28
+ };
29
+ export {};
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ACCOUNT_MAIL_KINDS = void 0;
4
+ exports.accountMailWords = accountMailWords;
5
+ exports.accountMail = accountMail;
6
+ const email_base_1 = require("@things-factory/email-base");
7
+ const env_1 = require("@things-factory/env");
8
+ exports.ACCOUNT_MAIL_KINDS = ['verify-email', 'reset-password', 'set-password', 'unlock-account'];
9
+ /** What one account mail says, in the language `t` speaks. `brand` is the application's name. */
10
+ function accountMailWords(t, kind, brand) {
11
+ return {
12
+ subject: t(`title.account-mail-${kind}`),
13
+ body: t(`text.account-mail-${kind}`, { brand }),
14
+ action: t(`button.account-mail-${kind}`)
15
+ };
16
+ }
17
+ /**
18
+ * The subject and HTML of an account mail to `user`. The language is the person's own, else the one their request was
19
+ * made in, else the deployment's default - the request is theirs for every mail but the setup link, which an
20
+ * administrator asks for.
21
+ */
22
+ function accountMail({ kind, user, link, context }) {
23
+ const lng = (0, email_base_1.languageOf)(user.locale, context?.lng || env_1.config.get('i18n/defaultLanguage', 'en'));
24
+ const brand = (0, email_base_1.appBrand)();
25
+ const words = accountMailWords(context.i18next.getFixedT(lng), kind, brand.name);
26
+ return { subject: words.subject, content: (0, email_base_1.mailForm)({ lang: lng, words, link, brand }) };
27
+ }
28
+ //# sourceMappingURL=account-mail.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"account-mail.js","sourceRoot":"","sources":["../../server/controllers/account-mail.ts"],"names":[],"mappings":";;;AAgBA,4CAMC;AAOD,kCAgBC;AA7CD,2DAA2F;AAC3F,6CAA4C;AAU/B,QAAA,kBAAkB,GAA+B,CAAC,cAAc,EAAE,gBAAgB,EAAE,cAAc,EAAE,gBAAgB,CAAC,CAAA;AAIlI,iGAAiG;AACjG,SAAgB,gBAAgB,CAAC,CAAY,EAAE,IAAqB,EAAE,KAAa;IACjF,OAAO;QACL,OAAO,EAAE,CAAC,CAAC,sBAAsB,IAAI,EAAE,CAAC;QACxC,IAAI,EAAE,CAAC,CAAC,qBAAqB,IAAI,EAAE,EAAE,EAAE,KAAK,EAAE,CAAC;QAC/C,MAAM,EAAE,CAAC,CAAC,uBAAuB,IAAI,EAAE,CAAC;KACzC,CAAA;AACH,CAAC;AAED;;;;GAIG;AACH,SAAgB,WAAW,CAAC,EAC1B,IAAI,EACJ,IAAI,EACJ,IAAI,EACJ,OAAO,EAMR;IACC,MAAM,GAAG,GAAG,IAAA,uBAAU,EAAC,IAAI,CAAC,MAAM,EAAE,OAAO,EAAE,GAAG,IAAI,YAAM,CAAC,GAAG,CAAC,sBAAsB,EAAE,IAAI,CAAC,CAAC,CAAA;IAC7F,MAAM,KAAK,GAAG,IAAA,qBAAQ,GAAE,CAAA;IACxB,MAAM,KAAK,GAAG,gBAAgB,CAAC,OAAO,CAAC,OAAO,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,CAAC,CAAA;IAEhF,OAAO,EAAE,OAAO,EAAE,KAAK,CAAC,OAAO,EAAE,OAAO,EAAE,IAAA,qBAAQ,EAAC,EAAE,IAAI,EAAE,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,EAAE,CAAA;AACzF,CAAC","sourcesContent":["import { appBrand, languageOf, mailForm, type MailWords } from '@things-factory/email-base'\nimport { config } from '@things-factory/env'\n\n/**\n * The four mails an account gets, each with one link: confirm the address it signed up with, reset a forgotten\n * password, set the password an administrator left waiting (ADR-0161), and unlock after too many wrong passwords.\n * They are written in the person's language and sent in the same mail every module sends (email-base `mailForm`), so\n * they carry the application's brand like any notice.\n */\nexport type AccountMailKind = 'verify-email' | 'reset-password' | 'set-password' | 'unlock-account'\n\nexport const ACCOUNT_MAIL_KINDS: readonly AccountMailKind[] = ['verify-email', 'reset-password', 'set-password', 'unlock-account']\n\ntype Translate = (key: string, options?: Record<string, unknown>) => string\n\n/** What one account mail says, in the language `t` speaks. `brand` is the application's name. */\nexport function accountMailWords(t: Translate, kind: AccountMailKind, brand: string): MailWords {\n return {\n subject: t(`title.account-mail-${kind}`),\n body: t(`text.account-mail-${kind}`, { brand }),\n action: t(`button.account-mail-${kind}`)\n }\n}\n\n/**\n * The subject and HTML of an account mail to `user`. The language is the person's own, else the one their request was\n * made in, else the deployment's default - the request is theirs for every mail but the setup link, which an\n * administrator asks for.\n */\nexport function accountMail({\n kind,\n user,\n link,\n context\n}: {\n kind: AccountMailKind\n user: { locale?: string | null }\n link: string\n context: any\n}): { subject: string; content: string } {\n const lng = languageOf(user.locale, context?.lng || config.get('i18n/defaultLanguage', 'en'))\n const brand = appBrand()\n const words = accountMailWords(context.i18next.getFixedT(lng), kind, brand.name)\n\n return { subject: words.subject, content: mailForm({ lang: lng, words, link, brand }) }\n}\n"]}
@@ -6,9 +6,9 @@ const email_base_1 = require("@things-factory/email-base");
6
6
  const shell_1 = require("@things-factory/shell");
7
7
  const user_js_1 = require("../service/user/user.js");
8
8
  const verification_token_js_1 = require("../service/verification-token/verification-token.js");
9
- const reset_password_email_js_1 = require("../templates/reset-password-email.js");
10
9
  const build_auth_url_js_1 = require("../utils/build-auth-url.js");
11
10
  const verification_token_rule_js_1 = require("../utils/verification-token-rule.js");
11
+ const account_mail_js_1 = require("./account-mail.js");
12
12
  const reset_password_js_1 = require("./reset-password.js");
13
13
  const make_verification_token_js_1 = require("./utils/make-verification-token.js");
14
14
  /**
@@ -60,14 +60,7 @@ async function issuePasswordSetupLink({ user, mail, context, tx }) {
60
60
  */
61
61
  const sent = await (0, email_base_1.sendEmail)({
62
62
  receiver: user.email,
63
- subject: 'Set your password',
64
- content: (0, reset_password_email_js_1.getResetPasswordEmailForm)({
65
- name: user.name,
66
- resetUrl: link,
67
- heading: 'Set your password',
68
- action: 'set your password',
69
- note: 'The link works once, for 48 hours. The page asks for this email address.'
70
- })
63
+ ...(0, account_mail_js_1.accountMail)({ kind: 'set-password', user, link, context })
71
64
  });
72
65
  if (!sent) {
73
66
  throw new shell_1.Refusal('password-setup-mail-not-sent', { email: user.email }, `The mail to ${user.email} did not go out. Nothing was changed - copy the link instead.`);
@@ -1 +1 @@
1
- {"version":3,"file":"password-setup.js","sourceRoot":"","sources":["../../server/controllers/password-setup.ts"],"names":[],"mappings":";;AAsBA,oCAIC;AAcD,wDA2DC;AAjGD,2DAAsD;AACtD,iDAA8D;AAE9D,qDAA0D;AAC1D,+FAA8G;AAC9G,kFAAgF;AAChF,kEAAyD;AACzD,oFAAuG;AACvG,2DAAyD;AACzD,mFAA0E;AAE1E;;;;;;GAMG;AAEH,yFAAyF;AACzF,SAAgB,YAAY,CAAC,IAAmB;IAC9C,IAAI,CAAC,IAAI,GAAG,cAAI,CAAC,YAAY,EAAE,CAAA;IAC/B,IAAI,CAAC,QAAQ,GAAG,cAAI,CAAC,MAAM,CAAC,IAAA,kDAAqB,GAAE,GAAG,IAAA,kDAAqB,GAAE,EAAE,IAAI,CAAC,IAAI,CAAC,CAAA;IACzF,IAAI,CAAC,MAAM,GAAG,oBAAU,CAAC,kBAAkB,CAAA;AAC7C,CAAC;AAED;;;;;;;;;;;GAWG;AACI,KAAK,UAAU,sBAAsB,CAAC,EAC3C,IAAI,EACJ,IAAI,EACJ,OAAO,EACP,EAAE,EAMH;IACC,YAAY,CAAC,IAAI,CAAC,CAAA;IAClB,IAAI,CAAC,SAAS,GAAG,CAAC,CAAA;IAClB,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAA;IACpB,IAAI,CAAC,gBAAgB,GAAG,CAAC,IAAI,CAAC,gBAAgB,IAAI,CAAC,CAAC,GAAG,CAAC,CAAA;IAExD,MAAM,IAAA,qBAAa,EAAC,cAAI,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IAExC,MAAM,KAAK,GAAG,IAAA,kDAAqB,GAAE,CAAA;IACrC,MAAM,SAAS,GAAG,IAAA,uDAA0B,EAAC,6CAAqB,CAAC,cAAc,CAAC,CAAA;IAElF,MAAM,IAAA,qBAAa,EAAC,yCAAiB,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC;QAC9C,MAAM,EAAE,IAAI,CAAC,EAAE;QACf,KAAK,EAAE,IAAA,kDAAqB,EAAC,KAAK,CAAC;QACnC,IAAI,EAAE,6CAAqB,CAAC,cAAc;QAC1C,SAAS;KACV,CAAC,CAAA;IAEF,MAAM,IAAI,GAAG,GAAG,IAAA,gCAAY,EAAC,uCAAmB,EAAE,OAAO,CAAC,IAAI,KAAK,EAAE,CAAA;IAErE,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,SAAS,EAAE,CAAA;IAC5C,CAAC;IAED;;;OAGG;IACH,MAAM,IAAI,GAAY,MAAM,IAAA,sBAAS,EAAC;QACpC,QAAQ,EAAE,IAAI,CAAC,KAAK;QACpB,OAAO,EAAE,mBAAmB;QAC5B,OAAO,EAAE,IAAA,mDAAyB,EAAC;YACjC,IAAI,EAAE,IAAI,CAAC,IAAI;YACf,QAAQ,EAAE,IAAI;YACd,OAAO,EAAE,mBAAmB;YAC5B,MAAM,EAAE,mBAAmB;YAC3B,IAAI,EAAE,0EAA0E;SACjF,CAAC;KACH,CAAC,CAAA;IAEF,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,MAAM,IAAI,eAAO,CACf,8BAA8B,EAC9B,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,EACrB,eAAe,IAAI,CAAC,KAAK,+DAA+D,CACzF,CAAA;IACH,CAAC;IAED,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,CAAC,KAAK,EAAE,SAAS,EAAE,CAAA;AACxD,CAAC","sourcesContent":["import { EntityManager } from 'typeorm'\n\nimport { sendEmail } from '@things-factory/email-base'\nimport { getRepository, Refusal } from '@things-factory/shell'\n\nimport { User, UserStatus } from '../service/user/user.js'\nimport { VerificationToken, VerificationTokenType } from '../service/verification-token/verification-token.js'\nimport { getResetPasswordEmailForm } from '../templates/reset-password-email.js'\nimport { buildAuthURL } from '../utils/build-auth-url.js'\nimport { hashVerificationToken, verificationTokenExpiresAt } from '../utils/verification-token-rule.js'\nimport { RESET_PASSWORD_PATH } from './reset-password.js'\nimport { makeVerificationToken } from './utils/make-verification-token.js'\n\n/**\n * An administrator never sets anybody's password (ADR-0161).\n *\n * An account an administrator makes, or one whose password they take back, is put **waiting for its password**: the\n * status `password_reset_required` and a hash of a random value nobody holds. The person sets their own through a\n * one-time link - mailed, or handed over by the administrator - on the same page a forgotten password is reset on.\n */\n\n/** A password nobody knows: what a waiting account carries until its person sets one. */\nexport function sealPassword(user: Partial<User>): void {\n user.salt = User.generateSalt()\n user.password = User.encode(makeVerificationToken() + makeVerificationToken(), user.salt)\n user.status = UserStatus.PWD_RESET_REQUIRED\n}\n\n/**\n * Put `user` waiting for its password and issue the link.\n *\n * Whatever password the account had stops working now, and its generation moves on, so a session opened before this\n * - by the person or by whoever made the administrator take the password back - ends with it.\n *\n * The person holds one live token at a time (the table is keyed by person), so issuing again retires the last link.\n *\n * @param mail true mails the link to the account's address; the answer then carries no link. A mail that does not go\n * out refuses, and the transaction takes back the rest.\n * @returns the link when it is not mailed - this is the only time it exists outside the person's hands.\n */\nexport async function issuePasswordSetupLink({\n user,\n mail,\n context,\n tx\n}: {\n user: User\n mail: boolean\n context: any\n tx?: EntityManager\n}): Promise<{ link: string | null; mailedTo: string | null; expiresAt: Date }> {\n sealPassword(user)\n user.failCount = 0\n user.lockedAt = null\n user.credentialSerial = (user.credentialSerial ?? 0) + 1\n\n await getRepository(User, tx).save(user)\n\n const token = makeVerificationToken()\n const expiresAt = verificationTokenExpiresAt(VerificationTokenType.PASSWORD_SETUP)\n\n await getRepository(VerificationToken, tx).save({\n userId: user.id,\n token: hashVerificationToken(token),\n type: VerificationTokenType.PASSWORD_SETUP,\n expiresAt\n })\n\n const link = `${buildAuthURL(RESET_PASSWORD_PATH, context)}#${token}`\n\n if (!mail) {\n return { link, mailedTo: null, expiresAt }\n }\n\n /*\n * `sendEmail` swallows a failure and answers nothing; a sent mail answers the transport's receipt. Its declared type\n * is `void`, so it is read as unknown here - the receipt is the only sign the mail went out.\n */\n const sent: unknown = await sendEmail({\n receiver: user.email,\n subject: 'Set your password',\n content: getResetPasswordEmailForm({\n name: user.name,\n resetUrl: link,\n heading: 'Set your password',\n action: 'set your password',\n note: 'The link works once, for 48 hours. The page asks for this email address.'\n })\n })\n\n if (!sent) {\n throw new Refusal(\n 'password-setup-mail-not-sent',\n { email: user.email },\n `The mail to ${user.email} did not go out. Nothing was changed - copy the link instead.`\n )\n }\n\n return { link: null, mailedTo: user.email, expiresAt }\n}\n"]}
1
+ {"version":3,"file":"password-setup.js","sourceRoot":"","sources":["../../server/controllers/password-setup.ts"],"names":[],"mappings":";;AAsBA,oCAIC;AAcD,wDAoDC;AA1FD,2DAAsD;AACtD,iDAA8D;AAE9D,qDAA0D;AAC1D,+FAA8G;AAC9G,kEAAyD;AACzD,oFAAuG;AACvG,uDAA+C;AAC/C,2DAAyD;AACzD,mFAA0E;AAE1E;;;;;;GAMG;AAEH,yFAAyF;AACzF,SAAgB,YAAY,CAAC,IAAmB;IAC9C,IAAI,CAAC,IAAI,GAAG,cAAI,CAAC,YAAY,EAAE,CAAA;IAC/B,IAAI,CAAC,QAAQ,GAAG,cAAI,CAAC,MAAM,CAAC,IAAA,kDAAqB,GAAE,GAAG,IAAA,kDAAqB,GAAE,EAAE,IAAI,CAAC,IAAI,CAAC,CAAA;IACzF,IAAI,CAAC,MAAM,GAAG,oBAAU,CAAC,kBAAkB,CAAA;AAC7C,CAAC;AAED;;;;;;;;;;;GAWG;AACI,KAAK,UAAU,sBAAsB,CAAC,EAC3C,IAAI,EACJ,IAAI,EACJ,OAAO,EACP,EAAE,EAMH;IACC,YAAY,CAAC,IAAI,CAAC,CAAA;IAClB,IAAI,CAAC,SAAS,GAAG,CAAC,CAAA;IAClB,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAA;IACpB,IAAI,CAAC,gBAAgB,GAAG,CAAC,IAAI,CAAC,gBAAgB,IAAI,CAAC,CAAC,GAAG,CAAC,CAAA;IAExD,MAAM,IAAA,qBAAa,EAAC,cAAI,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IAExC,MAAM,KAAK,GAAG,IAAA,kDAAqB,GAAE,CAAA;IACrC,MAAM,SAAS,GAAG,IAAA,uDAA0B,EAAC,6CAAqB,CAAC,cAAc,CAAC,CAAA;IAElF,MAAM,IAAA,qBAAa,EAAC,yCAAiB,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC;QAC9C,MAAM,EAAE,IAAI,CAAC,EAAE;QACf,KAAK,EAAE,IAAA,kDAAqB,EAAC,KAAK,CAAC;QACnC,IAAI,EAAE,6CAAqB,CAAC,cAAc;QAC1C,SAAS;KACV,CAAC,CAAA;IAEF,MAAM,IAAI,GAAG,GAAG,IAAA,gCAAY,EAAC,uCAAmB,EAAE,OAAO,CAAC,IAAI,KAAK,EAAE,CAAA;IAErE,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,SAAS,EAAE,CAAA;IAC5C,CAAC;IAED;;;OAGG;IACH,MAAM,IAAI,GAAY,MAAM,IAAA,sBAAS,EAAC;QACpC,QAAQ,EAAE,IAAI,CAAC,KAAK;QACpB,GAAG,IAAA,6BAAW,EAAC,EAAE,IAAI,EAAE,cAAc,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC;KAC9D,CAAC,CAAA;IAEF,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,MAAM,IAAI,eAAO,CACf,8BAA8B,EAC9B,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,EACrB,eAAe,IAAI,CAAC,KAAK,+DAA+D,CACzF,CAAA;IACH,CAAC;IAED,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,CAAC,KAAK,EAAE,SAAS,EAAE,CAAA;AACxD,CAAC","sourcesContent":["import { EntityManager } from 'typeorm'\n\nimport { sendEmail } from '@things-factory/email-base'\nimport { getRepository, Refusal } from '@things-factory/shell'\n\nimport { User, UserStatus } from '../service/user/user.js'\nimport { VerificationToken, VerificationTokenType } from '../service/verification-token/verification-token.js'\nimport { buildAuthURL } from '../utils/build-auth-url.js'\nimport { hashVerificationToken, verificationTokenExpiresAt } from '../utils/verification-token-rule.js'\nimport { accountMail } from './account-mail.js'\nimport { RESET_PASSWORD_PATH } from './reset-password.js'\nimport { makeVerificationToken } from './utils/make-verification-token.js'\n\n/**\n * An administrator never sets anybody's password (ADR-0161).\n *\n * An account an administrator makes, or one whose password they take back, is put **waiting for its password**: the\n * status `password_reset_required` and a hash of a random value nobody holds. The person sets their own through a\n * one-time link - mailed, or handed over by the administrator - on the same page a forgotten password is reset on.\n */\n\n/** A password nobody knows: what a waiting account carries until its person sets one. */\nexport function sealPassword(user: Partial<User>): void {\n user.salt = User.generateSalt()\n user.password = User.encode(makeVerificationToken() + makeVerificationToken(), user.salt)\n user.status = UserStatus.PWD_RESET_REQUIRED\n}\n\n/**\n * Put `user` waiting for its password and issue the link.\n *\n * Whatever password the account had stops working now, and its generation moves on, so a session opened before this\n * - by the person or by whoever made the administrator take the password back - ends with it.\n *\n * The person holds one live token at a time (the table is keyed by person), so issuing again retires the last link.\n *\n * @param mail true mails the link to the account's address; the answer then carries no link. A mail that does not go\n * out refuses, and the transaction takes back the rest.\n * @returns the link when it is not mailed - this is the only time it exists outside the person's hands.\n */\nexport async function issuePasswordSetupLink({\n user,\n mail,\n context,\n tx\n}: {\n user: User\n mail: boolean\n context: any\n tx?: EntityManager\n}): Promise<{ link: string | null; mailedTo: string | null; expiresAt: Date }> {\n sealPassword(user)\n user.failCount = 0\n user.lockedAt = null\n user.credentialSerial = (user.credentialSerial ?? 0) + 1\n\n await getRepository(User, tx).save(user)\n\n const token = makeVerificationToken()\n const expiresAt = verificationTokenExpiresAt(VerificationTokenType.PASSWORD_SETUP)\n\n await getRepository(VerificationToken, tx).save({\n userId: user.id,\n token: hashVerificationToken(token),\n type: VerificationTokenType.PASSWORD_SETUP,\n expiresAt\n })\n\n const link = `${buildAuthURL(RESET_PASSWORD_PATH, context)}#${token}`\n\n if (!mail) {\n return { link, mailedTo: null, expiresAt }\n }\n\n /*\n * `sendEmail` swallows a failure and answers nothing; a sent mail answers the transport's receipt. Its declared type\n * is `void`, so it is read as unknown here - the receipt is the only sign the mail went out.\n */\n const sent: unknown = await sendEmail({\n receiver: user.email,\n ...accountMail({ kind: 'set-password', user, link, context })\n })\n\n if (!sent) {\n throw new Refusal(\n 'password-setup-mail-not-sent',\n { email: user.email },\n `The mail to ${user.email} did not go out. Nothing was changed - copy the link instead.`\n )\n }\n\n return { link: null, mailedTo: user.email, expiresAt }\n}\n"]}
@@ -11,8 +11,8 @@ const auth_error_js_1 = require("../errors/auth-error.js");
11
11
  const password_history_js_1 = require("../service/password-history/password-history.js");
12
12
  const user_js_1 = require("../service/user/user.js");
13
13
  const verification_token_js_1 = require("../service/verification-token/verification-token.js");
14
- const reset_password_email_js_1 = require("../templates/reset-password-email.js");
15
14
  const build_auth_url_js_1 = require("../utils/build-auth-url.js");
15
+ const account_mail_js_1 = require("./account-mail.js");
16
16
  const find_verification_token_js_1 = require("./utils/find-verification-token.js");
17
17
  const make_verification_token_js_1 = require("./utils/make-verification-token.js");
18
18
  const save_verification_token_js_1 = require("./utils/save-verification-token.js");
@@ -31,11 +31,7 @@ async function sendPasswordResetEmail({ user, context }) {
31
31
  var serviceUrl = `${(0, build_auth_url_js_1.buildAuthURL)(exports.RESET_PASSWORD_PATH, context)}#${token}`;
32
32
  await (0, email_base_1.sendEmail)({
33
33
  receiver: user.email,
34
- subject: 'Reset your password',
35
- content: (0, reset_password_email_js_1.getResetPasswordEmailForm)({
36
- name: user.name,
37
- resetUrl: serviceUrl
38
- })
34
+ ...(0, account_mail_js_1.accountMail)({ kind: 'reset-password', user, link: serviceUrl, context })
39
35
  });
40
36
  return true;
41
37
  }
@@ -1 +1 @@
1
- {"version":3,"file":"reset-password.js","sourceRoot":"","sources":["../../server/controllers/reset-password.ts"],"names":[],"mappings":"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","sourcesContent":["import { sendEmail } from '@things-factory/email-base'\nimport { config } from '@things-factory/env'\nimport { getRepository } from '@things-factory/shell'\n\nimport { PASSWORD_USED_PAST } from '../constants/error-code.js'\nimport { AuthError } from '../errors/auth-error.js'\nimport { PasswordHistory } from '../service/password-history/password-history.js'\nimport { User, UserStatus } from '../service/user/user.js'\nimport { VerificationTokenType } from '../service/verification-token/verification-token.js'\nimport { getResetPasswordEmailForm } from '../templates/reset-password-email.js'\nimport { buildAuthURL } from '../utils/build-auth-url.js'\nimport { consumeVerificationToken, findLiveVerificationToken } from './utils/find-verification-token.js'\nimport { makeVerificationToken } from './utils/make-verification-token.js'\nimport { saveVerificationToken } from './utils/save-verification-token.js'\n\nconst HISTORY_SIZE = config.get('password', { history: 0 }).history\n\n/**\n * The page the mail links to. The token rides after `#`, so the browser never sends it — not to our\n * access log, not to a proxy's. The page reads it and posts it. `tests/invitation-route.test.ts`\n * holds this against the router's table.\n */\nexport const RESET_PASSWORD_PATH = '/auth/reset-password'\n\nexport async function sendPasswordResetEmail({ user, context }) {\n try {\n var token = makeVerificationToken()\n var verifaction = await saveVerificationToken(user.id, token, VerificationTokenType.PASSWORD_RESET)\n\n if (verifaction) {\n var serviceUrl = `${buildAuthURL(RESET_PASSWORD_PATH, context)}#${token}`\n await sendEmail({\n receiver: user.email,\n subject: 'Reset your password',\n content: getResetPasswordEmailForm({\n name: user.name,\n resetUrl: serviceUrl\n })\n })\n\n return true\n }\n } catch (e) {\n return false\n }\n}\n\n/**\n * Set a new password through a link: the one a person mails themselves when they forgot theirs, or the setup link an\n * administrator issued (ADR-0161) - one page and one door for both.\n *\n * The address the link was for has to be given as well. A link is forwarded, left in a chat, copied onto paper; holding\n * it is not enough on its own. A wrong token, a wrong address and an expired link are one answer.\n */\nexport async function resetPassword(token, password, email, context) {\n const { t } = context\n\n const verificationToken =\n (await findLiveVerificationToken(token, VerificationTokenType.PASSWORD_RESET)) ||\n (await findLiveVerificationToken(token, VerificationTokenType.PASSWORD_SETUP))\n\n var user = verificationToken?.userId ? await getRepository(User).findOneBy({ id: verificationToken.userId }) : null\n\n const given = typeof email === 'string' ? email.trim().toLowerCase() : ''\n if (!user || !given || user.email?.toLowerCase() !== given) {\n throw new Error(t('text.invalid verification token'))\n }\n\n // if (user.status == UserStatus.INACTIVE) {\n // throw new Error(t('text.inactive user'))\n // }\n\n /* check if password is following the rule */\n User.validatePasswordByRule(password, context?.lng)\n\n user.password = User.encode(password, user.salt)\n\n if (HISTORY_SIZE > 0) {\n var passwordHistory: PasswordHistory = await getRepository(PasswordHistory).findOneBy({ userId: user.id })\n var history = []\n\n if (passwordHistory) {\n try {\n history = JSON.parse(passwordHistory.history)\n if (!(history instanceof Array)) {\n console.error('password history maybe currupted - not an array')\n history = []\n }\n } catch (e) {\n console.error('password history currupted - not json format')\n }\n\n const found = history.slice(0, HISTORY_SIZE).find(h => {\n return User.verify(h.password, password, h.salt)\n })\n\n if (found) {\n throw new AuthError({\n errorCode: PASSWORD_USED_PAST\n })\n }\n }\n }\n\n /*\n * A waiting account opens here (ADR-0161 decision 3). Any other status is left as it was - a locked account stays\n * locked, which is the unlock link's to undo (`tests/lock-recovery.test.ts`).\n */\n if (user.status === UserStatus.PWD_RESET_REQUIRED) {\n user.status = UserStatus.ACTIVATED\n user.failCount = 0\n }\n\n await getRepository(User).save({\n ...user,\n passwordUpdatedAt: new Date()\n })\n\n await consumeVerificationToken(verificationToken)\n\n if (HISTORY_SIZE > 0) {\n history = [\n {\n password: user.password,\n salt: user.salt\n },\n ...history\n ].slice(0, HISTORY_SIZE)\n\n await getRepository(PasswordHistory).save({\n userId: user.id,\n history: JSON.stringify(history)\n })\n }\n}\n"]}
1
+ {"version":3,"file":"reset-password.js","sourceRoot":"","sources":["../../server/controllers/reset-password.ts"],"names":[],"mappings":";;;AAwBA,wDAiBC;AASD,sCAgFC;AAlID,2DAAsD;AACtD,6CAA4C;AAC5C,iDAAqD;AAErD,8DAA+D;AAC/D,2DAAmD;AACnD,yFAAiF;AACjF,qDAA0D;AAC1D,+FAA2F;AAC3F,kEAAyD;AACzD,uDAA+C;AAC/C,mFAAwG;AACxG,mFAA0E;AAC1E,mFAA0E;AAE1E,MAAM,YAAY,GAAG,YAAM,CAAC,GAAG,CAAC,UAAU,EAAE,EAAE,OAAO,EAAE,CAAC,EAAE,CAAC,CAAC,OAAO,CAAA;AAEnE;;;;GAIG;AACU,QAAA,mBAAmB,GAAG,sBAAsB,CAAA;AAElD,KAAK,UAAU,sBAAsB,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE;IAC5D,IAAI,CAAC;QACH,IAAI,KAAK,GAAG,IAAA,kDAAqB,GAAE,CAAA;QACnC,IAAI,WAAW,GAAG,MAAM,IAAA,kDAAqB,EAAC,IAAI,CAAC,EAAE,EAAE,KAAK,EAAE,6CAAqB,CAAC,cAAc,CAAC,CAAA;QAEnG,IAAI,WAAW,EAAE,CAAC;YAChB,IAAI,UAAU,GAAG,GAAG,IAAA,gCAAY,EAAC,2BAAmB,EAAE,OAAO,CAAC,IAAI,KAAK,EAAE,CAAA;YACzE,MAAM,IAAA,sBAAS,EAAC;gBACd,QAAQ,EAAE,IAAI,CAAC,KAAK;gBACpB,GAAG,IAAA,6BAAW,EAAC,EAAE,IAAI,EAAE,gBAAgB,EAAE,IAAI,EAAE,IAAI,EAAE,UAAU,EAAE,OAAO,EAAE,CAAC;aAC5E,CAAC,CAAA;YAEF,OAAO,IAAI,CAAA;QACb,CAAC;IACH,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,OAAO,KAAK,CAAA;IACd,CAAC;AACH,CAAC;AAED;;;;;;GAMG;AACI,KAAK,UAAU,aAAa,CAAC,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE,OAAO;IACjE,MAAM,EAAE,CAAC,EAAE,GAAG,OAAO,CAAA;IAErB,MAAM,iBAAiB,GACrB,CAAC,MAAM,IAAA,sDAAyB,EAAC,KAAK,EAAE,6CAAqB,CAAC,cAAc,CAAC,CAAC;QAC9E,CAAC,MAAM,IAAA,sDAAyB,EAAC,KAAK,EAAE,6CAAqB,CAAC,cAAc,CAAC,CAAC,CAAA;IAEhF,IAAI,IAAI,GAAG,iBAAiB,EAAE,MAAM,CAAC,CAAC,CAAC,MAAM,IAAA,qBAAa,EAAC,cAAI,CAAC,CAAC,SAAS,CAAC,EAAE,EAAE,EAAE,iBAAiB,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAA;IAEnH,MAAM,KAAK,GAAG,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,CAAA;IACzE,IAAI,CAAC,IAAI,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,KAAK,EAAE,WAAW,EAAE,KAAK,KAAK,EAAE,CAAC;QAC3D,MAAM,IAAI,KAAK,CAAC,CAAC,CAAC,iCAAiC,CAAC,CAAC,CAAA;IACvD,CAAC;IAED,4CAA4C;IAC5C,6CAA6C;IAC7C,IAAI;IAEJ,6CAA6C;IAC7C,cAAI,CAAC,sBAAsB,CAAC,QAAQ,EAAE,OAAO,EAAE,GAAG,CAAC,CAAA;IAEnD,IAAI,CAAC,QAAQ,GAAG,cAAI,CAAC,MAAM,CAAC,QAAQ,EAAE,IAAI,CAAC,IAAI,CAAC,CAAA;IAEhD,IAAI,YAAY,GAAG,CAAC,EAAE,CAAC;QACrB,IAAI,eAAe,GAAoB,MAAM,IAAA,qBAAa,EAAC,qCAAe,CAAC,CAAC,SAAS,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,CAAA;QAC1G,IAAI,OAAO,GAAG,EAAE,CAAA;QAEhB,IAAI,eAAe,EAAE,CAAC;YACpB,IAAI,CAAC;gBACH,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,eAAe,CAAC,OAAO,CAAC,CAAA;gBAC7C,IAAI,CAAC,CAAC,OAAO,YAAY,KAAK,CAAC,EAAE,CAAC;oBAChC,OAAO,CAAC,KAAK,CAAC,iDAAiD,CAAC,CAAA;oBAChE,OAAO,GAAG,EAAE,CAAA;gBACd,CAAC;YACH,CAAC;YAAC,OAAO,CAAC,EAAE,CAAC;gBACX,OAAO,CAAC,KAAK,CAAC,8CAA8C,CAAC,CAAA;YAC/D,CAAC;YAED,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,YAAY,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE;gBACpD,OAAO,cAAI,CAAC,MAAM,CAAC,CAAC,CAAC,QAAQ,EAAE,QAAQ,EAAE,CAAC,CAAC,IAAI,CAAC,CAAA;YAClD,CAAC,CAAC,CAAA;YAEF,IAAI,KAAK,EAAE,CAAC;gBACV,MAAM,IAAI,yBAAS,CAAC;oBAClB,SAAS,EAAE,kCAAkB;iBAC9B,CAAC,CAAA;YACJ,CAAC;QACH,CAAC;IACH,CAAC;IAED;;;OAGG;IACH,IAAI,IAAI,CAAC,MAAM,KAAK,oBAAU,CAAC,kBAAkB,EAAE,CAAC;QAClD,IAAI,CAAC,MAAM,GAAG,oBAAU,CAAC,SAAS,CAAA;QAClC,IAAI,CAAC,SAAS,GAAG,CAAC,CAAA;IACpB,CAAC;IAED,MAAM,IAAA,qBAAa,EAAC,cAAI,CAAC,CAAC,IAAI,CAAC;QAC7B,GAAG,IAAI;QACP,iBAAiB,EAAE,IAAI,IAAI,EAAE;KAC9B,CAAC,CAAA;IAEF,MAAM,IAAA,qDAAwB,EAAC,iBAAiB,CAAC,CAAA;IAEjD,IAAI,YAAY,GAAG,CAAC,EAAE,CAAC;QACrB,OAAO,GAAG;YACR;gBACE,QAAQ,EAAE,IAAI,CAAC,QAAQ;gBACvB,IAAI,EAAE,IAAI,CAAC,IAAI;aAChB;YACD,GAAG,OAAO;SACX,CAAC,KAAK,CAAC,CAAC,EAAE,YAAY,CAAC,CAAA;QAExB,MAAM,IAAA,qBAAa,EAAC,qCAAe,CAAC,CAAC,IAAI,CAAC;YACxC,MAAM,EAAE,IAAI,CAAC,EAAE;YACf,OAAO,EAAE,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC;SACjC,CAAC,CAAA;IACJ,CAAC;AACH,CAAC","sourcesContent":["import { sendEmail } from '@things-factory/email-base'\nimport { config } from '@things-factory/env'\nimport { getRepository } from '@things-factory/shell'\n\nimport { PASSWORD_USED_PAST } from '../constants/error-code.js'\nimport { AuthError } from '../errors/auth-error.js'\nimport { PasswordHistory } from '../service/password-history/password-history.js'\nimport { User, UserStatus } from '../service/user/user.js'\nimport { VerificationTokenType } from '../service/verification-token/verification-token.js'\nimport { buildAuthURL } from '../utils/build-auth-url.js'\nimport { accountMail } from './account-mail.js'\nimport { consumeVerificationToken, findLiveVerificationToken } from './utils/find-verification-token.js'\nimport { makeVerificationToken } from './utils/make-verification-token.js'\nimport { saveVerificationToken } from './utils/save-verification-token.js'\n\nconst HISTORY_SIZE = config.get('password', { history: 0 }).history\n\n/**\n * The page the mail links to. The token rides after `#`, so the browser never sends it — not to our\n * access log, not to a proxy's. The page reads it and posts it. `tests/invitation-route.test.ts`\n * holds this against the router's table.\n */\nexport const RESET_PASSWORD_PATH = '/auth/reset-password'\n\nexport async function sendPasswordResetEmail({ user, context }) {\n try {\n var token = makeVerificationToken()\n var verifaction = await saveVerificationToken(user.id, token, VerificationTokenType.PASSWORD_RESET)\n\n if (verifaction) {\n var serviceUrl = `${buildAuthURL(RESET_PASSWORD_PATH, context)}#${token}`\n await sendEmail({\n receiver: user.email,\n ...accountMail({ kind: 'reset-password', user, link: serviceUrl, context })\n })\n\n return true\n }\n } catch (e) {\n return false\n }\n}\n\n/**\n * Set a new password through a link: the one a person mails themselves when they forgot theirs, or the setup link an\n * administrator issued (ADR-0161) - one page and one door for both.\n *\n * The address the link was for has to be given as well. A link is forwarded, left in a chat, copied onto paper; holding\n * it is not enough on its own. A wrong token, a wrong address and an expired link are one answer.\n */\nexport async function resetPassword(token, password, email, context) {\n const { t } = context\n\n const verificationToken =\n (await findLiveVerificationToken(token, VerificationTokenType.PASSWORD_RESET)) ||\n (await findLiveVerificationToken(token, VerificationTokenType.PASSWORD_SETUP))\n\n var user = verificationToken?.userId ? await getRepository(User).findOneBy({ id: verificationToken.userId }) : null\n\n const given = typeof email === 'string' ? email.trim().toLowerCase() : ''\n if (!user || !given || user.email?.toLowerCase() !== given) {\n throw new Error(t('text.invalid verification token'))\n }\n\n // if (user.status == UserStatus.INACTIVE) {\n // throw new Error(t('text.inactive user'))\n // }\n\n /* check if password is following the rule */\n User.validatePasswordByRule(password, context?.lng)\n\n user.password = User.encode(password, user.salt)\n\n if (HISTORY_SIZE > 0) {\n var passwordHistory: PasswordHistory = await getRepository(PasswordHistory).findOneBy({ userId: user.id })\n var history = []\n\n if (passwordHistory) {\n try {\n history = JSON.parse(passwordHistory.history)\n if (!(history instanceof Array)) {\n console.error('password history maybe currupted - not an array')\n history = []\n }\n } catch (e) {\n console.error('password history currupted - not json format')\n }\n\n const found = history.slice(0, HISTORY_SIZE).find(h => {\n return User.verify(h.password, password, h.salt)\n })\n\n if (found) {\n throw new AuthError({\n errorCode: PASSWORD_USED_PAST\n })\n }\n }\n }\n\n /*\n * A waiting account opens here (ADR-0161 decision 3). Any other status is left as it was - a locked account stays\n * locked, which is the unlock link's to undo (`tests/lock-recovery.test.ts`).\n */\n if (user.status === UserStatus.PWD_RESET_REQUIRED) {\n user.status = UserStatus.ACTIVATED\n user.failCount = 0\n }\n\n await getRepository(User).save({\n ...user,\n passwordUpdatedAt: new Date()\n })\n\n await consumeVerificationToken(verificationToken)\n\n if (HISTORY_SIZE > 0) {\n history = [\n {\n password: user.password,\n salt: user.salt\n },\n ...history\n ].slice(0, HISTORY_SIZE)\n\n await getRepository(PasswordHistory).save({\n userId: user.id,\n history: JSON.stringify(history)\n })\n }\n}\n"]}
@@ -7,8 +7,8 @@ const email_base_1 = require("@things-factory/email-base");
7
7
  const shell_1 = require("@things-factory/shell");
8
8
  const user_js_1 = require("../service/user/user.js");
9
9
  const verification_token_js_1 = require("../service/verification-token/verification-token.js");
10
- const account_unlock_email_js_1 = require("../templates/account-unlock-email.js");
11
10
  const build_auth_url_js_1 = require("../utils/build-auth-url.js");
11
+ const account_mail_js_1 = require("./account-mail.js");
12
12
  const find_verification_token_js_1 = require("./utils/find-verification-token.js");
13
13
  const make_verification_token_js_1 = require("./utils/make-verification-token.js");
14
14
  const save_verification_token_js_1 = require("./utils/save-verification-token.js");
@@ -26,12 +26,7 @@ async function sendUnlockUserEmail({ user, context }) {
26
26
  var serviceUrl = `${(0, build_auth_url_js_1.buildAuthURL)(exports.UNLOCK_USER_PATH, context)}#${token}`;
27
27
  await (0, email_base_1.sendEmail)({
28
28
  receiver: user.email,
29
- subject: 'Your account is locked',
30
- content: (0, account_unlock_email_js_1.getUnlockUserEmailForm)({
31
- username: user.username,
32
- name: user.name,
33
- resetUrl: serviceUrl
34
- })
29
+ ...(0, account_mail_js_1.accountMail)({ kind: 'unlock-account', user, link: serviceUrl, context })
35
30
  });
36
31
  return true;
37
32
  }
@@ -1 +1 @@
1
- {"version":3,"file":"unlock-user.js","sourceRoot":"","sources":["../../server/controllers/unlock-user.ts"],"names":[],"mappings":";;;AAkBA,kDAsBC;AAED,gCAuCC;AAjFD,2DAAsD;AACtD,iDAAqD;AAErD,qDAA0D;AAC1D,+FAA2F;AAC3F,kFAA6E;AAC7E,kEAAyD;AACzD,mFAAwG;AACxG,mFAA0E;AAC1E,mFAA0E;AAE1E;;;;GAIG;AACU,QAAA,gBAAgB,GAAG,mBAAmB,CAAA;AAE5C,KAAK,UAAU,mBAAmB,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE;IACzD,IAAI,CAAC;QACH,IAAI,KAAK,GAAG,IAAA,kDAAqB,GAAE,CAAA;QACnC,IAAI,WAAW,GAAG,MAAM,IAAA,kDAAqB,EAAC,IAAI,CAAC,EAAE,EAAE,KAAK,EAAE,6CAAqB,CAAC,MAAM,CAAC,CAAA;QAE3F,IAAI,WAAW,EAAE,CAAC;YAChB,IAAI,UAAU,GAAG,GAAG,IAAA,gCAAY,EAAC,wBAAgB,EAAE,OAAO,CAAC,IAAI,KAAK,EAAE,CAAA;YACtE,MAAM,IAAA,sBAAS,EAAC;gBACd,QAAQ,EAAE,IAAI,CAAC,KAAK;gBACpB,OAAO,EAAE,wBAAwB;gBACjC,OAAO,EAAE,IAAA,gDAAsB,EAAC;oBAC9B,QAAQ,EAAE,IAAI,CAAC,QAAQ;oBACvB,IAAI,EAAE,IAAI,CAAC,IAAI;oBACf,QAAQ,EAAE,UAAU;iBACrB,CAAC;aACH,CAAC,CAAA;YAEF,OAAO,IAAI,CAAA;QACb,CAAC;IACH,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,OAAO,KAAK,CAAA;IACd,CAAC;AACH,CAAC;AAEM,KAAK,UAAU,UAAU,CAAC,KAAK,EAAE,QAAQ;IAC9C;;;;;;;;;;;OAWG;IACH,IAAI,CAAC,KAAK,IAAI,CAAC,QAAQ,EAAE,CAAC;QACxB,OAAO,KAAK,CAAA;IACd,CAAC;IAED,MAAM,YAAY,GAAG,MAAM,IAAA,sDAAyB,EAAC,KAAK,EAAE,6CAAqB,CAAC,MAAM,CAAC,CAAA;IAEzF;iFAC6E;IAC7E,IAAI,CAAC,YAAY,EAAE,MAAM,EAAE,CAAC;QAC1B,OAAO,KAAK,CAAA;IACd,CAAC;IAED,MAAM,EAAE,MAAM,EAAE,GAAG,YAAY,CAAA;IAE/B,IAAI,QAAQ,GAAG,MAAM,IAAA,qBAAa,EAAC,cAAI,CAAC,CAAC,SAAS,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,CAAC,CAAA;IAClE,IAAI,CAAC,QAAQ;QAAE,OAAO,KAAK,CAAA;IAC3B,IAAI,QAAQ,CAAC,MAAM,IAAI,oBAAU,CAAC,MAAM;QAAE,OAAO,KAAK,CAAA;IAEtD,QAAQ,CAAC,MAAM,GAAG,oBAAU,CAAC,SAAS,CAAA;IACtC,QAAQ,CAAC,QAAQ,GAAG,cAAI,CAAC,MAAM,CAAC,QAAQ,EAAE,QAAQ,CAAC,IAAI,CAAC,CAAA;IACxD,QAAQ,CAAC,SAAS,GAAG,CAAC,CAAA;IAEtB,MAAM,IAAA,qBAAa,EAAC,cAAI,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;IACxC,MAAM,IAAA,qDAAwB,EAAC,YAAY,CAAC,CAAA;IAE5C,OAAO,IAAI,CAAA;AACb,CAAC","sourcesContent":["import { sendEmail } from '@things-factory/email-base'\nimport { getRepository } from '@things-factory/shell'\n\nimport { User, UserStatus } from '../service/user/user.js'\nimport { VerificationTokenType } from '../service/verification-token/verification-token.js'\nimport { getUnlockUserEmailForm } from '../templates/account-unlock-email.js'\nimport { buildAuthURL } from '../utils/build-auth-url.js'\nimport { consumeVerificationToken, findLiveVerificationToken } from './utils/find-verification-token.js'\nimport { makeVerificationToken } from './utils/make-verification-token.js'\nimport { saveVerificationToken } from './utils/save-verification-token.js'\n\n/**\n * The page the mail links to. The token rides after `#`, so the browser never sends it — not to our\n * access log, not to a proxy's. The page reads it and posts it. `tests/invitation-route.test.ts`\n * holds this against the router's table.\n */\nexport const UNLOCK_USER_PATH = '/auth/unlock-user'\n\nexport async function sendUnlockUserEmail({ user, context }) {\n try {\n var token = makeVerificationToken()\n var verifaction = await saveVerificationToken(user.id, token, VerificationTokenType.UNLOCK)\n\n if (verifaction) {\n var serviceUrl = `${buildAuthURL(UNLOCK_USER_PATH, context)}#${token}`\n await sendEmail({\n receiver: user.email,\n subject: 'Your account is locked',\n content: getUnlockUserEmailForm({\n username: user.username,\n name: user.name,\n resetUrl: serviceUrl\n })\n })\n\n return true\n }\n } catch (e) {\n return false\n }\n}\n\nexport async function unlockUser(token, password) {\n /*\n * An absent condition is not a narrow condition — TypeORM drops `undefined` from `where`.\n *\n * Without this guard, a call with no token searched `{ type: UNLOCK }` alone and matched\n * **anyone's** pending unlock token, then handed that account the password the caller chose.\n * Both halves are reachable without credentials: five wrong guesses lock a victim and mint the\n * token, and `/auth/unlock-user` accepted a body with a password and no token because its own\n * guard read `!(token || password)`.\n *\n * [확인함] 2026-09-09 · locked a victim by failing signin five times, posted a password with no\n * token, and signed in as them — status activated, failCount 0, password the attacker's.\n */\n if (!token || !password) {\n return false\n }\n\n const verification = await findLiveVerificationToken(token, VerificationTokenType.UNLOCK)\n\n /* Refuse a token that is gone, rather than destructuring null — a reused link showed the\n screen a 500 instead of a reason. `resetPassword` refuses the same way. */\n if (!verification?.userId) {\n return false\n }\n\n const { userId } = verification\n\n var userInfo = await getRepository(User).findOneBy({ id: userId })\n if (!userInfo) return false\n if (userInfo.status != UserStatus.LOCKED) return false\n\n userInfo.status = UserStatus.ACTIVATED\n userInfo.password = User.encode(password, userInfo.salt)\n userInfo.failCount = 0\n\n await getRepository(User).save(userInfo)\n await consumeVerificationToken(verification)\n\n return true\n}\n"]}
1
+ {"version":3,"file":"unlock-user.js","sourceRoot":"","sources":["../../server/controllers/unlock-user.ts"],"names":[],"mappings":";;;AAkBA,kDAiBC;AAED,gCAuCC;AA5ED,2DAAsD;AACtD,iDAAqD;AAErD,qDAA0D;AAC1D,+FAA2F;AAC3F,kEAAyD;AACzD,uDAA+C;AAC/C,mFAAwG;AACxG,mFAA0E;AAC1E,mFAA0E;AAE1E;;;;GAIG;AACU,QAAA,gBAAgB,GAAG,mBAAmB,CAAA;AAE5C,KAAK,UAAU,mBAAmB,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE;IACzD,IAAI,CAAC;QACH,IAAI,KAAK,GAAG,IAAA,kDAAqB,GAAE,CAAA;QACnC,IAAI,WAAW,GAAG,MAAM,IAAA,kDAAqB,EAAC,IAAI,CAAC,EAAE,EAAE,KAAK,EAAE,6CAAqB,CAAC,MAAM,CAAC,CAAA;QAE3F,IAAI,WAAW,EAAE,CAAC;YAChB,IAAI,UAAU,GAAG,GAAG,IAAA,gCAAY,EAAC,wBAAgB,EAAE,OAAO,CAAC,IAAI,KAAK,EAAE,CAAA;YACtE,MAAM,IAAA,sBAAS,EAAC;gBACd,QAAQ,EAAE,IAAI,CAAC,KAAK;gBACpB,GAAG,IAAA,6BAAW,EAAC,EAAE,IAAI,EAAE,gBAAgB,EAAE,IAAI,EAAE,IAAI,EAAE,UAAU,EAAE,OAAO,EAAE,CAAC;aAC5E,CAAC,CAAA;YAEF,OAAO,IAAI,CAAA;QACb,CAAC;IACH,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,OAAO,KAAK,CAAA;IACd,CAAC;AACH,CAAC;AAEM,KAAK,UAAU,UAAU,CAAC,KAAK,EAAE,QAAQ;IAC9C;;;;;;;;;;;OAWG;IACH,IAAI,CAAC,KAAK,IAAI,CAAC,QAAQ,EAAE,CAAC;QACxB,OAAO,KAAK,CAAA;IACd,CAAC;IAED,MAAM,YAAY,GAAG,MAAM,IAAA,sDAAyB,EAAC,KAAK,EAAE,6CAAqB,CAAC,MAAM,CAAC,CAAA;IAEzF;iFAC6E;IAC7E,IAAI,CAAC,YAAY,EAAE,MAAM,EAAE,CAAC;QAC1B,OAAO,KAAK,CAAA;IACd,CAAC;IAED,MAAM,EAAE,MAAM,EAAE,GAAG,YAAY,CAAA;IAE/B,IAAI,QAAQ,GAAG,MAAM,IAAA,qBAAa,EAAC,cAAI,CAAC,CAAC,SAAS,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,CAAC,CAAA;IAClE,IAAI,CAAC,QAAQ;QAAE,OAAO,KAAK,CAAA;IAC3B,IAAI,QAAQ,CAAC,MAAM,IAAI,oBAAU,CAAC,MAAM;QAAE,OAAO,KAAK,CAAA;IAEtD,QAAQ,CAAC,MAAM,GAAG,oBAAU,CAAC,SAAS,CAAA;IACtC,QAAQ,CAAC,QAAQ,GAAG,cAAI,CAAC,MAAM,CAAC,QAAQ,EAAE,QAAQ,CAAC,IAAI,CAAC,CAAA;IACxD,QAAQ,CAAC,SAAS,GAAG,CAAC,CAAA;IAEtB,MAAM,IAAA,qBAAa,EAAC,cAAI,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;IACxC,MAAM,IAAA,qDAAwB,EAAC,YAAY,CAAC,CAAA;IAE5C,OAAO,IAAI,CAAA;AACb,CAAC","sourcesContent":["import { sendEmail } from '@things-factory/email-base'\nimport { getRepository } from '@things-factory/shell'\n\nimport { User, UserStatus } from '../service/user/user.js'\nimport { VerificationTokenType } from '../service/verification-token/verification-token.js'\nimport { buildAuthURL } from '../utils/build-auth-url.js'\nimport { accountMail } from './account-mail.js'\nimport { consumeVerificationToken, findLiveVerificationToken } from './utils/find-verification-token.js'\nimport { makeVerificationToken } from './utils/make-verification-token.js'\nimport { saveVerificationToken } from './utils/save-verification-token.js'\n\n/**\n * The page the mail links to. The token rides after `#`, so the browser never sends it — not to our\n * access log, not to a proxy's. The page reads it and posts it. `tests/invitation-route.test.ts`\n * holds this against the router's table.\n */\nexport const UNLOCK_USER_PATH = '/auth/unlock-user'\n\nexport async function sendUnlockUserEmail({ user, context }) {\n try {\n var token = makeVerificationToken()\n var verifaction = await saveVerificationToken(user.id, token, VerificationTokenType.UNLOCK)\n\n if (verifaction) {\n var serviceUrl = `${buildAuthURL(UNLOCK_USER_PATH, context)}#${token}`\n await sendEmail({\n receiver: user.email,\n ...accountMail({ kind: 'unlock-account', user, link: serviceUrl, context })\n })\n\n return true\n }\n } catch (e) {\n return false\n }\n}\n\nexport async function unlockUser(token, password) {\n /*\n * An absent condition is not a narrow condition — TypeORM drops `undefined` from `where`.\n *\n * Without this guard, a call with no token searched `{ type: UNLOCK }` alone and matched\n * **anyone's** pending unlock token, then handed that account the password the caller chose.\n * Both halves are reachable without credentials: five wrong guesses lock a victim and mint the\n * token, and `/auth/unlock-user` accepted a body with a password and no token because its own\n * guard read `!(token || password)`.\n *\n * [확인함] 2026-09-09 · locked a victim by failing signin five times, posted a password with no\n * token, and signed in as them — status activated, failCount 0, password the attacker's.\n */\n if (!token || !password) {\n return false\n }\n\n const verification = await findLiveVerificationToken(token, VerificationTokenType.UNLOCK)\n\n /* Refuse a token that is gone, rather than destructuring null — a reused link showed the\n screen a 500 instead of a reason. `resetPassword` refuses the same way. */\n if (!verification?.userId) {\n return false\n }\n\n const { userId } = verification\n\n var userInfo = await getRepository(User).findOneBy({ id: userId })\n if (!userInfo) return false\n if (userInfo.status != UserStatus.LOCKED) return false\n\n userInfo.status = UserStatus.ACTIVATED\n userInfo.password = User.encode(password, userInfo.salt)\n userInfo.failCount = 0\n\n await getRepository(User).save(userInfo)\n await consumeVerificationToken(verification)\n\n return true\n}\n"]}
@@ -9,8 +9,8 @@ const shell_1 = require("@things-factory/shell");
9
9
  const auth_error_js_1 = require("../errors/auth-error.js");
10
10
  const user_js_1 = require("../service/user/user.js");
11
11
  const verification_token_js_1 = require("../service/verification-token/verification-token.js");
12
- const verification_email_js_1 = require("../templates/verification-email.js");
13
12
  const build_auth_url_js_1 = require("../utils/build-auth-url.js");
13
+ const account_mail_js_1 = require("./account-mail.js");
14
14
  const find_verification_token_js_1 = require("./utils/find-verification-token.js");
15
15
  const make_verification_token_js_1 = require("./utils/make-verification-token.js");
16
16
  const save_verification_token_js_1 = require("./utils/save-verification-token.js");
@@ -28,12 +28,7 @@ async function sendVerificationEmail({ user, context }) {
28
28
  var serviceUrl = `${(0, build_auth_url_js_1.buildAuthURL)(exports.VERIFY_EMAIL_PATH, context)}#${token}`;
29
29
  await (0, email_base_1.sendEmail)({
30
30
  receiver: user.email,
31
- subject: 'Verify your email',
32
- content: (0, verification_email_js_1.getVerificationEmailForm)({
33
- username: user.username,
34
- name: user.name,
35
- verifyUrl: serviceUrl
36
- })
31
+ ...(0, account_mail_js_1.accountMail)({ kind: 'verify-email', user, link: serviceUrl, context })
37
32
  });
38
33
  return true;
39
34
  }
@@ -1 +1 @@
1
- {"version":3,"file":"verification.js","sourceRoot":"","sources":["../../server/controllers/verification.ts"],"names":[],"mappings":"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","sourcesContent":["import { sendEmail } from '@things-factory/email-base'\nimport { getRepository } from '@things-factory/shell'\n\nimport { AuthError } from '../errors/auth-error.js'\nimport { User, UserStatus } from '../service/user/user.js'\nimport { VerificationTokenType } from '../service/verification-token/verification-token.js'\nimport { getVerificationEmailForm } from '../templates/verification-email.js'\nimport { buildAuthURL } from '../utils/build-auth-url.js'\nimport { consumeVerificationToken, findLiveVerificationToken } from './utils/find-verification-token.js'\nimport { makeVerificationToken } from './utils/make-verification-token.js'\nimport { saveVerificationToken } from './utils/save-verification-token.js'\n\n/**\n * The page the mail links to. The token rides after `#`, so the browser never sends it — not to our\n * access log, not to a proxy's. The page reads it and posts it. `tests/invitation-route.test.ts`\n * holds this against the router's table.\n */\nexport const VERIFY_EMAIL_PATH = '/auth/verify'\n\nexport async function sendVerificationEmail({ user, context }) {\n try {\n var token = makeVerificationToken()\n var verifaction = await saveVerificationToken(user.id, token)\n\n if (verifaction) {\n var serviceUrl = `${buildAuthURL(VERIFY_EMAIL_PATH, context)}#${token}`\n await sendEmail({\n receiver: user.email,\n subject: 'Verify your email',\n content: getVerificationEmailForm({\n username: user.username,\n name: user.name,\n verifyUrl: serviceUrl\n })\n })\n\n return true\n }\n } catch (e) {\n return false\n }\n}\n\nexport async function verify(token) {\n /* Activation only. This read the token alone, so a reset or unlock link confirmed an address too. */\n var verification = await findLiveVerificationToken(token, VerificationTokenType.ACTIVATION)\n\n if (!verification) {\n throw new AuthError({\n errorCode: AuthError.ERROR_CODES.VERIFICATION_ERROR\n })\n }\n\n var userInfo = await getRepository(User).findOneBy({ id: verification.userId })\n if (!userInfo) {\n throw new AuthError({\n errorCode: AuthError.ERROR_CODES.VERIFICATION_ERROR\n })\n }\n\n if (!(userInfo.status == UserStatus.INACTIVE || userInfo.status == UserStatus.LOCKED)) {\n throw new AuthError({\n errorCode: AuthError.ERROR_CODES.VERIFICATION_ERROR\n })\n }\n\n userInfo.status = UserStatus.ACTIVATED\n userInfo.failCount = 0\n\n await getRepository(User).save(userInfo)\n await consumeVerificationToken(verification)\n}\n\n/**\n * `'not-due'` when no account at this address waits for confirmation, so the route can answer it\n * the way it answers a sent mail. `'failed'` only when a mail was due and did not go.\n */\nexport async function resendVerificationEmail(email, context): Promise<'sent' | 'not-due' | 'failed'> {\n /* TypeORM drops an undefined condition from `where` - without this, a post with no address\n matched the first account in the table and mailed it. */\n if (!email) return 'not-due'\n\n var user = await getRepository(User).findOne({\n where: {\n email\n }\n })\n\n if (!user) return 'not-due'\n if (user.status == UserStatus.ACTIVATED) return 'not-due'\n\n return (await sendVerificationEmail({ user, context })) ? 'sent' : 'failed'\n}\n"]}
1
+ {"version":3,"file":"verification.js","sourceRoot":"","sources":["../../server/controllers/verification.ts"],"names":[],"mappings":"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","sourcesContent":["import { sendEmail } from '@things-factory/email-base'\nimport { getRepository } from '@things-factory/shell'\n\nimport { AuthError } from '../errors/auth-error.js'\nimport { User, UserStatus } from '../service/user/user.js'\nimport { VerificationTokenType } from '../service/verification-token/verification-token.js'\nimport { buildAuthURL } from '../utils/build-auth-url.js'\nimport { accountMail } from './account-mail.js'\nimport { consumeVerificationToken, findLiveVerificationToken } from './utils/find-verification-token.js'\nimport { makeVerificationToken } from './utils/make-verification-token.js'\nimport { saveVerificationToken } from './utils/save-verification-token.js'\n\n/**\n * The page the mail links to. The token rides after `#`, so the browser never sends it — not to our\n * access log, not to a proxy's. The page reads it and posts it. `tests/invitation-route.test.ts`\n * holds this against the router's table.\n */\nexport const VERIFY_EMAIL_PATH = '/auth/verify'\n\nexport async function sendVerificationEmail({ user, context }) {\n try {\n var token = makeVerificationToken()\n var verifaction = await saveVerificationToken(user.id, token)\n\n if (verifaction) {\n var serviceUrl = `${buildAuthURL(VERIFY_EMAIL_PATH, context)}#${token}`\n await sendEmail({\n receiver: user.email,\n ...accountMail({ kind: 'verify-email', user, link: serviceUrl, context })\n })\n\n return true\n }\n } catch (e) {\n return false\n }\n}\n\nexport async function verify(token) {\n /* Activation only. This read the token alone, so a reset or unlock link confirmed an address too. */\n var verification = await findLiveVerificationToken(token, VerificationTokenType.ACTIVATION)\n\n if (!verification) {\n throw new AuthError({\n errorCode: AuthError.ERROR_CODES.VERIFICATION_ERROR\n })\n }\n\n var userInfo = await getRepository(User).findOneBy({ id: verification.userId })\n if (!userInfo) {\n throw new AuthError({\n errorCode: AuthError.ERROR_CODES.VERIFICATION_ERROR\n })\n }\n\n if (!(userInfo.status == UserStatus.INACTIVE || userInfo.status == UserStatus.LOCKED)) {\n throw new AuthError({\n errorCode: AuthError.ERROR_CODES.VERIFICATION_ERROR\n })\n }\n\n userInfo.status = UserStatus.ACTIVATED\n userInfo.failCount = 0\n\n await getRepository(User).save(userInfo)\n await consumeVerificationToken(verification)\n}\n\n/**\n * `'not-due'` when no account at this address waits for confirmation, so the route can answer it\n * the way it answers a sent mail. `'failed'` only when a mail was due and did not go.\n */\nexport async function resendVerificationEmail(email, context): Promise<'sent' | 'not-due' | 'failed'> {\n /* TypeORM drops an undefined condition from `where` - without this, a post with no address\n matched the first account in the table and mailed it. */\n if (!email) return 'not-due'\n\n var user = await getRepository(User).findOne({\n where: {\n email\n }\n })\n\n if (!user) return 'not-due'\n if (user.status == UserStatus.ACTIVATED) return 'not-due'\n\n return (await sendVerificationEmail({ user, context })) ? 'sent' : 'failed'\n}\n"]}
@@ -6,6 +6,7 @@ const koa_router_1 = tslib_1.__importDefault(require("koa-router"));
6
6
  const env_1 = require("@things-factory/env");
7
7
  const passkey_js_1 = require("../controllers/passkey.js");
8
8
  const confirm_identity_js_1 = require("../controllers/confirm-identity.js");
9
+ const auth_error_js_1 = require("../errors/auth-error.js");
9
10
  const signin_js_1 = require("../controllers/signin.js");
10
11
  const identity_confirmation_js_1 = require("../service/identity-confirmation/identity-confirmation.js");
11
12
  const access_token_cookie_js_1 = require("../utils/access-token-cookie.js");
@@ -86,15 +87,27 @@ exports.webAuthnGlobalPublicRouter.get('/auth/signin-webauthn/challenge', async
86
87
  (0, passkey_js_1.rememberChallenge)(context, options.challenge);
87
88
  context.body = options;
88
89
  });
89
- /* The body is `{ response, redirectTo }`. */
90
+ /*
91
+ * The sign-in screen posts this as a form, the way it posts a password: `response` is the browser's
92
+ * answer as JSON text, `redirectTo` where to go after. So a refusal takes the password's way too -
93
+ * `authenticate401Middleware` answers the page with the one that follows from the code (a locked
94
+ * account to the activate page, which sends the unlock mail again), never a page inside a fetch body.
95
+ */
90
96
  exports.webAuthnGlobalPublicRouter.post('/auth/signin-webauthn', async (context) => {
91
97
  const challenge = (0, passkey_js_1.takeChallenge)(context);
92
98
  const { response, redirectTo } = context.request.body || {};
93
99
  const { domain } = context.state;
94
- const { token } = await (0, passkey_js_1.signinWithPasskey)(response, challenge, (0, passkey_js_1.siteOf)(context), context);
100
+ const { token } = await (0, passkey_js_1.signinWithPasskey)(passkeyAnswerOf(response), challenge, (0, passkey_js_1.siteOf)(context), context);
95
101
  (0, access_token_cookie_js_1.setAccessTokenCookie)(context, token);
96
- /* A two-step exchange run by fetch in the browser cannot follow a 3xx, so the next address is data. */
97
- const redirectURL = `/auth/checkin${domain ? '/' + domain.subdomain : ''}?redirect_to=${encodeURIComponent(redirectTo || '/')}`;
98
- context.body = { redirectURL, verified: true };
102
+ context.redirect(`/auth/checkin${domain ? '/' + domain.subdomain : ''}?redirect_to=${encodeURIComponent(redirectTo || '/')}`);
99
103
  });
104
+ /* The browser's answer from the form field. Text that is not one is a failed verification, like any other bad answer. */
105
+ function passkeyAnswerOf(field) {
106
+ try {
107
+ return JSON.parse(String(field));
108
+ }
109
+ catch {
110
+ throw new auth_error_js_1.AuthError({ errorCode: auth_error_js_1.AuthError.ERROR_CODES.AUTHN_VERIFICATION_FAILED, detail: { reason: 'unreadable answer' } });
111
+ }
112
+ }
100
113
  //# sourceMappingURL=webauthn-router.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"webauthn-router.js","sourceRoot":"","sources":["../../server/router/webauthn-router.ts"],"names":[],"mappings":"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","sourcesContent":["import Router from 'koa-router'\nimport { appPackage, config } from '@things-factory/env'\n\nimport {\n registerPasskey,\n registrationOptions,\n rememberChallenge,\n reverifyOptions,\n reverifyWithPasskey,\n signinOptions,\n signinWithPasskey,\n siteOf,\n takeChallenge\n} from '../controllers/passkey.js'\nimport { confirmIdentity } from '../controllers/confirm-identity.js'\nimport { verifyPassword } from '../controllers/signin.js'\nimport { confirmedUntil, forgetConfirmation } from '../service/identity-confirmation/identity-confirmation.js'\nimport { setAccessTokenCookie } from '../utils/access-token-cookie.js'\nimport { sameOriginRedirectPath } from '../utils/same-origin-redirect.js'\n\nexport const webAuthnGlobalPublicRouter = new Router()\nexport const webAuthnGlobalPrivateRouter = new Router()\n\nconst { name: rpName } = appPackage as any\n\nconst disableUserFavoredLanguage = config.get('i18n/disableUserFavoredLanguage', false)\nconst languages = config.get('i18n/languages', false)\n\n/*\n * A refusal throws `AuthError`; `authenticate401Middleware` answers it with 401 and the refusal in\n * the request's language as the body. The screens read that body as the message.\n */\n\n// Confirming it is the signed-in person - only their own passkeys can answer\nwebAuthnGlobalPrivateRouter.get('/auth/verify-webauthn/challenge', async context => {\n const options = await reverifyOptions(context.state.user, siteOf(context))\n\n if (!options) {\n context.status = 400\n context.body = { error: 'No biometric credentials registered for this user' }\n return\n }\n\n rememberChallenge(context, options.challenge)\n context.body = options\n})\n\n/* A passkey that answers is also the recent confirmation a door may ask for (ADR-0168). */\nwebAuthnGlobalPrivateRouter.post('/auth/verify-webauthn', async context => {\n const challenge = takeChallenge(context)\n await confirmIdentity('passkey', context, user => reverifyWithPasskey(user, context.request.body, challenge, siteOf(context)))\n\n context.body = { verified: true, message: 'Biometric authentication successful' }\n})\n\n/* The same confirmation by password - the body is `{ password }` (ADR-0168). */\nwebAuthnGlobalPrivateRouter.post('/auth/confirm-identity', async context => {\n const { password } = context.request.body || {}\n await confirmIdentity('password', context, user => verifyPassword(user, String(password ?? ''), context))\n\n context.body = { confirmed: true }\n})\n\n/* Until when the confirmation holds - a screen puts away what it shows at that moment. Null when there is none. */\nwebAuthnGlobalPrivateRouter.get('/auth/confirm-identity', async context => {\n context.body = { confirmedUntil: confirmedUntil(context, context.state.user?.id)?.toISOString() ?? null }\n})\n\n/* Letting the confirmation go before it ends by itself. */\nwebAuthnGlobalPrivateRouter.delete('/auth/confirm-identity', async context => {\n forgetConfirmation(context)\n context.body = { confirmed: false }\n})\n\n// Adding a passkey for the signed-in person\nwebAuthnGlobalPrivateRouter.get('/auth/register-webauthn/challenge', async context => {\n const options = await registrationOptions(context.state.user, rpName, siteOf(context))\n\n rememberChallenge(context, options.challenge)\n context.body = options\n})\n\n/* The body is `{ response, name }` - the browser's answer, and the label the screen proposes. */\nwebAuthnGlobalPrivateRouter.post('/auth/verify-registration', async context => {\n const challenge = takeChallenge(context)\n const { response, name } = context.request.body || {}\n\n await registerPasskey(context.state.user, response, name, challenge, siteOf(context))\n\n context.body = { verified: true }\n})\n\n/*\n * After a password sign-in on the screen, before check-in: an offer to add a passkey on this device\n * (ADR-0156 decision 5). The page moves on by itself where it has nothing to offer.\n */\nwebAuthnGlobalPrivateRouter.get('/auth/passkey-offer', async context => {\n await context.render('auth-page', {\n pageElement: 'auth-passkey-offer',\n elementScript: '/auth/passkey-offer.js',\n data: {\n username: context.state.user.username,\n redirectTo: sameOriginRedirectPath(context.query.redirect_to, context),\n disableUserFavoredLanguage,\n languages\n }\n })\n})\n\n// Signing in\nwebAuthnGlobalPublicRouter.get('/auth/signin-webauthn/challenge', async context => {\n const options = await signinOptions(siteOf(context))\n\n rememberChallenge(context, options.challenge)\n context.body = options\n})\n\n/* The body is `{ response, redirectTo }`. */\nwebAuthnGlobalPublicRouter.post('/auth/signin-webauthn', async context => {\n const challenge = takeChallenge(context)\n const { response, redirectTo } = context.request.body || {}\n const { domain } = context.state\n\n const { token } = await signinWithPasskey(response, challenge, siteOf(context), context)\n setAccessTokenCookie(context, token)\n\n /* A two-step exchange run by fetch in the browser cannot follow a 3xx, so the next address is data. */\n const redirectURL = `/auth/checkin${domain ? '/' + domain.subdomain : ''}?redirect_to=${encodeURIComponent(redirectTo || '/')}`\n\n context.body = { redirectURL, verified: true }\n})\n"]}
1
+ {"version":3,"file":"webauthn-router.js","sourceRoot":"","sources":["../../server/router/webauthn-router.ts"],"names":[],"mappings":"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","sourcesContent":["import Router from 'koa-router'\nimport { appPackage, config } from '@things-factory/env'\n\nimport {\n registerPasskey,\n registrationOptions,\n rememberChallenge,\n reverifyOptions,\n reverifyWithPasskey,\n signinOptions,\n signinWithPasskey,\n siteOf,\n takeChallenge\n} from '../controllers/passkey.js'\nimport { confirmIdentity } from '../controllers/confirm-identity.js'\nimport { AuthError } from '../errors/auth-error.js'\nimport { verifyPassword } from '../controllers/signin.js'\nimport { confirmedUntil, forgetConfirmation } from '../service/identity-confirmation/identity-confirmation.js'\nimport { setAccessTokenCookie } from '../utils/access-token-cookie.js'\nimport { sameOriginRedirectPath } from '../utils/same-origin-redirect.js'\n\nexport const webAuthnGlobalPublicRouter = new Router()\nexport const webAuthnGlobalPrivateRouter = new Router()\n\nconst { name: rpName } = appPackage as any\n\nconst disableUserFavoredLanguage = config.get('i18n/disableUserFavoredLanguage', false)\nconst languages = config.get('i18n/languages', false)\n\n/*\n * A refusal throws `AuthError`; `authenticate401Middleware` answers it with 401 and the refusal in\n * the request's language as the body. The screens read that body as the message.\n */\n\n// Confirming it is the signed-in person - only their own passkeys can answer\nwebAuthnGlobalPrivateRouter.get('/auth/verify-webauthn/challenge', async context => {\n const options = await reverifyOptions(context.state.user, siteOf(context))\n\n if (!options) {\n context.status = 400\n context.body = { error: 'No biometric credentials registered for this user' }\n return\n }\n\n rememberChallenge(context, options.challenge)\n context.body = options\n})\n\n/* A passkey that answers is also the recent confirmation a door may ask for (ADR-0168). */\nwebAuthnGlobalPrivateRouter.post('/auth/verify-webauthn', async context => {\n const challenge = takeChallenge(context)\n await confirmIdentity('passkey', context, user => reverifyWithPasskey(user, context.request.body, challenge, siteOf(context)))\n\n context.body = { verified: true, message: 'Biometric authentication successful' }\n})\n\n/* The same confirmation by password - the body is `{ password }` (ADR-0168). */\nwebAuthnGlobalPrivateRouter.post('/auth/confirm-identity', async context => {\n const { password } = context.request.body || {}\n await confirmIdentity('password', context, user => verifyPassword(user, String(password ?? ''), context))\n\n context.body = { confirmed: true }\n})\n\n/* Until when the confirmation holds - a screen puts away what it shows at that moment. Null when there is none. */\nwebAuthnGlobalPrivateRouter.get('/auth/confirm-identity', async context => {\n context.body = { confirmedUntil: confirmedUntil(context, context.state.user?.id)?.toISOString() ?? null }\n})\n\n/* Letting the confirmation go before it ends by itself. */\nwebAuthnGlobalPrivateRouter.delete('/auth/confirm-identity', async context => {\n forgetConfirmation(context)\n context.body = { confirmed: false }\n})\n\n// Adding a passkey for the signed-in person\nwebAuthnGlobalPrivateRouter.get('/auth/register-webauthn/challenge', async context => {\n const options = await registrationOptions(context.state.user, rpName, siteOf(context))\n\n rememberChallenge(context, options.challenge)\n context.body = options\n})\n\n/* The body is `{ response, name }` - the browser's answer, and the label the screen proposes. */\nwebAuthnGlobalPrivateRouter.post('/auth/verify-registration', async context => {\n const challenge = takeChallenge(context)\n const { response, name } = context.request.body || {}\n\n await registerPasskey(context.state.user, response, name, challenge, siteOf(context))\n\n context.body = { verified: true }\n})\n\n/*\n * After a password sign-in on the screen, before check-in: an offer to add a passkey on this device\n * (ADR-0156 decision 5). The page moves on by itself where it has nothing to offer.\n */\nwebAuthnGlobalPrivateRouter.get('/auth/passkey-offer', async context => {\n await context.render('auth-page', {\n pageElement: 'auth-passkey-offer',\n elementScript: '/auth/passkey-offer.js',\n data: {\n username: context.state.user.username,\n redirectTo: sameOriginRedirectPath(context.query.redirect_to, context),\n disableUserFavoredLanguage,\n languages\n }\n })\n})\n\n// Signing in\nwebAuthnGlobalPublicRouter.get('/auth/signin-webauthn/challenge', async context => {\n const options = await signinOptions(siteOf(context))\n\n rememberChallenge(context, options.challenge)\n context.body = options\n})\n\n/*\n * The sign-in screen posts this as a form, the way it posts a password: `response` is the browser's\n * answer as JSON text, `redirectTo` where to go after. So a refusal takes the password's way too -\n * `authenticate401Middleware` answers the page with the one that follows from the code (a locked\n * account to the activate page, which sends the unlock mail again), never a page inside a fetch body.\n */\nwebAuthnGlobalPublicRouter.post('/auth/signin-webauthn', async context => {\n const challenge = takeChallenge(context)\n const { response, redirectTo } = context.request.body || {}\n const { domain } = context.state\n\n const { token } = await signinWithPasskey(passkeyAnswerOf(response), challenge, siteOf(context), context)\n setAccessTokenCookie(context, token)\n\n context.redirect(`/auth/checkin${domain ? '/' + domain.subdomain : ''}?redirect_to=${encodeURIComponent(redirectTo || '/')}`)\n})\n\n/* The browser's answer from the form field. Text that is not one is a failed verification, like any other bad answer. */\nfunction passkeyAnswerOf(field: unknown) {\n try {\n return JSON.parse(String(field))\n } catch {\n throw new AuthError({ errorCode: AuthError.ERROR_CODES.AUTHN_VERIFICATION_FAILED, detail: { reason: 'unreadable answer' } })\n }\n}\n"]}