@0x0501/mystack 0.0.0-stage → 0.1.0-bootstrap.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 (211) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -2
  3. package/assets/auth.sqlite.schema.ts +479 -0
  4. package/assets/bun-sqlite.test.ts +46 -0
  5. package/assets/d1.test.ts +41 -0
  6. package/assets/drizzle.config.ts +2 -0
  7. package/assets/generate-auth-schema.mjs +25 -0
  8. package/assets/platform.sqlite.schema.ts +46 -0
  9. package/assets/polar-checkout.ts +10 -0
  10. package/assets/polar-route.ts +19 -0
  11. package/assets/polar.test.ts +30 -0
  12. package/assets/polar.ts +12 -0
  13. package/assets/purchase.bun-sqlite.ts +38 -0
  14. package/assets/purchase.sqlite.ts +39 -0
  15. package/assets/sqlite-migrations/0000_thin_phalanx.sql +295 -0
  16. package/assets/sqlite-migrations/meta/0000_snapshot.json +2058 -0
  17. package/assets/sqlite-migrations/meta/_journal.json +13 -0
  18. package/assets/turso.test.ts +40 -0
  19. package/package.json +44 -5
  20. package/src/adapters/database.mjs +448 -0
  21. package/src/adapters/deployment.mjs +101 -0
  22. package/src/adapters/email.mjs +75 -0
  23. package/src/adapters/payments.mjs +270 -0
  24. package/src/catalog.mjs +128 -0
  25. package/src/cli.mjs +294 -0
  26. package/src/generate.mjs +183 -0
  27. package/src/template.mjs +93 -0
  28. package/template/.env.docker +16 -0
  29. package/template/.env.example +67 -0
  30. package/template/DESIGN.md +58 -0
  31. package/template/LICENSE +21 -0
  32. package/template/biome.json +46 -0
  33. package/template/docker-compose.yml +47 -0
  34. package/template/docs/project-guide.md +61 -0
  35. package/template/drizzle/0000_create-app-role.sql +54 -0
  36. package/template/drizzle/0001_add-rls-context.sql +21 -0
  37. package/template/drizzle/0002_platform_tables.sql +320 -0
  38. package/template/drizzle/meta/0002_snapshot.json +2125 -0
  39. package/template/drizzle/meta/_journal.json +27 -0
  40. package/template/drizzle.config.ts +25 -0
  41. package/template/e2e/authenticated-ui.spec.ts +40 -0
  42. package/template/e2e/headless-design.spec.ts +66 -0
  43. package/template/e2e/navigation-loading.spec.ts +70 -0
  44. package/template/e2e/theme-responsive.spec.ts +260 -0
  45. package/template/gitignore +24 -0
  46. package/template/messages/de.json +212 -0
  47. package/template/messages/en.json +212 -0
  48. package/template/package.json +101 -0
  49. package/template/paraglide.config.ts +49 -0
  50. package/template/playwright.config.ts +58 -0
  51. package/template/project.inlang/settings.json +12 -0
  52. package/template/public/drizzle.svg +1 -0
  53. package/template/public/favicon.ico +0 -0
  54. package/template/public/logo192.png +0 -0
  55. package/template/public/logo512.png +0 -0
  56. package/template/public/manifest.json +25 -0
  57. package/template/public/robots.txt +3 -0
  58. package/template/scripts/check-client-bundle.ts +150 -0
  59. package/template/scripts/check-server-fns.ts +290 -0
  60. package/template/scripts/check-sql-raw.ts +50 -0
  61. package/template/scripts/compile-paraglide.ts +12 -0
  62. package/template/scripts/postgres/README.md +76 -0
  63. package/template/scripts/postgres/create-app-role.sh +60 -0
  64. package/template/scripts/seed-local.ts +331 -0
  65. package/template/scripts/validate-prod-env.ts +53 -0
  66. package/template/src/__tests__/README.md +9 -0
  67. package/template/src/__tests__/account-security.test.ts +664 -0
  68. package/template/src/__tests__/admin-http-surface.test.ts +311 -0
  69. package/template/src/__tests__/admin-peer-protection.test.ts +178 -0
  70. package/template/src/__tests__/auth-harness.ts +104 -0
  71. package/template/src/__tests__/auth-rate-limit.test.ts +43 -0
  72. package/template/src/__tests__/auth-redirect.test.ts +120 -0
  73. package/template/src/__tests__/body-limit.test.ts +47 -0
  74. package/template/src/__tests__/creem.test.ts +62 -0
  75. package/template/src/__tests__/demo-checkout.test.ts +141 -0
  76. package/template/src/__tests__/email-locale.test.ts +110 -0
  77. package/template/src/__tests__/global-setup.ts +80 -0
  78. package/template/src/__tests__/input-rules.test.ts +0 -0
  79. package/template/src/__tests__/message-parity.test.ts +38 -0
  80. package/template/src/__tests__/navigation-loading.test.tsx +252 -0
  81. package/template/src/__tests__/nowpayments.test.ts +106 -0
  82. package/template/src/__tests__/paraglide-url-contract.test.ts +87 -0
  83. package/template/src/__tests__/purchase-fulfillment.test.ts +204 -0
  84. package/template/src/__tests__/query-unauthorized-redirect.test.ts +95 -0
  85. package/template/src/__tests__/rls-policy.test.ts +120 -0
  86. package/template/src/__tests__/same-page-locale-href.test.ts +47 -0
  87. package/template/src/__tests__/search-params-oauth.test.ts +57 -0
  88. package/template/src/__tests__/security-headers.test.ts +12 -0
  89. package/template/src/__tests__/server-fn-error-contract.test.ts +173 -0
  90. package/template/src/__tests__/stripe.test.ts +84 -0
  91. package/template/src/__tests__/test-db.ts +32 -0
  92. package/template/src/__tests__/test-rls-db.ts +23 -0
  93. package/template/src/__tests__/user-update-guard.test.ts +109 -0
  94. package/template/src/components/LocaleSwitcher.tsx +79 -0
  95. package/template/src/components/ThemeToggle.tsx +47 -0
  96. package/template/src/components/auth/social.tsx +90 -0
  97. package/template/src/components/auth/turnstile.tsx +67 -0
  98. package/template/src/components/root/navigation-progress.tsx +32 -0
  99. package/template/src/components/root/not-found.tsx +20 -0
  100. package/template/src/components/root/pending.tsx +9 -0
  101. package/template/src/components/root/root-document.tsx +37 -0
  102. package/template/src/components/root/root-error.tsx +34 -0
  103. package/template/src/components/shells.tsx +201 -0
  104. package/template/src/components/ui/styles.ts +9 -0
  105. package/template/src/db/auth.schema.ts +455 -0
  106. package/template/src/db/helper.ts +38 -0
  107. package/template/src/db/index.ts +15 -0
  108. package/template/src/db/platform.schema.ts +122 -0
  109. package/template/src/db/schema.ts +2 -0
  110. package/template/src/env.ts +65 -0
  111. package/template/src/hooks/use-theme.ts +32 -0
  112. package/template/src/integrations/better-auth/header-user.tsx +36 -0
  113. package/template/src/integrations/better-auth/session-query.ts +11 -0
  114. package/template/src/integrations/react-email/PasswordOtpEmail.tsx +62 -0
  115. package/template/src/integrations/react-email/ResetPasswordEmail.tsx +64 -0
  116. package/template/src/integrations/react-email/VerifyEmail.tsx +72 -0
  117. package/template/src/integrations/react-email/theme.ts +75 -0
  118. package/template/src/integrations/tanstack-query/devtools.tsx +6 -0
  119. package/template/src/integrations/tanstack-query/root-provider.tsx +60 -0
  120. package/template/src/lib/auth-client.ts +27 -0
  121. package/template/src/lib/auth-paths.ts +94 -0
  122. package/template/src/lib/auth-plugins/existing-email-signup.ts +86 -0
  123. package/template/src/lib/auth-plugins/field-rules.ts +84 -0
  124. package/template/src/lib/auth-plugins/social-two-factor.ts +194 -0
  125. package/template/src/lib/auth-timing.ts +9 -0
  126. package/template/src/lib/auth.ts +279 -0
  127. package/template/src/lib/captcha.ts +11 -0
  128. package/template/src/lib/email.tsx +162 -0
  129. package/template/src/lib/github.ts +18 -0
  130. package/template/src/lib/locale-cookie.ts +12 -0
  131. package/template/src/lib/localized-pages.ts +25 -0
  132. package/template/src/lib/oauth-metadata.ts +19 -0
  133. package/template/src/lib/permissions.ts +29 -0
  134. package/template/src/lib/security-headers.ts +20 -0
  135. package/template/src/lib/site.ts +59 -0
  136. package/template/src/lib/theme.ts +42 -0
  137. package/template/src/lib/user-update-guard.ts +35 -0
  138. package/template/src/middlewares/admin.ts +46 -0
  139. package/template/src/middlewares/auth-rate-limit.ts +37 -0
  140. package/template/src/middlewares/better-auth.ts +15 -0
  141. package/template/src/middlewares/body-limit.ts +62 -0
  142. package/template/src/middlewares/database.ts +13 -0
  143. package/template/src/middlewares/protected.ts +45 -0
  144. package/template/src/routeTree.gen.ts +536 -0
  145. package/template/src/router.tsx +53 -0
  146. package/template/src/routes/[.]well-known/oauth-authorization-server/api/auth.ts +18 -0
  147. package/template/src/routes/[.]well-known/oauth-protected-resource/index.ts +32 -0
  148. package/template/src/routes/__root.tsx +175 -0
  149. package/template/src/routes/admin/audit.tsx +74 -0
  150. package/template/src/routes/admin/index.tsx +7 -0
  151. package/template/src/routes/admin/oauth-apps.tsx +164 -0
  152. package/template/src/routes/admin/route.tsx +27 -0
  153. package/template/src/routes/admin/users.tsx +215 -0
  154. package/template/src/routes/api/auth/$.ts +14 -0
  155. package/template/src/routes/api/auth/[.]well-known/openid-configuration.ts +15 -0
  156. package/template/src/routes/api/webhooks/creem.ts +69 -0
  157. package/template/src/routes/api/webhooks/nowpayments.ts +62 -0
  158. package/template/src/routes/api/webhooks/stripe.ts +74 -0
  159. package/template/src/routes/dashboard/account.tsx +430 -0
  160. package/template/src/routes/dashboard/index.tsx +128 -0
  161. package/template/src/routes/dashboard/route.tsx +17 -0
  162. package/template/src/routes/forgot-password.tsx +65 -0
  163. package/template/src/routes/index.tsx +85 -0
  164. package/template/src/routes/oauth/consent.tsx +77 -0
  165. package/template/src/routes/reset-password.tsx +78 -0
  166. package/template/src/routes/sign-in.tsx +174 -0
  167. package/template/src/routes/sign-up.tsx +135 -0
  168. package/template/src/routes/two-factor.tsx +74 -0
  169. package/template/src/server/README.md +3 -0
  170. package/template/src/server/account-security.ts +163 -0
  171. package/template/src/server/admin-audit.ts +37 -0
  172. package/template/src/server/admin-users.ts +112 -0
  173. package/template/src/server/auth.ts +44 -0
  174. package/template/src/server/demo-checkout.ts +67 -0
  175. package/template/src/server/oauth-clients.ts +83 -0
  176. package/template/src/server/payment-toggles.ts +39 -0
  177. package/template/src/server.ts +20 -0
  178. package/template/src/services/README.md +3 -0
  179. package/template/src/services/account-security.ts +893 -0
  180. package/template/src/services/admin-security.ts +164 -0
  181. package/template/src/services/admin-users.ts +181 -0
  182. package/template/src/services/auth-rate-limit.ts +32 -0
  183. package/template/src/services/creem.ts +83 -0
  184. package/template/src/services/demo-checkout.ts +100 -0
  185. package/template/src/services/nowpayments.ts +154 -0
  186. package/template/src/services/oauth-client.ts +85 -0
  187. package/template/src/services/oauth-clients.ts +15 -0
  188. package/template/src/services/payment-providers.ts +157 -0
  189. package/template/src/services/payment-toggles.ts +48 -0
  190. package/template/src/services/purchase.ts +233 -0
  191. package/template/src/services/stripe.ts +106 -0
  192. package/template/src/start.ts +35 -0
  193. package/template/src/styles/layout.css +93 -0
  194. package/template/src/styles/theme.css +84 -0
  195. package/template/src/styles/ui.css +233 -0
  196. package/template/src/styles.css +90 -0
  197. package/template/src/utils/api-error.ts +129 -0
  198. package/template/src/utils/auth-errors.ts +157 -0
  199. package/template/src/utils/auth-redirect.ts +61 -0
  200. package/template/src/utils/body-limit.ts +84 -0
  201. package/template/src/utils/cn.ts +6 -0
  202. package/template/src/utils/input-rules.ts +172 -0
  203. package/template/src/utils/locale-paths.ts +18 -0
  204. package/template/src/utils/same-page-locale-href.ts +20 -0
  205. package/template/src/utils/search-params.ts +71 -0
  206. package/template/tsconfig.json +30 -0
  207. package/template/tsr.config.json +6 -0
  208. package/template/vite.config.ts +24 -0
  209. package/template/vitest.config.ts +26 -0
  210. package/template/worker-configuration.d.ts +14660 -0
  211. package/template/wrangler.jsonc +39 -0
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Better Auth HTTP paths the router must not expose.
3
+ *
4
+ * `disabledPaths` only 404s the *router* — server-side `auth.api.*` calls still
5
+ * reach these endpoints, which is how the app uses every one of them. Kept in
6
+ * its own binding-free module so tests can assert the list against routes a
7
+ * real auth instance mounts.
8
+ *
9
+ * The Starter deliberately omits Better Auth's api-key plugin (not a platform
10
+ * default), so those paths are not listed here.
11
+ */
12
+
13
+ /**
14
+ * OTPs are issued and consumed exclusively through account-security server
15
+ * fns, with the email always taken from the session. Public email-OTP sign-in
16
+ * would also bypass the social/password 2FA challenge design.
17
+ */
18
+ const EMAIL_OTP_PATHS = [
19
+ "/email-otp/send-verification-otp",
20
+ "/email-otp/check-verification-otp",
21
+ "/email-otp/verify-email",
22
+ "/sign-in/email-otp",
23
+ "/email-otp/request-password-reset",
24
+ "/forget-password/email-otp",
25
+ "/email-otp/reset-password",
26
+ "/email-otp/request-email-change",
27
+ "/email-otp/change-email",
28
+ ] as const;
29
+
30
+ /**
31
+ * 2FA management is OTP/TOTP-gated through account-security server fns, so
32
+ * password-gated HTTP endpoints stay off. `verify-totp` / `verify-backup-code`
33
+ * remain public — sign-in and enrollment confirmation need them.
34
+ */
35
+ const TWO_FACTOR_PATHS = [
36
+ "/two-factor/enable",
37
+ "/two-factor/disable",
38
+ "/two-factor/generate-backup-codes",
39
+ "/two-factor/get-totp-uri",
40
+ "/two-factor/send-otp",
41
+ "/two-factor/verify-otp",
42
+ ] as const;
43
+
44
+ /**
45
+ * The whole admin plugin surface. These routes check nothing but
46
+ * `hasPermission`, so a plain admin holding `user:set-role` could demote a
47
+ * superadmin over HTTP. Admin UI goes through `auth.api.*` and server fns.
48
+ */
49
+ const ADMIN_PATHS = [
50
+ "/admin/set-role",
51
+ "/admin/create-user",
52
+ "/admin/update-user",
53
+ "/admin/list-users",
54
+ "/admin/list-user-sessions",
55
+ "/admin/unban-user",
56
+ "/admin/ban-user",
57
+ "/admin/impersonate-user",
58
+ "/admin/stop-impersonating",
59
+ "/admin/revoke-user-session",
60
+ "/admin/revoke-user-sessions",
61
+ "/admin/remove-user",
62
+ "/admin/set-user-password",
63
+ "/admin/get-user",
64
+ "/admin/has-permission",
65
+ ] as const;
66
+
67
+ /**
68
+ * Endpoints Turnstile must gate. Explicit so captcha coverage tests pin the
69
+ * list against mounted password paths rather than trusting plugin defaults.
70
+ */
71
+ export const CAPTCHA_ENDPOINTS = [
72
+ "/sign-up/email",
73
+ "/sign-in/email",
74
+ "/request-password-reset",
75
+ ] as const;
76
+
77
+ /**
78
+ * Credential mutations whose guards live in account-security server fns.
79
+ * `/change-password` skips session eviction; `/passkey/delete-passkey` has no
80
+ * freshness gate in the library.
81
+ */
82
+ const CREDENTIAL_MUTATION_PATHS = [
83
+ "/change-password",
84
+ "/passkey/delete-passkey",
85
+ ] as const;
86
+
87
+ export const DISABLED_AUTH_PATHS = [
88
+ // jwt plugin session→JWT shortcut: tokens only through OAuth flow.
89
+ "/token",
90
+ ...EMAIL_OTP_PATHS,
91
+ ...TWO_FACTOR_PATHS,
92
+ ...ADMIN_PATHS,
93
+ ...CREDENTIAL_MUTATION_PATHS,
94
+ ] as const;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Re-registering an address that already has an account (ADR 0008).
3
+ *
4
+ * Stock Better Auth answers a duplicate sign-up with a synthetic success and
5
+ * silently discards the submitted password — so a consumer who "fixed a typo"
6
+ * by re-registering could never sign in after clicking the link. The previous
7
+ * fix stored the second password instead, which handed the account to anyone
8
+ * who merely knew the address: they re-register with a password of their
9
+ * choosing, the victim clicks the link *they* already received, and the account
10
+ * verifies under the attacker's password.
11
+ *
12
+ * So: re-registration writes nothing at all — not the password, not the name,
13
+ * not any other field, whatever the account's verification state. It re-sends
14
+ * the verification link when the account is still unverified (the one
15
+ * legitimate reason to re-register), and it tells the user the address is
16
+ * already registered.
17
+ *
18
+ * That message is deliberately **the same for verified and unverified
19
+ * accounts**. Differing copy would turn the endpoint into a finer oracle,
20
+ * distinguishing three states (absent / unverified / verified) instead of two.
21
+ * Disclosing existence at all is accepted here because the endpoint sits behind
22
+ * Turnstile and rate limiting, and the alternative was a silent credential
23
+ * overwrite.
24
+ */
25
+ import type { BetterAuthPlugin } from "better-auth";
26
+ import {
27
+ APIError,
28
+ createAuthMiddleware,
29
+ createEmailVerificationToken,
30
+ } from "better-auth/api";
31
+
32
+ export const EMAIL_ALREADY_REGISTERED_MESSAGE =
33
+ "This email is already registered. We've sent a verification link if it still needs one — otherwise sign in, or reset your password.";
34
+
35
+ export function existingEmailSignup() {
36
+ return {
37
+ id: "existing-email-signup",
38
+ hooks: {
39
+ before: [
40
+ {
41
+ matcher(ctx) {
42
+ return ctx.path === "/sign-up/email";
43
+ },
44
+ handler: createAuthMiddleware(async (ctx) => {
45
+ const body = ctx.body as
46
+ | { email?: unknown; callbackURL?: unknown }
47
+ | undefined;
48
+ const email =
49
+ typeof body?.email === "string" ? body.email.trim() : "";
50
+ if (!email) return;
51
+
52
+ const existing = await ctx.context.internalAdapter.findUserByEmail(
53
+ email.toLowerCase(),
54
+ );
55
+ if (!existing?.user) return;
56
+
57
+ const send =
58
+ ctx.context.options.emailVerification?.sendVerificationEmail;
59
+ if (!existing.user.emailVerified && send) {
60
+ const token = await createEmailVerificationToken(
61
+ ctx.context.secret,
62
+ existing.user.email,
63
+ undefined,
64
+ ctx.context.options.emailVerification?.expiresIn,
65
+ );
66
+ const callbackURL =
67
+ typeof body?.callbackURL === "string" && body.callbackURL
68
+ ? encodeURIComponent(body.callbackURL)
69
+ : encodeURIComponent("/");
70
+ const url = `${ctx.context.baseURL}/verify-email?token=${token}&callbackURL=${callbackURL}`;
71
+ await ctx.context.runInBackgroundOrAwait(
72
+ send({ user: existing.user, url, token }, ctx.request),
73
+ );
74
+ }
75
+
76
+ // One message for any existing address — see the note above.
77
+ throw new APIError("UNPROCESSABLE_ENTITY", {
78
+ message: EMAIL_ALREADY_REGISTERED_MESSAGE,
79
+ code: "USER_ALREADY_EXISTS",
80
+ });
81
+ }),
82
+ },
83
+ ],
84
+ },
85
+ } satisfies BetterAuthPlugin;
86
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Server-side field rules on the Better Auth HTTP surface (ADR 0008).
3
+ *
4
+ * The sign-up form's zod schema runs in the browser only, so these endpoints
5
+ * accept whatever is posted. Rules that need the raw request body — password
6
+ * composition, email limits — are enforced here, before Better Auth hashes or
7
+ * stores anything. Rules that belong to a stored column (e.g. display name)
8
+ * live in `databaseHooks` instead, so they cover every writer.
9
+ */
10
+ import type { BetterAuthPlugin } from "better-auth";
11
+ import { APIError, createAuthMiddleware } from "better-auth/api";
12
+ import {
13
+ assertEmailLimits,
14
+ assertPasswordRules,
15
+ InvalidFieldError,
16
+ } from "@/utils/input-rules";
17
+
18
+ /**
19
+ * The paths that *set* a password, and the body field each uses.
20
+ *
21
+ * Sign-in is deliberately absent: an account whose stored password predates the
22
+ * 32-character maximum keeps working until its owner changes it. So are the
23
+ * paths that take a password to *prove* identity (`/two-factor/enable`,
24
+ * `/delete-user`) — the same reason.
25
+ */
26
+ const PASSWORD_FIELD_BY_PATH: Record<string, "password" | "newPassword"> = {
27
+ "/sign-up/email": "password",
28
+ "/reset-password": "newPassword",
29
+ "/change-password": "newPassword",
30
+ "/email-otp/reset-password": "password",
31
+ "/admin/create-user": "password",
32
+ "/admin/set-user-password": "newPassword",
33
+ };
34
+
35
+ /** The paths that accept an address we will store. */
36
+ const EMAIL_PATHS = new Set([
37
+ "/sign-up/email",
38
+ "/change-email",
39
+ "/email-otp/change-email",
40
+ "/admin/create-user",
41
+ "/admin/update-user",
42
+ ]);
43
+
44
+ export function fieldRules() {
45
+ return {
46
+ id: "field-rules",
47
+ hooks: {
48
+ before: [
49
+ {
50
+ matcher(ctx) {
51
+ const path = ctx.path ?? "";
52
+ return path in PASSWORD_FIELD_BY_PATH || EMAIL_PATHS.has(path);
53
+ },
54
+ handler: createAuthMiddleware(async (ctx) => {
55
+ const body = ctx.body as Record<string, unknown> | undefined;
56
+ if (!body) return;
57
+ const path = ctx.path ?? "";
58
+
59
+ try {
60
+ const passwordField = PASSWORD_FIELD_BY_PATH[path];
61
+ if (passwordField) {
62
+ const password = body[passwordField];
63
+ if (typeof password === "string") assertPasswordRules(password);
64
+ }
65
+ if (EMAIL_PATHS.has(path)) {
66
+ // `/admin/update-user` posts `{ userId, data }`, so the
67
+ // address arrives nested; every other path is flat.
68
+ const fields =
69
+ (body.data as Record<string, unknown> | undefined) ?? body;
70
+ const email = fields.email ?? fields.newEmail;
71
+ if (typeof email === "string") assertEmailLimits(email.trim());
72
+ }
73
+ } catch (error) {
74
+ if (error instanceof InvalidFieldError) {
75
+ throw new APIError("BAD_REQUEST", { message: error.message });
76
+ }
77
+ throw error;
78
+ }
79
+ }),
80
+ },
81
+ ],
82
+ },
83
+ } satisfies BetterAuthPlugin;
84
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Second factor on social sign-in (ADR 0008).
3
+ *
4
+ * Better Auth's own 2FA hook matches only `/sign-in/email`,
5
+ * `/sign-in/username` and `/sign-in/phone-number` — the matcher is hardcoded in
6
+ * `better-auth/plugins/two-factor`, so it cannot be configured to cover the
7
+ * OAuth callback. A 2FA-enrolled account signing in with GitHub therefore got a
8
+ * complete, brand-new session with no code ever presented, which also cleared
9
+ * every fresh-session gate (passkey enrolment among them).
10
+ *
11
+ * This reproduces the teardown the password path already performs: delete the
12
+ * session row, clear the session cookie, null `newSession`, write the pending-
13
+ * challenge verification records, set the signed `two_factor` cookie. Because
14
+ * no session exists, every authenticated surface refuses the pending state by
15
+ * construction — no `aal`/`amr` flag to remember to check.
16
+ *
17
+ * One difference: the callback answers with a redirect rather than JSON, so
18
+ * instead of returning `{ twoFactorRedirect: true }` this rewrites the redirect
19
+ * target to the two-factor page, carrying the original destination.
20
+ */
21
+ import type { BetterAuthPlugin } from "better-auth";
22
+ import { createAuthMiddleware } from "better-auth/api";
23
+ import { deleteSessionCookie, expireCookie } from "better-auth/cookies";
24
+ import { generateRandomString } from "better-auth/crypto";
25
+
26
+ /** Better Auth's own `two_factor` cookie name — not publicly re-exported. */
27
+ const TWO_FACTOR_COOKIE = "two_factor";
28
+
29
+ /** Ditto for `trust_device`, written by `/two-factor/verify-totp`. */
30
+ const TRUST_DEVICE_COOKIE = "trust_device";
31
+
32
+ /** `twoFactorCookieMaxAge ?? 600` in the twoFactor plugin; we leave it unset. */
33
+ const CHALLENGE_MAX_AGE_SEC = 600;
34
+
35
+ /** `trustDeviceMaxAge ?? 2592e3` in the twoFactor plugin; also left unset. */
36
+ const TRUST_DEVICE_MAX_AGE_SEC = 2592e3;
37
+
38
+ /** Where the browser lands to answer the challenge. */
39
+ const TWO_FACTOR_PAGE = "/two-factor";
40
+
41
+ // Returns the literal shape rather than a widened `BetterAuthPlugin`: annotating
42
+ // the broad type erases the sibling plugins' endpoints from `auth.api`.
43
+ export function socialTwoFactor() {
44
+ return {
45
+ id: "social-two-factor",
46
+ hooks: {
47
+ after: [
48
+ {
49
+ // The route is registered as `/callback/:id`; the dispatcher sets
50
+ // `path` to the route pattern, not the concrete provider path.
51
+ matcher(ctx) {
52
+ return ctx.path === "/callback/:id";
53
+ },
54
+ handler: createAuthMiddleware(async (ctx) => {
55
+ const data = ctx.context.newSession;
56
+ // Null on the account-link arm, on every redirectOnError path,
57
+ // and on the POST form_post pre-redirect arm.
58
+ if (!data) return;
59
+ if (!data.user.twoFactorEnabled) return;
60
+
61
+ // "Trust this device" is offered on our own challenge page, so
62
+ // it has to be honoured here too — otherwise the checkbox only
63
+ // ever works for the password path that wrote the cookie.
64
+ if (await deviceIsTrusted(ctx, data.user.id)) return;
65
+
66
+ // Same teardown, same order, as the password path.
67
+ // `true` = skipDontRememberMe: verify-totp reads that cookie
68
+ // later to decide the session lifetime.
69
+ deleteSessionCookie(ctx, true);
70
+ await ctx.context.internalAdapter.deleteSession(data.session.token);
71
+ ctx.context.setNewSession(null);
72
+
73
+ const cookie = ctx.context.createAuthCookie(TWO_FACTOR_COOKIE, {
74
+ maxAge: CHALLENGE_MAX_AGE_SEC,
75
+ });
76
+ const identifier = `2fa-${generateRandomString(20)}`;
77
+ const expiresAt = new Date(
78
+ Date.now() + CHALLENGE_MAX_AGE_SEC * 1000,
79
+ );
80
+ await ctx.context.internalAdapter.createVerificationValue({
81
+ value: data.user.id,
82
+ identifier,
83
+ expiresAt,
84
+ });
85
+ await ctx.context.internalAdapter.createVerificationValue({
86
+ value: "0",
87
+ identifier: `2fa-attempts-${identifier}`,
88
+ expiresAt,
89
+ });
90
+ await ctx.setSignedCookie(
91
+ cookie.name,
92
+ identifier,
93
+ ctx.context.secret,
94
+ cookie.attributes,
95
+ );
96
+
97
+ // The callback answers with a 302, so send the browser to the
98
+ // challenge instead of stranding it with a JSON body. The
99
+ // original destination rides along so it is not lost.
100
+ const target = new URL(TWO_FACTOR_PAGE, ctx.context.baseURL);
101
+ const original = ctx.context.responseHeaders?.get("location");
102
+ if (original) {
103
+ target.searchParams.set(
104
+ "redirect",
105
+ toRelative(original, ctx.context.baseURL),
106
+ );
107
+ }
108
+ throw ctx.redirect(`${target.pathname}${target.search}`);
109
+ }),
110
+ },
111
+ ],
112
+ },
113
+ } satisfies BetterAuthPlugin;
114
+ }
115
+
116
+ /** `GenericEndpointContext`; `@better-auth/core` is not a resolvable import. */
117
+ type HookContext = Parameters<typeof expireCookie>[0];
118
+
119
+ /**
120
+ * The password path's trust-device check, verbatim (`two-factor/index.mjs`):
121
+ * the cookie carries `${token}!${identifier}`, the token is an HMAC over
122
+ * `${user.id}!${identifier}`, and the identifier must still resolve to a live
123
+ * verification row holding that same user id. A match rotates both record and
124
+ * cookie; anything else expires the cookie and falls through to the challenge.
125
+ */
126
+ async function deviceIsTrusted(ctx: HookContext, userId: string) {
127
+ const attrs = ctx.context.createAuthCookie(TRUST_DEVICE_COOKIE, {
128
+ maxAge: TRUST_DEVICE_MAX_AGE_SEC,
129
+ });
130
+ const cookie = await ctx.getSignedCookie(attrs.name, ctx.context.secret);
131
+ if (!cookie) return false;
132
+
133
+ const [token, identifier] = cookie.split("!");
134
+ if (token && identifier && token === (await sign(ctx, userId, identifier))) {
135
+ const record =
136
+ await ctx.context.internalAdapter.findVerificationValue(identifier);
137
+ if (record && record.value === userId && record.expiresAt > new Date()) {
138
+ await ctx.context.internalAdapter.deleteVerificationByIdentifier(
139
+ identifier,
140
+ );
141
+ const next = `trust-device-${generateRandomString(32)}`;
142
+ await ctx.context.internalAdapter.createVerificationValue({
143
+ value: userId,
144
+ identifier: next,
145
+ expiresAt: new Date(Date.now() + TRUST_DEVICE_MAX_AGE_SEC * 1000),
146
+ });
147
+ await ctx.setSignedCookie(
148
+ attrs.name,
149
+ `${await sign(ctx, userId, next)}!${next}`,
150
+ ctx.context.secret,
151
+ attrs.attributes,
152
+ );
153
+ return true;
154
+ }
155
+ }
156
+ expireCookie(ctx, attrs);
157
+ return false;
158
+ }
159
+
160
+ /**
161
+ * `createHMAC("SHA-256", "base64urlnopad").sign` — `@better-auth/utils` is a
162
+ * transitive dependency of better-auth, not one we can import.
163
+ */
164
+ async function sign(ctx: HookContext, userId: string, identifier: string) {
165
+ const bytes = new TextEncoder();
166
+ const key = await crypto.subtle.importKey(
167
+ "raw",
168
+ bytes.encode(ctx.context.secret),
169
+ { name: "HMAC", hash: "SHA-256" },
170
+ false,
171
+ ["sign"],
172
+ );
173
+ const signature = await crypto.subtle.sign(
174
+ "HMAC",
175
+ key,
176
+ bytes.encode(`${userId}!${identifier}`),
177
+ );
178
+ return btoa(String.fromCharCode(...new Uint8Array(signature)))
179
+ .replace(/\+/g, "-")
180
+ .replace(/\//g, "_")
181
+ .replace(/=+$/, "");
182
+ }
183
+
184
+ /** Keep same-origin redirects relative; drop anything pointing elsewhere. */
185
+ function toRelative(location: string, baseURL: string): string {
186
+ try {
187
+ const url = new URL(location, baseURL);
188
+ const base = new URL(baseURL);
189
+ if (url.origin !== base.origin) return "/";
190
+ return `${url.pathname}${url.search}${url.hash}`;
191
+ } catch {
192
+ return "/";
193
+ }
194
+ }
@@ -0,0 +1,9 @@
1
+ /** Shared auth / OAuth issuer timing constants (seconds). */
2
+
3
+ const HOUR = 60 * 60;
4
+ export const DAY = 24 * HOUR;
5
+
6
+ /** OAuth access-token lifetime — single source for issuer config and helpers. */
7
+ export const ACCESS_TOKEN_TTL_SEC = 6 * HOUR;
8
+
9
+ export const EMAIL_VERIFICATION_EXPIRES_IN_SEC = HOUR;