@things-factory/auth-base 10.1.40 → 10.1.46

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 (51) hide show
  1. package/dist-server/controllers/reset-password.d.ts +6 -0
  2. package/dist-server/controllers/reset-password.js +12 -13
  3. package/dist-server/controllers/reset-password.js.map +1 -1
  4. package/dist-server/controllers/unlock-user.d.ts +6 -0
  5. package/dist-server/controllers/unlock-user.js +12 -13
  6. package/dist-server/controllers/unlock-user.js.map +1 -1
  7. package/dist-server/controllers/utils/find-verification-token.d.ts +16 -0
  8. package/dist-server/controllers/utils/find-verification-token.js +40 -0
  9. package/dist-server/controllers/utils/find-verification-token.js.map +1 -0
  10. package/dist-server/controllers/utils/save-verification-token.d.ts +8 -1
  11. package/dist-server/controllers/utils/save-verification-token.js +10 -2
  12. package/dist-server/controllers/utils/save-verification-token.js.map +1 -1
  13. package/dist-server/controllers/verification.d.ts +6 -0
  14. package/dist-server/controllers/verification.js +13 -8
  15. package/dist-server/controllers/verification.js.map +1 -1
  16. package/dist-server/middlewares/jwt-authenticate-middleware.js +3 -1
  17. package/dist-server/middlewares/jwt-authenticate-middleware.js.map +1 -1
  18. package/dist-server/router/auth-checkin-router.js +2 -1
  19. package/dist-server/router/auth-checkin-router.js.map +1 -1
  20. package/dist-server/router/auth-public-process-router.js +31 -8
  21. package/dist-server/router/auth-public-process-router.js.map +1 -1
  22. package/dist-server/service/index.d.ts +1 -1
  23. package/dist-server/service/role-template/index.d.ts +2 -1
  24. package/dist-server/service/role-template/index.js +2 -1
  25. package/dist-server/service/role-template/index.js.map +1 -1
  26. package/dist-server/service/role-template/role-template-gap-resolver.d.ts +10 -0
  27. package/dist-server/service/role-template/role-template-gap-resolver.js +41 -0
  28. package/dist-server/service/role-template/role-template-gap-resolver.js.map +1 -0
  29. package/dist-server/service/role-template/role-template-types.d.ts +10 -0
  30. package/dist-server/service/role-template/role-template-types.js +41 -1
  31. package/dist-server/service/role-template/role-template-types.js.map +1 -1
  32. package/dist-server/service/role-template/role-template.d.ts +25 -0
  33. package/dist-server/service/role-template/role-template.js +32 -0
  34. package/dist-server/service/role-template/role-template.js.map +1 -1
  35. package/dist-server/service/verification-token/verification-token.d.ts +5 -0
  36. package/dist-server/service/verification-token/verification-token.js +6 -1
  37. package/dist-server/service/verification-token/verification-token.js.map +1 -1
  38. package/dist-server/tsconfig.tsbuildinfo +1 -1
  39. package/dist-server/utils/same-origin-redirect.d.ts +4 -0
  40. package/dist-server/utils/same-origin-redirect.js +39 -0
  41. package/dist-server/utils/same-origin-redirect.js.map +1 -0
  42. package/dist-server/utils/verification-token-rule.d.ts +16 -0
  43. package/dist-server/utils/verification-token-rule.js +73 -0
  44. package/dist-server/utils/verification-token-rule.js.map +1 -0
  45. package/package.json +4 -4
  46. package/tests/invitation-route.test.ts +36 -17
  47. package/tests/lock-recovery.test.ts +3 -1
  48. package/tests/open-fields-framework.test.ts +212 -0
  49. package/tests/open-redirect-db.test.ts +143 -0
  50. package/tests/role-template-gap-db.test.ts +125 -0
  51. package/tests/verification-token-db.test.ts +277 -0
@@ -0,0 +1,4 @@
1
+ export declare function sameOriginRedirectPath(redirectTo: unknown, context: {
2
+ host?: string;
3
+ headers?: any;
4
+ }): string;
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.sameOriginRedirectPath = sameOriginRedirectPath;
4
+ /**
5
+ * Where a sign-in or check-in may send the browser afterwards.
6
+ *
7
+ * `redirect_to` arrives in the query string, so anyone can write a link that passes through our
8
+ * login page and lands on a host of their choosing — the address bar shows our host until the
9
+ * last hop. Before this, `/auth/checkin?redirect_to=https://elsewhere` did exactly that: in
10
+ * fixed-domain mode `getRedirectSubdomainPath` hands the value back untouched, and otherwise
11
+ * `new URL(value, base)` keeps the value's own host.
12
+ *
13
+ * The answer here is always a path on this site. A value that resolves to another origin becomes
14
+ * `'/'` — the same place a missing value goes. An absolute URL on our own host is kept as its
15
+ * path, since the 401 redirect and older links may carry one.
16
+ *
17
+ * Leading slashes are collapsed to one: `/.//elsewhere` resolves to the path `//elsewhere`, which
18
+ * a browser reads as a host.
19
+ */
20
+ const PROBE_ORIGIN = 'http://same-origin.invalid';
21
+ function sameOriginRedirectPath(redirectTo, context) {
22
+ if (typeof redirectTo !== 'string' || !redirectTo.trim()) {
23
+ return '/';
24
+ }
25
+ let url;
26
+ try {
27
+ url = new URL(redirectTo, `${PROBE_ORIGIN}/`);
28
+ }
29
+ catch {
30
+ return '/';
31
+ }
32
+ const ownHosts = [context.host, context.headers?.['x-forwarded-host']].filter(Boolean);
33
+ const sameOrigin = url.origin === PROBE_ORIGIN || ((url.protocol === 'http:' || url.protocol === 'https:') && ownHosts.includes(url.host));
34
+ if (!sameOrigin) {
35
+ return '/';
36
+ }
37
+ return `${url.pathname.replace(/^\/+/, '/')}${url.search}${url.hash}`;
38
+ }
39
+ //# sourceMappingURL=same-origin-redirect.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"same-origin-redirect.js","sourceRoot":"","sources":["../../server/utils/same-origin-redirect.ts"],"names":[],"mappings":";;AAkBA,wDAqBC;AAvCD;;;;;;;;;;;;;;;GAeG;AACH,MAAM,YAAY,GAAG,4BAA4B,CAAA;AAEjD,SAAgB,sBAAsB,CAAC,UAAmB,EAAE,OAAyC;IACnG,IAAI,OAAO,UAAU,KAAK,QAAQ,IAAI,CAAC,UAAU,CAAC,IAAI,EAAE,EAAE,CAAC;QACzD,OAAO,GAAG,CAAA;IACZ,CAAC;IAED,IAAI,GAAQ,CAAA;IACZ,IAAI,CAAC;QACH,GAAG,GAAG,IAAI,GAAG,CAAC,UAAU,EAAE,GAAG,YAAY,GAAG,CAAC,CAAA;IAC/C,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,GAAG,CAAA;IACZ,CAAC;IAED,MAAM,QAAQ,GAAG,CAAC,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,OAAO,EAAE,CAAC,kBAAkB,CAAC,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;IACtF,MAAM,UAAU,GACd,GAAG,CAAC,MAAM,KAAK,YAAY,IAAI,CAAC,CAAC,GAAG,CAAC,QAAQ,KAAK,OAAO,IAAI,GAAG,CAAC,QAAQ,KAAK,QAAQ,CAAC,IAAI,QAAQ,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAA;IAEzH,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,OAAO,GAAG,CAAA;IACZ,CAAC;IAED,OAAO,GAAG,GAAG,CAAC,QAAQ,CAAC,OAAO,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,CAAC,MAAM,GAAG,GAAG,CAAC,IAAI,EAAE,CAAA;AACvE,CAAC","sourcesContent":["/**\n * Where a sign-in or check-in may send the browser afterwards.\n *\n * `redirect_to` arrives in the query string, so anyone can write a link that passes through our\n * login page and lands on a host of their choosing — the address bar shows our host until the\n * last hop. Before this, `/auth/checkin?redirect_to=https://elsewhere` did exactly that: in\n * fixed-domain mode `getRedirectSubdomainPath` hands the value back untouched, and otherwise\n * `new URL(value, base)` keeps the value's own host.\n *\n * The answer here is always a path on this site. A value that resolves to another origin becomes\n * `'/'` — the same place a missing value goes. An absolute URL on our own host is kept as its\n * path, since the 401 redirect and older links may carry one.\n *\n * Leading slashes are collapsed to one: `/.//elsewhere` resolves to the path `//elsewhere`, which\n * a browser reads as a host.\n */\nconst PROBE_ORIGIN = 'http://same-origin.invalid'\n\nexport function sameOriginRedirectPath(redirectTo: unknown, context: { host?: string; headers?: any }): string {\n if (typeof redirectTo !== 'string' || !redirectTo.trim()) {\n return '/'\n }\n\n let url: URL\n try {\n url = new URL(redirectTo, `${PROBE_ORIGIN}/`)\n } catch {\n return '/'\n }\n\n const ownHosts = [context.host, context.headers?.['x-forwarded-host']].filter(Boolean)\n const sameOrigin =\n url.origin === PROBE_ORIGIN || ((url.protocol === 'http:' || url.protocol === 'https:') && ownHosts.includes(url.host))\n\n if (!sameOrigin) {\n return '/'\n }\n\n return `${url.pathname.replace(/^\\/+/, '/')}${url.search}${url.hash}`\n}\n"]}
@@ -0,0 +1,16 @@
1
+ export declare const VERIFICATION_TOKEN_LIFETIME_MS: Readonly<Record<string, number>>;
2
+ /** The value the table keeps for a token. */
3
+ export declare function hashVerificationToken(token: string): string;
4
+ /**
5
+ * When a token of this type, issued at `now`, stops working.
6
+ *
7
+ * Throws for a type with no lifetime here — a new kind of mailed link has to say how long it lives
8
+ * rather than inherit forever.
9
+ */
10
+ export declare function verificationTokenExpiresAt(type: string, now?: Date): Date;
11
+ /** The parts of a stored token this rule reads. */
12
+ export type VerificationTokenRecord = {
13
+ expiresAt?: Date | string | null;
14
+ } | null | undefined;
15
+ /** May this token still be used, as of `now`? Absence of an expiry refuses; the expiry itself refuses. */
16
+ export declare function isVerificationTokenLive(record: VerificationTokenRecord, now?: Date): boolean;
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.VERIFICATION_TOKEN_LIFETIME_MS = void 0;
4
+ exports.hashVerificationToken = hashVerificationToken;
5
+ exports.verificationTokenExpiresAt = verificationTokenExpiresAt;
6
+ exports.isVerificationTokenLive = isVerificationTokenLive;
7
+ const crypto_1 = require("crypto");
8
+ /**
9
+ * How long a mailed link lives, and what the table keeps of it. (Pure rule.)
10
+ *
11
+ * Password reset, unlock and sign-up confirmation each mail a link that carries a token, and the
12
+ * token is the whole credential: whoever holds the link resets the password or unlocks the account.
13
+ * Before 2026-09-24 those tokens never expired, were stored as written and were found by that same
14
+ * value — so a reset mail from last year still changed a password today, and a copy of the table
15
+ * (a backup, a replica, a dump handed to support) was a set of live reset links.
16
+ *
17
+ * ── The lifetimes ──────────────────────────────────────────────────────────
18
+ *
19
+ * password-reset 1 hour hands the account over at once, so short
20
+ * unlock 1 hour the same
21
+ * activation 48 hours the person may read the mail late; it only confirms an address
22
+ *
23
+ * Decided with the architect, 2026-09-24 (`docs/design/auth-rebuild-questions-2026-09-24.md` Q4).
24
+ *
25
+ * ── Expiry is its own column ───────────────────────────────────────────────
26
+ * Not derived from `createdAt` or `updatedAt`: `updatedAt` moves every time the row is saved again,
27
+ * and a rule that reads it would extend a link by touching the row. `expiresAt` is written once,
28
+ * with the token.
29
+ *
30
+ * A row without `expiresAt` reads as **expired** — the rule `invitation-state.ts` uses. Such rows
31
+ * were written before this existed, with the token in plain text; refusing them is also how links
32
+ * already sent stop working when this ships.
33
+ *
34
+ * ── What is stored is a hash ───────────────────────────────────────────────
35
+ * sha256 of the token. The token is 16 random bytes, so there is nothing for a slow hash to protect
36
+ * — it guards against guessing, and there is no guessing a random 128-bit value. The point is only
37
+ * that the table no longer holds anything a link can be made from.
38
+ */
39
+ const HOUR = 60 * 60 * 1000;
40
+ exports.VERIFICATION_TOKEN_LIFETIME_MS = Object.freeze({
41
+ 'password-reset': 1 * HOUR,
42
+ unlock: 1 * HOUR,
43
+ activation: 48 * HOUR
44
+ });
45
+ /** The value the table keeps for a token. */
46
+ function hashVerificationToken(token) {
47
+ return (0, crypto_1.createHash)('sha256').update(token, 'utf8').digest('hex');
48
+ }
49
+ /**
50
+ * When a token of this type, issued at `now`, stops working.
51
+ *
52
+ * Throws for a type with no lifetime here — a new kind of mailed link has to say how long it lives
53
+ * rather than inherit forever.
54
+ */
55
+ function verificationTokenExpiresAt(type, now = new Date()) {
56
+ const lifetime = exports.VERIFICATION_TOKEN_LIFETIME_MS[type];
57
+ if (!lifetime) {
58
+ throw new Error(`verification token type '${type}' has no lifetime`);
59
+ }
60
+ return new Date(now.getTime() + lifetime);
61
+ }
62
+ /** May this token still be used, as of `now`? Absence of an expiry refuses; the expiry itself refuses. */
63
+ function isVerificationTokenLive(record, now = new Date()) {
64
+ if (!record?.expiresAt) {
65
+ return false;
66
+ }
67
+ const at = record.expiresAt instanceof Date ? record.expiresAt : new Date(record.expiresAt);
68
+ if (Number.isNaN(at.getTime())) {
69
+ return false;
70
+ }
71
+ return now.getTime() < at.getTime();
72
+ }
73
+ //# sourceMappingURL=verification-token-rule.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"verification-token-rule.js","sourceRoot":"","sources":["../../server/utils/verification-token-rule.ts"],"names":[],"mappings":";;;AA2CA,sDAEC;AAQD,gEAQC;AAMD,0DAYC;AA/ED,mCAAmC;AAEnC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,MAAM,IAAI,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAA;AAEd,QAAA,8BAA8B,GAAqC,MAAM,CAAC,MAAM,CAAC;IAC5F,gBAAgB,EAAE,CAAC,GAAG,IAAI;IAC1B,MAAM,EAAE,CAAC,GAAG,IAAI;IAChB,UAAU,EAAE,EAAE,GAAG,IAAI;CACtB,CAAC,CAAA;AAEF,6CAA6C;AAC7C,SAAgB,qBAAqB,CAAC,KAAa;IACjD,OAAO,IAAA,mBAAU,EAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;AACjE,CAAC;AAED;;;;;GAKG;AACH,SAAgB,0BAA0B,CAAC,IAAY,EAAE,MAAY,IAAI,IAAI,EAAE;IAC7E,MAAM,QAAQ,GAAG,sCAA8B,CAAC,IAAI,CAAC,CAAA;IAErD,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,MAAM,IAAI,KAAK,CAAC,4BAA4B,IAAI,mBAAmB,CAAC,CAAA;IACtE,CAAC;IAED,OAAO,IAAI,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,QAAQ,CAAC,CAAA;AAC3C,CAAC;AAKD,0GAA0G;AAC1G,SAAgB,uBAAuB,CAAC,MAA+B,EAAE,MAAY,IAAI,IAAI,EAAE;IAC7F,IAAI,CAAC,MAAM,EAAE,SAAS,EAAE,CAAC;QACvB,OAAO,KAAK,CAAA;IACd,CAAC;IAED,MAAM,EAAE,GAAG,MAAM,CAAC,SAAS,YAAY,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,CAAA;IAE3F,IAAI,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC;QAC/B,OAAO,KAAK,CAAA;IACd,CAAC;IAED,OAAO,GAAG,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,OAAO,EAAE,CAAA;AACrC,CAAC","sourcesContent":["import { createHash } from 'crypto'\n\n/**\n * How long a mailed link lives, and what the table keeps of it. (Pure rule.)\n *\n * Password reset, unlock and sign-up confirmation each mail a link that carries a token, and the\n * token is the whole credential: whoever holds the link resets the password or unlocks the account.\n * Before 2026-09-24 those tokens never expired, were stored as written and were found by that same\n * value — so a reset mail from last year still changed a password today, and a copy of the table\n * (a backup, a replica, a dump handed to support) was a set of live reset links.\n *\n * ── The lifetimes ──────────────────────────────────────────────────────────\n *\n * password-reset 1 hour hands the account over at once, so short\n * unlock 1 hour the same\n * activation 48 hours the person may read the mail late; it only confirms an address\n *\n * Decided with the architect, 2026-09-24 (`docs/design/auth-rebuild-questions-2026-09-24.md` Q4).\n *\n * ── Expiry is its own column ───────────────────────────────────────────────\n * Not derived from `createdAt` or `updatedAt`: `updatedAt` moves every time the row is saved again,\n * and a rule that reads it would extend a link by touching the row. `expiresAt` is written once,\n * with the token.\n *\n * A row without `expiresAt` reads as **expired** — the rule `invitation-state.ts` uses. Such rows\n * were written before this existed, with the token in plain text; refusing them is also how links\n * already sent stop working when this ships.\n *\n * ── What is stored is a hash ───────────────────────────────────────────────\n * sha256 of the token. The token is 16 random bytes, so there is nothing for a slow hash to protect\n * — it guards against guessing, and there is no guessing a random 128-bit value. The point is only\n * that the table no longer holds anything a link can be made from.\n */\n\nconst HOUR = 60 * 60 * 1000\n\nexport const VERIFICATION_TOKEN_LIFETIME_MS: Readonly<Record<string, number>> = Object.freeze({\n 'password-reset': 1 * HOUR,\n unlock: 1 * HOUR,\n activation: 48 * HOUR\n})\n\n/** The value the table keeps for a token. */\nexport function hashVerificationToken(token: string): string {\n return createHash('sha256').update(token, 'utf8').digest('hex')\n}\n\n/**\n * When a token of this type, issued at `now`, stops working.\n *\n * Throws for a type with no lifetime here — a new kind of mailed link has to say how long it lives\n * rather than inherit forever.\n */\nexport function verificationTokenExpiresAt(type: string, now: Date = new Date()): Date {\n const lifetime = VERIFICATION_TOKEN_LIFETIME_MS[type]\n\n if (!lifetime) {\n throw new Error(`verification token type '${type}' has no lifetime`)\n }\n\n return new Date(now.getTime() + lifetime)\n}\n\n/** The parts of a stored token this rule reads. */\nexport type VerificationTokenRecord = { expiresAt?: Date | string | null } | null | undefined\n\n/** May this token still be used, as of `now`? Absence of an expiry refuses; the expiry itself refuses. */\nexport function isVerificationTokenLive(record: VerificationTokenRecord, now: Date = new Date()): boolean {\n if (!record?.expiresAt) {\n return false\n }\n\n const at = record.expiresAt instanceof Date ? record.expiresAt : new Date(record.expiresAt)\n\n if (Number.isNaN(at.getTime())) {\n return false\n }\n\n return now.getTime() < at.getTime()\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/auth-base",
3
- "version": "10.1.40",
3
+ "version": "10.1.46",
4
4
  "main": "dist-server/index.js",
5
5
  "browser": "dist-client/index.js",
6
6
  "things-factory": true,
@@ -34,9 +34,9 @@
34
34
  "@reduxjs/toolkit": "^2.2.5",
35
35
  "@simplewebauthn/browser": "^13.0.0",
36
36
  "@simplewebauthn/server": "^13.0.0",
37
- "@things-factory/email-base": "^10.1.40",
37
+ "@things-factory/email-base": "^10.1.46",
38
38
  "@things-factory/env": "^10.1.20",
39
- "@things-factory/shell": "^10.1.40",
39
+ "@things-factory/shell": "^10.1.46",
40
40
  "@things-factory/utils": "^10.1.20",
41
41
  "@types/webappsec-credential-management": "^0.6.9",
42
42
  "jsonwebtoken": "^9.0.0",
@@ -48,5 +48,5 @@
48
48
  "passport-jwt": "^4.0.0",
49
49
  "passport-local": "^1.0.0"
50
50
  },
51
- "gitHead": "51c5d804c14ad8fcc6d4707e6ef80c9deede2766"
51
+ "gitHead": "d3467cde550453f3348fed0ed6c79a12a6ba0db4"
52
52
  }
@@ -71,27 +71,46 @@ describe('the invitation link', () => {
71
71
 
72
72
  describe('the other links sent by mail', () => {
73
73
  /*
74
- * Same class, older links. Each controller builds its URL from a literal; every literal has to be
75
- * a route this router answers. Read from the source because they are not exported — the point is
76
- * that the link and the route are two spellings of one thing and nothing else ties them.
74
+ * Reset, unlock and sign-up confirmation moved to the invitation's shape on 2026-09-24 (Q4): one
75
+ * exported path, the token after `#`. Each page answers GET where the mail points and POST where
76
+ * the page sends the token.
77
77
  */
78
- const CONTROLLERS = join(__dirname, '..', 'server', 'controllers')
79
-
80
- const linked = readdirSync(CONTROLLERS)
81
- .filter(file => file.endsWith('.ts'))
82
- .flatMap(file =>
83
- [...readFileSync(join(CONTROLLERS, file), 'utf-8').matchAll(/new URL\(`(\/auth\/[^`?$]*)/g)].map(m => ({
84
- file,
85
- path: m[1].replace(/\/$/, '/:token')
86
- }))
87
- )
78
+ const { RESET_PASSWORD_PATH } = loadCompiled('controllers/reset-password')
79
+ const { UNLOCK_USER_PATH } = loadCompiled('controllers/unlock-user')
80
+ const { VERIFY_EMAIL_PATH } = loadCompiled('controllers/verification')
81
+
82
+ const paths = [RESET_PASSWORD_PATH, UNLOCK_USER_PATH, VERIFY_EMAIL_PATH]
88
83
 
89
- it('finds the links it means to check', () => {
90
- /* A scan that matches nothing passes whatever the links say. */
91
- expect(linked.map(l => l.file).sort()).toEqual(['reset-password.ts', 'unlock-user.ts', 'verification.ts'])
84
+ it('names the three it means to check', () => {
85
+ expect(paths).toEqual(['/auth/reset-password', '/auth/unlock-user', '/auth/verify'])
92
86
  })
93
87
 
94
- it.each(linked.map(l => [l.file, l.path]))('%s → %s', (_file, path) => {
88
+ it.each(paths)('%s — the page answers where the mail points', path => {
95
89
  expect(answers('GET', path)).toBe(true)
96
90
  })
91
+
92
+ it.each(paths)('%s — and listens where the page posts', path => {
93
+ expect(answers('POST', path)).toBe(true)
94
+ })
95
+
96
+ it('no longer answers the old link that confirmed on opening', () => {
97
+ /* `GET /auth/verify/<token>` confirmed the address for whoever fetched it — a mail scanner too. */
98
+ expect(answers('GET', '/auth/verify/0123456789abcdef0123456789abcdef')).toBe(false)
99
+ })
100
+
101
+ it('no controller puts a token in a query string or a path', () => {
102
+ const CONTROLLERS = join(__dirname, '..', 'server', 'controllers')
103
+ const offending = readdirSync(CONTROLLERS)
104
+ .filter(file => file.endsWith('.ts'))
105
+ .filter(file => /\?token=\$\{|\/verify\/\$\{/.test(readFileSync(join(CONTROLLERS, file), 'utf-8')))
106
+
107
+ expect(offending).toEqual([])
108
+ })
109
+
110
+ it('nor does the redirect for a password that must be reset', () => {
111
+ const middleware = readFileSync(join(__dirname, '..', 'server', 'middlewares', 'jwt-authenticate-middleware.ts'), 'utf-8')
112
+
113
+ expect(middleware).toContain('context.redirect(`${RESET_PASSWORD_PATH}#${token}`)')
114
+ expect(middleware).not.toMatch(/\?token=/)
115
+ })
97
116
  })
@@ -20,6 +20,7 @@ const { signin } = loadCompiled('controllers/signin')
20
20
  const { resetPassword } = loadCompiled('controllers/reset-password')
21
21
  const { unlockUser } = loadCompiled('controllers/unlock-user')
22
22
  const { VerificationToken, VerificationTokenType } = loadCompiled('service/verification-token/verification-token')
23
+ const { saveVerificationToken } = loadCompiled('controllers/utils/save-verification-token')
23
24
 
24
25
  const OLD = 'Passw0rd!'
25
26
  const NEW = 'N3wPassw0rd!'
@@ -41,8 +42,9 @@ async function lockedUser() {
41
42
  })
42
43
  }
43
44
 
45
+ /* Through the one writer, so the row carries its hash and expiry the way a mailed link's does. */
44
46
  async function giveToken(userId: string, token: string, type: string) {
45
- await getRepository(VerificationToken).save({ userId, token, type })
47
+ await saveVerificationToken(userId, token, type)
46
48
  }
47
49
 
48
50
  /** `resetPassword` 가 컨텍스트에서 읽는 것만 담는다. */
@@ -0,0 +1,212 @@
1
+ import { buildSchema, getMetadataStorage } from 'type-graphql'
2
+ import { createPubSub } from '@graphql-yoga/subscription'
3
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'fs'
4
+ import { join } from 'path'
5
+
6
+ import { loadCompiled } from './compiled'
7
+
8
+ const { privilegeDirectiveResolver } = loadCompiled('service/privilege/privilege-directive')
9
+ const companion = loadCompiled('service/index').schema.resolverClasses
10
+
11
+ /**
12
+ * Every things-factory module with resolvers says which of its fields are open (§8 step 1).
13
+ *
14
+ * ── Two modes ───────────────────────────────────────────────────────────────
15
+ * Normally this is the check. For each module that declares `schema.openFields`, the directive
16
+ * must accept it in boot mode — no field is open without being listed — and no name on its lists
17
+ * may be stale (a field that has since been given @privilege, or that no longer exists). A list
18
+ * that keeps names after they are closed stops telling anyone what is left.
19
+ *
20
+ * With `GENERATE_OPEN_FIELDS=<package>[,<package>…]` it writes that module's declaration from
21
+ * what its schema serves today: every undeclared root field goes under `notYetDeclared`, and the
22
+ * module's `server/service/index.ts` gains the one key. Behaviour does not change — everything
23
+ * that was open is listed. Sorting a field into `byDesign` with its reason, or closing it, is the
24
+ * work of step 2 and is done by hand.
25
+ *
26
+ * ── How a module is measured ────────────────────────────────────────────────
27
+ * The way the server builds it: the module's own `resolverClasses`, `buildSchema` with a pub/sub
28
+ * (modules with subscriptions do not build without one), and the privilege directive's record of
29
+ * what each field declares. A module with mutations and no query cannot build alone, so every
30
+ * build carries auth-base's resolvers beside it, and only the module's own fields — by the class
31
+ * that declared them, from type-graphql's metadata — are counted.
32
+ *
33
+ * ⚠ Reads `dist-server`. After editing a module, build it before this means anything.
34
+ */
35
+
36
+ const PACKAGES = join(__dirname, '..', '..')
37
+
38
+ /**
39
+ * The modules that have declared their lists. Named rather than counted, the way `screen-words` names
40
+ * the packages that owe words: a module that opts in is added here in the same commit, and the day
41
+ * every module with resolvers is on it, the "not yet" half of this file goes.
42
+ *
43
+ * 2026-09-24: auth-base only. The generator below writes the rest; running it for the framework is
44
+ * the next step of §8 step 1 and was held back from the 10.1.38 cut so it could be verified first.
45
+ */
46
+ const OPTED_IN = ['auth-base']
47
+
48
+ /** Modules that cannot be measured here, and why. Adding one needs its reason. */
49
+ const CANNOT_MEASURE: Record<string, string> = {
50
+ calendar:
51
+ "its compiled entry throws on load when required on its own (`events` of `Attendee` has no inferable type). None of the four products loads it."
52
+ }
53
+
54
+ type Measured = { name: string; own: string[]; undeclared: string[]; schema: any }
55
+
56
+ function modulesWithResolvers(): string[] {
57
+ return readdirSync(PACKAGES)
58
+ .filter(name => existsSync(join(PACKAGES, name, 'dist-server', 'service', 'index.js')))
59
+ .filter(name => {
60
+ const source = join(PACKAGES, name, 'server', 'service', 'index.ts')
61
+ return existsSync(source) && readFileSync(source, 'utf-8').includes('resolverClasses')
62
+ })
63
+ .sort()
64
+ }
65
+
66
+ async function measure(name: string): Promise<Measured> {
67
+ const schema = require(join(PACKAGES, name, 'dist-server', 'service', 'index.js')).schema
68
+ const classes = new Set<Function>(schema?.resolverClasses || [])
69
+
70
+ const storage = getMetadataStorage() as any
71
+ const own: string[] = []
72
+ for (const [typeName, bucket] of [
73
+ ['Query', 'queries'],
74
+ ['Mutation', 'mutations'],
75
+ ['Subscription', 'subscriptions']
76
+ ]) {
77
+ for (const field of storage[bucket] || []) {
78
+ if (classes.has(field.target)) own.push(`${typeName}.${field.schemaName}`)
79
+ }
80
+ }
81
+
82
+ process['PRIVILEGE_OF_FIELD'] = {}
83
+ const built = await buildSchema({
84
+ resolvers: (name === 'auth-base' ? [...classes] : [...companion, ...classes]) as any,
85
+ validate: false,
86
+ pubSub: createPubSub() as any
87
+ })
88
+ privilegeDirectiveResolver(built, { modules: [] })
89
+
90
+ const record = process['PRIVILEGE_OF_FIELD'] as Record<string, unknown>
91
+ const undeclared = own.filter(where => record[where] === null).sort()
92
+
93
+ return { name, own: own.sort(), undeclared, schema }
94
+ }
95
+
96
+ function declarationSource(name: string, undeclared: string[]): string {
97
+ return `/**
98
+ * ${name}'s root fields that carry no \`@privilege\` (auth-base \`docs/design/auth-rebuild.md\` §8).
99
+ *
100
+ * Written from what this module served on the day it opted in, so nothing that was open stopped
101
+ * working. Every name starts under \`notYetDeclared\`: nobody has yet decided what each one guards.
102
+ * Closing one means giving it \`@privilege(category: …, privilege: …)\` and taking it off this list.
103
+ * A field that is open on purpose moves to \`byDesign\`, with its reason.
104
+ *
105
+ * A new root field that is on neither list, and has no \`@privilege\`, stops the development server
106
+ * and is refused in production — see \`privilege/open-field-rule.ts\` in auth-base.
107
+ */
108
+ export const openFields = {
109
+ byDesign: {} as Record<string, string>,
110
+ notYetDeclared: [
111
+ ${undeclared.map(where => ` '${where}'`).join(',\n')}${undeclared.length ? '\n' : ''} ]
112
+ }
113
+ `
114
+ }
115
+
116
+ function optIn(name: string, undeclared: string[]) {
117
+ const dir = join(PACKAGES, name, 'server', 'service')
118
+ writeFileSync(join(dir, 'open-fields.ts'), declarationSource(name, undeclared))
119
+
120
+ const indexPath = join(dir, 'index.ts')
121
+ let index = readFileSync(indexPath, 'utf-8')
122
+
123
+ if (!index.includes("from './open-fields.js'")) {
124
+ /* After the last import line, so the module's own import order is left as it was. */
125
+ const lines = index.split('\n')
126
+ let last = -1
127
+ lines.forEach((line, i) => {
128
+ if (/^import .* from '.*'$/.test(line) || /^} from '.*'$/.test(line)) last = i
129
+ })
130
+ lines.splice(last + 1, 0, "import { openFields } from './open-fields.js'")
131
+ index = lines.join('\n')
132
+ }
133
+
134
+ if (!/export const schema = \{\n\s*openFields,/.test(index)) {
135
+ index = index.replace('export const schema = {', 'export const schema = {\n openFields,')
136
+ }
137
+
138
+ writeFileSync(indexPath, index)
139
+ }
140
+
141
+ const GENERATE = (process.env.GENERATE_OPEN_FIELDS || '').split(',').map(s => s.trim()).filter(Boolean)
142
+
143
+ if (GENERATE.length) {
144
+ it(`writes the declarations for ${GENERATE.join(', ')}`, async () => {
145
+ for (const name of GENERATE) {
146
+ const measured = await measure(name)
147
+ optIn(name, measured.undeclared)
148
+ }
149
+ }, 600000)
150
+ } else {
151
+ const names = modulesWithResolvers().filter(name => !(name in CANNOT_MEASURE))
152
+
153
+ describe('every module with resolvers', () => {
154
+ it('finds the modules it means to check', () => {
155
+ /* A walk that finds nothing passes whatever the modules say. */
156
+ expect(names.length).toBeGreaterThan(50)
157
+ expect(names).toContain('auth-base')
158
+ expect(names).toContain('shell')
159
+ })
160
+
161
+ it('the modules that have opted in are exactly the ones named', async () => {
162
+ /*
163
+ * ⚠ Both directions. A module that declares lists without being named here is caught, and so is
164
+ * one named here that has lost its lists — either way the name list stops saying what is true.
165
+ */
166
+ const optedIn: string[] = []
167
+ for (const name of names) {
168
+ const schema = require(join(PACKAGES, name, 'dist-server', 'service', 'index.js')).schema
169
+ if (schema?.openFields) optedIn.push(name)
170
+ }
171
+
172
+ expect(optedIn.sort()).toEqual([...OPTED_IN].sort())
173
+ })
174
+
175
+ it.each(OPTED_IN)('%s says which of its fields are open', async name => {
176
+ const measured = await measure(name)
177
+
178
+ expect(measured.schema?.openFields).toBeTruthy()
179
+
180
+ /* The directive in boot mode is the check the development server runs. */
181
+ process['PRIVILEGE_OF_FIELD'] = {}
182
+ const built = await buildSchema({
183
+ resolvers: (name === 'auth-base'
184
+ ? measured.schema.resolverClasses
185
+ : [...companion, ...measured.schema.resolverClasses]) as any,
186
+ validate: false,
187
+ pubSub: createPubSub() as any
188
+ })
189
+
190
+ expect(() =>
191
+ privilegeDirectiveResolver(built, {
192
+ modules: [{ name, schema: measured.schema }],
193
+ mode: 'boot'
194
+ })
195
+ ).not.toThrow()
196
+
197
+ /* No stale names: every listed field is one this module serves and has not yet closed. */
198
+ const declaration = measured.schema.openFields
199
+ const listed = [...Object.keys(declaration.byDesign || {}), ...(declaration.notYetDeclared || [])]
200
+ const stale = listed.filter(where => !measured.undeclared.includes(where))
201
+
202
+ expect(stale).toEqual([])
203
+ }, 120000)
204
+ })
205
+
206
+ describe('what cannot be measured here', () => {
207
+ it.each(Object.entries(CANNOT_MEASURE))('%s — %s', name => {
208
+ /* Still listed as a module with resolvers — the day it can be measured, this fails and it moves. */
209
+ expect(modulesWithResolvers()).toContain(name)
210
+ })
211
+ })
212
+ }
@@ -0,0 +1,143 @@
1
+ import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
2
+ import { loadCompiled } from './compiled'
3
+ import { sameOriginRedirectPath } from '../server/utils/same-origin-redirect'
4
+
5
+ const { Privilege } = loadCompiled('service/privilege/privilege')
6
+ const { Role } = loadCompiled('service/role/role')
7
+ const { User, UserStatus } = loadCompiled('service/user/user')
8
+ const { authCheckinRouter } = loadCompiled('router/auth-checkin-router')
9
+
10
+ /**
11
+ * `/auth/checkin?redirect_to=` must not send the browser to another host.
12
+ *
13
+ * Every sign-in ends there: the 401 redirect, the sign-in form and the WebAuthn sign-in all hand
14
+ * their `redirect_to` to the check-in page, and the check-in page is where `context.redirect` runs.
15
+ * So a link like `https://our-host/auth/checkin?redirect_to=https://elsewhere` used to show our
16
+ * login page and then land on `elsewhere` — the open redirect this file closes.
17
+ *
18
+ * The first half drives the real route with the value an attacker would write, because a test of
19
+ * the helper alone would stay green if the route stopped calling it. The second half pins the
20
+ * helper's reading of the shapes a browser treats as "another host".
21
+ *
22
+ * ⚠ Fixed-domain mode (`config.subdomain`) is read once when shell loads, so this suite runs the
23
+ * path-domain branch only. The helper runs before either branch, so both see the same value.
24
+ */
25
+
26
+ const OUR_HOST = 'ex.test'
27
+
28
+ let acme: any
29
+ let person: any
30
+
31
+ beforeAll(async () => {
32
+ await openAuthDatabase()
33
+ })
34
+
35
+ afterAll(async () => {
36
+ await closeAuthDatabase()
37
+ })
38
+
39
+ beforeEach(async () => {
40
+ await resetAuthDatabase()
41
+
42
+ acme = await getRepository(Domain).save({ name: 'acme', subdomain: 'acme' })
43
+ const seeing = await getRepository(Privilege).save({ name: 'query', category: 'board' })
44
+ const role = await getRepository(Role).save({ name: 'viewer', domain: { id: acme.id }, privileges: [seeing] })
45
+
46
+ const saved = await getRepository(User).save({
47
+ username: 'kim@acme.z',
48
+ email: 'kim@acme.z',
49
+ name: 'kim',
50
+ status: UserStatus.ACTIVATED,
51
+ domains: [{ id: acme.id }],
52
+ roles: [role]
53
+ })
54
+ person = await getRepository(User).findOne({ where: { id: saved.id }, relations: ['domains', 'roles'] })
55
+ })
56
+
57
+ /** Runs `GET /auth/checkin` the way a browser following a link would, and returns where it sent it. */
58
+ async function checkinRedirectsTo(redirectTo: string): Promise<string> {
59
+ const layer = authCheckinRouter.stack.find((layer: any) => layer.path === '/auth/checkin/:subdomain?')
60
+ const redirect = jest.fn()
61
+ const render = jest.fn()
62
+
63
+ const context: any = {
64
+ method: 'GET',
65
+ host: OUR_HOST,
66
+ href: `http://${OUR_HOST}/auth/checkin`,
67
+ header: {},
68
+ headers: {},
69
+ request: { header: { accept: 'text/html' } },
70
+ req: { headers: {}, connection: { remoteAddress: '127.0.0.1' } },
71
+ t: (key: string) => key,
72
+ state: { user: person },
73
+ params: {},
74
+ query: { redirect_to: redirectTo },
75
+ redirect,
76
+ render
77
+ }
78
+
79
+ await layer.stack[layer.stack.length - 1](context, async () => {})
80
+
81
+ expect(render).not.toHaveBeenCalled()
82
+ expect(redirect).toHaveBeenCalledTimes(1)
83
+ return redirect.mock.calls[0][0]
84
+ }
85
+
86
+ describe('GET /auth/checkin with a single domain', () => {
87
+ it('still goes where it was asked, on this host', async () => {
88
+ const target = new URL(await checkinRedirectsTo('/domain/acme/board-list?x=1'))
89
+
90
+ expect(target.host).toBe(OUR_HOST)
91
+ expect(target.pathname + target.search).toBe('/domain/acme/board-list?x=1')
92
+ })
93
+
94
+ it.each([
95
+ 'https://evil.example/steal',
96
+ '//evil.example/steal',
97
+ '/\\evil.example/steal',
98
+ 'https:evil.example',
99
+ '\t//evil.example'
100
+ ])('does not leave this host for %j', async redirectTo => {
101
+ const target = new URL(await checkinRedirectsTo(redirectTo))
102
+
103
+ expect(target.host).toBe(OUR_HOST)
104
+ })
105
+ })
106
+
107
+ describe('sameOriginRedirectPath', () => {
108
+ const context = { host: OUR_HOST, headers: {} }
109
+
110
+ it.each([
111
+ ['/domain/acme/x?y=1#z', '/domain/acme/x?y=1#z'],
112
+ [`http://${OUR_HOST}/domain/acme/x`, '/domain/acme/x'],
113
+ ['board-list', '/board-list']
114
+ ])('keeps %j on this site as %j', (value, path) => {
115
+ expect(sameOriginRedirectPath(value, context)).toBe(path)
116
+ })
117
+
118
+ it('keeps an absolute URL on the host the proxy says we are', () => {
119
+ expect(sameOriginRedirectPath('https://public.example/x', { host: 'internal:3000', headers: { 'x-forwarded-host': 'public.example' } })).toBe('/x')
120
+ })
121
+
122
+ it.each([
123
+ 'https://evil.example/x',
124
+ '//evil.example/x',
125
+ '/\\evil.example/x',
126
+ 'https:evil.example/x',
127
+ '\t//evil.example/x',
128
+ '/\t/evil.example/x',
129
+ 'javascript:alert(1)',
130
+ `ftp://${OUR_HOST}/x`
131
+ ])('sends %j home', value => {
132
+ expect(sameOriginRedirectPath(value, context)).toBe('/')
133
+ })
134
+
135
+ it('does not return a path a browser reads as a host', () => {
136
+ /* `/.//evil.example` resolves on our origin to the path `//evil.example`. */
137
+ expect(sameOriginRedirectPath('/.//evil.example', context)).toBe('/evil.example')
138
+ })
139
+
140
+ it.each([undefined, '', ' ', ['/a', '/b']])('sends %j home', value => {
141
+ expect(sameOriginRedirectPath(value, context)).toBe('/')
142
+ })
143
+ })