@warlock.js/auth 5.12.0 → 5.14.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 (169) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +88 -86
  3. package/esm/commands/auth-cleanup-command.mjs +1 -1
  4. package/esm/commands/auth-cleanup-command.mjs.map +1 -1
  5. package/esm/contracts/index.d.mts +2 -1
  6. package/esm/contracts/providers.d.mts +101 -0
  7. package/esm/contracts/providers.d.mts.map +1 -0
  8. package/esm/contracts/types.d.mts +94 -1
  9. package/esm/contracts/types.d.mts.map +1 -1
  10. package/esm/contracts/types.mjs.map +1 -1
  11. package/esm/errors/auth-provider-sdk-missing.error.d.mts +17 -0
  12. package/esm/errors/auth-provider-sdk-missing.error.d.mts.map +1 -0
  13. package/esm/errors/auth-provider-sdk-missing.error.mjs +22 -0
  14. package/esm/errors/auth-provider-sdk-missing.error.mjs.map +1 -0
  15. package/esm/errors/email-not-verified.error.d.mts +16 -0
  16. package/esm/errors/email-not-verified.error.d.mts.map +1 -0
  17. package/esm/errors/email-not-verified.error.mjs +20 -0
  18. package/esm/errors/email-not-verified.error.mjs.map +1 -0
  19. package/esm/errors/index.d.mts +7 -0
  20. package/esm/errors/index.mjs +9 -0
  21. package/esm/errors/invalid-one-time-token.error.d.mts +17 -0
  22. package/esm/errors/invalid-one-time-token.error.d.mts.map +1 -0
  23. package/esm/errors/invalid-one-time-token.error.mjs +21 -0
  24. package/esm/errors/invalid-one-time-token.error.mjs.map +1 -0
  25. package/esm/errors/invalid-passkey.error.d.mts +18 -0
  26. package/esm/errors/invalid-passkey.error.d.mts.map +1 -0
  27. package/esm/errors/invalid-passkey.error.mjs +23 -0
  28. package/esm/errors/invalid-passkey.error.mjs.map +1 -0
  29. package/esm/errors/invalid-provider-callback.error.d.mts +18 -0
  30. package/esm/errors/invalid-provider-callback.error.d.mts.map +1 -0
  31. package/esm/errors/invalid-provider-callback.error.mjs +23 -0
  32. package/esm/errors/invalid-provider-callback.error.mjs.map +1 -0
  33. package/esm/errors/notifications-unavailable.error.d.mts +15 -0
  34. package/esm/errors/notifications-unavailable.error.d.mts.map +1 -0
  35. package/esm/errors/notifications-unavailable.error.mjs +20 -0
  36. package/esm/errors/notifications-unavailable.error.mjs.map +1 -0
  37. package/esm/errors/provider-email-not-verified.error.d.mts +16 -0
  38. package/esm/errors/provider-email-not-verified.error.d.mts.map +1 -0
  39. package/esm/errors/provider-email-not-verified.error.mjs +20 -0
  40. package/esm/errors/provider-email-not-verified.error.mjs.map +1 -0
  41. package/esm/index.d.mts +27 -3
  42. package/esm/index.mjs +27 -1
  43. package/esm/middleware/index.d.mts +4 -1
  44. package/esm/middleware/index.mjs +3 -0
  45. package/esm/middleware/one-time-token-throttle.middleware.d.mts +36 -0
  46. package/esm/middleware/one-time-token-throttle.middleware.d.mts.map +1 -0
  47. package/esm/middleware/one-time-token-throttle.middleware.mjs +53 -0
  48. package/esm/middleware/one-time-token-throttle.middleware.mjs.map +1 -0
  49. package/esm/middleware/otp-throttle.middleware.d.mts +30 -0
  50. package/esm/middleware/otp-throttle.middleware.d.mts.map +1 -0
  51. package/esm/middleware/otp-throttle.middleware.mjs +45 -0
  52. package/esm/middleware/otp-throttle.middleware.mjs.map +1 -0
  53. package/esm/middleware/require-verified-email.middleware.d.mts +18 -0
  54. package/esm/middleware/require-verified-email.middleware.d.mts.map +1 -0
  55. package/esm/middleware/require-verified-email.middleware.mjs +24 -0
  56. package/esm/middleware/require-verified-email.middleware.mjs.map +1 -0
  57. package/esm/models/access-token/access-token.model.d.mts +8 -0
  58. package/esm/models/access-token/access-token.model.d.mts.map +1 -1
  59. package/esm/models/access-token/access-token.model.mjs +3 -0
  60. package/esm/models/access-token/access-token.model.mjs.map +1 -1
  61. package/esm/models/index.d.mts +3 -1
  62. package/esm/models/index.d.mts.map +1 -1
  63. package/esm/models/index.mjs +16 -1
  64. package/esm/models/index.mjs.map +1 -1
  65. package/esm/models/one-time-token/index.d.mts +1 -0
  66. package/esm/models/one-time-token/index.mjs +3 -0
  67. package/esm/models/one-time-token/migration.mjs +31 -0
  68. package/esm/models/one-time-token/migration.mjs.map +1 -0
  69. package/esm/models/one-time-token/one-time-token.model.d.mts +173 -0
  70. package/esm/models/one-time-token/one-time-token.model.d.mts.map +1 -0
  71. package/esm/models/one-time-token/one-time-token.model.mjs +151 -0
  72. package/esm/models/one-time-token/one-time-token.model.mjs.map +1 -0
  73. package/esm/models/passkey-credential/index.d.mts +1 -0
  74. package/esm/models/passkey-credential/index.mjs +3 -0
  75. package/esm/models/passkey-credential/migration.mjs +25 -0
  76. package/esm/models/passkey-credential/migration.mjs.map +1 -0
  77. package/esm/models/passkey-credential/passkey-credential.model.d.mts +108 -0
  78. package/esm/models/passkey-credential/passkey-credential.model.d.mts.map +1 -0
  79. package/esm/models/passkey-credential/passkey-credential.model.mjs +70 -0
  80. package/esm/models/passkey-credential/passkey-credential.model.mjs.map +1 -0
  81. package/esm/models/provider-account/index.d.mts +1 -0
  82. package/esm/models/provider-account/index.mjs +3 -0
  83. package/esm/models/provider-account/migration.mjs +25 -0
  84. package/esm/models/provider-account/migration.mjs.map +1 -0
  85. package/esm/models/provider-account/provider-account.model.d.mts +75 -0
  86. package/esm/models/provider-account/provider-account.model.d.mts.map +1 -0
  87. package/esm/models/provider-account/provider-account.model.mjs +55 -0
  88. package/esm/models/provider-account/provider-account.model.mjs.map +1 -0
  89. package/esm/models/refresh-token/refresh-token.model.d.mts +7 -0
  90. package/esm/models/refresh-token/refresh-token.model.d.mts.map +1 -1
  91. package/esm/models/refresh-token/refresh-token.model.mjs +3 -0
  92. package/esm/models/refresh-token/refresh-token.model.mjs.map +1 -1
  93. package/esm/otp/index.d.mts +1 -0
  94. package/esm/otp/index.mjs +3 -0
  95. package/esm/otp/otp.d.mts +38 -0
  96. package/esm/otp/otp.d.mts.map +1 -0
  97. package/esm/otp/otp.mjs +105 -0
  98. package/esm/otp/otp.mjs.map +1 -0
  99. package/esm/passkeys/index.d.mts +3 -0
  100. package/esm/passkeys/index.mjs +4 -0
  101. package/esm/passkeys/passkey-authentication.d.mts +33 -0
  102. package/esm/passkeys/passkey-authentication.d.mts.map +1 -0
  103. package/esm/passkeys/passkey-authentication.mjs +93 -0
  104. package/esm/passkeys/passkey-authentication.mjs.map +1 -0
  105. package/esm/passkeys/passkey-challenges.mjs +53 -0
  106. package/esm/passkeys/passkey-challenges.mjs.map +1 -0
  107. package/esm/passkeys/passkey-registration.d.mts +29 -0
  108. package/esm/passkeys/passkey-registration.d.mts.map +1 -0
  109. package/esm/passkeys/passkey-registration.mjs +88 -0
  110. package/esm/passkeys/passkey-registration.mjs.map +1 -0
  111. package/esm/passkeys/simplewebauthn.d.mts +21 -0
  112. package/esm/passkeys/simplewebauthn.d.mts.map +1 -0
  113. package/esm/passkeys/simplewebauthn.mjs +13 -0
  114. package/esm/passkeys/simplewebauthn.mjs.map +1 -0
  115. package/esm/providers/google-provider.d.mts +40 -0
  116. package/esm/providers/google-provider.d.mts.map +1 -0
  117. package/esm/providers/google-provider.mjs +105 -0
  118. package/esm/providers/google-provider.mjs.map +1 -0
  119. package/esm/providers/index.d.mts +2 -0
  120. package/esm/providers/index.mjs +4 -0
  121. package/esm/providers/provider-login.d.mts +43 -0
  122. package/esm/providers/provider-login.d.mts.map +1 -0
  123. package/esm/providers/provider-login.mjs +86 -0
  124. package/esm/providers/provider-login.mjs.map +1 -0
  125. package/esm/providers/provider-state-cookie.mjs +63 -0
  126. package/esm/providers/provider-state-cookie.mjs.map +1 -0
  127. package/esm/providers/resolve-provider-user.mjs +45 -0
  128. package/esm/providers/resolve-provider-user.mjs.map +1 -0
  129. package/esm/services/auth-config.mjs +72 -0
  130. package/esm/services/auth-config.mjs.map +1 -1
  131. package/esm/services/auth.service.d.mts +18 -1
  132. package/esm/services/auth.service.d.mts.map +1 -1
  133. package/esm/services/auth.service.mjs +28 -1
  134. package/esm/services/auth.service.mjs.map +1 -1
  135. package/esm/services/email-verification.d.mts +35 -0
  136. package/esm/services/email-verification.d.mts.map +1 -0
  137. package/esm/services/email-verification.mjs +55 -0
  138. package/esm/services/email-verification.mjs.map +1 -0
  139. package/esm/services/index.d.mts +4 -1
  140. package/esm/services/index.mjs +3 -0
  141. package/esm/services/one-time-tokens.d.mts +32 -0
  142. package/esm/services/one-time-tokens.d.mts.map +1 -0
  143. package/esm/services/one-time-tokens.mjs +56 -0
  144. package/esm/services/one-time-tokens.mjs.map +1 -0
  145. package/esm/services/optional-peer.mjs +37 -0
  146. package/esm/services/optional-peer.mjs.map +1 -0
  147. package/esm/services/password-reset.d.mts +34 -0
  148. package/esm/services/password-reset.d.mts.map +1 -0
  149. package/esm/services/password-reset.mjs +81 -0
  150. package/esm/services/password-reset.mjs.map +1 -0
  151. package/esm/services/token-notifications.mjs +87 -0
  152. package/esm/services/token-notifications.mjs.map +1 -0
  153. package/esm/utils/auth-error-codes.d.mts +39 -1
  154. package/esm/utils/auth-error-codes.d.mts.map +1 -1
  155. package/esm/utils/auth-error-codes.mjs +33 -0
  156. package/esm/utils/auth-error-codes.mjs.map +1 -1
  157. package/esm/utils/index.mjs +1 -0
  158. package/esm/utils/safe-equal.d.mts +6 -0
  159. package/esm/utils/safe-equal.d.mts.map +1 -0
  160. package/esm/utils/safe-equal.mjs +13 -0
  161. package/esm/utils/safe-equal.mjs.map +1 -0
  162. package/llms-full.txt +313 -10
  163. package/llms.txt +2 -0
  164. package/package.json +21 -7
  165. package/skills/login-with-providers/SKILL.md +154 -0
  166. package/skills/manage-tokens/SKILL.md +7 -1
  167. package/skills/register-user/SKILL.md +6 -8
  168. package/skills/run-auth-commands/SKILL.md +1 -1
  169. package/skills/verify-email-and-reset-password/SKILL.md +137 -0
@@ -0,0 +1,87 @@
1
+ import { authConfig } from "./auth-config.mjs";
2
+ import { NotificationsUnavailableError } from "../errors/notifications-unavailable.error.mjs";
3
+
4
+ //#region ../auth/src/services/token-notifications.ts
5
+ /** Non-literal on purpose: keeps type-checkers and bundlers from requiring the optional peer. */
6
+ const NOTIFICATIONS_PACKAGE = "@warlock.js/notifications";
7
+ const defaults = /* @__PURE__ */ new Map();
8
+ /**
9
+ * Whether `error` (or an error it wraps as `cause`, as some loaders do) is the
10
+ * resolver saying the notifications package itself is absent. A missing
11
+ * TRANSITIVE dependency names a different package and is rethrown untouched.
12
+ */
13
+ function isPackageMissing(error) {
14
+ for (let current = error, depth = 0; current && depth < 5; depth++) {
15
+ const { code, message } = current;
16
+ if ((code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") && typeof message === "string" && message.includes(`'${NOTIFICATIONS_PACKAGE}'`)) return true;
17
+ current = current.cause;
18
+ }
19
+ return false;
20
+ }
21
+ async function loadNotifications() {
22
+ let notifications;
23
+ try {
24
+ notifications = await import(NOTIFICATIONS_PACKAGE);
25
+ } catch (error) {
26
+ if (isPackageMissing(error)) throw new NotificationsUnavailableError("not-installed", error);
27
+ throw error;
28
+ }
29
+ try {
30
+ notifications.getNotificationConfig();
31
+ } catch (error) {
32
+ throw new NotificationsUnavailableError("not-configured", error);
33
+ }
34
+ return notifications;
35
+ }
36
+ function escapeHtml(value) {
37
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
38
+ }
39
+ function mailBody(intro, data) {
40
+ const action = data.url ?? data.token;
41
+ const expires = data.expiresAt.toISOString();
42
+ return {
43
+ html: `<p>${intro}</p>` + (data.url ? `<p><a href="${escapeHtml(data.url)}">${escapeHtml(data.url)}</a></p>` : `<p><code>${escapeHtml(data.token)}</code></p>`) + `<p>This link expires at ${expires}. If you did not request it, ignore this email.</p>`,
44
+ text: `${intro}\n\n${action}\n\nThis link expires at ${expires}. If you did not request it, ignore this email.`
45
+ };
46
+ }
47
+ function defineDefault(notifications, type) {
48
+ if (type === "email-verification") return notifications.defineNotification({
49
+ type: "auth.email-verification",
50
+ via: ["mail"],
51
+ mail: (data) => ({
52
+ subject: "Verify your email address",
53
+ ...mailBody("Confirm your email address to finish setting up your account.", data)
54
+ })
55
+ });
56
+ return notifications.defineNotification({
57
+ type: "auth.password-reset",
58
+ via: ["mail"],
59
+ mail: (data) => ({
60
+ subject: "Reset your password",
61
+ ...mailBody("Use this to choose a new password.", data)
62
+ })
63
+ });
64
+ }
65
+ /**
66
+ * Resolve the notification a token of `type` is delivered through — the app's
67
+ * configured override, else auth's default mail notification.
68
+ *
69
+ * Always confirms `@warlock.js/notifications` is installed AND configured
70
+ * first, even when an override is set, so an app can never issue a token that
71
+ * silently goes nowhere. Throws {@link NotificationsUnavailableError}.
72
+ */
73
+ async function resolveTokenNotification(type) {
74
+ const notifications = await loadNotifications();
75
+ const override = type === "email-verification" ? authConfig.verification.notification() : authConfig.passwordReset.notification();
76
+ if (override) return override;
77
+ let notification = defaults.get(type);
78
+ if (!notification) {
79
+ notification = defineDefault(notifications, type);
80
+ defaults.set(type, notification);
81
+ }
82
+ return notification;
83
+ }
84
+
85
+ //#endregion
86
+ export { resolveTokenNotification };
87
+ //# sourceMappingURL=token-notifications.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"token-notifications.mjs","names":[],"sources":["../../../../../../../auth/src/services/token-notifications.ts"],"sourcesContent":["import type { AuthNotification, OneTimeTokenNotificationData } from \"../contracts/types\";\nimport { NotificationsUnavailableError } from \"../errors/notifications-unavailable.error\";\nimport { authConfig } from \"./auth-config\";\n\n/** Which token notification to resolve. */\nexport type TokenNotificationType = \"email-verification\" | \"password-reset\";\n\n/**\n * The slice of `@warlock.js/notifications` auth uses. Declared locally because\n * the package is an OPTIONAL peer: auth must type-check and load without it.\n */\ntype NotificationsModule = {\n getNotificationConfig: () => unknown;\n defineNotification: (definition: {\n type: string;\n via: string[];\n mail: (data: OneTimeTokenNotificationData) => { subject: string; html: string; text: string };\n }) => AuthNotification;\n};\n\n/** Non-literal on purpose: keeps type-checkers and bundlers from requiring the optional peer. */\nconst NOTIFICATIONS_PACKAGE: string = \"@warlock.js/notifications\";\n\nconst defaults = new Map<TokenNotificationType, AuthNotification>();\n\n/**\n * Whether `error` (or an error it wraps as `cause`, as some loaders do) is the\n * resolver saying the notifications package itself is absent. A missing\n * TRANSITIVE dependency names a different package and is rethrown untouched.\n */\nfunction isPackageMissing(error: unknown): boolean {\n for (let current = error, depth = 0; current && depth < 5; depth++) {\n const { code, message } = current as { code?: unknown; message?: unknown };\n\n if (\n (code === \"ERR_MODULE_NOT_FOUND\" || code === \"MODULE_NOT_FOUND\") &&\n typeof message === \"string\" &&\n message.includes(`'${NOTIFICATIONS_PACKAGE}'`)\n ) {\n return true;\n }\n\n current = (current as { cause?: unknown }).cause;\n }\n\n return false;\n}\n\nasync function loadNotifications(): Promise<NotificationsModule> {\n let notifications: NotificationsModule;\n\n try {\n notifications = (await import(NOTIFICATIONS_PACKAGE)) as NotificationsModule;\n } catch (error) {\n if (isPackageMissing(error)) {\n throw new NotificationsUnavailableError(\"not-installed\", error);\n }\n\n throw error;\n }\n\n try {\n notifications.getNotificationConfig();\n } catch (error) {\n throw new NotificationsUnavailableError(\"not-configured\", error);\n }\n\n return notifications;\n}\n\nfunction escapeHtml(value: string): string {\n return value\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\");\n}\n\nfunction mailBody(intro: string, data: OneTimeTokenNotificationData) {\n const action = data.url ?? data.token;\n const expires = data.expiresAt.toISOString();\n\n return {\n html:\n `<p>${intro}</p>` +\n (data.url\n ? `<p><a href=\"${escapeHtml(data.url)}\">${escapeHtml(data.url)}</a></p>`\n : `<p><code>${escapeHtml(data.token)}</code></p>`) +\n `<p>This link expires at ${expires}. If you did not request it, ignore this email.</p>`,\n text: `${intro}\\n\\n${action}\\n\\nThis link expires at ${expires}. If you did not request it, ignore this email.`,\n };\n}\n\nfunction defineDefault(\n notifications: NotificationsModule,\n type: TokenNotificationType,\n): AuthNotification {\n if (type === \"email-verification\") {\n return notifications.defineNotification({\n type: \"auth.email-verification\",\n via: [\"mail\"],\n mail: (data) => ({\n subject: \"Verify your email address\",\n ...mailBody(\"Confirm your email address to finish setting up your account.\", data),\n }),\n });\n }\n\n return notifications.defineNotification({\n type: \"auth.password-reset\",\n via: [\"mail\"],\n mail: (data) => ({\n subject: \"Reset your password\",\n ...mailBody(\"Use this to choose a new password.\", data),\n }),\n });\n}\n\n/**\n * Resolve the notification a token of `type` is delivered through — the app's\n * configured override, else auth's default mail notification.\n *\n * Always confirms `@warlock.js/notifications` is installed AND configured\n * first, even when an override is set, so an app can never issue a token that\n * silently goes nowhere. Throws {@link NotificationsUnavailableError}.\n */\nexport async function resolveTokenNotification(\n type: TokenNotificationType,\n): Promise<AuthNotification> {\n const notifications = await loadNotifications();\n\n const override =\n type === \"email-verification\"\n ? authConfig.verification.notification()\n : authConfig.passwordReset.notification();\n\n if (override) return override;\n\n let notification = defaults.get(type);\n\n if (!notification) {\n notification = defineDefault(notifications, type);\n defaults.set(type, notification);\n }\n\n return notification;\n}\n"],"mappings":";;;;;AAqBA,MAAM,wBAAgC;AAEtC,MAAM,2BAAW,IAAI,IAA6C;;;;;;AAOlE,SAAS,iBAAiB,OAAyB;CACjD,KAAK,IAAI,UAAU,OAAO,QAAQ,GAAG,WAAW,QAAQ,GAAG,SAAS;EAClE,MAAM,EAAE,MAAM,YAAY;EAE1B,KACG,SAAS,0BAA0B,SAAS,uBAC7C,OAAO,YAAY,YACnB,QAAQ,SAAS,IAAI,sBAAsB,EAAE,GAE7C,OAAO;EAGT,UAAW,QAAgC;CAC7C;CAEA,OAAO;AACT;AAEA,eAAe,oBAAkD;CAC/D,IAAI;CAEJ,IAAI;EACF,gBAAiB,MAAM,OAAO;CAChC,SAAS,OAAO;EACd,IAAI,iBAAiB,KAAK,GACxB,MAAM,IAAI,8BAA8B,iBAAiB,KAAK;EAGhE,MAAM;CACR;CAEA,IAAI;EACF,cAAc,sBAAsB;CACtC,SAAS,OAAO;EACd,MAAM,IAAI,8BAA8B,kBAAkB,KAAK;CACjE;CAEA,OAAO;AACT;AAEA,SAAS,WAAW,OAAuB;CACzC,OAAO,MACJ,QAAQ,MAAM,OAAO,CAAC,CACtB,QAAQ,MAAM,MAAM,CAAC,CACrB,QAAQ,MAAM,MAAM,CAAC,CACrB,QAAQ,MAAM,QAAQ;AAC3B;AAEA,SAAS,SAAS,OAAe,MAAoC;CACnE,MAAM,SAAS,KAAK,OAAO,KAAK;CAChC,MAAM,UAAU,KAAK,UAAU,YAAY;CAE3C,OAAO;EACL,MACE,MAAM,MAAM,SACX,KAAK,MACF,eAAe,WAAW,KAAK,GAAG,EAAE,IAAI,WAAW,KAAK,GAAG,EAAE,YAC7D,YAAY,WAAW,KAAK,KAAK,EAAE,gBACvC,2BAA2B,QAAQ;EACrC,MAAM,GAAG,MAAM,MAAM,OAAO,2BAA2B,QAAQ;CACjE;AACF;AAEA,SAAS,cACP,eACA,MACkB;CAClB,IAAI,SAAS,sBACX,OAAO,cAAc,mBAAmB;EACtC,MAAM;EACN,KAAK,CAAC,MAAM;EACZ,OAAO,UAAU;GACf,SAAS;GACT,GAAG,SAAS,iEAAiE,IAAI;EACnF;CACF,CAAC;CAGH,OAAO,cAAc,mBAAmB;EACtC,MAAM;EACN,KAAK,CAAC,MAAM;EACZ,OAAO,UAAU;GACf,SAAS;GACT,GAAG,SAAS,sCAAsC,IAAI;EACxD;CACF,CAAC;AACH;;;;;;;;;AAUA,eAAsB,yBACpB,MAC2B;CAC3B,MAAM,gBAAgB,MAAM,kBAAkB;CAE9C,MAAM,WACJ,SAAS,uBACL,WAAW,aAAa,aAAa,IACrC,WAAW,cAAc,aAAa;CAE5C,IAAI,UAAU,OAAO;CAErB,IAAI,eAAe,SAAS,IAAI,IAAI;CAEpC,IAAI,CAAC,cAAc;EACjB,eAAe,cAAc,eAAe,IAAI;EAChD,SAAS,IAAI,MAAM,YAAY;CACjC;CAEA,OAAO;AACT"}
@@ -42,7 +42,45 @@ declare enum AuthErrorCodes {
42
42
  * request's own origin or an entry in `auth.csrf.allowedOrigins`, or whose
43
43
  * request carried neither header at all.
44
44
  */
45
- CsrfOriginMismatch = "EC006"
45
+ CsrfOriginMismatch = "EC006",
46
+ // Error Code 006
47
+ /**
48
+ * Email Not Verified Error Code EC007
49
+ * EC007 = the authenticated user has not verified their email address —
50
+ * thrown by `requireVerifiedEmail()` as `EmailNotVerifiedError` (403).
51
+ */
52
+ EmailNotVerified = "EC007",
53
+ // Error Code 007
54
+ /**
55
+ * Invalid One-Time Token Error Code EC008
56
+ * EC008 = an email-verification or password-reset token that is unknown,
57
+ * issued for the other purpose, expired, or already used. One code for all
58
+ * four on purpose: the caller learns nothing about which it was.
59
+ */
60
+ InvalidOneTimeToken = "EC008",
61
+ // Error Code 008
62
+ /**
63
+ * Invalid Provider Callback Error Code EC009
64
+ * EC009 = a redirect-provider callback was rejected: missing, forged or
65
+ * expired state cookie, `state` mismatch, failed code exchange, or an
66
+ * id_token with a bad signature, issuer, audience, expiry or nonce.
67
+ */
68
+ InvalidProviderCallback = "EC009",
69
+ // Error Code 009
70
+ /**
71
+ * Provider Email Not Verified Error Code EC010
72
+ * EC010 = the provider did not verify the email, so auth refused to link or
73
+ * create an account from it.
74
+ */
75
+ ProviderEmailNotVerified = "EC010",
76
+ // Error Code 010
77
+ /**
78
+ * Invalid Passkey Error Code EC011
79
+ * EC011 = a passkey ceremony was rejected: unknown, expired or used
80
+ * challenge, unknown credential, failed verification, or a signature counter
81
+ * that did not advance.
82
+ */
83
+ InvalidPasskey = "EC011"
46
84
  }
47
85
  //#endregion
48
86
  export { AuthErrorCodes };
@@ -1 +1 @@
1
- {"version":3,"file":"auth-error-codes.d.mts","names":[],"sources":["../../../../../../../auth/src/utils/auth-error-codes.ts"],"mappings":";aAAY,cAAA;EAAA;;;;EAKV,kBAAA;EAAA;EAKA;;;;EAAA,kBAAA;EAAA;EA4BkB;AAAA;;;EAvBlB,YAAA;EAAA;;;;;;EAMA,eAAA;EAAA;;;;;;;;;EASA,gBAAA;EAAA;;;;;;;;EAQA,kBAAA;AAAA"}
1
+ {"version":3,"file":"auth-error-codes.d.mts","names":[],"sources":["../../../../../../../auth/src/utils/auth-error-codes.ts"],"mappings":";aAAY,cAAA;EAAA;;;;EAKV,kBAAA;EAAA;EAKA;;;;EAAA,kBAAA;EAAA;EAkCA;;;;EA7BA,YAAA;EAAA;EAwDc;AAAA;;;;EAlDd,eAAA;EAAA;;;;;;;;;EASA,gBAAA;EAAA;;;;;;;;EAQA,kBAAA;EAAA;;;;;;EAMA,gBAAA;EAAA;;;;;;;EAOA,mBAAA;EAAA;;;;;;;EAOA,uBAAA;EAAA;;;;;;EAMA,wBAAA;EAAA;;;;;;;EAOA,cAAA;AAAA"}
@@ -38,6 +38,39 @@ let AuthErrorCodes = /* @__PURE__ */ function(AuthErrorCodes) {
38
38
  * request carried neither header at all.
39
39
  */
40
40
  AuthErrorCodes["CsrfOriginMismatch"] = "EC006";
41
+ /**
42
+ * Email Not Verified Error Code EC007
43
+ * EC007 = the authenticated user has not verified their email address —
44
+ * thrown by `requireVerifiedEmail()` as `EmailNotVerifiedError` (403).
45
+ */
46
+ AuthErrorCodes["EmailNotVerified"] = "EC007";
47
+ /**
48
+ * Invalid One-Time Token Error Code EC008
49
+ * EC008 = an email-verification or password-reset token that is unknown,
50
+ * issued for the other purpose, expired, or already used. One code for all
51
+ * four on purpose: the caller learns nothing about which it was.
52
+ */
53
+ AuthErrorCodes["InvalidOneTimeToken"] = "EC008";
54
+ /**
55
+ * Invalid Provider Callback Error Code EC009
56
+ * EC009 = a redirect-provider callback was rejected: missing, forged or
57
+ * expired state cookie, `state` mismatch, failed code exchange, or an
58
+ * id_token with a bad signature, issuer, audience, expiry or nonce.
59
+ */
60
+ AuthErrorCodes["InvalidProviderCallback"] = "EC009";
61
+ /**
62
+ * Provider Email Not Verified Error Code EC010
63
+ * EC010 = the provider did not verify the email, so auth refused to link or
64
+ * create an account from it.
65
+ */
66
+ AuthErrorCodes["ProviderEmailNotVerified"] = "EC010";
67
+ /**
68
+ * Invalid Passkey Error Code EC011
69
+ * EC011 = a passkey ceremony was rejected: unknown, expired or used
70
+ * challenge, unknown credential, failed verification, or a signature counter
71
+ * that did not advance.
72
+ */
73
+ AuthErrorCodes["InvalidPasskey"] = "EC011";
41
74
  return AuthErrorCodes;
42
75
  }({});
43
76
 
@@ -1 +1 @@
1
- {"version":3,"file":"auth-error-codes.mjs","names":[],"sources":["../../../../../../../auth/src/utils/auth-error-codes.ts"],"sourcesContent":["export enum AuthErrorCodes {\r\n /**\r\n * Missing Access Token Error Code EC001\r\n * EC001 = Missing Access Token\r\n */\r\n MissingAccessToken = \"EC001\", // Error Code 001\r\n /**\r\n * Invalid Access Token Error Code EC002\r\n * EC002 = Invalid Access Token\r\n */\r\n InvalidAccessToken = \"EC002\", // Error Code 002\r\n /**\r\n * Unauthorized Error Code EC003\r\n * EC003 = Unauthorized\r\n */\r\n Unauthorized = \"EC003\", // Error Code 003\r\n /**\r\n * Too Many Attempts Error Code EC004\r\n * EC004 = Too Many Attempts — issued by the login-throttle middleware once\r\n * a per-account or per-IP failure counter trips its threshold.\r\n */\r\n TooManyAttempts = \"EC004\", // Error Code 004\r\n /**\r\n * Invalid Token Type Error Code EC005\r\n * EC005 = Invalid Token Type — the token's `tokenType` claim does not match\r\n * what the caller expects (e.g. a refresh token presented where an access\r\n * token is required, or vice versa). Carried on the `code` property of the\r\n * `Error` thrown by `assertTokenType` (`services/jwt.ts`) so callers can\r\n * classify it as a credential failure without matching on the message.\r\n */\r\n InvalidTokenType = \"EC005\", // Error Code 005\r\n /**\r\n * CSRF Origin Mismatch Error Code EC006\r\n * EC006 = a cookie-authenticated, unsafe-method (POST/PUT/PATCH/DELETE)\r\n * request whose `Origin` (or, absent that, `Referer`) did not match the\r\n * request's own origin or an entry in `auth.csrf.allowedOrigins`, or whose\r\n * request carried neither header at all.\r\n */\r\n CsrfOriginMismatch = \"EC006\", // Error Code 006\r\n}\r\n"],"mappings":";AAAA,IAAY,iBAAL;;;;;CAKL;;;;;CAKA;;;;;CAKA;;;;;;CAMA;;;;;;;;;CASA;;;;;;;;CAQA;;AACF"}
1
+ {"version":3,"file":"auth-error-codes.mjs","names":[],"sources":["../../../../../../../auth/src/utils/auth-error-codes.ts"],"sourcesContent":["export enum AuthErrorCodes {\r\n /**\r\n * Missing Access Token Error Code EC001\r\n * EC001 = Missing Access Token\r\n */\r\n MissingAccessToken = \"EC001\", // Error Code 001\r\n /**\r\n * Invalid Access Token Error Code EC002\r\n * EC002 = Invalid Access Token\r\n */\r\n InvalidAccessToken = \"EC002\", // Error Code 002\r\n /**\r\n * Unauthorized Error Code EC003\r\n * EC003 = Unauthorized\r\n */\r\n Unauthorized = \"EC003\", // Error Code 003\r\n /**\r\n * Too Many Attempts Error Code EC004\r\n * EC004 = Too Many Attempts — issued by the login-throttle middleware once\r\n * a per-account or per-IP failure counter trips its threshold.\r\n */\r\n TooManyAttempts = \"EC004\", // Error Code 004\r\n /**\r\n * Invalid Token Type Error Code EC005\r\n * EC005 = Invalid Token Type — the token's `tokenType` claim does not match\r\n * what the caller expects (e.g. a refresh token presented where an access\r\n * token is required, or vice versa). Carried on the `code` property of the\r\n * `Error` thrown by `assertTokenType` (`services/jwt.ts`) so callers can\r\n * classify it as a credential failure without matching on the message.\r\n */\r\n InvalidTokenType = \"EC005\", // Error Code 005\r\n /**\r\n * CSRF Origin Mismatch Error Code EC006\r\n * EC006 = a cookie-authenticated, unsafe-method (POST/PUT/PATCH/DELETE)\r\n * request whose `Origin` (or, absent that, `Referer`) did not match the\r\n * request's own origin or an entry in `auth.csrf.allowedOrigins`, or whose\r\n * request carried neither header at all.\r\n */\r\n CsrfOriginMismatch = \"EC006\", // Error Code 006\r\n /**\r\n * Email Not Verified Error Code EC007\r\n * EC007 = the authenticated user has not verified their email address —\r\n * thrown by `requireVerifiedEmail()` as `EmailNotVerifiedError` (403).\r\n */\r\n EmailNotVerified = \"EC007\", // Error Code 007\r\n /**\r\n * Invalid One-Time Token Error Code EC008\r\n * EC008 = an email-verification or password-reset token that is unknown,\r\n * issued for the other purpose, expired, or already used. One code for all\r\n * four on purpose: the caller learns nothing about which it was.\r\n */\r\n InvalidOneTimeToken = \"EC008\", // Error Code 008\r\n /**\r\n * Invalid Provider Callback Error Code EC009\r\n * EC009 = a redirect-provider callback was rejected: missing, forged or\r\n * expired state cookie, `state` mismatch, failed code exchange, or an\r\n * id_token with a bad signature, issuer, audience, expiry or nonce.\r\n */\r\n InvalidProviderCallback = \"EC009\", // Error Code 009\r\n /**\r\n * Provider Email Not Verified Error Code EC010\r\n * EC010 = the provider did not verify the email, so auth refused to link or\r\n * create an account from it.\r\n */\r\n ProviderEmailNotVerified = \"EC010\", // Error Code 010\r\n /**\r\n * Invalid Passkey Error Code EC011\r\n * EC011 = a passkey ceremony was rejected: unknown, expired or used\r\n * challenge, unknown credential, failed verification, or a signature counter\r\n * that did not advance.\r\n */\r\n InvalidPasskey = \"EC011\", // Error Code 011\r\n}\r\n"],"mappings":";AAAA,IAAY,iBAAL;;;;;CAKL;;;;;CAKA;;;;;CAKA;;;;;;CAMA;;;;;;;;;CASA;;;;;;;;CAQA;;;;;;CAMA;;;;;;;CAOA;;;;;;;CAOA;;;;;;CAMA;;;;;;;CAOA;;AACF"}
@@ -1,4 +1,5 @@
1
1
  import { isNeverExpiring, isUsableExpiry, tokenHasExpClaim } from "./token-expiry.mjs";
2
2
  import { AuthErrorCodes } from "./auth-error-codes.mjs";
3
+ import { safeEqual } from "./safe-equal.mjs";
3
4
 
4
5
  export { };
@@ -0,0 +1,6 @@
1
+ //#region ../auth/src/utils/safe-equal.d.ts
2
+ /** Constant-time string equality; strings of unequal length are simply unequal. */
3
+ declare function safeEqual(left: string, right: string): boolean;
4
+ //#endregion
5
+ export { safeEqual };
6
+ //# sourceMappingURL=safe-equal.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"safe-equal.d.mts","names":[],"sources":["../../../../../../../auth/src/utils/safe-equal.ts"],"mappings":";;iBAGgB,SAAA,CAAU,IAAA,UAAc,KAAa"}
@@ -0,0 +1,13 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+
3
+ //#region ../auth/src/utils/safe-equal.ts
4
+ /** Constant-time string equality; strings of unequal length are simply unequal. */
5
+ function safeEqual(left, right) {
6
+ const a = Buffer.from(left);
7
+ const b = Buffer.from(right);
8
+ return a.length === b.length && timingSafeEqual(a, b);
9
+ }
10
+
11
+ //#endregion
12
+ export { safeEqual };
13
+ //# sourceMappingURL=safe-equal.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"safe-equal.mjs","names":[],"sources":["../../../../../../../auth/src/utils/safe-equal.ts"],"sourcesContent":["import { timingSafeEqual } from \"node:crypto\";\n\n/** Constant-time string equality; strings of unequal length are simply unequal. */\nexport function safeEqual(left: string, right: string): boolean {\n const a = Buffer.from(left);\n const b = Buffer.from(right);\n\n return a.length === b.length && timingSafeEqual(a, b);\n}\n"],"mappings":";;;;AAGA,SAAgB,UAAU,MAAc,OAAwB;CAC9D,MAAM,IAAI,OAAO,KAAK,IAAI;CAC1B,MAAM,IAAI,OAAO,KAAK,KAAK;CAE3B,OAAO,EAAE,WAAW,EAAE,UAAU,gBAAgB,GAAG,CAAC;AACtD"}
package/llms-full.txt CHANGED
@@ -553,6 +553,164 @@ Full event list: `login.attempt`, `login.success`, `login.failed`, `logout`, `lo
553
553
  - [`@warlock.js/auth/protect-routes/SKILL.md`](@warlock.js/auth/protect-routes/SKILL.md) — where the access token gets consumed
554
554
 
555
555
 
556
+ ## login-with-providers `@warlock.js/auth/login-with-providers/SKILL.md`
557
+
558
+ ---
559
+ name: login-with-providers
560
+ description: 'Log in without a password — Google (OIDC code + PKCE + state + nonce, id_token verified with `jose`), passkeys (WebAuthn via `@simplewebauthn/server`) and phone one-time codes (delivered through a `@warlock.js/notifications` channel). Every method ends in `authService.completeLogin(user)`, the same tokens, rows and events as password login. `startProviderLogin`, `completeProviderLogin`, `generatePasskeyRegistrationOptions`, `verifyPasskeyRegistration`, `generatePasskeyAuthenticationOptions`, `verifyPasskeyAuthentication`, `requestOtp`, `verifyOtp`, `otpRequestThrottleMiddleware`, `otpVerifyThrottleMiddleware`, `AuthProvider`. Triggers: "login with Google", "sign in with Google", "OAuth", "OIDC", "passkeys", "WebAuthn", "passwordless", "SMS code login", "WhatsApp OTP", "phone login", `warlock add auth-google`, `warlock add auth-passkeys`, `AuthProviderSdkMissingError`, `InvalidProviderCallbackError`, `ProviderEmailNotVerifiedError`, `InvalidPasskeyError`, `EC009`, `EC010`, `EC011`, `auth.providers`, `auth.passkeys`, `auth.otp`; typical import `import { completeProviderLogin, verifyOtp } from "@warlock.js/auth"`. Skip: password login — `@warlock.js/auth/handle-login-and-logout/SKILL.md`; email tokens — `@warlock.js/auth/verify-email-and-reset-password/SKILL.md`; defining an SMS channel — `@warlock.js/notifications/define-channel/SKILL.md`.'
561
+ ---
562
+
563
+ # Login with Google, passkeys, or a phone code
564
+
565
+ Services only, as with password login: you write the routes. Each `verify`/`complete` call returns the same `LoginResult` as `authService.login`, so the next step is the same too:
566
+
567
+ ```ts
568
+ const { user, tokens } = await completeProviderLogin(User, "google", request, response);
569
+ authService.setAuthCookie(response, tokens.accessToken); // cookie session, or return tokens as JSON
570
+ ```
571
+
572
+ `authService.completeLogin(user, deviceInfo?)` is that shared ending. It applies `auth.canAuthenticate` (403) and issues tokens. Call it only for a user you have already authenticated.
573
+
574
+ ## Install
575
+
576
+ | Method | Command | Installs |
577
+ | --- | --- | --- |
578
+ | Google | `warlock add auth-google` | `jose` |
579
+ | Passkeys | `warlock add auth-passkeys` | `@simplewebauthn/server` (add `@simplewebauthn/browser` to your client bundle) |
580
+ | Phone code | `warlock add notifications` + your own `sms`/`whatsapp` channel | nothing else. Auth ships no SMS/WhatsApp driver |
581
+
582
+ The SDKs are optional peers, loaded with `import()` only when a method runs. If one is missing, the call throws `AuthProviderSdkMissingError` (500), and the message names the `warlock add` command. Run the migrations: `authMigrations` now includes `provider_accounts` and `passkey_credentials`, and `one_time_tokens` gained an `attempts` column.
583
+
584
+ ## Google
585
+
586
+ ```ts
587
+ // src/config/auth.ts
588
+ providers: {
589
+ google: {
590
+ clientId: env("GOOGLE_CLIENT_ID"),
591
+ clientSecret: env("GOOGLE_CLIENT_SECRET"),
592
+ redirectUri: `${env("APP_URL")}/auth/google/callback`, // exactly as registered at Google
593
+ // scopes: ["openid", "email", "profile"],
594
+ },
595
+ // emailField: "email",
596
+ // createUser: async (profile, Model) => Model.create({ ... }),
597
+ },
598
+ ```
599
+
600
+ ```ts
601
+ router.get("/auth/google", async ({ response }) =>
602
+ response.redirect(await startProviderLogin(response, "google")),
603
+ );
604
+
605
+ router.get("/auth/google/callback", async ({ request, response }) => {
606
+ const { tokens } = await completeProviderLogin(User, "google", request, response);
607
+ authService.setAuthCookie(response, tokens.accessToken);
608
+ return response.redirect("/");
609
+ });
610
+ ```
611
+
612
+ Client side, the button is just a link. It must be a top-level navigation, not a `fetch`, so the state cookie goes out and comes back:
613
+
614
+ ```tsx
615
+ <a href="/auth/google">Continue with Google</a>
616
+ ```
617
+
618
+ - `state`, `nonce` and the PKCE verifier go into a signed, 10-minute, HttpOnly `auth_provider_state` cookie (SameSite=Lax, so it survives Google's redirect back). The callback clears the cookie, so a started login can complete only once.
619
+ - The callback rejects, with `InvalidProviderCallbackError` (400, `EC009`): a missing, forged or expired cookie, a `state` mismatch, a `?error=` from Google, a failed code exchange, and an id_token with a bad signature, `iss`, `aud`, `exp` or `nonce`. `error.reason` says which. The response message is generic.
620
+ - **Linking.** An existing `provider_accounts` row (provider plus Google `sub`) always decides the user. Without one, the Google email must be verified (`email_verified: true`), or the call throws `ProviderEmailNotVerifiedError` (403, `EC010`) and nothing is linked or created. A verified email links the matching user, or creates one with `{ email, name, emailVerifiedAt: now }`.
621
+ - Other OIDC providers: implement `AuthProvider` (`authorizationUrl(state)`, `handleCallback({ query, expected })`) and register it under `auth.providers.custom.<name>`.
622
+
623
+ ## Passkeys
624
+
625
+ ```ts
626
+ passkeys: { rpID: "example.com", rpName: "Example", origin: "https://example.com" },
627
+ ```
628
+
629
+ ```ts
630
+ // Register (logged in)
631
+ router.post("/auth/passkeys/register/options", async ({ request, response }) =>
632
+ response.success(await generatePasskeyRegistrationOptions(request.locals.user)),
633
+ { middleware: [authMiddleware("user")] });
634
+ router.post("/auth/passkeys/register", async ({ request, response }) => {
635
+ await verifyPasskeyRegistration(request, request.locals.user, request.input("credential"));
636
+ return response.success({ registered: true });
637
+ }, { middleware: [authMiddleware("user")] });
638
+
639
+ // Log in
640
+ router.post("/auth/passkeys/login/options", async ({ response }) =>
641
+ response.success(await generatePasskeyAuthenticationOptions()));
642
+ router.post("/auth/passkeys/login", async ({ request, response }) => {
643
+ const { tokens } = await verifyPasskeyAuthentication(request, request.input("credential"));
644
+ authService.setAuthCookie(response, tokens.accessToken);
645
+ return response.success({ ok: true });
646
+ });
647
+ ```
648
+
649
+ Browser:
650
+
651
+ ```ts
652
+ import { startAuthentication, startRegistration } from "@simplewebauthn/browser";
653
+
654
+ const post = (url: string, body?: unknown) =>
655
+ fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) })
656
+ .then((r) => r.json());
657
+
658
+ // register
659
+ const regOptions = await post("/auth/passkeys/register/options");
660
+ await post("/auth/passkeys/register", { credential: await startRegistration({ optionsJSON: regOptions }) });
661
+
662
+ // log in
663
+ const authOptions = await post("/auth/passkeys/login/options");
664
+ await post("/auth/passkeys/login", { credential: await startAuthentication({ optionsJSON: authOptions }) });
665
+ ```
666
+
667
+ (Unwrap `response.success`'s envelope if your app wraps payloads.)
668
+
669
+ - Challenges live in `one_time_tokens` as SHA-256 hashes, expire after 5m (`auth.passkeys.challengeExpiresIn`), and are **consumed before verification**. A replayed challenge fails, and so does one whose first verification failed.
670
+ - The request `Origin` must be one of `auth.passkeys.origin`. The same list is checked against the signed `clientDataJSON`.
671
+ - The signature counter must advance. A counter that did not move past the stored value (when either is non-zero) is rejected as a cloned authenticator. The new counter is saved with a compare-and-set.
672
+ - Every rejection throws `InvalidPasskeyError` (400, `EC011`) with a `reason`.
673
+
674
+ ## Phone code (OTP)
675
+
676
+ ```ts
677
+ otp: { channel: "sms" /* or "whatsapp" */, phoneField: "phone", expiresIn: "5m", maxAttempts: 5 },
678
+ ```
679
+
680
+ ```ts
681
+ router.post("/auth/otp/request", async ({ request, response }) => {
682
+ await requestOtp(User, request.input("phone"), { channel: request.input("via") === "whatsapp" ? "whatsapp" : "sms" });
683
+ return response.success({ message: "If that number is registered, a code is on its way." });
684
+ }, { middleware: [otpRequestThrottleMiddleware()] }); // 3 / 1h per phone + IP
685
+
686
+ router.post("/auth/otp/verify", async ({ request, response }) => {
687
+ const { tokens } = await verifyOtp(User, request.input("phone"), request.input("code"));
688
+ authService.setAuthCookie(response, tokens.accessToken);
689
+ return response.success({ ok: true });
690
+ }, { middleware: [otpVerifyThrottleMiddleware()] }); // 5 failures / 15m per phone + IP
691
+ ```
692
+
693
+ - Codes have 6 digits and are stored in `one_time_tokens` (purpose `otp`) as a salted HMAC keyed from your access-token secret, never as plain SHA-256, which is trivially reversible for 6 digits. A new request invalidates the previous code.
694
+ - Every verify counts an attempt atomically first. After `maxAttempts` the code is invalidated, even if the right code comes next, and even under concurrent guesses.
695
+ - Delivery: `notify.channel(channel).send(phone, { body, code, expiresAt })`. Register that channel in `config/notifications.ts`, or set `auth.otp.send(phone, message, channel)`. `auth.otp.message(code)` changes the text.
696
+ - **Anti-enumeration:** an unknown phone gets the same `requestOtp` result (nothing sent) and the same `InvalidOneTimeTokenError` (400, `EC008`) from `verifyOtp`.
697
+ - CSRF: `verifyOtp` is a POST. If the route uses cookie auth, the 5.12 Origin check applies. Otherwise there is no ambient credential to ride.
698
+
699
+ ## Gotchas
700
+
701
+ - `completeProviderLogin` needs the **same** response object to clear the state cookie. Do not pass a different one.
702
+ - One provider identity links to one account, of one user type.
703
+ - `auth.cleanup` hard-deletes expired and consumed `one_time_tokens` rows (challenges and codes included).
704
+ - The passkey specs run the real `@simplewebauthn/server` against a software ES256 authenticator, and the consume-once, OTP attempt cap, counter compare-and-set and provider-link uniqueness are proven on real MongoDB and Postgres. Google's token endpoint is still mocked, so test a real Google client before you ship.
705
+ - A counter the SDK rejects is reported with `reason: "counter-regression"`, the same as auth's own check.
706
+
707
+ ## See also
708
+
709
+ - [`@warlock.js/auth/handle-login-and-logout/SKILL.md`](@warlock.js/auth/handle-login-and-logout/SKILL.md): password login and `setAuthCookie`
710
+ - [`@warlock.js/auth/throttle-login-attempts/SKILL.md`](@warlock.js/auth/throttle-login-attempts/SKILL.md): the throttle the OTP presets wrap
711
+ - [`@warlock.js/notifications/define-channel/SKILL.md`](@warlock.js/notifications/define-channel/SKILL.md): an SMS/WhatsApp channel
712
+
713
+
556
714
  ## manage-tokens `@warlock.js/auth/manage-tokens/SKILL.md`
557
715
 
558
716
  ---
@@ -677,10 +835,16 @@ When issuing a new refresh token, the service counts active tokens for the user
677
835
 
678
836
  ```ts
679
837
  const cleaned = await authService.cleanupExpiredTokens();
680
- // Returns: number of expired refresh tokens removed (also purges expired access-token rows).
838
+ // Returns: number of expired refresh tokens removed (also purges expired access-token rows
839
+ // and hard-deletes expired or consumed one_time_tokens rows).
681
840
  // Fires "token.expired" event per token + "cleanup.completed" with the count.
682
841
  ```
683
842
 
843
+ Every purge is a hard delete on every driver, including MongoDB. `AccessToken`, `RefreshToken`,
844
+ `OneTimeToken` and `PasskeyCredential` all declare `static deleteStrategy = "permanent"`, so a
845
+ purge never copies credential material into a `*Trash` collection despite MongoDB's driver
846
+ default being `"trash"`.
847
+
684
848
  Run this periodically via the scheduler:
685
849
 
686
850
  ```ts
@@ -1156,22 +1320,20 @@ Without this, `JSON.stringify(user)` in your response leaks the hash.
1156
1320
 
1157
1321
  ## Email verification flow (extending registration)
1158
1322
 
1159
- Common pattern: create the user as `email_verified = false`, send a verification email, mark verified on click. The auth package doesn't ship this; build it on top:
1323
+ Since 5.13 auth ships this. Declare `emailVerifiedAt: v.date().optional()` on the schema, then send the verification after creating the user:
1160
1324
 
1161
1325
  ```ts
1162
- const user = await User.create({
1163
- ...data,
1164
- email_verified: false,
1165
- verification_token: Random.string(64),
1166
- });
1326
+ import { authService, sendEmailVerification } from "@warlock.js/auth";
1327
+
1328
+ const user = await User.create({ ...data, password: await hashPassword(data.password) });
1167
1329
 
1168
- await mailer.sendVerificationEmail(user.get("email"), user.get("verification_token"));
1330
+ await sendEmailVerification(user); // hashed, single-use, 24h token via @warlock.js/notifications
1169
1331
 
1170
1332
  const tokens = await authService.createTokenPair(user);
1171
1333
  return response.successCreate({ user, tokens });
1172
1334
  ```
1173
1335
 
1174
- Optional: pre-verification, restrict the user to a `unverified` user-type and gate routes accordingly via `authMiddleware("user")`. After verification, swap user-type to `user`.
1336
+ Gate routes that need a confirmed address with `requireVerifiedEmail()` after `authMiddleware`. See [`@warlock.js/auth/verify-email-and-reset-password/SKILL.md`](@warlock.js/auth/verify-email-and-reset-password/SKILL.md).
1175
1337
 
1176
1338
  ## Side effects via auth events
1177
1339
 
@@ -1254,7 +1416,7 @@ Run it once when setting up a new project. Each developer typically runs it loca
1254
1416
  npx warlock auth.cleanup
1255
1417
  ```
1256
1418
 
1257
- Runs `authService.cleanupExpiredTokens()` — deletes every refresh token whose `expires_at` has passed, then sweeps expired access-token rows too. Fires `token.expired` per refresh token and `cleanup.completed` once.
1419
+ Runs `authService.cleanupExpiredTokens()` — deletes every refresh token whose `expires_at` has passed, then sweeps expired access-token rows and hard-deletes expired or consumed `one_time_tokens` rows (verification/reset tokens, OTP codes, passkey challenges). Fires `token.expired` per refresh token and `cleanup.completed` once.
1258
1420
 
1259
1421
  Schedule it periodically. Two common shapes:
1260
1422
 
@@ -1472,3 +1634,144 @@ They compose. For a hard ceiling on request volume *and* failure-aware account l
1472
1634
  - [`@warlock.js/core/use-middleware/SKILL.md`](@warlock.js/core/use-middleware/SKILL.md) — `middleware.rateLimit` and the built-in middleware suite.
1473
1635
 
1474
1636
 
1637
+ ## verify-email-and-reset-password `@warlock.js/auth/verify-email-and-reset-password/SKILL.md`
1638
+
1639
+ ---
1640
+ name: verify-email-and-reset-password
1641
+ description: 'Email verification and password reset with hashed, single-use, expiring, purpose-bound tokens delivered through `@warlock.js/notifications` — `sendEmailVerification(user)`, `verifyEmail(token)`, `requestPasswordReset(Model, email)`, `resetPassword(token, newPassword)`, the `requireVerifiedEmail()` guard, and the `tokenIssueThrottleMiddleware` / `tokenConsumeThrottleMiddleware` presets. Reset revokes every access token, refresh token and cookie session; a reset request answers the same for unknown emails. Triggers: `sendEmailVerification`, `verifyEmail`, `requestPasswordReset`, `resetPassword`, `requireVerifiedEmail`, `isEmailVerified`, `OneTimeToken`, `InvalidOneTimeTokenError`, `EmailNotVerifiedError`, `NotificationsUnavailableError`, `EC007`, `EC008`, `auth.verification`, `auth.passwordReset`; "verify email", "confirm email address", "forgot password", "reset password link", "block unverified users", "resend verification email"; typical import `import { requestPasswordReset, resetPassword } from "@warlock.js/auth"`. Skip: sign-up itself — `@warlock.js/auth/register-user/SKILL.md`; login throttling — `@warlock.js/auth/throttle-login-attempts/SKILL.md`; defining notifications — `@warlock.js/notifications/define-notification/SKILL.md`.'
1642
+ ---
1643
+
1644
+ # Verify email + reset password
1645
+
1646
+ Four service functions, one guard, two throttle presets. Auth ships no controllers — wire the routes yourself (examples below).
1647
+
1648
+ ## Prerequisites
1649
+
1650
+ 1. **`@warlock.js/notifications` installed and configured with a `mail` channel.** It is an optional peer; without it (or without `src/config/notifications.ts`) every send/request call throws `NotificationsUnavailableError` **before** a token is issued — never a silent skip.
1651
+ 2. **The `one_time_tokens` table.** It ships in `authMigrations` next to the access/refresh tables; run your migrations.
1652
+ 3. **A verified-date field on the user schema** (default `emailVerifiedAt`):
1653
+
1654
+ ```ts
1655
+ export const userSchema = v.object({
1656
+ // ...
1657
+ emailVerifiedAt: v.date().optional(),
1658
+ });
1659
+ ```
1660
+
1661
+ If the schema strips it, `verifyEmail` throws naming the field instead of pretending it worked.
1662
+
1663
+ ## Tokens
1664
+
1665
+ - 32 random bytes, base64url. Only the **SHA-256 hash** is stored; the raw token exists only in the notification.
1666
+ - **Single use**, consumed with a conditional update — of two concurrent uses, exactly one succeeds.
1667
+ - **Expiring**: verification `24h`, reset `60m` by default.
1668
+ - **Purpose-bound**: a verification token is never accepted by `resetPassword`, and vice versa.
1669
+ - A new reset request **invalidates the user's earlier unused reset tokens**. Verification tokens are not invalidated by a resend.
1670
+ - Unknown, wrong-purpose, expired and used tokens all throw one `InvalidOneTimeTokenError` (`400`, `EC008`).
1671
+
1672
+ ## Email verification
1673
+
1674
+ ```ts
1675
+ import { requireVerifiedEmail, sendEmailVerification, verifyEmail } from "@warlock.js/auth";
1676
+ import { type RequestHandler } from "@warlock.js/core";
1677
+
1678
+ // after User.create(...) in your register controller
1679
+ await sendEmailVerification(user);
1680
+
1681
+ export const verifyEmailController: RequestHandler = async ({ request, response }) => {
1682
+ const user = await verifyEmail(request.input("token")); // throws InvalidOneTimeTokenError
1683
+ return response.success({ user });
1684
+ };
1685
+
1686
+ router.post("/auth/verify-email", verifyEmailController, {
1687
+ middleware: [tokenConsumeThrottleMiddleware()],
1688
+ });
1689
+
1690
+ router.post("/auth/verify-email/resend", resendController, {
1691
+ middleware: [authMiddleware("user"), tokenIssueThrottleMiddleware({ by: ["ip"] })],
1692
+ });
1693
+
1694
+ // Opt-in guard, AFTER authMiddleware: 403 EmailNotVerifiedError (EC007)
1695
+ router.post("/orders", createOrder, {
1696
+ middleware: [authMiddleware("user"), requireVerifiedEmail()],
1697
+ });
1698
+ ```
1699
+
1700
+ `isEmailVerified(user)` answers the same question in code.
1701
+
1702
+ ## Password reset
1703
+
1704
+ ```ts
1705
+ import { requestPasswordReset, resetPassword } from "@warlock.js/auth";
1706
+
1707
+ export const forgotPasswordController: RequestHandler = async ({ request, response }) => {
1708
+ await requestPasswordReset(User, request.input("email"));
1709
+ // Same answer whether or not the account exists.
1710
+ return response.success({ message: "If that account exists, we sent a reset link." });
1711
+ };
1712
+
1713
+ export const resetPasswordController: RequestHandler = async ({ request, response }) => {
1714
+ // Validate strength FIRST — the token is consumed before the password is written.
1715
+ await resetPassword(request.input("token"), request.input("password"));
1716
+ return response.success({ message: "Password updated. Please log in again." });
1717
+ };
1718
+
1719
+ router.post("/auth/forgot-password", forgotPasswordController, {
1720
+ middleware: [tokenIssueThrottleMiddleware()], // 3 / 1h per email + per IP
1721
+ });
1722
+ router.post("/auth/reset-password", resetPasswordController, {
1723
+ middleware: [tokenConsumeThrottleMiddleware()], // 10 failures / 15m per IP
1724
+ });
1725
+ ```
1726
+
1727
+ `resetPassword` writes the password, then calls `authService.revokeAllTokens(user)`: every refresh token is revoked and every access token deleted, which also kills cookie sessions (a cookie holds an access token). It emits `password.reset`; `requestPasswordReset` emits `password.resetRequested`.
1728
+
1729
+ **Password hashing.** By default auth saves `hashPassword(plain)` and checks it with `verifyPassword`, the same check login uses. If your model hashes on save (`useHashedPassword()`), that first write gets hashed twice and fails the check, so auth saves the plaintext and lets your transformer hash it once. If neither write passes the check, it throws. Plaintext is never the first thing written. To write it yourself, set `auth.passwordReset.setPassword`.
1730
+
1731
+ ## Throttling
1732
+
1733
+ Both presets wrap `loginThrottleMiddleware` and accept its options:
1734
+
1735
+ | Preset | Tracks | Counts | Default |
1736
+ | --- | --- | --- | --- |
1737
+ | `tokenIssueThrottleMiddleware` | email + IP | **every** request (a reset request's success is the anti-enumeration answer, so failure-aware counting would never trip) | 3 / 1h, 1h lock |
1738
+ | `tokenConsumeThrottleMiddleware` | IP | failures only, cleared on success | 10 / 15m, 15m lock |
1739
+
1740
+ ## Configuration (`src/config/auth.ts`)
1741
+
1742
+ ```ts
1743
+ const authConfigurations: AuthConfigurations = {
1744
+ // ...
1745
+ verification: {
1746
+ expiresIn: "24h",
1747
+ field: "emailVerifiedAt",
1748
+ url: (token) => `${env("APP_URL")}/verify-email?token=${token}`,
1749
+ notification: myVerificationNotification, // optional replacement
1750
+ },
1751
+ passwordReset: {
1752
+ expiresIn: "60m",
1753
+ identifierField: "email",
1754
+ url: (token) => `${env("APP_URL")}/reset-password?token=${token}`,
1755
+ notification: myResetNotification,
1756
+ setPassword: async (user, plain) => { /* optional */ },
1757
+ },
1758
+ oneTimeToken: { model: AppOneTimeToken }, // optional model override
1759
+ };
1760
+ ```
1761
+
1762
+ The default notifications are `defineNotification` objects (`type: "auth.email-verification"` / `"auth.password-reset"`, `via: ["mail"]`). To replace one, pass anything with a `send(user, { token, expiresAt, url? })` method, for example your own `defineNotification<OneTimeTokenNotificationData>({...})`. `notifications` still has to be installed and configured.
1763
+
1764
+ ## Gotchas
1765
+
1766
+ - **Without `url` config, the email contains the raw token**, not a link. Set `url` for anything user-facing.
1767
+ - **Timing isn't equalised.** An unknown email skips the database write and the send, so the response can come back measurably faster. The throttle limits probing, but it doesn't hide the timing difference.
1768
+ - **Channel send failures don't throw.** `@warlock.js/notifications` only rethrows configuration errors (such as a missing `mail` channel). An SMTP failure goes to its `failed` event, so observe it there.
1769
+ - `auth.cleanup` hard-deletes expired and consumed `one_time_tokens` rows (never trashed, even on MongoDB).
1770
+
1771
+ ## See also
1772
+
1773
+ - [`@warlock.js/auth/register-user/SKILL.md`](@warlock.js/auth/register-user/SKILL.md) — where `sendEmailVerification` is usually called
1774
+ - [`@warlock.js/auth/throttle-login-attempts/SKILL.md`](@warlock.js/auth/throttle-login-attempts/SKILL.md) — the underlying throttle
1775
+ - [`@warlock.js/notifications/configure-notifications/SKILL.md`](@warlock.js/notifications/configure-notifications/SKILL.md) — mail channel setup
1776
+
1777
+
package/llms.txt CHANGED
@@ -10,9 +10,11 @@
10
10
  - [customize-token-storage](@warlock.js/auth/customize-token-storage/SKILL.md): (no description)
11
11
  - [customize-user-type](@warlock.js/auth/customize-user-type/SKILL.md): (no description)
12
12
  - [handle-login-and-logout](@warlock.js/auth/handle-login-and-logout/SKILL.md): (no description)
13
+ - [login-with-providers](@warlock.js/auth/login-with-providers/SKILL.md): Log in without a password — Google (OIDC code + PKCE + state + nonce, id_token verified with `jose`), passkeys (WebAuthn via `@simplewebauthn/server`) and phone one-time codes (delivered through a `@warlock.js/notifications` channel). Every method ends in `authService.completeLogin(user)`, the same tokens, rows and events as password login. `startProviderLogin`, `completeProviderLogin`, `generatePasskeyRegistrationOptions`, `verifyPasskeyRegistration`, `generatePasskeyAuthenticationOptions`, `verifyPasskeyAuthentication`, `requestOtp`, `verifyOtp`, `otpRequestThrottleMiddleware`, `otpVerifyThrottleMiddleware`, `AuthProvider`. Triggers: "login with Google", "sign in with Google", "OAuth", "OIDC", "passkeys", "WebAuthn", "passwordless", "SMS code login", "WhatsApp OTP", "phone login", `warlock add auth-google`, `warlock add auth-passkeys`, `AuthProviderSdkMissingError`, `InvalidProviderCallbackError`, `ProviderEmailNotVerifiedError`, `InvalidPasskeyError`, `EC009`, `EC010`, `EC011`, `auth.providers`, `auth.passkeys`, `auth.otp`; typical import `import { completeProviderLogin, verifyOtp } from "@warlock.js/auth"`. Skip: password login — `@warlock.js/auth/handle-login-and-logout/SKILL.md`; email tokens — `@warlock.js/auth/verify-email-and-reset-password/SKILL.md`; defining an SMS channel — `@warlock.js/notifications/define-channel/SKILL.md`.
13
14
  - [manage-tokens](@warlock.js/auth/manage-tokens/SKILL.md): (no description)
14
15
  - [overview](@warlock.js/auth/overview/SKILL.md): (no description)
15
16
  - [protect-routes](@warlock.js/auth/protect-routes/SKILL.md): (no description)
16
17
  - [register-user](@warlock.js/auth/register-user/SKILL.md): Sign up a new user and issue the initial token pair — User.create({...password: await hashPassword(plain)}) then authService.createTokenPair(user). Triggers: `User.create`, `hashPassword`, `verifyPassword`, `authService.createTokenPair`, `toJsonColumns`, `strongPassword`, `authEvents`; "build a register endpoint", "POST /register controller", "sign up a new user", "hash password on signup", "email verification flow"; typical import `import { authService } from "@warlock.js/auth"; import { hashPassword } from "@warlock.js/core"`. Skip: login — `@warlock.js/auth/handle-login-and-logout/SKILL.md`; token internals — `@warlock.js/auth/manage-tokens/SKILL.md`; competing libs `bcrypt`, `bcryptjs`, `argon2`.
17
18
  - [run-auth-commands](@warlock.js/auth/run-auth-commands/SKILL.md): Three bundled CLI commands — warlock jwt.generate (creates strong JWT secret + writes to .env), warlock auth.cleanup (removes expired refresh tokens), and warlock auth.purge-never-expiring (finds and revokes token rows that can never expire). Register via registerJWTSecretGeneratorCommand(), registerAuthCleanupCommand() and registerAuthPurgeNeverExpiringCommand(). Triggers: `registerJWTSecretGeneratorCommand`, `registerAuthCleanupCommand`, `registerAuthPurgeNeverExpiringCommand`, `warlock jwt.generate`, `warlock auth.cleanup`, `warlock auth.purge-never-expiring`, `cleanupExpiredTokens`, `purgeNeverExpiringTokens`, `command`; "token never expires", "no exp claim", "Invalid Date expires_at"; "generate JWT secret", "bootstrap .env JWT_SECRET", "cron job for expired tokens", "schedule auth cleanup"; typical import `import { registerJWTSecretGeneratorCommand, registerAuthCleanupCommand, registerAuthPurgeNeverExpiringCommand } from "@warlock.js/auth"`. Skip: programmatic cleanup — `@warlock.js/auth/manage-tokens/SKILL.md`; in-process scheduling — `@warlock.js/scheduler/scheduler-basics/SKILL.md`; competing tools `dotenv-cli`, `node-cron`.
18
19
  - [throttle-login-attempts](@warlock.js/auth/throttle-login-attempts/SKILL.md): (no description)
20
+ - [verify-email-and-reset-password](@warlock.js/auth/verify-email-and-reset-password/SKILL.md): Email verification and password reset with hashed, single-use, expiring, purpose-bound tokens delivered through `@warlock.js/notifications` — `sendEmailVerification(user)`, `verifyEmail(token)`, `requestPasswordReset(Model, email)`, `resetPassword(token, newPassword)`, the `requireVerifiedEmail()` guard, and the `tokenIssueThrottleMiddleware` / `tokenConsumeThrottleMiddleware` presets. Reset revokes every access token, refresh token and cookie session; a reset request answers the same for unknown emails. Triggers: `sendEmailVerification`, `verifyEmail`, `requestPasswordReset`, `resetPassword`, `requireVerifiedEmail`, `isEmailVerified`, `OneTimeToken`, `InvalidOneTimeTokenError`, `EmailNotVerifiedError`, `NotificationsUnavailableError`, `EC007`, `EC008`, `auth.verification`, `auth.passwordReset`; "verify email", "confirm email address", "forgot password", "reset password link", "block unverified users", "resend verification email"; typical import `import { requestPasswordReset, resetPassword } from "@warlock.js/auth"`. Skip: sign-up itself — `@warlock.js/auth/register-user/SKILL.md`; login throttling — `@warlock.js/auth/throttle-login-attempts/SKILL.md`; defining notifications — `@warlock.js/notifications/define-notification/SKILL.md`.