@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.
- package/LICENSE +21 -0
- package/README.md +68 -2
- package/assets/auth.sqlite.schema.ts +479 -0
- package/assets/bun-sqlite.test.ts +46 -0
- package/assets/d1.test.ts +41 -0
- package/assets/drizzle.config.ts +2 -0
- package/assets/generate-auth-schema.mjs +25 -0
- package/assets/platform.sqlite.schema.ts +46 -0
- package/assets/polar-checkout.ts +10 -0
- package/assets/polar-route.ts +19 -0
- package/assets/polar.test.ts +30 -0
- package/assets/polar.ts +12 -0
- package/assets/purchase.bun-sqlite.ts +38 -0
- package/assets/purchase.sqlite.ts +39 -0
- package/assets/sqlite-migrations/0000_thin_phalanx.sql +295 -0
- package/assets/sqlite-migrations/meta/0000_snapshot.json +2058 -0
- package/assets/sqlite-migrations/meta/_journal.json +13 -0
- package/assets/turso.test.ts +40 -0
- package/package.json +44 -5
- package/src/adapters/database.mjs +448 -0
- package/src/adapters/deployment.mjs +101 -0
- package/src/adapters/email.mjs +75 -0
- package/src/adapters/payments.mjs +270 -0
- package/src/catalog.mjs +128 -0
- package/src/cli.mjs +294 -0
- package/src/generate.mjs +183 -0
- package/src/template.mjs +93 -0
- package/template/.env.docker +16 -0
- package/template/.env.example +67 -0
- package/template/DESIGN.md +58 -0
- package/template/LICENSE +21 -0
- package/template/biome.json +46 -0
- package/template/docker-compose.yml +47 -0
- package/template/docs/project-guide.md +61 -0
- package/template/drizzle/0000_create-app-role.sql +54 -0
- package/template/drizzle/0001_add-rls-context.sql +21 -0
- package/template/drizzle/0002_platform_tables.sql +320 -0
- package/template/drizzle/meta/0002_snapshot.json +2125 -0
- package/template/drizzle/meta/_journal.json +27 -0
- package/template/drizzle.config.ts +25 -0
- package/template/e2e/authenticated-ui.spec.ts +40 -0
- package/template/e2e/headless-design.spec.ts +66 -0
- package/template/e2e/navigation-loading.spec.ts +70 -0
- package/template/e2e/theme-responsive.spec.ts +260 -0
- package/template/gitignore +24 -0
- package/template/messages/de.json +212 -0
- package/template/messages/en.json +212 -0
- package/template/package.json +101 -0
- package/template/paraglide.config.ts +49 -0
- package/template/playwright.config.ts +58 -0
- package/template/project.inlang/settings.json +12 -0
- package/template/public/drizzle.svg +1 -0
- package/template/public/favicon.ico +0 -0
- package/template/public/logo192.png +0 -0
- package/template/public/logo512.png +0 -0
- package/template/public/manifest.json +25 -0
- package/template/public/robots.txt +3 -0
- package/template/scripts/check-client-bundle.ts +150 -0
- package/template/scripts/check-server-fns.ts +290 -0
- package/template/scripts/check-sql-raw.ts +50 -0
- package/template/scripts/compile-paraglide.ts +12 -0
- package/template/scripts/postgres/README.md +76 -0
- package/template/scripts/postgres/create-app-role.sh +60 -0
- package/template/scripts/seed-local.ts +331 -0
- package/template/scripts/validate-prod-env.ts +53 -0
- package/template/src/__tests__/README.md +9 -0
- package/template/src/__tests__/account-security.test.ts +664 -0
- package/template/src/__tests__/admin-http-surface.test.ts +311 -0
- package/template/src/__tests__/admin-peer-protection.test.ts +178 -0
- package/template/src/__tests__/auth-harness.ts +104 -0
- package/template/src/__tests__/auth-rate-limit.test.ts +43 -0
- package/template/src/__tests__/auth-redirect.test.ts +120 -0
- package/template/src/__tests__/body-limit.test.ts +47 -0
- package/template/src/__tests__/creem.test.ts +62 -0
- package/template/src/__tests__/demo-checkout.test.ts +141 -0
- package/template/src/__tests__/email-locale.test.ts +110 -0
- package/template/src/__tests__/global-setup.ts +80 -0
- package/template/src/__tests__/input-rules.test.ts +0 -0
- package/template/src/__tests__/message-parity.test.ts +38 -0
- package/template/src/__tests__/navigation-loading.test.tsx +252 -0
- package/template/src/__tests__/nowpayments.test.ts +106 -0
- package/template/src/__tests__/paraglide-url-contract.test.ts +87 -0
- package/template/src/__tests__/purchase-fulfillment.test.ts +204 -0
- package/template/src/__tests__/query-unauthorized-redirect.test.ts +95 -0
- package/template/src/__tests__/rls-policy.test.ts +120 -0
- package/template/src/__tests__/same-page-locale-href.test.ts +47 -0
- package/template/src/__tests__/search-params-oauth.test.ts +57 -0
- package/template/src/__tests__/security-headers.test.ts +12 -0
- package/template/src/__tests__/server-fn-error-contract.test.ts +173 -0
- package/template/src/__tests__/stripe.test.ts +84 -0
- package/template/src/__tests__/test-db.ts +32 -0
- package/template/src/__tests__/test-rls-db.ts +23 -0
- package/template/src/__tests__/user-update-guard.test.ts +109 -0
- package/template/src/components/LocaleSwitcher.tsx +79 -0
- package/template/src/components/ThemeToggle.tsx +47 -0
- package/template/src/components/auth/social.tsx +90 -0
- package/template/src/components/auth/turnstile.tsx +67 -0
- package/template/src/components/root/navigation-progress.tsx +32 -0
- package/template/src/components/root/not-found.tsx +20 -0
- package/template/src/components/root/pending.tsx +9 -0
- package/template/src/components/root/root-document.tsx +37 -0
- package/template/src/components/root/root-error.tsx +34 -0
- package/template/src/components/shells.tsx +201 -0
- package/template/src/components/ui/styles.ts +9 -0
- package/template/src/db/auth.schema.ts +455 -0
- package/template/src/db/helper.ts +38 -0
- package/template/src/db/index.ts +15 -0
- package/template/src/db/platform.schema.ts +122 -0
- package/template/src/db/schema.ts +2 -0
- package/template/src/env.ts +65 -0
- package/template/src/hooks/use-theme.ts +32 -0
- package/template/src/integrations/better-auth/header-user.tsx +36 -0
- package/template/src/integrations/better-auth/session-query.ts +11 -0
- package/template/src/integrations/react-email/PasswordOtpEmail.tsx +62 -0
- package/template/src/integrations/react-email/ResetPasswordEmail.tsx +64 -0
- package/template/src/integrations/react-email/VerifyEmail.tsx +72 -0
- package/template/src/integrations/react-email/theme.ts +75 -0
- package/template/src/integrations/tanstack-query/devtools.tsx +6 -0
- package/template/src/integrations/tanstack-query/root-provider.tsx +60 -0
- package/template/src/lib/auth-client.ts +27 -0
- package/template/src/lib/auth-paths.ts +94 -0
- package/template/src/lib/auth-plugins/existing-email-signup.ts +86 -0
- package/template/src/lib/auth-plugins/field-rules.ts +84 -0
- package/template/src/lib/auth-plugins/social-two-factor.ts +194 -0
- package/template/src/lib/auth-timing.ts +9 -0
- package/template/src/lib/auth.ts +279 -0
- package/template/src/lib/captcha.ts +11 -0
- package/template/src/lib/email.tsx +162 -0
- package/template/src/lib/github.ts +18 -0
- package/template/src/lib/locale-cookie.ts +12 -0
- package/template/src/lib/localized-pages.ts +25 -0
- package/template/src/lib/oauth-metadata.ts +19 -0
- package/template/src/lib/permissions.ts +29 -0
- package/template/src/lib/security-headers.ts +20 -0
- package/template/src/lib/site.ts +59 -0
- package/template/src/lib/theme.ts +42 -0
- package/template/src/lib/user-update-guard.ts +35 -0
- package/template/src/middlewares/admin.ts +46 -0
- package/template/src/middlewares/auth-rate-limit.ts +37 -0
- package/template/src/middlewares/better-auth.ts +15 -0
- package/template/src/middlewares/body-limit.ts +62 -0
- package/template/src/middlewares/database.ts +13 -0
- package/template/src/middlewares/protected.ts +45 -0
- package/template/src/routeTree.gen.ts +536 -0
- package/template/src/router.tsx +53 -0
- package/template/src/routes/[.]well-known/oauth-authorization-server/api/auth.ts +18 -0
- package/template/src/routes/[.]well-known/oauth-protected-resource/index.ts +32 -0
- package/template/src/routes/__root.tsx +175 -0
- package/template/src/routes/admin/audit.tsx +74 -0
- package/template/src/routes/admin/index.tsx +7 -0
- package/template/src/routes/admin/oauth-apps.tsx +164 -0
- package/template/src/routes/admin/route.tsx +27 -0
- package/template/src/routes/admin/users.tsx +215 -0
- package/template/src/routes/api/auth/$.ts +14 -0
- package/template/src/routes/api/auth/[.]well-known/openid-configuration.ts +15 -0
- package/template/src/routes/api/webhooks/creem.ts +69 -0
- package/template/src/routes/api/webhooks/nowpayments.ts +62 -0
- package/template/src/routes/api/webhooks/stripe.ts +74 -0
- package/template/src/routes/dashboard/account.tsx +430 -0
- package/template/src/routes/dashboard/index.tsx +128 -0
- package/template/src/routes/dashboard/route.tsx +17 -0
- package/template/src/routes/forgot-password.tsx +65 -0
- package/template/src/routes/index.tsx +85 -0
- package/template/src/routes/oauth/consent.tsx +77 -0
- package/template/src/routes/reset-password.tsx +78 -0
- package/template/src/routes/sign-in.tsx +174 -0
- package/template/src/routes/sign-up.tsx +135 -0
- package/template/src/routes/two-factor.tsx +74 -0
- package/template/src/server/README.md +3 -0
- package/template/src/server/account-security.ts +163 -0
- package/template/src/server/admin-audit.ts +37 -0
- package/template/src/server/admin-users.ts +112 -0
- package/template/src/server/auth.ts +44 -0
- package/template/src/server/demo-checkout.ts +67 -0
- package/template/src/server/oauth-clients.ts +83 -0
- package/template/src/server/payment-toggles.ts +39 -0
- package/template/src/server.ts +20 -0
- package/template/src/services/README.md +3 -0
- package/template/src/services/account-security.ts +893 -0
- package/template/src/services/admin-security.ts +164 -0
- package/template/src/services/admin-users.ts +181 -0
- package/template/src/services/auth-rate-limit.ts +32 -0
- package/template/src/services/creem.ts +83 -0
- package/template/src/services/demo-checkout.ts +100 -0
- package/template/src/services/nowpayments.ts +154 -0
- package/template/src/services/oauth-client.ts +85 -0
- package/template/src/services/oauth-clients.ts +15 -0
- package/template/src/services/payment-providers.ts +157 -0
- package/template/src/services/payment-toggles.ts +48 -0
- package/template/src/services/purchase.ts +233 -0
- package/template/src/services/stripe.ts +106 -0
- package/template/src/start.ts +35 -0
- package/template/src/styles/layout.css +93 -0
- package/template/src/styles/theme.css +84 -0
- package/template/src/styles/ui.css +233 -0
- package/template/src/styles.css +90 -0
- package/template/src/utils/api-error.ts +129 -0
- package/template/src/utils/auth-errors.ts +157 -0
- package/template/src/utils/auth-redirect.ts +61 -0
- package/template/src/utils/body-limit.ts +84 -0
- package/template/src/utils/cn.ts +6 -0
- package/template/src/utils/input-rules.ts +172 -0
- package/template/src/utils/locale-paths.ts +18 -0
- package/template/src/utils/same-page-locale-href.ts +20 -0
- package/template/src/utils/search-params.ts +71 -0
- package/template/tsconfig.json +30 -0
- package/template/tsr.config.json +6 -0
- package/template/vite.config.ts +24 -0
- package/template/vitest.config.ts +26 -0
- package/template/worker-configuration.d.ts +14660 -0
- 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;
|