@warlock.js/auth 5.15.0 → 5.17.0

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 (94) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/esm/commands/auth-cleanup-command.mjs +1 -1
  3. package/esm/commands/auth-purge-never-expiring-command.mjs +1 -1
  4. package/esm/contracts/index.d.mts +1 -1
  5. package/esm/contracts/providers.d.mts +59 -1
  6. package/esm/contracts/providers.d.mts.map +1 -1
  7. package/esm/index.d.mts +11 -2
  8. package/esm/index.mjs +11 -1
  9. package/esm/localization/ar.translations.mjs +16 -0
  10. package/esm/localization/ar.translations.mjs.map +1 -0
  11. package/esm/localization/auth-default-translations.d.mts +10 -0
  12. package/esm/localization/auth-default-translations.d.mts.map +1 -0
  13. package/esm/localization/auth-default-translations.mjs +15 -0
  14. package/esm/localization/auth-default-translations.mjs.map +1 -0
  15. package/esm/localization/auth-translations.type.d.mts +27 -0
  16. package/esm/localization/auth-translations.type.d.mts.map +1 -0
  17. package/esm/localization/en.translations.mjs +16 -0
  18. package/esm/localization/en.translations.mjs.map +1 -0
  19. package/esm/localization/index.mjs +4 -0
  20. package/esm/localization/init.mjs +8 -0
  21. package/esm/localization/init.mjs.map +1 -0
  22. package/esm/localization/register-auth-translations.d.mts +17 -0
  23. package/esm/localization/register-auth-translations.d.mts.map +1 -0
  24. package/esm/localization/register-auth-translations.mjs +29 -0
  25. package/esm/localization/register-auth-translations.mjs.map +1 -0
  26. package/esm/middleware/auth.middleware.d.mts.map +1 -1
  27. package/esm/middleware/auth.middleware.mjs +4 -2
  28. package/esm/middleware/auth.middleware.mjs.map +1 -1
  29. package/esm/middleware/csrf-origin-check.d.mts +9 -0
  30. package/esm/middleware/csrf-origin-check.d.mts.map +1 -1
  31. package/esm/middleware/csrf-origin-check.mjs +12 -56
  32. package/esm/middleware/csrf-origin-check.mjs.map +1 -1
  33. package/esm/middleware/localized-login-path.mjs +48 -0
  34. package/esm/middleware/localized-login-path.mjs.map +1 -0
  35. package/esm/models/access-token/access-token.model.d.mts +7 -0
  36. package/esm/models/access-token/access-token.model.d.mts.map +1 -1
  37. package/esm/models/access-token/access-token.model.mjs +3 -0
  38. package/esm/models/access-token/access-token.model.mjs.map +1 -1
  39. package/esm/models/one-time-token/one-time-token.model.d.mts +6 -0
  40. package/esm/models/one-time-token/one-time-token.model.d.mts.map +1 -1
  41. package/esm/models/one-time-token/one-time-token.model.mjs +3 -0
  42. package/esm/models/one-time-token/one-time-token.model.mjs.map +1 -1
  43. package/esm/models/refresh-token/refresh-token.model.d.mts +7 -0
  44. package/esm/models/refresh-token/refresh-token.model.d.mts.map +1 -1
  45. package/esm/models/refresh-token/refresh-token.model.mjs +3 -0
  46. package/esm/models/refresh-token/refresh-token.model.mjs.map +1 -1
  47. package/esm/providers/apple-provider.d.mts +74 -0
  48. package/esm/providers/apple-provider.d.mts.map +1 -0
  49. package/esm/providers/apple-provider.mjs +157 -0
  50. package/esm/providers/apple-provider.mjs.map +1 -0
  51. package/esm/providers/discord-provider.d.mts +25 -0
  52. package/esm/providers/discord-provider.d.mts.map +1 -0
  53. package/esm/providers/discord-provider.mjs +84 -0
  54. package/esm/providers/discord-provider.mjs.map +1 -0
  55. package/esm/providers/facebook-provider.d.mts +36 -0
  56. package/esm/providers/facebook-provider.d.mts.map +1 -0
  57. package/esm/providers/facebook-provider.mjs +94 -0
  58. package/esm/providers/facebook-provider.mjs.map +1 -0
  59. package/esm/providers/github-provider.d.mts +31 -0
  60. package/esm/providers/github-provider.d.mts.map +1 -0
  61. package/esm/providers/github-provider.mjs +115 -0
  62. package/esm/providers/github-provider.mjs.map +1 -0
  63. package/esm/providers/index.d.mts +7 -1
  64. package/esm/providers/index.mjs +6 -0
  65. package/esm/providers/linkedin-provider.d.mts +35 -0
  66. package/esm/providers/linkedin-provider.d.mts.map +1 -0
  67. package/esm/providers/linkedin-provider.mjs +108 -0
  68. package/esm/providers/linkedin-provider.mjs.map +1 -0
  69. package/esm/providers/provider-login.d.mts +2 -1
  70. package/esm/providers/provider-login.d.mts.map +1 -1
  71. package/esm/providers/provider-login.mjs +47 -7
  72. package/esm/providers/provider-login.mjs.map +1 -1
  73. package/esm/providers/provider-state-cookie.mjs +35 -16
  74. package/esm/providers/provider-state-cookie.mjs.map +1 -1
  75. package/esm/providers/x-provider.d.mts +35 -0
  76. package/esm/providers/x-provider.d.mts.map +1 -0
  77. package/esm/providers/x-provider.mjs +101 -0
  78. package/esm/providers/x-provider.mjs.map +1 -0
  79. package/esm/services/auth-config.mjs +16 -1
  80. package/esm/services/auth-config.mjs.map +1 -1
  81. package/esm/services/auth.service.mjs +1 -1
  82. package/esm/services/generate-jwt-secret.mjs +1 -1
  83. package/llms-full.txt +1075 -765
  84. package/llms.txt +5 -5
  85. package/package.json +8 -8
  86. package/skills/auth-basics/SKILL.md +16 -1
  87. package/skills/customize-token-storage/SKILL.md +160 -122
  88. package/skills/customize-user-type/SKILL.md +168 -142
  89. package/skills/handle-login-and-logout/SKILL.md +179 -165
  90. package/skills/login-with-providers/SKILL.md +131 -2
  91. package/skills/protect-routes/SKILL.md +308 -249
  92. package/skills/register-user/SKILL.md +15 -1
  93. package/skills/run-auth-commands/SKILL.md +16 -1
  94. package/skills/throttle-login-attempts/SKILL.md +1 -1
@@ -1,5 +1,5 @@
1
- import { authConfig } from "../services/auth-config.mjs";
2
1
  import { AuthErrorCodes } from "../utils/auth-error-codes.mjs";
2
+ import { resolveCsrfOriginVerdict } from "@warlock.js/core";
3
3
 
4
4
  //#region ../auth/src/middleware/csrf-origin-check.ts
5
5
  /** HTTP methods the CSRF Origin check exempts — reads, never state changes. */
@@ -33,71 +33,27 @@ function requiresCsrfOriginCheck(tokenFrom, method) {
33
33
  return tokenFrom.startsWith("cookie:") && !SAFE_METHODS.has(method.toUpperCase());
34
34
  }
35
35
  /**
36
- * The request's own origin — what an `Origin`/`Referer` header must match.
37
- *
38
- * `request.hostname` (core's `Request`, backed by Express/Fastify's
39
- * `hostname`) never carries a port. A browser's `Origin` header on a
40
- * non-default port (e.g. any `warlock dev` session) does, so the port must
41
- * come from the raw `Host` header instead — core exposes no getter for that,
42
- * so it is read directly here.
43
- */
44
- function ownOrigin(request) {
45
- const hostHeader = request.header("host");
46
- const host = typeof hostHeader === "string" && hostHeader ? hostHeader : request.hostname;
47
- return `${request.protocol}://${host}`;
48
- }
49
- /** Extract `scheme://host` from a full URL (e.g. a `Referer` header value). */
50
- function originOf(rawUrl) {
51
- try {
52
- const url = new URL(rawUrl);
53
- return `${url.protocol}//${url.host}`;
54
- } catch {
55
- return;
56
- }
57
- }
58
- /**
59
- * Normalize an origin string (`scheme://host[:port]`) so that a default port
60
- * (`:80` on `http:`, `:443` on `https:`) compares equal to the same origin
61
- * written without a port. Falls back to the raw value if it does not parse
62
- * as a URL (in which case it will simply fail the exact-match comparison).
63
- */
64
- function normalizeOrigin(origin) {
65
- try {
66
- const url = new URL(origin);
67
- const isDefaultPort = url.protocol === "http:" && (url.port === "" || url.port === "80") || url.protocol === "https:" && (url.port === "" || url.port === "443");
68
- return `${url.protocol}//${isDefaultPort ? url.hostname : url.host}`;
69
- } catch {
70
- return origin;
71
- }
72
- }
73
- /** Same-origin, or an explicit entry in `auth.csrf.allowedOrigins` (default `[]`). */
74
- function isAllowedOrigin(origin, request) {
75
- if (normalizeOrigin(origin) === normalizeOrigin(ownOrigin(request))) return true;
76
- return authConfig.csrf.allowedOrigins().includes(origin);
77
- }
78
- /**
79
36
  * CSRF Origin check for a cookie-authenticated unsafe-method request (lead
80
37
  * decision 3). Allowed when `Origin` — or, when `Origin` is absent, `Referer`
81
38
  * — names the request's own origin or an entry in `auth.csrf.allowedOrigins`.
82
39
  * Throws {@link CsrfOriginMismatchError} otherwise, including when BOTH
83
40
  * headers are absent.
84
41
  *
42
+ * Delegates the actual same-origin/allowedOrigins/trustProxy-aware
43
+ * comparison to `@warlock.js/core`'s `resolveCsrfOriginVerdict`
44
+ * (`core/src/http/csrf-origin-policy.ts`) — SECURITY card 8a752ab2 moved that
45
+ * logic down into `core` so this middleware's check and core's own default
46
+ * CSRF-Origin guard (`core/src/http/csrf-default-guard.ts`, which now runs
47
+ * even earlier, ahead of this middleware, for cookie-carrying unsafe-method
48
+ * requests generally) share one implementation instead of two copies that
49
+ * could drift apart.
50
+ *
85
51
  * Callers gate this behind {@link requiresCsrfOriginCheck} — this function
86
52
  * itself does not re-check the token source or the method.
87
53
  */
88
54
  function assertCsrfOriginAllowed(request) {
89
- const origin = request.origin;
90
- if (origin) {
91
- if (isAllowedOrigin(origin, request)) return;
92
- throw new CsrfOriginMismatchError("origin-mismatch");
93
- }
94
- const referer = request.header("referer");
95
- const refererOrigin = typeof referer === "string" ? originOf(referer) : void 0;
96
- if (refererOrigin) {
97
- if (isAllowedOrigin(refererOrigin, request)) return;
98
- throw new CsrfOriginMismatchError("referer-mismatch");
99
- }
100
- throw new CsrfOriginMismatchError("missing-origin-and-referer");
55
+ const verdict = resolveCsrfOriginVerdict(request);
56
+ if (!verdict.allowed) throw new CsrfOriginMismatchError(verdict.reason);
101
57
  }
102
58
 
103
59
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"csrf-origin-check.mjs","names":[],"sources":["../../../../../../../auth/src/middleware/csrf-origin-check.ts"],"sourcesContent":["import type { Request } from \"@warlock.js/core\";\nimport type { TokenFrom } from \"../contracts/types\";\nimport { authConfig } from \"../services/auth-config\";\nimport { AuthErrorCodes } from \"../utils/auth-error-codes\";\n\n/** HTTP methods the CSRF Origin check exempts — reads, never state changes. */\nconst SAFE_METHODS = new Set([\"GET\", \"HEAD\", \"OPTIONS\"]);\n\n/** Why {@link assertCsrfOriginAllowed} refused a request. */\nexport type CsrfOriginMismatchReason =\n | \"origin-mismatch\"\n | \"referer-mismatch\"\n | \"missing-origin-and-referer\";\n\n/**\n * A cookie-authenticated, unsafe-method request whose `Origin`/`Referer`\n * failed the check. `reason` names exactly why,\n * so an error transformer or an incident log never has to string-match the\n * message.\n */\nexport class CsrfOriginMismatchError extends Error {\n readonly code = AuthErrorCodes.CsrfOriginMismatch;\n\n constructor(public readonly reason: CsrfOriginMismatchReason) {\n super(`CSRF Origin check failed: ${reason}`);\n this.name = \"CsrfOriginMismatchError\";\n }\n}\n\n/**\n * Whether a request is in scope for the CSRF Origin check at all — only a\n * `cookie:`-sourced credential on an unsafe method (POST/PUT/PATCH/DELETE) is.\n * Header-token auth and safe methods (GET/HEAD/OPTIONS) are always exempt:\n * only a cookie-sourced credential can be silently replayed cross-site by a\n * browser, so only it needs the check.\n */\nexport function requiresCsrfOriginCheck(tokenFrom: TokenFrom, method: string): boolean {\n return tokenFrom.startsWith(\"cookie:\") && !SAFE_METHODS.has(method.toUpperCase());\n}\n\n/**\n * The request's own origin — what an `Origin`/`Referer` header must match.\n *\n * `request.hostname` (core's `Request`, backed by Express/Fastify's\n * `hostname`) never carries a port. A browser's `Origin` header on a\n * non-default port (e.g. any `warlock dev` session) does, so the port must\n * come from the raw `Host` header instead — core exposes no getter for that,\n * so it is read directly here.\n */\nfunction ownOrigin(request: Request): string {\n const hostHeader = request.header(\"host\");\n const host = typeof hostHeader === \"string\" && hostHeader ? hostHeader : request.hostname;\n\n return `${request.protocol}://${host}`;\n}\n\n/** Extract `scheme://host` from a full URL (e.g. a `Referer` header value). */\nfunction originOf(rawUrl: string): string | undefined {\n try {\n const url = new URL(rawUrl);\n\n return `${url.protocol}//${url.host}`;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Normalize an origin string (`scheme://host[:port]`) so that a default port\n * (`:80` on `http:`, `:443` on `https:`) compares equal to the same origin\n * written without a port. Falls back to the raw value if it does not parse\n * as a URL (in which case it will simply fail the exact-match comparison).\n */\nfunction normalizeOrigin(origin: string): string {\n try {\n const url = new URL(origin);\n const isDefaultPort =\n (url.protocol === \"http:\" && (url.port === \"\" || url.port === \"80\")) ||\n (url.protocol === \"https:\" && (url.port === \"\" || url.port === \"443\"));\n\n return `${url.protocol}//${isDefaultPort ? url.hostname : url.host}`;\n } catch {\n return origin;\n }\n}\n\n/** Same-origin, or an explicit entry in `auth.csrf.allowedOrigins` (default `[]`). */\nfunction isAllowedOrigin(origin: string, request: Request): boolean {\n if (normalizeOrigin(origin) === normalizeOrigin(ownOrigin(request))) return true;\n\n return authConfig.csrf.allowedOrigins().includes(origin);\n}\n\n/**\n * CSRF Origin check for a cookie-authenticated unsafe-method request (lead\n * decision 3). Allowed when `Origin` — or, when `Origin` is absent, `Referer`\n * — names the request's own origin or an entry in `auth.csrf.allowedOrigins`.\n * Throws {@link CsrfOriginMismatchError} otherwise, including when BOTH\n * headers are absent.\n *\n * Callers gate this behind {@link requiresCsrfOriginCheck} — this function\n * itself does not re-check the token source or the method.\n */\nexport function assertCsrfOriginAllowed(request: Request): void {\n const origin = request.origin;\n\n if (origin) {\n if (isAllowedOrigin(origin, request)) return;\n\n throw new CsrfOriginMismatchError(\"origin-mismatch\");\n }\n\n const referer = request.header(\"referer\");\n const refererOrigin = typeof referer === \"string\" ? originOf(referer) : undefined;\n\n if (refererOrigin) {\n if (isAllowedOrigin(refererOrigin, request)) return;\n\n throw new CsrfOriginMismatchError(\"referer-mismatch\");\n }\n\n throw new CsrfOriginMismatchError(\"missing-origin-and-referer\");\n}\n"],"mappings":";;;;;AAMA,MAAM,eAAe,IAAI,IAAI;CAAC;CAAO;CAAQ;AAAS,CAAC;;;;;;;AAcvD,IAAa,0BAAb,cAA6C,MAAM;CAGjD,YAAY,AAAgB,QAAkC;EAC5D,MAAM,6BAA6B,QAAQ;EADjB;;EAE1B,KAAK,OAAO;CACd;AACF;;;;;;;;AASA,SAAgB,wBAAwB,WAAsB,QAAyB;CACrF,OAAO,UAAU,WAAW,SAAS,KAAK,CAAC,aAAa,IAAI,OAAO,YAAY,CAAC;AAClF;;;;;;;;;;AAWA,SAAS,UAAU,SAA0B;CAC3C,MAAM,aAAa,QAAQ,OAAO,MAAM;CACxC,MAAM,OAAO,OAAO,eAAe,YAAY,aAAa,aAAa,QAAQ;CAEjF,OAAO,GAAG,QAAQ,SAAS,KAAK;AAClC;;AAGA,SAAS,SAAS,QAAoC;CACpD,IAAI;EACF,MAAM,MAAM,IAAI,IAAI,MAAM;EAE1B,OAAO,GAAG,IAAI,SAAS,IAAI,IAAI;CACjC,QAAQ;EACN;CACF;AACF;;;;;;;AAQA,SAAS,gBAAgB,QAAwB;CAC/C,IAAI;EACF,MAAM,MAAM,IAAI,IAAI,MAAM;EAC1B,MAAM,gBACH,IAAI,aAAa,YAAY,IAAI,SAAS,MAAM,IAAI,SAAS,SAC7D,IAAI,aAAa,aAAa,IAAI,SAAS,MAAM,IAAI,SAAS;EAEjE,OAAO,GAAG,IAAI,SAAS,IAAI,gBAAgB,IAAI,WAAW,IAAI;CAChE,QAAQ;EACN,OAAO;CACT;AACF;;AAGA,SAAS,gBAAgB,QAAgB,SAA2B;CAClE,IAAI,gBAAgB,MAAM,MAAM,gBAAgB,UAAU,OAAO,CAAC,GAAG,OAAO;CAE5E,OAAO,WAAW,KAAK,eAAe,CAAC,CAAC,SAAS,MAAM;AACzD;;;;;;;;;;;AAYA,SAAgB,wBAAwB,SAAwB;CAC9D,MAAM,SAAS,QAAQ;CAEvB,IAAI,QAAQ;EACV,IAAI,gBAAgB,QAAQ,OAAO,GAAG;EAEtC,MAAM,IAAI,wBAAwB,iBAAiB;CACrD;CAEA,MAAM,UAAU,QAAQ,OAAO,SAAS;CACxC,MAAM,gBAAgB,OAAO,YAAY,WAAW,SAAS,OAAO,IAAI;CAExE,IAAI,eAAe;EACjB,IAAI,gBAAgB,eAAe,OAAO,GAAG;EAE7C,MAAM,IAAI,wBAAwB,kBAAkB;CACtD;CAEA,MAAM,IAAI,wBAAwB,4BAA4B;AAChE"}
1
+ {"version":3,"file":"csrf-origin-check.mjs","names":[],"sources":["../../../../../../../auth/src/middleware/csrf-origin-check.ts"],"sourcesContent":["import { resolveCsrfOriginVerdict, type Request } from \"@warlock.js/core\";\nimport type { TokenFrom } from \"../contracts/types\";\nimport { AuthErrorCodes } from \"../utils/auth-error-codes\";\n\n/** HTTP methods the CSRF Origin check exempts — reads, never state changes. */\nconst SAFE_METHODS = new Set([\"GET\", \"HEAD\", \"OPTIONS\"]);\n\n/** Why {@link assertCsrfOriginAllowed} refused a request. */\nexport type CsrfOriginMismatchReason =\n | \"origin-mismatch\"\n | \"referer-mismatch\"\n | \"missing-origin-and-referer\";\n\n/**\n * A cookie-authenticated, unsafe-method request whose `Origin`/`Referer`\n * failed the check. `reason` names exactly why,\n * so an error transformer or an incident log never has to string-match the\n * message.\n */\nexport class CsrfOriginMismatchError extends Error {\n readonly code = AuthErrorCodes.CsrfOriginMismatch;\n\n constructor(public readonly reason: CsrfOriginMismatchReason) {\n super(`CSRF Origin check failed: ${reason}`);\n this.name = \"CsrfOriginMismatchError\";\n }\n}\n\n/**\n * Whether a request is in scope for the CSRF Origin check at all — only a\n * `cookie:`-sourced credential on an unsafe method (POST/PUT/PATCH/DELETE) is.\n * Header-token auth and safe methods (GET/HEAD/OPTIONS) are always exempt:\n * only a cookie-sourced credential can be silently replayed cross-site by a\n * browser, so only it needs the check.\n */\nexport function requiresCsrfOriginCheck(tokenFrom: TokenFrom, method: string): boolean {\n return tokenFrom.startsWith(\"cookie:\") && !SAFE_METHODS.has(method.toUpperCase());\n}\n\n/**\n * CSRF Origin check for a cookie-authenticated unsafe-method request (lead\n * decision 3). Allowed when `Origin` — or, when `Origin` is absent, `Referer`\n * — names the request's own origin or an entry in `auth.csrf.allowedOrigins`.\n * Throws {@link CsrfOriginMismatchError} otherwise, including when BOTH\n * headers are absent.\n *\n * Delegates the actual same-origin/allowedOrigins/trustProxy-aware\n * comparison to `@warlock.js/core`'s `resolveCsrfOriginVerdict`\n * (`core/src/http/csrf-origin-policy.ts`) — SECURITY card 8a752ab2 moved that\n * logic down into `core` so this middleware's check and core's own default\n * CSRF-Origin guard (`core/src/http/csrf-default-guard.ts`, which now runs\n * even earlier, ahead of this middleware, for cookie-carrying unsafe-method\n * requests generally) share one implementation instead of two copies that\n * could drift apart.\n *\n * Callers gate this behind {@link requiresCsrfOriginCheck} — this function\n * itself does not re-check the token source or the method.\n */\nexport function assertCsrfOriginAllowed(request: Request): void {\n const verdict = resolveCsrfOriginVerdict(request);\n\n if (!verdict.allowed) {\n throw new CsrfOriginMismatchError(verdict.reason);\n }\n}\n"],"mappings":";;;;;AAKA,MAAM,eAAe,IAAI,IAAI;CAAC;CAAO;CAAQ;AAAS,CAAC;;;;;;;AAcvD,IAAa,0BAAb,cAA6C,MAAM;CAGjD,YAAY,AAAgB,QAAkC;EAC5D,MAAM,6BAA6B,QAAQ;EADjB;;EAE1B,KAAK,OAAO;CACd;AACF;;;;;;;;AASA,SAAgB,wBAAwB,WAAsB,QAAyB;CACrF,OAAO,UAAU,WAAW,SAAS,KAAK,CAAC,aAAa,IAAI,OAAO,YAAY,CAAC;AAClF;;;;;;;;;;;;;;;;;;;;AAqBA,SAAgB,wBAAwB,SAAwB;CAC9D,MAAM,UAAU,yBAAyB,OAAO;CAEhD,IAAI,CAAC,QAAQ,SACX,MAAM,IAAI,wBAAwB,QAAQ,MAAM;AAEpD"}
@@ -0,0 +1,48 @@
1
+ import { config } from "@warlock.js/core";
2
+
3
+ //#region ../auth/src/middleware/localized-login-path.ts
4
+ /**
5
+ * Prefixes `loginPath` with `locale` when `web`'s locale-URL routing is
6
+ * active for that locale — so a guarded `/ar/admin` page redirects an
7
+ * anonymous request to `/ar/login`, not the bare `/login` (finding: the
8
+ * `returnUrl` carried the locale, the login destination itself did not).
9
+ *
10
+ * `auth` is not on `web`'s dependency graph, and `web` is not on `auth`'s
11
+ * (neither package declares the other, in either `dependencies` or
12
+ * `peerDependencies`) — so this cannot import `web`'s
13
+ * `resolveLocaleRouting()` / `withLocalePrefix()` / `isPrefixedLocale()`
14
+ * (`web/src/routing/locale-routing.ts`, `web/src/routing/locale-prefixed-paths.ts`).
15
+ * Instead it reads the SAME config keys those read — `web.localeRouting.strategy`,
16
+ * `app.localeCodes`, `app.localeCode` — by convention rather than by import,
17
+ * the same pattern `web/src/server/auth-cookie-name.ts` uses in the other
18
+ * direction for `auth.cookie.name`.
19
+ *
20
+ * Left alone (returned unchanged) when:
21
+ * - `loginPath` is absolute (`http://…`, `https://…`) — a redirect target
22
+ * outside this app has no "locale prefix" to add.
23
+ * - `loginPath` is already prefixed with a known locale code (an app that
24
+ * configured `auth.pageAuth.loginPath` as `/ar/login` itself).
25
+ * - `web.localeRouting.strategy` is `"none"` (the default) or unset.
26
+ * - `locale` is not one of `app.localeCodes`, or — under
27
+ * `"prefix-except-default"` — `locale` is the default locale.
28
+ */
29
+ function localizedLoginPath(loginPath, locale) {
30
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(loginPath)) return loginPath;
31
+ const strategy = config.key("web.localeRouting.strategy", "none");
32
+ if (strategy !== "prefix" && strategy !== "prefix-except-default") return loginPath;
33
+ const codes = config.key("app.localeCodes", []) ?? [];
34
+ if (isAlreadyPrefixed(loginPath, codes)) return loginPath;
35
+ if (!codes.includes(locale)) return loginPath;
36
+ if (strategy === "prefix-except-default") {
37
+ if (locale === config.key("app.localeCode", "")) return loginPath;
38
+ }
39
+ return loginPath === "/" ? `/${locale}` : `/${locale}${loginPath}`;
40
+ }
41
+ /** `true` when `path` already starts with one of `codes` as its own URL segment. */
42
+ function isAlreadyPrefixed(path, codes) {
43
+ return codes.some((code) => path === `/${code}` || path.startsWith(`/${code}/`));
44
+ }
45
+
46
+ //#endregion
47
+ export { localizedLoginPath };
48
+ //# sourceMappingURL=localized-login-path.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"localized-login-path.mjs","names":[],"sources":["../../../../../../../auth/src/middleware/localized-login-path.ts"],"sourcesContent":["import { config } from \"@warlock.js/core\";\n\n/**\n * Prefixes `loginPath` with `locale` when `web`'s locale-URL routing is\n * active for that locale — so a guarded `/ar/admin` page redirects an\n * anonymous request to `/ar/login`, not the bare `/login` (finding: the\n * `returnUrl` carried the locale, the login destination itself did not).\n *\n * `auth` is not on `web`'s dependency graph, and `web` is not on `auth`'s\n * (neither package declares the other, in either `dependencies` or\n * `peerDependencies`) — so this cannot import `web`'s\n * `resolveLocaleRouting()` / `withLocalePrefix()` / `isPrefixedLocale()`\n * (`web/src/routing/locale-routing.ts`, `web/src/routing/locale-prefixed-paths.ts`).\n * Instead it reads the SAME config keys those read — `web.localeRouting.strategy`,\n * `app.localeCodes`, `app.localeCode` — by convention rather than by import,\n * the same pattern `web/src/server/auth-cookie-name.ts` uses in the other\n * direction for `auth.cookie.name`.\n *\n * Left alone (returned unchanged) when:\n * - `loginPath` is absolute (`http://…`, `https://…`) — a redirect target\n * outside this app has no \"locale prefix\" to add.\n * - `loginPath` is already prefixed with a known locale code (an app that\n * configured `auth.pageAuth.loginPath` as `/ar/login` itself).\n * - `web.localeRouting.strategy` is `\"none\"` (the default) or unset.\n * - `locale` is not one of `app.localeCodes`, or — under\n * `\"prefix-except-default\"` — `locale` is the default locale.\n */\nexport function localizedLoginPath(loginPath: string, locale: string): string {\n if (/^[a-z][a-z0-9+.-]*:\\/\\//i.test(loginPath)) return loginPath;\n\n const strategy = config.key<string>(\"web.localeRouting.strategy\", \"none\");\n\n if (strategy !== \"prefix\" && strategy !== \"prefix-except-default\") return loginPath;\n\n const codes = config.key<string[]>(\"app.localeCodes\", []) ?? [];\n\n if (isAlreadyPrefixed(loginPath, codes)) return loginPath;\n\n if (!codes.includes(locale)) return loginPath;\n\n if (strategy === \"prefix-except-default\") {\n const defaultLocale = config.key<string>(\"app.localeCode\", \"\");\n\n if (locale === defaultLocale) return loginPath;\n }\n\n return loginPath === \"/\" ? `/${locale}` : `/${locale}${loginPath}`;\n}\n\n/** `true` when `path` already starts with one of `codes` as its own URL segment. */\nfunction isAlreadyPrefixed(path: string, codes: readonly string[]): boolean {\n return codes.some((code) => path === `/${code}` || path.startsWith(`/${code}/`));\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2BA,SAAgB,mBAAmB,WAAmB,QAAwB;CAC5E,IAAI,2BAA2B,KAAK,SAAS,GAAG,OAAO;CAEvD,MAAM,WAAW,OAAO,IAAY,8BAA8B,MAAM;CAExE,IAAI,aAAa,YAAY,aAAa,yBAAyB,OAAO;CAE1E,MAAM,QAAQ,OAAO,IAAc,mBAAmB,CAAC,CAAC,KAAK,CAAC;CAE9D,IAAI,kBAAkB,WAAW,KAAK,GAAG,OAAO;CAEhD,IAAI,CAAC,MAAM,SAAS,MAAM,GAAG,OAAO;CAEpC,IAAI,aAAa,yBAGf;MAAI,WAFkB,OAAO,IAAY,kBAAkB,EAEhC,GAAG,OAAO;CAAS;CAGhD,OAAO,cAAc,MAAM,IAAI,WAAW,IAAI,SAAS;AACzD;;AAGA,SAAS,kBAAkB,MAAc,OAAmC;CAC1E,OAAO,MAAM,MAAM,SAAS,SAAS,IAAI,UAAU,KAAK,WAAW,IAAI,KAAK,EAAE,CAAC;AACjF"}
@@ -54,6 +54,13 @@ declare class AccessToken extends Model {
54
54
  user_type: import("@warlock.js/seal").StringValidator & import("@warlock.js/seal").StandardSchemaV1<string, string>;
55
55
  expires_at: import("@warlock.js/seal").DateValidator & import("@warlock.js/seal").StandardSchemaV1<Date, Date>;
56
56
  }>>;
57
+ /**
58
+ * The raw signed JWT is a bearer credential — never serialize it. `hidden`
59
+ * only strips it from `toJSON()` / `JSON.stringify(model)` (so
60
+ * `response.json(token)` or a resource cannot leak it); `get("token")`,
61
+ * `findByToken` and every query still read the column.
62
+ */
63
+ static hidden: string[];
57
64
  /**
58
65
  * Permanent regardless of the data source default — Mongo's driver default
59
66
  * is `"trash"`, which would copy live credential material (the JWT itself)
@@ -1 +1 @@
1
- {"version":3,"file":"access-token.model.d.mts","names":[],"sources":["../../../../../../../../auth/src/models/access-token/access-token.model.ts"],"mappings":";;;;;;AAaA;;;;;;cAAa,iBAAA,6BAAiB,eAAA;;;;;;;;;;;;;;;;;;;;;;;;;;cAiBjB,WAAA,SAAoB,KAAA;EAAA,OACjB,KAAA;EAAA,OAEA,MAAA,6BAAM,eAAA;;;;;;;;;;;;;;;;;;;;;;;SASN,cAAA;;MAGH,MAAA;EAfY;EAAA,IAoBZ,QAAA;;;;;;;;;;;;;;;;MAmBA,SAAA;EAuDyB;;;;;;EAAA,IAzCzB,YAAA;EArDyB;;;EAAA,OA4DtB,KAAA,CAAM,IAAA,EAAM,IAAA,EAAM,KAAA,UAAe,SAAA,EAAW,IAAA,GAAI,OAAA,CAAA,WAAA;EAzD1C;;;EAAA,OAqEN,WAAA,CAAY,KAAA,WAAgB,OAAA,CAAQ,WAAA;;;;SAOpC,aAAA,CAAc,IAAA,EAAM,IAAA,EAAM,KAAA,WAAa,OAAA;;;;SAOvC,gBAAA,CAAiB,IAAA,EAAM,IAAA,GAAI,OAAA;;;;;SAQrB,YAAA,IAAgB,OAAA;;;;;;;;;;;;SAqBhB,iBAAA,IAAqB,OAAA,CAAQ,WAAA;;;;;;SAW7B,kBAAA,IAAsB,OAAA,CAAQ,WAAA;AAAA"}
1
+ {"version":3,"file":"access-token.model.d.mts","names":[],"sources":["../../../../../../../../auth/src/models/access-token/access-token.model.ts"],"mappings":";;;;;;AAaA;;;;;;cAAa,iBAAA,6BAAiB,eAAA;;;;;;;;;;;;;;;;;;;;;;;;;;cAiBjB,WAAA,SAAoB,KAAA;EAAA,OACjB,KAAA;EAAA,OAEA,MAAA,6BAAM,eAAA;;;;;;;;;;;;;;;;;;;;;;SAQN,MAAA;;;AAXhB;;;;;SAoBgB,cAAA;;MAGH,MAAA;;MAKA,QAAA;EAwC+C;;;;;;;;;;;;;;;EAAA,IArB/C,SAAA;EA/CyB;;;;;;EAAA,IA6DzB,YAAA;;;;SAOG,KAAA,CAAM,IAAA,EAAM,IAAA,EAAM,KAAA,UAAe,SAAA,EAAW,IAAA,GAAI,OAAA,CAAA,WAAA;;;;SAYhD,WAAA,CAAY,KAAA,WAAgB,OAAA,CAAQ,WAAA;;;;SAOpC,aAAA,CAAc,IAAA,EAAM,IAAA,EAAM,KAAA,WAAa,OAAA;;;;SAOvC,gBAAA,CAAiB,IAAA,EAAM,IAAA,GAAI,OAAA;;;;;SAQrB,YAAA,IAAgB,OAAA;;;;;;;;;;;;SAqBhB,iBAAA,IAAqB,OAAA,CAAQ,WAAA;;;;;;SAW7B,kBAAA,IAAsB,OAAA,CAAQ,WAAA;AAAA"}
@@ -34,6 +34,9 @@ var AccessToken = class extends Model {
34
34
  static {
35
35
  this.schema = accessTokenSchema;
36
36
  }
37
+ static {
38
+ this.hidden = ["token"];
39
+ }
37
40
  static {
38
41
  this.deleteStrategy = "permanent";
39
42
  }
@@ -1 +1 @@
1
- {"version":3,"file":"access-token.model.mjs","names":[],"sources":["../../../../../../../../auth/src/models/access-token/access-token.model.ts"],"sourcesContent":["import { Model } from \"@warlock.js/cascade\";\r\nimport { v } from \"@warlock.js/seal\";\r\nimport { isNeverExpiring, isUsableExpiry } from \"../../utils/token-expiry\";\r\nimport type { Auth } from \"../auth.model\";\r\n\r\n/**\r\n * Seal schema for the persisted access-token record. Exported so an override\r\n * can spread it and add columns (e.g. a tenant key) without re-declaring the\r\n * base shape.\r\n *\r\n * `last_access` and the `is_active` soft-revoke flag were removed — neither was\r\n * ever read; access tokens are revoked by deleting the row.\r\n */\r\nexport const accessTokenSchema = v.object({\r\n token: v.string().required(),\r\n user_id: v.scalar().required(),\r\n user_type: v.string().required(),\r\n expires_at: v.date().required(),\r\n});\r\n\r\n/**\r\n * Persisted access-token record + the data layer for access tokens.\r\n *\r\n * **Role.** Owns access-token persistence and lookup. The middleware checks a\r\n * presented JWT against this table so deleting a row (logout) invalidates the\r\n * token immediately, before its JWT expiry. The auth service goes through the\r\n * named statics exclusively, so it never hard-codes a column name and an\r\n * override can rename/add columns by registering under\r\n * `config.auth.accessToken.model`.\r\n */\r\nexport class AccessToken extends Model {\r\n public static table = \"access_tokens\";\r\n\r\n public static schema = accessTokenSchema;\r\n\r\n /**\r\n * Permanent regardless of the data source default — Mongo's driver default\r\n * is `\"trash\"`, which would copy live credential material (the JWT itself)\r\n * into `access_tokensTrash` on every `destroy()` (logout, expiry purge,\r\n * never-expiring purge). A revoked/expired access token must not survive\r\n * anywhere.\r\n */\r\n public static deleteStrategy = \"permanent\" as const;\r\n\r\n /** The user this token was issued for. */\r\n public get userId() {\r\n return this.get(\"user_id\");\r\n }\r\n\r\n /** The user-type slug this token was issued for. */\r\n public get userType(): string {\r\n return this.get(\"user_type\");\r\n }\r\n\r\n /**\r\n * Whether the persisted expiry has passed.\r\n *\r\n * The middleware asks this on every request, which is what makes the row the\r\n * authority it always looked like it was: before 4.12.0 the gate only checked\r\n * that the row *existed*, so a row the database knew was dead still let its\r\n * token through — the database was never asked.\r\n *\r\n * **Fails closed.** A missing or unparseable `expires_at` counts as expired.\r\n * `expires_at` is `required` in the schema, so a row that cannot answer \"when\r\n * does this die\" is malformed, and the safe reading of a malformed credential\r\n * is that it is not one. This is deliberately stricter than a naive\r\n * `now > expires_at`, which answers `false` for an `Invalid Date` and thereby\r\n * grants exactly the poisoned rows an unlimited life.\r\n */\r\n public get isExpired(): boolean {\r\n const expiresAt = this.get(\"expires_at\");\r\n\r\n if (!isUsableExpiry(expiresAt)) return true;\r\n\r\n return new Date(expiresAt).getTime() <= Date.now();\r\n }\r\n\r\n /**\r\n * Whether nothing about this row can ever retire it — an unusable\r\n * `expires_at`, or a token carrying no `exp` claim. See\r\n * {@link isNeverExpiring}; this is the predicate `purgeNeverExpiring` selects\r\n * on.\r\n */\r\n public get neverExpires(): boolean {\r\n return isNeverExpiring(this.get(\"token\"), this.get(\"expires_at\"));\r\n }\r\n\r\n /**\r\n * Persist a freshly-signed access token for the user.\r\n */\r\n public static issue(user: Auth, token: string, expiresAt: Date) {\r\n return this.create({\r\n token,\r\n user_id: user.id,\r\n user_type: user.userType,\r\n expires_at: expiresAt,\r\n });\r\n }\r\n\r\n /**\r\n * Find an access-token row by its raw token string.\r\n */\r\n public static findByToken(token: string): Promise<AccessToken | null> {\r\n return this.first({ token });\r\n }\r\n\r\n /**\r\n * Delete a specific token that belongs to the given user.\r\n */\r\n public static deleteForUser(user: Auth, token: string) {\r\n return this.delete({ token, user_id: user.id });\r\n }\r\n\r\n /**\r\n * Delete every access token belonging to the user.\r\n */\r\n public static deleteAllForUser(user: Auth) {\r\n return this.delete({ user_id: user.id });\r\n }\r\n\r\n /**\r\n * Hard-delete every expired access-token row. Returns the number removed.\r\n * Runs from the `auth.cleanup` CLI command (a cold batch path).\r\n */\r\n public static async purgeExpired(): Promise<number> {\r\n const expiredTokens = await this.query().where(\"expires_at\", \"<\", new Date()).get();\r\n\r\n for (const token of expiredTokens) {\r\n await token.destroy();\r\n }\r\n\r\n return expiredTokens.length;\r\n }\r\n\r\n /**\r\n * Every row that can never retire itself — see {@link neverExpires}.\r\n *\r\n * **A full scan, filtered in memory, on purpose.** The defining case is a row\r\n * whose `expires_at` is an `Invalid Date`, which no date predicate can select\r\n * (`< now` and `> now` are both `false` for it), and the definitive case is a\r\n * token with no `exp` claim, which lives inside the token string rather than\r\n * in a column. Neither is expressible as a `where`, so the rows have to be\r\n * read to be judged. This runs from a one-off remediation command, not a\r\n * request path.\r\n */\r\n public static async findNeverExpiring(): Promise<AccessToken[]> {\r\n const tokens = await this.query().get();\r\n\r\n return tokens.filter((token: AccessToken) => token.neverExpires);\r\n }\r\n\r\n /**\r\n * Hard-delete every never-expiring row, returning the rows removed so the\r\n * caller can report what it revoked. Deletion *is* revocation for access\r\n * tokens — the middleware rejects a token with no row.\r\n */\r\n public static async purgeNeverExpiring(): Promise<AccessToken[]> {\r\n const tokens = await this.findNeverExpiring();\r\n\r\n for (const token of tokens) {\r\n await token.destroy();\r\n }\r\n\r\n return tokens;\r\n }\r\n}\r\n"],"mappings":";;;;;;;;;;;;;AAaA,MAAa,oBAAoB,EAAE,OAAO;CACxC,OAAO,EAAE,OAAO,CAAC,CAAC,SAAS;CAC3B,SAAS,EAAE,OAAO,CAAC,CAAC,SAAS;CAC7B,WAAW,EAAE,OAAO,CAAC,CAAC,SAAS;CAC/B,YAAY,EAAE,KAAK,CAAC,CAAC,SAAS;AAChC,CAAC;;;;;;;;;;;AAYD,IAAa,cAAb,cAAiC,MAAM;;eACf;;;gBAEC;;;wBASQ;;;CAG/B,IAAW,SAAS;EAClB,OAAO,KAAK,IAAI,SAAS;CAC3B;;CAGA,IAAW,WAAmB;EAC5B,OAAO,KAAK,IAAI,WAAW;CAC7B;;;;;;;;;;;;;;;;CAiBA,IAAW,YAAqB;EAC9B,MAAM,YAAY,KAAK,IAAI,YAAY;EAEvC,IAAI,CAAC,eAAe,SAAS,GAAG,OAAO;EAEvC,OAAO,IAAI,KAAK,SAAS,CAAC,CAAC,QAAQ,KAAK,KAAK,IAAI;CACnD;;;;;;;CAQA,IAAW,eAAwB;EACjC,OAAO,gBAAgB,KAAK,IAAI,OAAO,GAAG,KAAK,IAAI,YAAY,CAAC;CAClE;;;;CAKA,OAAc,MAAM,MAAY,OAAe,WAAiB;EAC9D,OAAO,KAAK,OAAO;GACjB;GACA,SAAS,KAAK;GACd,WAAW,KAAK;GAChB,YAAY;EACd,CAAC;CACH;;;;CAKA,OAAc,YAAY,OAA4C;EACpE,OAAO,KAAK,MAAM,EAAE,MAAM,CAAC;CAC7B;;;;CAKA,OAAc,cAAc,MAAY,OAAe;EACrD,OAAO,KAAK,OAAO;GAAE;GAAO,SAAS,KAAK;EAAG,CAAC;CAChD;;;;CAKA,OAAc,iBAAiB,MAAY;EACzC,OAAO,KAAK,OAAO,EAAE,SAAS,KAAK,GAAG,CAAC;CACzC;;;;;CAMA,aAAoB,eAAgC;EAClD,MAAM,gBAAgB,MAAM,KAAK,MAAM,CAAC,CAAC,MAAM,cAAc,qBAAK,IAAI,KAAK,CAAC,CAAC,CAAC,IAAI;EAElF,KAAK,MAAM,SAAS,eAClB,MAAM,MAAM,QAAQ;EAGtB,OAAO,cAAc;CACvB;;;;;;;;;;;;CAaA,aAAoB,oBAA4C;EAG9D,QAAO,MAFc,KAAK,MAAM,CAAC,CAAC,IAAI,EAEzB,CAAC,QAAQ,UAAuB,MAAM,YAAY;CACjE;;;;;;CAOA,aAAoB,qBAA6C;EAC/D,MAAM,SAAS,MAAM,KAAK,kBAAkB;EAE5C,KAAK,MAAM,SAAS,QAClB,MAAM,MAAM,QAAQ;EAGtB,OAAO;CACT;AACF"}
1
+ {"version":3,"file":"access-token.model.mjs","names":[],"sources":["../../../../../../../../auth/src/models/access-token/access-token.model.ts"],"sourcesContent":["import { Model } from \"@warlock.js/cascade\";\r\nimport { v } from \"@warlock.js/seal\";\r\nimport { isNeverExpiring, isUsableExpiry } from \"../../utils/token-expiry\";\r\nimport type { Auth } from \"../auth.model\";\r\n\r\n/**\r\n * Seal schema for the persisted access-token record. Exported so an override\r\n * can spread it and add columns (e.g. a tenant key) without re-declaring the\r\n * base shape.\r\n *\r\n * `last_access` and the `is_active` soft-revoke flag were removed — neither was\r\n * ever read; access tokens are revoked by deleting the row.\r\n */\r\nexport const accessTokenSchema = v.object({\r\n token: v.string().required(),\r\n user_id: v.scalar().required(),\r\n user_type: v.string().required(),\r\n expires_at: v.date().required(),\r\n});\r\n\r\n/**\r\n * Persisted access-token record + the data layer for access tokens.\r\n *\r\n * **Role.** Owns access-token persistence and lookup. The middleware checks a\r\n * presented JWT against this table so deleting a row (logout) invalidates the\r\n * token immediately, before its JWT expiry. The auth service goes through the\r\n * named statics exclusively, so it never hard-codes a column name and an\r\n * override can rename/add columns by registering under\r\n * `config.auth.accessToken.model`.\r\n */\r\nexport class AccessToken extends Model {\r\n public static table = \"access_tokens\";\r\n\r\n public static schema = accessTokenSchema;\r\n\r\n /**\r\n * The raw signed JWT is a bearer credential — never serialize it. `hidden`\r\n * only strips it from `toJSON()` / `JSON.stringify(model)` (so\r\n * `response.json(token)` or a resource cannot leak it); `get(\"token\")`,\r\n * `findByToken` and every query still read the column.\r\n */\r\n public static hidden = [\"token\"];\r\n\r\n /**\r\n * Permanent regardless of the data source default — Mongo's driver default\r\n * is `\"trash\"`, which would copy live credential material (the JWT itself)\r\n * into `access_tokensTrash` on every `destroy()` (logout, expiry purge,\r\n * never-expiring purge). A revoked/expired access token must not survive\r\n * anywhere.\r\n */\r\n public static deleteStrategy = \"permanent\" as const;\r\n\r\n /** The user this token was issued for. */\r\n public get userId() {\r\n return this.get(\"user_id\");\r\n }\r\n\r\n /** The user-type slug this token was issued for. */\r\n public get userType(): string {\r\n return this.get(\"user_type\");\r\n }\r\n\r\n /**\r\n * Whether the persisted expiry has passed.\r\n *\r\n * The middleware asks this on every request, which is what makes the row the\r\n * authority it always looked like it was: before 4.12.0 the gate only checked\r\n * that the row *existed*, so a row the database knew was dead still let its\r\n * token through — the database was never asked.\r\n *\r\n * **Fails closed.** A missing or unparseable `expires_at` counts as expired.\r\n * `expires_at` is `required` in the schema, so a row that cannot answer \"when\r\n * does this die\" is malformed, and the safe reading of a malformed credential\r\n * is that it is not one. This is deliberately stricter than a naive\r\n * `now > expires_at`, which answers `false` for an `Invalid Date` and thereby\r\n * grants exactly the poisoned rows an unlimited life.\r\n */\r\n public get isExpired(): boolean {\r\n const expiresAt = this.get(\"expires_at\");\r\n\r\n if (!isUsableExpiry(expiresAt)) return true;\r\n\r\n return new Date(expiresAt).getTime() <= Date.now();\r\n }\r\n\r\n /**\r\n * Whether nothing about this row can ever retire it — an unusable\r\n * `expires_at`, or a token carrying no `exp` claim. See\r\n * {@link isNeverExpiring}; this is the predicate `purgeNeverExpiring` selects\r\n * on.\r\n */\r\n public get neverExpires(): boolean {\r\n return isNeverExpiring(this.get(\"token\"), this.get(\"expires_at\"));\r\n }\r\n\r\n /**\r\n * Persist a freshly-signed access token for the user.\r\n */\r\n public static issue(user: Auth, token: string, expiresAt: Date) {\r\n return this.create({\r\n token,\r\n user_id: user.id,\r\n user_type: user.userType,\r\n expires_at: expiresAt,\r\n });\r\n }\r\n\r\n /**\r\n * Find an access-token row by its raw token string.\r\n */\r\n public static findByToken(token: string): Promise<AccessToken | null> {\r\n return this.first({ token });\r\n }\r\n\r\n /**\r\n * Delete a specific token that belongs to the given user.\r\n */\r\n public static deleteForUser(user: Auth, token: string) {\r\n return this.delete({ token, user_id: user.id });\r\n }\r\n\r\n /**\r\n * Delete every access token belonging to the user.\r\n */\r\n public static deleteAllForUser(user: Auth) {\r\n return this.delete({ user_id: user.id });\r\n }\r\n\r\n /**\r\n * Hard-delete every expired access-token row. Returns the number removed.\r\n * Runs from the `auth.cleanup` CLI command (a cold batch path).\r\n */\r\n public static async purgeExpired(): Promise<number> {\r\n const expiredTokens = await this.query().where(\"expires_at\", \"<\", new Date()).get();\r\n\r\n for (const token of expiredTokens) {\r\n await token.destroy();\r\n }\r\n\r\n return expiredTokens.length;\r\n }\r\n\r\n /**\r\n * Every row that can never retire itself — see {@link neverExpires}.\r\n *\r\n * **A full scan, filtered in memory, on purpose.** The defining case is a row\r\n * whose `expires_at` is an `Invalid Date`, which no date predicate can select\r\n * (`< now` and `> now` are both `false` for it), and the definitive case is a\r\n * token with no `exp` claim, which lives inside the token string rather than\r\n * in a column. Neither is expressible as a `where`, so the rows have to be\r\n * read to be judged. This runs from a one-off remediation command, not a\r\n * request path.\r\n */\r\n public static async findNeverExpiring(): Promise<AccessToken[]> {\r\n const tokens = await this.query().get();\r\n\r\n return tokens.filter((token: AccessToken) => token.neverExpires);\r\n }\r\n\r\n /**\r\n * Hard-delete every never-expiring row, returning the rows removed so the\r\n * caller can report what it revoked. Deletion *is* revocation for access\r\n * tokens — the middleware rejects a token with no row.\r\n */\r\n public static async purgeNeverExpiring(): Promise<AccessToken[]> {\r\n const tokens = await this.findNeverExpiring();\r\n\r\n for (const token of tokens) {\r\n await token.destroy();\r\n }\r\n\r\n return tokens;\r\n }\r\n}\r\n"],"mappings":";;;;;;;;;;;;;AAaA,MAAa,oBAAoB,EAAE,OAAO;CACxC,OAAO,EAAE,OAAO,CAAC,CAAC,SAAS;CAC3B,SAAS,EAAE,OAAO,CAAC,CAAC,SAAS;CAC7B,WAAW,EAAE,OAAO,CAAC,CAAC,SAAS;CAC/B,YAAY,EAAE,KAAK,CAAC,CAAC,SAAS;AAChC,CAAC;;;;;;;;;;;AAYD,IAAa,cAAb,cAAiC,MAAM;;eACf;;;gBAEC;;;gBAQA,CAAC,OAAO;;;wBASA;;;CAG/B,IAAW,SAAS;EAClB,OAAO,KAAK,IAAI,SAAS;CAC3B;;CAGA,IAAW,WAAmB;EAC5B,OAAO,KAAK,IAAI,WAAW;CAC7B;;;;;;;;;;;;;;;;CAiBA,IAAW,YAAqB;EAC9B,MAAM,YAAY,KAAK,IAAI,YAAY;EAEvC,IAAI,CAAC,eAAe,SAAS,GAAG,OAAO;EAEvC,OAAO,IAAI,KAAK,SAAS,CAAC,CAAC,QAAQ,KAAK,KAAK,IAAI;CACnD;;;;;;;CAQA,IAAW,eAAwB;EACjC,OAAO,gBAAgB,KAAK,IAAI,OAAO,GAAG,KAAK,IAAI,YAAY,CAAC;CAClE;;;;CAKA,OAAc,MAAM,MAAY,OAAe,WAAiB;EAC9D,OAAO,KAAK,OAAO;GACjB;GACA,SAAS,KAAK;GACd,WAAW,KAAK;GAChB,YAAY;EACd,CAAC;CACH;;;;CAKA,OAAc,YAAY,OAA4C;EACpE,OAAO,KAAK,MAAM,EAAE,MAAM,CAAC;CAC7B;;;;CAKA,OAAc,cAAc,MAAY,OAAe;EACrD,OAAO,KAAK,OAAO;GAAE;GAAO,SAAS,KAAK;EAAG,CAAC;CAChD;;;;CAKA,OAAc,iBAAiB,MAAY;EACzC,OAAO,KAAK,OAAO,EAAE,SAAS,KAAK,GAAG,CAAC;CACzC;;;;;CAMA,aAAoB,eAAgC;EAClD,MAAM,gBAAgB,MAAM,KAAK,MAAM,CAAC,CAAC,MAAM,cAAc,qBAAK,IAAI,KAAK,CAAC,CAAC,CAAC,IAAI;EAElF,KAAK,MAAM,SAAS,eAClB,MAAM,MAAM,QAAQ;EAGtB,OAAO,cAAc;CACvB;;;;;;;;;;;;CAaA,aAAoB,oBAA4C;EAG9D,QAAO,MAFc,KAAK,MAAM,CAAC,CAAC,IAAI,EAEzB,CAAC,QAAQ,UAAuB,MAAM,YAAY;CACjE;;;;;;CAOA,aAAoB,qBAA6C;EAC/D,MAAM,SAAS,MAAM,KAAK,kBAAkB;EAE5C,KAAK,MAAM,SAAS,QAClB,MAAM,MAAM,QAAQ;EAGtB,OAAO;CACT;AACF"}
@@ -117,6 +117,12 @@ declare class OneTimeToken extends Model {
117
117
  isOptional: true;
118
118
  };
119
119
  }>>;
120
+ /**
121
+ * The SHA-256 of a one-time secret. For a short OTP code the hash is
122
+ * brute-forceable offline, so it is as sensitive as the code itself — never
123
+ * serialize it. Only `toJSON()` is affected; `findByHash` still queries it.
124
+ */
125
+ static hidden: string[];
120
126
  /**
121
127
  * Permanent regardless of the data source default — Mongo's driver default
122
128
  * is `"trash"`, which would copy spent token hashes into
@@ -1 +1 @@
1
- {"version":3,"file":"one-time-token.model.d.mts","names":[],"sources":["../../../../../../../../auth/src/models/one-time-token/one-time-token.model.ts"],"mappings":";;;;;KAMY,mBAAA;AAAZ;;;;AAAA,cAWa,kBAAA,6BAAkB,eAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cAoBlB,YAAA,SAAqB,KAAA;EAAA,OAClB,KAAA;EAAA,OAEA,MAAA,6BAAM,eAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAHtB;;;;SAWgB,cAAA;;MAGH,MAAA;;MAKA,QAAA;;MAKA,UAAA;;MAKA,SAAA;;;;;;EAaE,eAAA,IAAmB,OAAA;EAgBf;EAAA,OAJH,KAAA,CACZ,IAAA,EAAM,IAAA,EACN,OAAA,EAAS,mBAAA,EACT,SAAA,UACA,SAAA,EAAW,IAAA,GAAI,OAAA,CAAA,YAAA;EAkBN;;;;EAAA,OADG,cAAA,CACZ,OAAA,EAAS,mBAAA,EACT,SAAA,UACA,SAAA,EAAW,IAAA,EACX,IAAA,GAAO,IAAA,GAAI,OAAA,CAAA,YAAA;EAeL;EAAA,OADM,aAAA,CACZ,IAAA,EAAM,IAAA,EACN,OAAA,EAAS,mBAAA,GACR,OAAA,CAAQ,YAAA;EAAA;;;;;EAYE,aAAA,CAAc,GAAA,WAAc,OAAA;EAqCtC;;;;;;EAAA,OAlBiB,UAAA,IAAc,OAAA;EA/HF;EAAA,OA8IlB,UAAA,CACZ,SAAA,UACA,OAAA,EAAS,mBAAA,GACR,OAAA,CAAQ,YAAA;EA9IG;;;;EAAA,OAsJA,mBAAA,CAAoB,IAAA,EAAM,IAAA,EAAM,OAAA,EAAS,mBAAA,GAAsB,OAAA;AAAA"}
1
+ {"version":3,"file":"one-time-token.model.d.mts","names":[],"sources":["../../../../../../../../auth/src/models/one-time-token/one-time-token.model.ts"],"mappings":";;;;;KAMY,mBAAA;AAAZ;;;;AAAA,cAWa,kBAAA,6BAAkB,eAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cAoBlB,YAAA,SAAqB,KAAA;EAAA,OAClB,KAAA;EAAA,OAEA,MAAA,6BAAM,eAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAHtB;;;SAUgB,MAAA;;;;;;;SAQA,cAAA;;MAGH,MAAA;;MAKA,QAAA;EAuBqB;EAAA,IAlBrB,UAAA;EAgCA;EAAA,IA3BA,SAAA;EA6BM;;;;;EAhBJ,eAAA,IAAmB,OAAA;EAqCnB;EAAA,OAzBC,KAAA,CACZ,IAAA,EAAM,IAAA,EACN,OAAA,EAAS,mBAAA,EACT,SAAA,UACA,SAAA,EAAW,IAAA,GAAI,OAAA,CAAA,YAAA;EAqCN;;;;EAAA,OApBG,cAAA,CACZ,OAAA,EAAS,mBAAA,EACT,SAAA,UACA,SAAA,EAAW,IAAA,EACX,IAAA,GAAO,IAAA,GAAI,OAAA,CAAA,YAAA;EAiEF;EAAA,OAnDG,aAAA,CACZ,IAAA,EAAM,IAAA,EACN,OAAA,EAAS,mBAAA,GACR,OAAA,CAAQ,YAAA;EAiDR;;;;;EArCU,aAAA,CAAc,GAAA,WAAc,OAAA;EAnHJ;;;;;;EAAA,OAsIjB,UAAA,IAAc,OAAA;;SAepB,UAAA,CACZ,SAAA,UACA,OAAA,EAAS,mBAAA,GACR,OAAA,CAAQ,YAAA;;;;;SAQG,mBAAA,CAAoB,IAAA,EAAM,IAAA,EAAM,OAAA,EAAS,mBAAA,GAAsB,OAAA;AAAA"}
@@ -32,6 +32,9 @@ var OneTimeToken = class extends Model {
32
32
  static {
33
33
  this.schema = oneTimeTokenSchema;
34
34
  }
35
+ static {
36
+ this.hidden = ["token_hash"];
37
+ }
35
38
  static {
36
39
  this.deleteStrategy = "permanent";
37
40
  }
@@ -1 +1 @@
1
- {"version":3,"file":"one-time-token.model.mjs","names":[],"sources":["../../../../../../../../auth/src/models/one-time-token/one-time-token.model.ts"],"sourcesContent":["import { Model } from \"@warlock.js/cascade\";\nimport { v } from \"@warlock.js/seal\";\nimport { isUsableExpiry } from \"../../utils/token-expiry\";\nimport type { Auth } from \"../auth.model\";\n\n/** What a one-time token may be used for. A token only ever satisfies its own purpose. */\nexport type OneTimeTokenPurpose =\n | \"email-verification\"\n | \"password-reset\"\n | \"otp\"\n | \"passkey-registration\"\n | \"passkey-authentication\";\n\n/**\n * Seal schema for the persisted one-time token. Exported so an override can\n * extend it (e.g. a tenant key).\n */\nexport const oneTimeTokenSchema = v.object({\n token_hash: v.string().required(),\n purpose: v.string().required(),\n // Absent only on a passkey authentication challenge (no user identified yet).\n user_id: v.scalar().optional(),\n user_type: v.string().optional(),\n expires_at: v.date().required(),\n consumed_at: v.date().optional(),\n attempts: v.int().optional(),\n});\n\n/**\n * Persisted one-time secret: email-verification / password-reset token, OTP\n * code, or passkey ceremony challenge.\n *\n * **Only a SHA-256 hash is stored** — the raw token exists in the notification\n * and nowhere else, so a leaked table cannot be replayed. Single use is\n * enforced by {@link consumeIfActive}, a conditional update in the same shape\n * as `RefreshToken.revokeIfActive`. Override via `config.auth.oneTimeToken.model`.\n */\nexport class OneTimeToken extends Model {\n public static table = \"one_time_tokens\";\n\n public static schema = oneTimeTokenSchema;\n\n /**\n * Permanent regardless of the data source default — Mongo's driver default\n * is `\"trash\"`, which would copy spent token hashes into\n * `one_time_tokensTrash` on every `destroy()`. A purged one-time token must\n * not survive anywhere.\n */\n public static deleteStrategy = \"permanent\" as const;\n\n /** The user this token was issued for. */\n public get userId() {\n return this.get(\"user_id\");\n }\n\n /** The user-type slug this token was issued for. */\n public get userType(): string {\n return this.get(\"user_type\");\n }\n\n /** Whether the token has been used (or invalidated by a newer one). */\n public get isConsumed(): boolean {\n return !!this.get(\"consumed_at\");\n }\n\n /** Whether the expiry has passed. Fails closed: an unusable `expires_at` counts as expired. */\n public get isExpired(): boolean {\n const expiresAt = this.get(\"expires_at\");\n\n if (!isUsableExpiry(expiresAt)) return true;\n\n return new Date(expiresAt).getTime() <= Date.now();\n }\n\n /**\n * Atomically mark this token consumed ONLY if nobody has yet. Resolves `true`\n * for the single caller that won; every concurrent or later caller gets\n * `false`.\n */\n public async consumeIfActive(): Promise<boolean> {\n const modelClass = this.constructor as typeof OneTimeToken;\n\n const consumed = await modelClass.atomic(\n { id: this.id, consumed_at: null },\n { $set: { consumed_at: new Date() } },\n );\n\n return consumed > 0;\n }\n\n /** Persist a new token row. `tokenHash` is the SHA-256 hex digest, never the raw token. */\n public static issue(\n user: Auth,\n purpose: OneTimeTokenPurpose,\n tokenHash: string,\n expiresAt: Date,\n ) {\n return this.create({\n token_hash: tokenHash,\n purpose,\n user_id: user.id,\n user_type: user.userType,\n expires_at: expiresAt,\n consumed_at: null,\n attempts: 0,\n });\n }\n\n /**\n * Persist a passkey ceremony challenge. `user` is omitted for an\n * authentication challenge, issued before anyone is identified.\n */\n public static issueChallenge(\n purpose: OneTimeTokenPurpose,\n tokenHash: string,\n expiresAt: Date,\n user?: Auth,\n ) {\n return this.create({\n token_hash: tokenHash,\n purpose,\n user_id: user?.id ?? null,\n user_type: user?.userType ?? null,\n expires_at: expiresAt,\n consumed_at: null,\n attempts: 0,\n });\n }\n\n /** The user's newest unconsumed token of `purpose` (at most one for OTP). */\n public static findActiveFor(\n user: Auth,\n purpose: OneTimeTokenPurpose,\n ): Promise<OneTimeToken | null> {\n return this.query()\n .where({ user_id: user.id, user_type: user.userType, purpose, consumed_at: null })\n .orderBy(\"created_at\", \"desc\")\n .first();\n }\n\n /**\n * Atomically count one verify attempt, ONLY while fewer than `max` have been\n * counted and the token is unconsumed. Resolves `false` once the cap is\n * reached — of N concurrent attempts, at most `max` ever resolve `true`.\n */\n public async recordAttempt(max: number): Promise<boolean> {\n const modelClass = this.constructor as typeof OneTimeToken;\n\n const counted = await modelClass.atomic(\n { id: this.id, consumed_at: null, attempts: { $lt: max } },\n { $inc: { attempts: 1 } },\n // Code-authored filter; nothing here comes from the request.\n { trustedFilter: true },\n );\n\n return counted > 0;\n }\n\n /**\n * Hard-delete every spent row — expired, or consumed (used, invalidated by a\n * newer token, or exhausted). Neither can ever be redeemed again, so nothing\n * reads them. Hard-deleted, never trashed. Returns the number removed. Runs from the `auth.cleanup` CLI\n * command (a cold batch path).\n */\n public static async purgeSpent(): Promise<number> {\n const expired = await this.query().where(\"expires_at\", \"<\", new Date()).get();\n const consumed = await this.query().whereNotNull(\"consumed_at\").get();\n const spent = new Map([...expired, ...consumed].map((token) => [String(token.id), token]));\n\n for (const token of spent.values()) {\n // `static deleteStrategy` above already forces \"permanent\" regardless\n // of the data source default (Mongo's is \"trash\").\n await token.destroy();\n }\n\n return spent.size;\n }\n\n /** Find a token by hash, scoped to a purpose — a token of the other purpose is not found. */\n public static findByHash(\n tokenHash: string,\n purpose: OneTimeTokenPurpose,\n ): Promise<OneTimeToken | null> {\n return this.first({ token_hash: tokenHash, purpose });\n }\n\n /**\n * Consume every still-unused token of `purpose` for the user, returning how\n * many were invalidated. Used before issuing a new password-reset token.\n */\n public static invalidateActiveFor(user: Auth, purpose: OneTimeTokenPurpose): Promise<number> {\n return this.atomic(\n { user_id: user.id, user_type: user.userType, purpose, consumed_at: null },\n { $set: { consumed_at: new Date() } },\n );\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"one-time-token.model.mjs","names":[],"sources":["../../../../../../../../auth/src/models/one-time-token/one-time-token.model.ts"],"sourcesContent":["import { Model } from \"@warlock.js/cascade\";\nimport { v } from \"@warlock.js/seal\";\nimport { isUsableExpiry } from \"../../utils/token-expiry\";\nimport type { Auth } from \"../auth.model\";\n\n/** What a one-time token may be used for. A token only ever satisfies its own purpose. */\nexport type OneTimeTokenPurpose =\n | \"email-verification\"\n | \"password-reset\"\n | \"otp\"\n | \"passkey-registration\"\n | \"passkey-authentication\";\n\n/**\n * Seal schema for the persisted one-time token. Exported so an override can\n * extend it (e.g. a tenant key).\n */\nexport const oneTimeTokenSchema = v.object({\n token_hash: v.string().required(),\n purpose: v.string().required(),\n // Absent only on a passkey authentication challenge (no user identified yet).\n user_id: v.scalar().optional(),\n user_type: v.string().optional(),\n expires_at: v.date().required(),\n consumed_at: v.date().optional(),\n attempts: v.int().optional(),\n});\n\n/**\n * Persisted one-time secret: email-verification / password-reset token, OTP\n * code, or passkey ceremony challenge.\n *\n * **Only a SHA-256 hash is stored** — the raw token exists in the notification\n * and nowhere else, so a leaked table cannot be replayed. Single use is\n * enforced by {@link consumeIfActive}, a conditional update in the same shape\n * as `RefreshToken.revokeIfActive`. Override via `config.auth.oneTimeToken.model`.\n */\nexport class OneTimeToken extends Model {\n public static table = \"one_time_tokens\";\n\n public static schema = oneTimeTokenSchema;\n\n /**\n * The SHA-256 of a one-time secret. For a short OTP code the hash is\n * brute-forceable offline, so it is as sensitive as the code itself — never\n * serialize it. Only `toJSON()` is affected; `findByHash` still queries it.\n */\n public static hidden = [\"token_hash\"];\n\n /**\n * Permanent regardless of the data source default — Mongo's driver default\n * is `\"trash\"`, which would copy spent token hashes into\n * `one_time_tokensTrash` on every `destroy()`. A purged one-time token must\n * not survive anywhere.\n */\n public static deleteStrategy = \"permanent\" as const;\n\n /** The user this token was issued for. */\n public get userId() {\n return this.get(\"user_id\");\n }\n\n /** The user-type slug this token was issued for. */\n public get userType(): string {\n return this.get(\"user_type\");\n }\n\n /** Whether the token has been used (or invalidated by a newer one). */\n public get isConsumed(): boolean {\n return !!this.get(\"consumed_at\");\n }\n\n /** Whether the expiry has passed. Fails closed: an unusable `expires_at` counts as expired. */\n public get isExpired(): boolean {\n const expiresAt = this.get(\"expires_at\");\n\n if (!isUsableExpiry(expiresAt)) return true;\n\n return new Date(expiresAt).getTime() <= Date.now();\n }\n\n /**\n * Atomically mark this token consumed ONLY if nobody has yet. Resolves `true`\n * for the single caller that won; every concurrent or later caller gets\n * `false`.\n */\n public async consumeIfActive(): Promise<boolean> {\n const modelClass = this.constructor as typeof OneTimeToken;\n\n const consumed = await modelClass.atomic(\n { id: this.id, consumed_at: null },\n { $set: { consumed_at: new Date() } },\n );\n\n return consumed > 0;\n }\n\n /** Persist a new token row. `tokenHash` is the SHA-256 hex digest, never the raw token. */\n public static issue(\n user: Auth,\n purpose: OneTimeTokenPurpose,\n tokenHash: string,\n expiresAt: Date,\n ) {\n return this.create({\n token_hash: tokenHash,\n purpose,\n user_id: user.id,\n user_type: user.userType,\n expires_at: expiresAt,\n consumed_at: null,\n attempts: 0,\n });\n }\n\n /**\n * Persist a passkey ceremony challenge. `user` is omitted for an\n * authentication challenge, issued before anyone is identified.\n */\n public static issueChallenge(\n purpose: OneTimeTokenPurpose,\n tokenHash: string,\n expiresAt: Date,\n user?: Auth,\n ) {\n return this.create({\n token_hash: tokenHash,\n purpose,\n user_id: user?.id ?? null,\n user_type: user?.userType ?? null,\n expires_at: expiresAt,\n consumed_at: null,\n attempts: 0,\n });\n }\n\n /** The user's newest unconsumed token of `purpose` (at most one for OTP). */\n public static findActiveFor(\n user: Auth,\n purpose: OneTimeTokenPurpose,\n ): Promise<OneTimeToken | null> {\n return this.query()\n .where({ user_id: user.id, user_type: user.userType, purpose, consumed_at: null })\n .orderBy(\"created_at\", \"desc\")\n .first();\n }\n\n /**\n * Atomically count one verify attempt, ONLY while fewer than `max` have been\n * counted and the token is unconsumed. Resolves `false` once the cap is\n * reached — of N concurrent attempts, at most `max` ever resolve `true`.\n */\n public async recordAttempt(max: number): Promise<boolean> {\n const modelClass = this.constructor as typeof OneTimeToken;\n\n const counted = await modelClass.atomic(\n { id: this.id, consumed_at: null, attempts: { $lt: max } },\n { $inc: { attempts: 1 } },\n // Code-authored filter; nothing here comes from the request.\n { trustedFilter: true },\n );\n\n return counted > 0;\n }\n\n /**\n * Hard-delete every spent row — expired, or consumed (used, invalidated by a\n * newer token, or exhausted). Neither can ever be redeemed again, so nothing\n * reads them. Hard-deleted, never trashed. Returns the number removed. Runs from the `auth.cleanup` CLI\n * command (a cold batch path).\n */\n public static async purgeSpent(): Promise<number> {\n const expired = await this.query().where(\"expires_at\", \"<\", new Date()).get();\n const consumed = await this.query().whereNotNull(\"consumed_at\").get();\n const spent = new Map([...expired, ...consumed].map((token) => [String(token.id), token]));\n\n for (const token of spent.values()) {\n // `static deleteStrategy` above already forces \"permanent\" regardless\n // of the data source default (Mongo's is \"trash\").\n await token.destroy();\n }\n\n return spent.size;\n }\n\n /** Find a token by hash, scoped to a purpose — a token of the other purpose is not found. */\n public static findByHash(\n tokenHash: string,\n purpose: OneTimeTokenPurpose,\n ): Promise<OneTimeToken | null> {\n return this.first({ token_hash: tokenHash, purpose });\n }\n\n /**\n * Consume every still-unused token of `purpose` for the user, returning how\n * many were invalidated. Used before issuing a new password-reset token.\n */\n public static invalidateActiveFor(user: Auth, purpose: OneTimeTokenPurpose): Promise<number> {\n return this.atomic(\n { user_id: user.id, user_type: user.userType, purpose, consumed_at: null },\n { $set: { consumed_at: new Date() } },\n );\n }\n}\n"],"mappings":";;;;;;;;;AAiBA,MAAa,qBAAqB,EAAE,OAAO;CACzC,YAAY,EAAE,OAAO,CAAC,CAAC,SAAS;CAChC,SAAS,EAAE,OAAO,CAAC,CAAC,SAAS;CAE7B,SAAS,EAAE,OAAO,CAAC,CAAC,SAAS;CAC7B,WAAW,EAAE,OAAO,CAAC,CAAC,SAAS;CAC/B,YAAY,EAAE,KAAK,CAAC,CAAC,SAAS;CAC9B,aAAa,EAAE,KAAK,CAAC,CAAC,SAAS;CAC/B,UAAU,EAAE,IAAI,CAAC,CAAC,SAAS;AAC7B,CAAC;;;;;;;;;;AAWD,IAAa,eAAb,cAAkC,MAAM;;eAChB;;;gBAEC;;;gBAOA,CAAC,YAAY;;;wBAQL;;;CAG/B,IAAW,SAAS;EAClB,OAAO,KAAK,IAAI,SAAS;CAC3B;;CAGA,IAAW,WAAmB;EAC5B,OAAO,KAAK,IAAI,WAAW;CAC7B;;CAGA,IAAW,aAAsB;EAC/B,OAAO,CAAC,CAAC,KAAK,IAAI,aAAa;CACjC;;CAGA,IAAW,YAAqB;EAC9B,MAAM,YAAY,KAAK,IAAI,YAAY;EAEvC,IAAI,CAAC,eAAe,SAAS,GAAG,OAAO;EAEvC,OAAO,IAAI,KAAK,SAAS,CAAC,CAAC,QAAQ,KAAK,KAAK,IAAI;CACnD;;;;;;CAOA,MAAa,kBAAoC;EAQ/C,OAAO,MAPY,KAAK,YAEU,OAChC;GAAE,IAAI,KAAK;GAAI,aAAa;EAAK,GACjC,EAAE,MAAM,EAAE,6BAAa,IAAI,KAAK,EAAE,EAAE,CACtC,IAEkB;CACpB;;CAGA,OAAc,MACZ,MACA,SACA,WACA,WACA;EACA,OAAO,KAAK,OAAO;GACjB,YAAY;GACZ;GACA,SAAS,KAAK;GACd,WAAW,KAAK;GAChB,YAAY;GACZ,aAAa;GACb,UAAU;EACZ,CAAC;CACH;;;;;CAMA,OAAc,eACZ,SACA,WACA,WACA,MACA;EACA,OAAO,KAAK,OAAO;GACjB,YAAY;GACZ;GACA,SAAS,MAAM,MAAM;GACrB,WAAW,MAAM,YAAY;GAC7B,YAAY;GACZ,aAAa;GACb,UAAU;EACZ,CAAC;CACH;;CAGA,OAAc,cACZ,MACA,SAC8B;EAC9B,OAAO,KAAK,MAAM,CAAC,CAChB,MAAM;GAAE,SAAS,KAAK;GAAI,WAAW,KAAK;GAAU;GAAS,aAAa;EAAK,CAAC,CAAC,CACjF,QAAQ,cAAc,MAAM,CAAC,CAC7B,MAAM;CACX;;;;;;CAOA,MAAa,cAAc,KAA+B;EAUxD,OAAO,MATY,KAAK,YAES,OAC/B;GAAE,IAAI,KAAK;GAAI,aAAa;GAAM,UAAU,EAAE,KAAK,IAAI;EAAE,GACzD,EAAE,MAAM,EAAE,UAAU,EAAE,EAAE,GAExB,EAAE,eAAe,KAAK,CACxB,IAEiB;CACnB;;;;;;;CAQA,aAAoB,aAA8B;EAChD,MAAM,UAAU,MAAM,KAAK,MAAM,CAAC,CAAC,MAAM,cAAc,qBAAK,IAAI,KAAK,CAAC,CAAC,CAAC,IAAI;EAC5E,MAAM,WAAW,MAAM,KAAK,MAAM,CAAC,CAAC,aAAa,aAAa,CAAC,CAAC,IAAI;EACpE,MAAM,QAAQ,IAAI,IAAI,CAAC,GAAG,SAAS,GAAG,QAAQ,CAAC,CAAC,KAAK,UAAU,CAAC,OAAO,MAAM,EAAE,GAAG,KAAK,CAAC,CAAC;EAEzF,KAAK,MAAM,SAAS,MAAM,OAAO,GAG/B,MAAM,MAAM,QAAQ;EAGtB,OAAO,MAAM;CACf;;CAGA,OAAc,WACZ,WACA,SAC8B;EAC9B,OAAO,KAAK,MAAM;GAAE,YAAY;GAAW;EAAQ,CAAC;CACtD;;;;;CAMA,OAAc,oBAAoB,MAAY,SAA+C;EAC3F,OAAO,KAAK,OACV;GAAE,SAAS,KAAK;GAAI,WAAW,KAAK;GAAU;GAAS,aAAa;EAAK,GACzE,EAAE,MAAM,EAAE,6BAAa,IAAI,KAAK,EAAE,EAAE,CACtC;CACF;AACF"}
@@ -139,6 +139,13 @@ declare class RefreshToken extends Model {
139
139
  isOptional: true;
140
140
  };
141
141
  }>>;
142
+ /**
143
+ * The raw signed refresh JWT is a bearer credential — never serialize it.
144
+ * `hidden` only strips it from `toJSON()` / `JSON.stringify(model)` (e.g. a
145
+ * session list returned via `response.json`); `get("token")`,
146
+ * `findByToken` / `findForUser` and every query still read the column.
147
+ */
148
+ static hidden: string[];
142
149
  /**
143
150
  * Permanent regardless of the data source default — Mongo's driver default
144
151
  * is `"trash"`, which would copy live credential material (the JWT itself)
@@ -1 +1 @@
1
- {"version":3,"file":"refresh-token.model.d.mts","names":[],"sources":["../../../../../../../../auth/src/models/refresh-token/refresh-token.model.ts"],"mappings":";;;;;;;AAkBA;;;;;;;;;;cAAa,kBAAA,6BAAkB,eAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAcnB,wBAAA;EACV,QAAA;EACA,SAAA;EACA,UAAA,GAAa,UAAU;AAAA;;;;;;;;;;;cAaZ,YAAA,SAAqB,KAAA;EAAA,OAClB,KAAA;EAAA,OAEA,MAAA,6BAAM,eAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;SAQN,cAAA;;MAGH,QAAA;;;;;;;AA9Bb;;;;MA4Ca,SAAA;EA1CX;;;;EAAA,IAsDW,YAAA;EAxCA;EAAA,IA6CA,SAAA;;MAKA,OAAA;;;;EAOE,MAAA,IAAU,OAAA;;;;;;;;EAWV,cAAA,IAAkB,OAAA;;;;EAclB,UAAA,IAAc,OAAA;;;;SAOb,KAAA,CAAM,IAAA,EAAM,IAAA,EAAM,KAAA,UAAe,OAAA,EAAS,wBAAA,GAAwB,OAAA,CAAA,YAAA;;;;SAoBlE,WAAA,CAAY,KAAA,WAAgB,OAAA,CAAQ,YAAA;EAzCnB;;;;EAAA,OAiDjB,WAAA,CAAY,IAAA,EAAM,IAAA,EAAM,KAAA,WAAgB,OAAA,CAAQ,YAAA;EA5BkB;;;EAAA,OAmClE,aAAA,CAAc,IAAA,EAAM,IAAA,EAAM,KAAA,WAAa,OAAA;EAPS;;;EAAA,OAchD,SAAA,CAAU,IAAA,EAAM,IAAA,GAAO,OAAA,CAAQ,YAAA;EAAf;;;;;;;;EAAA,OAgBV,YAAA,CAAa,IAAA,EAAM,IAAA,GAAO,OAAA,CAAQ,YAAA;EA+BG;;;;EAAA,OAfrC,YAAA,CAAa,QAAA,WAAmB,OAAA,CAAQ,YAAA;EAkEV;;;;;EAAA,OAnD9B,UAAA,CAAW,IAAA,EAAM,IAAA,EAAM,GAAA,WAAc,OAAA;EAjL3C;;;;;EAAA,OAqMM,YAAA,IAAgB,OAAA,CAAQ,YAAA;;;;;;;SAgBxB,iBAAA,IAAqB,OAAA,CAAQ,YAAA;;;;;;;;;;SAe7B,kBAAA,IAAsB,OAAA,CAAQ,YAAA;AAAA"}
1
+ {"version":3,"file":"refresh-token.model.d.mts","names":[],"sources":["../../../../../../../../auth/src/models/refresh-token/refresh-token.model.ts"],"mappings":";;;;;;;AAkBA;;;;;;;;;;cAAa,kBAAA,6BAAkB,eAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAcnB,wBAAA;EACV,QAAA;EACA,SAAA;EACA,UAAA,GAAa,UAAU;AAAA;;;;;;;;;;;cAaZ,YAAA,SAAqB,KAAA;EAAA,OAClB,KAAA;EAAA,OAEA,MAAA,6BAAM,eAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;SAQN,MAAA;;;;;;;SAQA,cAAA;;MAGH,QAAA;EAtCuB;;;;;;;;AAGX;AAazB;EAhBoC,IAoDvB,SAAA;;;;;MAYA,YAAA;;MAKA,SAAA;;MAKA,OAAA;;;;EAOE,MAAA,IAAU,OAAA;;;;;;;;EAWV,cAAA,IAAkB,OAAA;;;;EAclB,UAAA,IAAc,OAAA;EAzBJ;;;EAAA,OAgCT,KAAA,CAAM,IAAA,EAAM,IAAA,EAAM,KAAA,UAAe,OAAA,EAAS,wBAAA,GAAwB,OAAA,CAAA,YAAA;EAAxB;;;EAAA,OAoB1C,WAAA,CAAY,KAAA,WAAgB,OAAA,CAAQ,YAAA;EAAR;;;;EAAA,OAQ5B,WAAA,CAAY,IAAA,EAAM,IAAA,EAAM,KAAA,WAAgB,OAAA,CAAQ,YAAA;EAOT;;;EAAA,OAAvC,aAAA,CAAc,IAAA,EAAM,IAAA,EAAM,KAAA,WAAa,OAAA;EAuBd;;;EAAA,OAhBzB,SAAA,CAAU,IAAA,EAAM,IAAA,GAAO,OAAA,CAAQ,YAAA;EAgCO;;;;;;;;EAAA,OAhBhC,YAAA,CAAa,IAAA,EAAM,IAAA,GAAO,OAAA,CAAQ,YAAA;EA3JtB;;;;EAAA,OA2KZ,YAAA,CAAa,QAAA,WAAmB,OAAA,CAAQ,YAAA;EAxK9C;;;;;EAAA,OAuLM,UAAA,CAAW,IAAA,EAAM,IAAA,EAAM,GAAA,WAAc,OAAA;;;;;;SAoBrC,YAAA,IAAgB,OAAA,CAAQ,YAAA;;;;;;;SAgBxB,iBAAA,IAAqB,OAAA,CAAQ,YAAA;;;;;;;;;;SAe7B,kBAAA,IAAsB,OAAA,CAAQ,YAAA;AAAA"}
@@ -42,6 +42,9 @@ var RefreshToken = class extends Model {
42
42
  static {
43
43
  this.schema = refreshTokenSchema;
44
44
  }
45
+ static {
46
+ this.hidden = ["token"];
47
+ }
45
48
  static {
46
49
  this.deleteStrategy = "permanent";
47
50
  }
@@ -1 +1 @@
1
- {"version":3,"file":"refresh-token.model.mjs","names":[],"sources":["../../../../../../../../auth/src/models/refresh-token/refresh-token.model.ts"],"sourcesContent":["import { Model } from \"@warlock.js/cascade\";\r\nimport { v } from \"@warlock.js/seal\";\r\nimport type { DeviceInfo } from \"../../contracts/types\";\r\nimport { isNeverExpiring, isUsableExpiry } from \"../../utils/token-expiry\";\r\nimport type { Auth } from \"../auth.model\";\r\n\r\n/**\r\n * Seal schema for the persisted refresh-token record. Exported so an override\r\n * can spread it and add columns (e.g. a tenant key) without re-declaring the\r\n * base shape:\r\n *\r\n * @example\r\n * export class AppRefreshToken extends RefreshToken {\r\n * public static schema = refreshTokenSchema.extend({\r\n * organization_id: v.string().required(),\r\n * });\r\n * }\r\n */\r\nexport const refreshTokenSchema = v.object({\r\n token: v.string().required(),\r\n user_id: v.scalar().required(),\r\n user_type: v.string().required(),\r\n family_id: v.string().required(),\r\n expires_at: v.date().required(),\r\n last_used_at: v.date().default(() => new Date()),\r\n revoked_at: v.date().optional(),\r\n device_info: v.record(v.any()).optional(),\r\n});\r\n\r\n/**\r\n * Extra attributes captured when a refresh token is issued.\r\n */\r\nexport type RefreshTokenIssueOptions = {\r\n familyId: string;\r\n expiresAt: string;\r\n deviceInfo?: DeviceInfo;\r\n};\r\n\r\n/**\r\n * Persisted refresh-token record + the data layer for refresh tokens.\r\n *\r\n * **Role.** Owns every refresh-token read, write, and lifecycle transition.\r\n * The auth service drives refresh-token state exclusively through this model's\r\n * named statics and instance methods, so it never hard-codes a column name —\r\n * which is what makes a snake/camel mismatch (the historical `userId` bug)\r\n * structurally impossible and lets an override rename or add columns by\r\n * extending this class and registering it under `config.auth.refreshToken.model`.\r\n */\r\nexport class RefreshToken extends Model {\r\n public static table = \"refresh_tokens\";\r\n\r\n public static schema = refreshTokenSchema;\r\n\r\n /**\r\n * Permanent regardless of the data source default — Mongo's driver default\r\n * is `\"trash\"`, which would copy live credential material (the JWT itself)\r\n * into `refresh_tokensTrash` on every `destroy()` (expiry purge,\r\n * never-expiring purge). A purged refresh token must not survive anywhere.\r\n */\r\n public static deleteStrategy = \"permanent\" as const;\r\n\r\n /** Token family this row belongs to (rotation / replay grouping). */\r\n public get familyId(): string {\r\n return this.get(\"family_id\");\r\n }\r\n\r\n /**\r\n * Whether the token's `expires_at` is in the past.\r\n *\r\n * **Fails closed as of 4.12.0**: a missing or unparseable `expires_at` now\r\n * counts as expired. It previously answered `false` for both — \"no expiry\r\n * recorded ⇒ never expires\" — which handed an unlimited life to precisely the\r\n * malformed rows, including the `Invalid Date` a pre-4.12.0 unparseable\r\n * `expiresIn` could write. `expires_at` is `required` in the schema, so a row\r\n * that cannot say when it dies is malformed, not immortal.\r\n */\r\n public get isExpired(): boolean {\r\n const expiresAt = this.get(\"expires_at\");\r\n\r\n if (!isUsableExpiry(expiresAt)) return true;\r\n\r\n return new Date().getTime() > new Date(expiresAt).getTime();\r\n }\r\n\r\n /**\r\n * Whether nothing can ever retire this row — an unusable `expires_at`, or a\r\n * token carrying no `exp` claim. See {@link isNeverExpiring}.\r\n */\r\n public get neverExpires(): boolean {\r\n return isNeverExpiring(this.get(\"token\"), this.get(\"expires_at\"));\r\n }\r\n\r\n /** Whether the token has been revoked. */\r\n public get isRevoked(): boolean {\r\n return !!this.get(\"revoked_at\");\r\n }\r\n\r\n /** Whether the token is still usable (not expired and not revoked). */\r\n public get isValid(): boolean {\r\n return !this.isExpired && !this.isRevoked;\r\n }\r\n\r\n /**\r\n * Unconditionally stamp `revoked_at` on this token.\r\n */\r\n public async revoke(): Promise<this> {\r\n return this.merge({ revoked_at: new Date() }).save();\r\n }\r\n\r\n /**\r\n * Atomically revoke this token ONLY if it is still active. Resolves to `true`\r\n * when this call performed the revoke, `false` when a concurrent request had\r\n * already revoked it — the win/lose signal that powers rotation replay\r\n * detection. Uses a conditional UPDATE so two concurrent rotations of the\r\n * same token can never both succeed.\r\n */\r\n public async revokeIfActive(): Promise<boolean> {\r\n const modelClass = this.constructor as typeof RefreshToken;\r\n\r\n const revokedCount = await modelClass.atomic(\r\n { id: this.id, revoked_at: null },\r\n { $set: { revoked_at: new Date() } },\r\n );\r\n\r\n return revokedCount > 0;\r\n }\r\n\r\n /**\r\n * Touch `last_used_at` without revoking — the non-rotating refresh path.\r\n */\r\n public async markAsUsed(): Promise<void> {\r\n await this.merge({ last_used_at: new Date() }).save();\r\n }\r\n\r\n /**\r\n * Persist a freshly-signed refresh token for the user.\r\n */\r\n public static issue(user: Auth, token: string, options: RefreshTokenIssueOptions) {\r\n return this.create({\r\n token,\r\n user_id: user.id,\r\n user_type: user.userType,\r\n family_id: options.familyId,\r\n expires_at: options.expiresAt,\r\n device_info: options.deviceInfo\r\n ? {\r\n userAgent: options.deviceInfo.userAgent,\r\n ip: options.deviceInfo.ip,\r\n deviceId: options.deviceInfo.deviceId,\r\n }\r\n : undefined,\r\n });\r\n }\r\n\r\n /**\r\n * Find a refresh-token row by its raw token string.\r\n */\r\n public static findByToken(token: string): Promise<RefreshToken | null> {\r\n return this.first({ token });\r\n }\r\n\r\n /**\r\n * Find a refresh token scoped to a user — used by logout so a caller can only\r\n * revoke a token that actually belongs to them.\r\n */\r\n public static findForUser(user: Auth, token: string): Promise<RefreshToken | null> {\r\n return this.first({ token, user_id: user.id });\r\n }\r\n\r\n /**\r\n * Delete a specific refresh token belonging to the user.\r\n */\r\n public static deleteForUser(user: Auth, token: string) {\r\n return this.delete({ token, user_id: user.id });\r\n }\r\n\r\n /**\r\n * Active, unexpired sessions for the user, newest first.\r\n */\r\n public static activeFor(user: Auth): Promise<RefreshToken[]> {\r\n return this.query()\r\n .where({ user_id: user.id, user_type: user.userType, revoked_at: null })\r\n .where(\"expires_at\", \">\", new Date())\r\n .orderBy(\"created_at\", \"desc\")\r\n .get();\r\n }\r\n\r\n /**\r\n * Revoke every still-active refresh token for the user, returning the rows\r\n * that were revoked so the caller can emit a per-token event for each.\r\n *\r\n * Rows are fetched BEFORE they are revoked: a bulk `findAndUpdate` keyed on\r\n * `revoked_at: null` would re-query the same predicate after the update and\r\n * match nothing, returning an empty set.\r\n */\r\n public static async revokeAllFor(user: Auth): Promise<RefreshToken[]> {\r\n const tokens = await this.query()\r\n .where({ user_id: user.id, user_type: user.userType, revoked_at: null })\r\n .get();\r\n\r\n for (const token of tokens) {\r\n await token.revoke();\r\n }\r\n\r\n return tokens;\r\n }\r\n\r\n /**\r\n * Revoke every still-active token in a family (rotation breach containment),\r\n * returning the revoked rows (fetched before revocation — see `revokeAllFor`).\r\n */\r\n public static async revokeFamily(familyId: string): Promise<RefreshToken[]> {\r\n const tokens = await this.query().where({ family_id: familyId, revoked_at: null }).get();\r\n\r\n for (const token of tokens) {\r\n await token.revoke();\r\n }\r\n\r\n return tokens;\r\n }\r\n\r\n /**\r\n * Revoke the oldest active tokens so at most `max - 1` remain — making room\r\n * for the about-to-be-issued one. Bounded by `max`, so the per-row loop is\r\n * small.\r\n */\r\n public static async enforceMax(user: Auth, max: number): Promise<void> {\r\n const activeTokens = await this.query()\r\n .where({ user_id: user.id, user_type: user.userType, revoked_at: null })\r\n .orderBy(\"created_at\", \"asc\")\r\n .get();\r\n\r\n if (activeTokens.length < max) return;\r\n\r\n const tokensToRevoke = activeTokens.slice(0, activeTokens.length - max + 1);\r\n\r\n for (const token of tokensToRevoke) {\r\n await token.revoke();\r\n }\r\n }\r\n\r\n /**\r\n * Hard-delete every expired refresh token, returning the deleted rows so the\r\n * caller can emit a per-token event. Runs from the `auth.cleanup` CLI command\r\n * (a cold batch path).\r\n */\r\n public static async purgeExpired(): Promise<RefreshToken[]> {\r\n const expiredTokens = await this.query().where(\"expires_at\", \"<\", new Date()).get();\r\n\r\n for (const token of expiredTokens) {\r\n await token.destroy();\r\n }\r\n\r\n return expiredTokens;\r\n }\r\n\r\n /**\r\n * Every row that can never retire itself — see {@link neverExpires}. A full\r\n * scan filtered in memory, for the reason given on\r\n * {@link AccessToken.findNeverExpiring}: neither an `Invalid Date` nor a\r\n * missing `exp` claim is expressible as a `where`.\r\n */\r\n public static async findNeverExpiring(): Promise<RefreshToken[]> {\r\n const tokens = await this.query().get();\r\n\r\n return tokens.filter((token: RefreshToken) => token.neverExpires);\r\n }\r\n\r\n /**\r\n * Hard-delete every never-expiring row, returning the rows removed.\r\n *\r\n * Deleted rather than `revoked_at`-stamped: a revoked row is still a row this\r\n * table has to carry, and one of the two shapes being removed is a row whose\r\n * date column cannot be compared at all — leaving it in place keeps a\r\n * permanently unpurgeable record. Rotation replay-detection is unaffected;\r\n * these rows can no longer be presented successfully either way.\r\n */\r\n public static async purgeNeverExpiring(): Promise<RefreshToken[]> {\r\n const tokens = await this.findNeverExpiring();\r\n\r\n for (const token of tokens) {\r\n await token.destroy();\r\n }\r\n\r\n return tokens;\r\n }\r\n}\r\n"],"mappings":"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"}
1
+ {"version":3,"file":"refresh-token.model.mjs","names":[],"sources":["../../../../../../../../auth/src/models/refresh-token/refresh-token.model.ts"],"sourcesContent":["import { Model } from \"@warlock.js/cascade\";\r\nimport { v } from \"@warlock.js/seal\";\r\nimport type { DeviceInfo } from \"../../contracts/types\";\r\nimport { isNeverExpiring, isUsableExpiry } from \"../../utils/token-expiry\";\r\nimport type { Auth } from \"../auth.model\";\r\n\r\n/**\r\n * Seal schema for the persisted refresh-token record. Exported so an override\r\n * can spread it and add columns (e.g. a tenant key) without re-declaring the\r\n * base shape:\r\n *\r\n * @example\r\n * export class AppRefreshToken extends RefreshToken {\r\n * public static schema = refreshTokenSchema.extend({\r\n * organization_id: v.string().required(),\r\n * });\r\n * }\r\n */\r\nexport const refreshTokenSchema = v.object({\r\n token: v.string().required(),\r\n user_id: v.scalar().required(),\r\n user_type: v.string().required(),\r\n family_id: v.string().required(),\r\n expires_at: v.date().required(),\r\n last_used_at: v.date().default(() => new Date()),\r\n revoked_at: v.date().optional(),\r\n device_info: v.record(v.any()).optional(),\r\n});\r\n\r\n/**\r\n * Extra attributes captured when a refresh token is issued.\r\n */\r\nexport type RefreshTokenIssueOptions = {\r\n familyId: string;\r\n expiresAt: string;\r\n deviceInfo?: DeviceInfo;\r\n};\r\n\r\n/**\r\n * Persisted refresh-token record + the data layer for refresh tokens.\r\n *\r\n * **Role.** Owns every refresh-token read, write, and lifecycle transition.\r\n * The auth service drives refresh-token state exclusively through this model's\r\n * named statics and instance methods, so it never hard-codes a column name —\r\n * which is what makes a snake/camel mismatch (the historical `userId` bug)\r\n * structurally impossible and lets an override rename or add columns by\r\n * extending this class and registering it under `config.auth.refreshToken.model`.\r\n */\r\nexport class RefreshToken extends Model {\r\n public static table = \"refresh_tokens\";\r\n\r\n public static schema = refreshTokenSchema;\r\n\r\n /**\r\n * The raw signed refresh JWT is a bearer credential — never serialize it.\r\n * `hidden` only strips it from `toJSON()` / `JSON.stringify(model)` (e.g. a\r\n * session list returned via `response.json`); `get(\"token\")`,\r\n * `findByToken` / `findForUser` and every query still read the column.\r\n */\r\n public static hidden = [\"token\"];\r\n\r\n /**\r\n * Permanent regardless of the data source default — Mongo's driver default\r\n * is `\"trash\"`, which would copy live credential material (the JWT itself)\r\n * into `refresh_tokensTrash` on every `destroy()` (expiry purge,\r\n * never-expiring purge). A purged refresh token must not survive anywhere.\r\n */\r\n public static deleteStrategy = \"permanent\" as const;\r\n\r\n /** Token family this row belongs to (rotation / replay grouping). */\r\n public get familyId(): string {\r\n return this.get(\"family_id\");\r\n }\r\n\r\n /**\r\n * Whether the token's `expires_at` is in the past.\r\n *\r\n * **Fails closed as of 4.12.0**: a missing or unparseable `expires_at` now\r\n * counts as expired. It previously answered `false` for both — \"no expiry\r\n * recorded ⇒ never expires\" — which handed an unlimited life to precisely the\r\n * malformed rows, including the `Invalid Date` a pre-4.12.0 unparseable\r\n * `expiresIn` could write. `expires_at` is `required` in the schema, so a row\r\n * that cannot say when it dies is malformed, not immortal.\r\n */\r\n public get isExpired(): boolean {\r\n const expiresAt = this.get(\"expires_at\");\r\n\r\n if (!isUsableExpiry(expiresAt)) return true;\r\n\r\n return new Date().getTime() > new Date(expiresAt).getTime();\r\n }\r\n\r\n /**\r\n * Whether nothing can ever retire this row — an unusable `expires_at`, or a\r\n * token carrying no `exp` claim. See {@link isNeverExpiring}.\r\n */\r\n public get neverExpires(): boolean {\r\n return isNeverExpiring(this.get(\"token\"), this.get(\"expires_at\"));\r\n }\r\n\r\n /** Whether the token has been revoked. */\r\n public get isRevoked(): boolean {\r\n return !!this.get(\"revoked_at\");\r\n }\r\n\r\n /** Whether the token is still usable (not expired and not revoked). */\r\n public get isValid(): boolean {\r\n return !this.isExpired && !this.isRevoked;\r\n }\r\n\r\n /**\r\n * Unconditionally stamp `revoked_at` on this token.\r\n */\r\n public async revoke(): Promise<this> {\r\n return this.merge({ revoked_at: new Date() }).save();\r\n }\r\n\r\n /**\r\n * Atomically revoke this token ONLY if it is still active. Resolves to `true`\r\n * when this call performed the revoke, `false` when a concurrent request had\r\n * already revoked it — the win/lose signal that powers rotation replay\r\n * detection. Uses a conditional UPDATE so two concurrent rotations of the\r\n * same token can never both succeed.\r\n */\r\n public async revokeIfActive(): Promise<boolean> {\r\n const modelClass = this.constructor as typeof RefreshToken;\r\n\r\n const revokedCount = await modelClass.atomic(\r\n { id: this.id, revoked_at: null },\r\n { $set: { revoked_at: new Date() } },\r\n );\r\n\r\n return revokedCount > 0;\r\n }\r\n\r\n /**\r\n * Touch `last_used_at` without revoking — the non-rotating refresh path.\r\n */\r\n public async markAsUsed(): Promise<void> {\r\n await this.merge({ last_used_at: new Date() }).save();\r\n }\r\n\r\n /**\r\n * Persist a freshly-signed refresh token for the user.\r\n */\r\n public static issue(user: Auth, token: string, options: RefreshTokenIssueOptions) {\r\n return this.create({\r\n token,\r\n user_id: user.id,\r\n user_type: user.userType,\r\n family_id: options.familyId,\r\n expires_at: options.expiresAt,\r\n device_info: options.deviceInfo\r\n ? {\r\n userAgent: options.deviceInfo.userAgent,\r\n ip: options.deviceInfo.ip,\r\n deviceId: options.deviceInfo.deviceId,\r\n }\r\n : undefined,\r\n });\r\n }\r\n\r\n /**\r\n * Find a refresh-token row by its raw token string.\r\n */\r\n public static findByToken(token: string): Promise<RefreshToken | null> {\r\n return this.first({ token });\r\n }\r\n\r\n /**\r\n * Find a refresh token scoped to a user — used by logout so a caller can only\r\n * revoke a token that actually belongs to them.\r\n */\r\n public static findForUser(user: Auth, token: string): Promise<RefreshToken | null> {\r\n return this.first({ token, user_id: user.id });\r\n }\r\n\r\n /**\r\n * Delete a specific refresh token belonging to the user.\r\n */\r\n public static deleteForUser(user: Auth, token: string) {\r\n return this.delete({ token, user_id: user.id });\r\n }\r\n\r\n /**\r\n * Active, unexpired sessions for the user, newest first.\r\n */\r\n public static activeFor(user: Auth): Promise<RefreshToken[]> {\r\n return this.query()\r\n .where({ user_id: user.id, user_type: user.userType, revoked_at: null })\r\n .where(\"expires_at\", \">\", new Date())\r\n .orderBy(\"created_at\", \"desc\")\r\n .get();\r\n }\r\n\r\n /**\r\n * Revoke every still-active refresh token for the user, returning the rows\r\n * that were revoked so the caller can emit a per-token event for each.\r\n *\r\n * Rows are fetched BEFORE they are revoked: a bulk `findAndUpdate` keyed on\r\n * `revoked_at: null` would re-query the same predicate after the update and\r\n * match nothing, returning an empty set.\r\n */\r\n public static async revokeAllFor(user: Auth): Promise<RefreshToken[]> {\r\n const tokens = await this.query()\r\n .where({ user_id: user.id, user_type: user.userType, revoked_at: null })\r\n .get();\r\n\r\n for (const token of tokens) {\r\n await token.revoke();\r\n }\r\n\r\n return tokens;\r\n }\r\n\r\n /**\r\n * Revoke every still-active token in a family (rotation breach containment),\r\n * returning the revoked rows (fetched before revocation — see `revokeAllFor`).\r\n */\r\n public static async revokeFamily(familyId: string): Promise<RefreshToken[]> {\r\n const tokens = await this.query().where({ family_id: familyId, revoked_at: null }).get();\r\n\r\n for (const token of tokens) {\r\n await token.revoke();\r\n }\r\n\r\n return tokens;\r\n }\r\n\r\n /**\r\n * Revoke the oldest active tokens so at most `max - 1` remain — making room\r\n * for the about-to-be-issued one. Bounded by `max`, so the per-row loop is\r\n * small.\r\n */\r\n public static async enforceMax(user: Auth, max: number): Promise<void> {\r\n const activeTokens = await this.query()\r\n .where({ user_id: user.id, user_type: user.userType, revoked_at: null })\r\n .orderBy(\"created_at\", \"asc\")\r\n .get();\r\n\r\n if (activeTokens.length < max) return;\r\n\r\n const tokensToRevoke = activeTokens.slice(0, activeTokens.length - max + 1);\r\n\r\n for (const token of tokensToRevoke) {\r\n await token.revoke();\r\n }\r\n }\r\n\r\n /**\r\n * Hard-delete every expired refresh token, returning the deleted rows so the\r\n * caller can emit a per-token event. Runs from the `auth.cleanup` CLI command\r\n * (a cold batch path).\r\n */\r\n public static async purgeExpired(): Promise<RefreshToken[]> {\r\n const expiredTokens = await this.query().where(\"expires_at\", \"<\", new Date()).get();\r\n\r\n for (const token of expiredTokens) {\r\n await token.destroy();\r\n }\r\n\r\n return expiredTokens;\r\n }\r\n\r\n /**\r\n * Every row that can never retire itself — see {@link neverExpires}. A full\r\n * scan filtered in memory, for the reason given on\r\n * {@link AccessToken.findNeverExpiring}: neither an `Invalid Date` nor a\r\n * missing `exp` claim is expressible as a `where`.\r\n */\r\n public static async findNeverExpiring(): Promise<RefreshToken[]> {\r\n const tokens = await this.query().get();\r\n\r\n return tokens.filter((token: RefreshToken) => token.neverExpires);\r\n }\r\n\r\n /**\r\n * Hard-delete every never-expiring row, returning the rows removed.\r\n *\r\n * Deleted rather than `revoked_at`-stamped: a revoked row is still a row this\r\n * table has to carry, and one of the two shapes being removed is a row whose\r\n * date column cannot be compared at all — leaving it in place keeps a\r\n * permanently unpurgeable record. Rotation replay-detection is unaffected;\r\n * these rows can no longer be presented successfully either way.\r\n */\r\n public static async purgeNeverExpiring(): Promise<RefreshToken[]> {\r\n const tokens = await this.findNeverExpiring();\r\n\r\n for (const token of tokens) {\r\n await token.destroy();\r\n }\r\n\r\n return tokens;\r\n }\r\n}\r\n"],"mappings":"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"}
@@ -0,0 +1,74 @@
1
+ import { AppleProviderConfig, AuthProvider, ProviderAuthorizationState, ProviderCallbackParams, ProviderProfile } from "../contracts/providers.mjs";
2
+ import { JoseModule } from "./google-provider.mjs";
3
+
4
+ //#region ../auth/src/providers/apple-provider.d.ts
5
+ /** The `warlock add` feature that installs `jose`. */
6
+ declare const APPLE_AUTH_FEATURE = "auth-apple";
7
+ type AppleSignJwtBuilder = {
8
+ setIssuer: (issuer: string) => AppleSignJwtBuilder;
9
+ setIssuedAt: () => AppleSignJwtBuilder;
10
+ setExpirationTime: (time: number) => AppleSignJwtBuilder;
11
+ setAudience: (audience: string) => AppleSignJwtBuilder;
12
+ setSubject: (subject: string) => AppleSignJwtBuilder;
13
+ sign: (key: unknown) => Promise<string>;
14
+ };
15
+ /**
16
+ * The slice of `jose` the Apple provider uses on top of {@link JoseModule} —
17
+ * `importPKCS8` and `SignJWT` sign the ES256 client-secret JWT Apple requires
18
+ * instead of a static client secret.
19
+ */
20
+ type AppleJoseModule = JoseModule & {
21
+ importPKCS8: (pkcs8: string, alg: string) => Promise<unknown>;
22
+ SignJWT: new (payload: Record<string, unknown>) => {
23
+ setProtectedHeader: (header: Record<string, unknown>) => AppleSignJwtBuilder;
24
+ };
25
+ };
26
+ /**
27
+ * Apple ("Sign in with Apple") is OpenID Connect over the authorization code
28
+ * flow with PKCE (S256), `state` and `nonce`, the same shape as
29
+ * {@link GoogleProvider} — with two differences that come straight from
30
+ * Apple's own docs (this package has no Apple developer account, so nothing
31
+ * here is proven against a live exchange):
32
+ *
33
+ * 1. Apple has no static client secret. The "secret" the token endpoint wants
34
+ * is a fresh ES256 JWT (`iss` = team id, `sub` = client id, `aud` =
35
+ * `https://appleid.apple.com`, signed with the app's private key and the
36
+ * key id as `kid`) — signed once per callback here rather than cached, to
37
+ * avoid ever presenting a stale one.
38
+ * 2. Requesting `name`/`email` (the default scopes) forces
39
+ * `response_mode=form_post`: Apple POSTs `code`, `state` and, on the
40
+ * user's FIRST authorization only, a `user` field — a JSON string with
41
+ * `{ name: { firstName, lastName }, email } ` that never comes back again
42
+ * on a later login. The callback route must therefore accept a POST body,
43
+ * not just a query string, and `request.input` must read that body the
44
+ * same way it already reads a GET query for the other providers.
45
+ *
46
+ * The id_token is verified with `jose` against Apple's JWKS (signature,
47
+ * `iss`, `aud` = the client id, `exp`), then its `nonce` is compared. Apple is
48
+ * also documented to send `email_verified` (and `is_private_email`) as the
49
+ * STRING `"true"`/`"false"` rather than a JSON boolean on some responses, so
50
+ * both forms are accepted here — treating only the unambiguous `"false"` (or
51
+ * `false`) as unverified. A private-relay address (`@privaterelay.appleid.com`)
52
+ * is still a real, working forwarding address and is accepted as any other.
53
+ */
54
+ declare class AppleProvider implements AuthProvider {
55
+ private readonly settings;
56
+ readonly name = "apple";
57
+ /** Apple's default scopes (`name`, `email`) force `response_mode=form_post`. */
58
+ readonly callbackMode = "form_post";
59
+ constructor(settings: AppleProviderConfig);
60
+ authorizationUrl(state: ProviderAuthorizationState): string;
61
+ handleCallback({
62
+ query,
63
+ expected
64
+ }: ProviderCallbackParams): Promise<ProviderProfile>;
65
+ /** Sign a fresh ES256 client-secret JWT — Apple has no static one. */
66
+ private clientSecret;
67
+ /** Redeem the authorization code (with the PKCE verifier) for an id_token. */
68
+ private exchangeCode;
69
+ /** Verify signature, issuer, audience and expiry; resolve the claims. */
70
+ private verifyIdToken;
71
+ }
72
+ //#endregion
73
+ export { APPLE_AUTH_FEATURE, AppleJoseModule, AppleProvider };
74
+ //# sourceMappingURL=apple-provider.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"apple-provider.d.mts","names":[],"sources":["../../../../../../../auth/src/providers/apple-provider.ts"],"mappings":";;;;;cAqBa,kBAAA;AAAA,KAER,mBAAA;EACH,SAAA,GAAY,MAAA,aAAmB,mBAAA;EAC/B,WAAA,QAAmB,mBAAA;EACnB,iBAAA,GAAoB,IAAA,aAAiB,mBAAA;EACrC,WAAA,GAAc,QAAA,aAAqB,mBAAA;EACnC,UAAA,GAAa,OAAA,aAAoB,mBAAA;EACjC,IAAA,GAAO,GAAA,cAAiB,OAAA;AAAA;;;;;;KAQd,eAAA,GAAkB,UAAA;EAC5B,WAAA,GAAc,KAAA,UAAe,GAAA,aAAgB,OAAA;EAC7C,OAAA,OAAc,OAAA,EAAS,MAAA;IACrB,kBAAA,GAAqB,MAAA,EAAQ,MAAA,sBAA4B,mBAAA;EAAA;AAAA;;;;;;;;;;;;;;;;AAX5B;AAQjC;;;;;;;;;;;;cAsCa,aAAA,YAAyB,YAAA;EAAA,iBAKA,QAAA;EAAA,SAJpB,IAAA;EArChB;EAAA,SAuCgB,YAAA;cAEoB,QAAA,EAAU,mBAAA;EAEvC,gBAAA,CAAiB,KAAA,EAAO,0BAAA;EAqBlB,cAAA;IACX,KAAA;IACA;EAAA,GACC,sBAAA,GAAyB,OAAA,CAAQ,eAAA;EAlEb;EAAA,QAkGT,YAAA;EAlGgE;EAAA,QAqHhE,YAAA;EAlFH;EAAA,QAyHG,aAAA;AAAA"}