@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,664 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
|
|
3
|
+
import { betterAuth } from "better-auth";
|
|
4
|
+
import { emailOTP, twoFactor } from "better-auth/plugins";
|
|
5
|
+
import { like } from "drizzle-orm";
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
import {
|
|
8
|
+
changePasswordWithOtp,
|
|
9
|
+
checkPasswordChangeOtp,
|
|
10
|
+
disableTwoFactorWithCode,
|
|
11
|
+
enableTwoFactorWithOtp,
|
|
12
|
+
evaluateGithubSignup,
|
|
13
|
+
reauthenticate,
|
|
14
|
+
regenerateBackupCodesWithCode,
|
|
15
|
+
requestPasswordChangeOtp,
|
|
16
|
+
requestReauthOtp,
|
|
17
|
+
} from "@/services/account-security";
|
|
18
|
+
import { AUTH_DDL } from "./auth-harness";
|
|
19
|
+
import { createTestDatabase, hasTestDatabase } from "./test-db";
|
|
20
|
+
|
|
21
|
+
/** Expire the server-side resend cooldown so the next send is allowed. */
|
|
22
|
+
async function expireCooldowns(db: Awaited<ReturnType<typeof makeAuth>>["db"]) {
|
|
23
|
+
const { verification } = await import("@/db/auth.schema");
|
|
24
|
+
await db
|
|
25
|
+
.update(verification)
|
|
26
|
+
.set({ expiresAt: new Date(Date.now() - 1000) })
|
|
27
|
+
.where(like(verification.identifier, "otp-cooldown-%"));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Minimal auth instance mirroring the production slice under test:
|
|
31
|
+
// emailAndPassword + emailOTP, revokeSessionsOnPasswordReset left at its
|
|
32
|
+
// default (off) — the orchestration itself must handle session revocation.
|
|
33
|
+
async function makeAuth() {
|
|
34
|
+
const otps: { email: string; otp: string; type: string }[] = [];
|
|
35
|
+
const db = await createTestDatabase(AUTH_DDL);
|
|
36
|
+
const auth = betterAuth({
|
|
37
|
+
baseURL: "http://localhost:3000",
|
|
38
|
+
secret: "account-security-test-secret",
|
|
39
|
+
telemetry: { enabled: false },
|
|
40
|
+
database: drizzleAdapter(db, { provider: "pg" }),
|
|
41
|
+
emailAndPassword: { enabled: true },
|
|
42
|
+
plugins: [
|
|
43
|
+
emailOTP({
|
|
44
|
+
async sendVerificationOTP(data) {
|
|
45
|
+
otps.push(data);
|
|
46
|
+
},
|
|
47
|
+
}),
|
|
48
|
+
twoFactor(),
|
|
49
|
+
],
|
|
50
|
+
});
|
|
51
|
+
return { auth, otps, db };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Turn a returnHeaders response into a Cookie header for follow-up calls. */
|
|
55
|
+
function cookieHeaders(headers: Headers): Headers {
|
|
56
|
+
const cookie = headers
|
|
57
|
+
.getSetCookie()
|
|
58
|
+
.map((c) => c.split(";")[0])
|
|
59
|
+
.join("; ");
|
|
60
|
+
return new Headers({ cookie });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const EMAIL = "alice@example.com";
|
|
64
|
+
const OLD_PW = "old-password-123";
|
|
65
|
+
const NEW_PW = "new-password-456";
|
|
66
|
+
|
|
67
|
+
describe.skipIf(!hasTestDatabase)("changePasswordWithOtp", () => {
|
|
68
|
+
it("changes the password, keeps the current session, revokes the rest", async () => {
|
|
69
|
+
const { auth, otps } = await makeAuth();
|
|
70
|
+
const signUp = await auth.api.signUpEmail({
|
|
71
|
+
body: { email: EMAIL, password: OLD_PW, name: "Alice" },
|
|
72
|
+
returnHeaders: true,
|
|
73
|
+
});
|
|
74
|
+
const current = cookieHeaders(signUp.headers);
|
|
75
|
+
const signInAgain = await auth.api.signInEmail({
|
|
76
|
+
body: { email: EMAIL, password: OLD_PW },
|
|
77
|
+
returnHeaders: true,
|
|
78
|
+
});
|
|
79
|
+
const other = cookieHeaders(signInAgain.headers);
|
|
80
|
+
|
|
81
|
+
expect(await requestPasswordChangeOtp(auth, current)).toEqual({
|
|
82
|
+
ok: true,
|
|
83
|
+
retryAfterSeconds: 30,
|
|
84
|
+
});
|
|
85
|
+
const otp = otps.at(-1);
|
|
86
|
+
expect(otp?.type).toBe("forget-password");
|
|
87
|
+
|
|
88
|
+
expect(
|
|
89
|
+
await changePasswordWithOtp(auth, current, {
|
|
90
|
+
otp: otp?.otp ?? "",
|
|
91
|
+
newPassword: NEW_PW,
|
|
92
|
+
}),
|
|
93
|
+
).toEqual({ ok: true });
|
|
94
|
+
|
|
95
|
+
// New password works, old one doesn't.
|
|
96
|
+
await expect(
|
|
97
|
+
auth.api.signInEmail({ body: { email: EMAIL, password: NEW_PW } }),
|
|
98
|
+
).resolves.toBeTruthy();
|
|
99
|
+
await expect(
|
|
100
|
+
auth.api.signInEmail({ body: { email: EMAIL, password: OLD_PW } }),
|
|
101
|
+
).rejects.toThrow();
|
|
102
|
+
|
|
103
|
+
// The session that performed the change survives; the other one is dead.
|
|
104
|
+
expect(await auth.api.getSession({ headers: current })).not.toBeNull();
|
|
105
|
+
expect(await auth.api.getSession({ headers: other })).toBeNull();
|
|
106
|
+
|
|
107
|
+
// Consuming the code lifts the resend cooldown — nothing is in-flight.
|
|
108
|
+
expect(await requestPasswordChangeOtp(auth, current)).toEqual({
|
|
109
|
+
ok: true,
|
|
110
|
+
retryAfterSeconds: 30,
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("lets a passwordless (social) user set a first password", async () => {
|
|
115
|
+
const { auth, otps, db } = await makeAuth();
|
|
116
|
+
// A GitHub-registered user: user row without any credential account.
|
|
117
|
+
// Seeded directly — social sign-up needs a live provider round-trip.
|
|
118
|
+
const { user } = await import("@/db/auth.schema");
|
|
119
|
+
await db.insert(user).values({
|
|
120
|
+
id: "gh-user",
|
|
121
|
+
name: "Hub",
|
|
122
|
+
email: EMAIL,
|
|
123
|
+
emailVerified: true,
|
|
124
|
+
});
|
|
125
|
+
// Session via the OTP sign-in endpoint (public API, no password needed).
|
|
126
|
+
await auth.api.sendVerificationOTP({
|
|
127
|
+
body: { email: EMAIL, type: "sign-in" },
|
|
128
|
+
});
|
|
129
|
+
const signIn = await auth.api.signInEmailOTP({
|
|
130
|
+
body: { email: EMAIL, otp: otps.at(-1)?.otp ?? "" },
|
|
131
|
+
returnHeaders: true,
|
|
132
|
+
});
|
|
133
|
+
const current = cookieHeaders(signIn.headers);
|
|
134
|
+
|
|
135
|
+
await requestPasswordChangeOtp(auth, current);
|
|
136
|
+
expect(
|
|
137
|
+
await changePasswordWithOtp(auth, current, {
|
|
138
|
+
otp: otps.at(-1)?.otp ?? "",
|
|
139
|
+
newPassword: NEW_PW,
|
|
140
|
+
}),
|
|
141
|
+
).toEqual({ ok: true });
|
|
142
|
+
|
|
143
|
+
// The credential now exists: password sign-in works.
|
|
144
|
+
await expect(
|
|
145
|
+
auth.api.signInEmail({ body: { email: EMAIL, password: NEW_PW } }),
|
|
146
|
+
).resolves.toBeTruthy();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("rejects a wrong OTP with no side effects", async () => {
|
|
150
|
+
const { auth } = await makeAuth();
|
|
151
|
+
const signUp = await auth.api.signUpEmail({
|
|
152
|
+
body: { email: EMAIL, password: OLD_PW, name: "Alice" },
|
|
153
|
+
returnHeaders: true,
|
|
154
|
+
});
|
|
155
|
+
const current = cookieHeaders(signUp.headers);
|
|
156
|
+
const signInAgain = await auth.api.signInEmail({
|
|
157
|
+
body: { email: EMAIL, password: OLD_PW },
|
|
158
|
+
returnHeaders: true,
|
|
159
|
+
});
|
|
160
|
+
const other = cookieHeaders(signInAgain.headers);
|
|
161
|
+
|
|
162
|
+
await requestPasswordChangeOtp(auth, current);
|
|
163
|
+
expect(
|
|
164
|
+
await changePasswordWithOtp(auth, current, {
|
|
165
|
+
otp: "000000",
|
|
166
|
+
newPassword: NEW_PW,
|
|
167
|
+
}),
|
|
168
|
+
).toEqual({ ok: false, code: "invalid_otp" });
|
|
169
|
+
|
|
170
|
+
// Old password still stands, and no session was revoked.
|
|
171
|
+
await expect(
|
|
172
|
+
auth.api.signInEmail({ body: { email: EMAIL, password: OLD_PW } }),
|
|
173
|
+
).resolves.toBeTruthy();
|
|
174
|
+
expect(await auth.api.getSession({ headers: other })).not.toBeNull();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("rejects a replayed OTP after a successful change", async () => {
|
|
178
|
+
const { auth, otps } = await makeAuth();
|
|
179
|
+
const signUp = await auth.api.signUpEmail({
|
|
180
|
+
body: { email: EMAIL, password: OLD_PW, name: "Alice" },
|
|
181
|
+
returnHeaders: true,
|
|
182
|
+
});
|
|
183
|
+
const current = cookieHeaders(signUp.headers);
|
|
184
|
+
|
|
185
|
+
await requestPasswordChangeOtp(auth, current);
|
|
186
|
+
const otp = otps.at(-1)?.otp ?? "";
|
|
187
|
+
expect(
|
|
188
|
+
await changePasswordWithOtp(auth, current, {
|
|
189
|
+
otp,
|
|
190
|
+
newPassword: NEW_PW,
|
|
191
|
+
}),
|
|
192
|
+
).toEqual({ ok: true });
|
|
193
|
+
|
|
194
|
+
// Same code again must not move the password a second time.
|
|
195
|
+
const replay = await changePasswordWithOtp(auth, current, {
|
|
196
|
+
otp,
|
|
197
|
+
newPassword: "attacker-chosen-789",
|
|
198
|
+
});
|
|
199
|
+
expect(replay).toEqual({ ok: false, code: "invalid_otp" });
|
|
200
|
+
await expect(
|
|
201
|
+
auth.api.signInEmail({ body: { email: EMAIL, password: NEW_PW } }),
|
|
202
|
+
).resolves.toBeTruthy();
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// RFC 6238 TOTP (SHA-1, 6 digits, 30s period) — enough to act as the
|
|
207
|
+
// authenticator app in tests.
|
|
208
|
+
function totp(base32Secret: string, at = Date.now()): string {
|
|
209
|
+
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
210
|
+
let bits = 0;
|
|
211
|
+
let value = 0;
|
|
212
|
+
const bytes: number[] = [];
|
|
213
|
+
for (const char of base32Secret.replace(/=+$/, "").toUpperCase()) {
|
|
214
|
+
value = (value << 5) | alphabet.indexOf(char);
|
|
215
|
+
bits += 5;
|
|
216
|
+
if (bits >= 8) {
|
|
217
|
+
bytes.push((value >>> (bits - 8)) & 0xff);
|
|
218
|
+
bits -= 8;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const counter = Buffer.alloc(8);
|
|
222
|
+
counter.writeBigUInt64BE(BigInt(Math.floor(at / 1000 / 30)));
|
|
223
|
+
const hmac = createHmac("sha1", Buffer.from(bytes)).update(counter).digest();
|
|
224
|
+
const offset = (hmac.at(-1) ?? 0) & 0xf;
|
|
225
|
+
const code = (hmac.readUInt32BE(offset) & 0x7fffffff) % 10 ** 6;
|
|
226
|
+
return String(code).padStart(6, "0");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Sign up and mark the email verified — production users always are, and
|
|
230
|
+
* signInEmailOTP takes a different (session-revoking) branch for unverified
|
|
231
|
+
* ones, which would mask the explicit stale-session replacement under test. */
|
|
232
|
+
async function signUpWithSession(
|
|
233
|
+
auth: Awaited<ReturnType<typeof makeAuth>>["auth"],
|
|
234
|
+
db: Awaited<ReturnType<typeof makeAuth>>["db"],
|
|
235
|
+
) {
|
|
236
|
+
const signUp = await auth.api.signUpEmail({
|
|
237
|
+
body: { email: EMAIL, password: OLD_PW, name: "Alice" },
|
|
238
|
+
returnHeaders: true,
|
|
239
|
+
});
|
|
240
|
+
const { user } = await import("@/db/auth.schema");
|
|
241
|
+
await db.update(user).set({ emailVerified: true });
|
|
242
|
+
return cookieHeaders(signUp.headers);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Full TOTP enrollment. Completing it replaces the caller's session, so the
|
|
246
|
+
* returned headers must be used for everything after. */
|
|
247
|
+
async function enrollTotp(
|
|
248
|
+
auth: Awaited<ReturnType<typeof makeAuth>>["auth"],
|
|
249
|
+
headers: Headers,
|
|
250
|
+
) {
|
|
251
|
+
const enabled = await auth.api.enableTwoFactor({
|
|
252
|
+
body: { password: OLD_PW },
|
|
253
|
+
headers,
|
|
254
|
+
});
|
|
255
|
+
if (enabled.method !== "totp") {
|
|
256
|
+
throw new Error("Expected TOTP enrollment.");
|
|
257
|
+
}
|
|
258
|
+
const secret = new URL(enabled.totpURI).searchParams.get("secret") ?? "";
|
|
259
|
+
const verified = await auth.api.verifyTOTP({
|
|
260
|
+
body: { code: totp(secret) },
|
|
261
|
+
headers,
|
|
262
|
+
returnHeaders: true,
|
|
263
|
+
});
|
|
264
|
+
return {
|
|
265
|
+
secret,
|
|
266
|
+
backupCodes: enabled.backupCodes,
|
|
267
|
+
headers: cookieHeaders(verified.headers),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
describe.skipIf(!hasTestDatabase)("reauthenticate", () => {
|
|
272
|
+
it("without 2FA: emails a sign-in code, and confirming it mints a fresh session", async () => {
|
|
273
|
+
const { auth, otps, db } = await makeAuth();
|
|
274
|
+
const current = await signUpWithSession(auth, db);
|
|
275
|
+
const { session } = await import("@/db/auth.schema");
|
|
276
|
+
const before = await db.select().from(session);
|
|
277
|
+
expect(before).toHaveLength(1);
|
|
278
|
+
|
|
279
|
+
expect(await requestReauthOtp(auth, current)).toEqual({
|
|
280
|
+
ok: true,
|
|
281
|
+
retryAfterSeconds: 30,
|
|
282
|
+
});
|
|
283
|
+
const otp = otps.at(-1);
|
|
284
|
+
expect(otp?.type).toBe("sign-in");
|
|
285
|
+
expect(otp?.email).toBe(EMAIL);
|
|
286
|
+
|
|
287
|
+
expect(
|
|
288
|
+
await reauthenticate(auth, current, { code: otp?.otp ?? "" }),
|
|
289
|
+
).toEqual({ ok: true });
|
|
290
|
+
|
|
291
|
+
// The stale session is REPLACED by a brand-new one (fresh createdAt is
|
|
292
|
+
// what clears freshSessionMiddleware; the old row dies with the swap).
|
|
293
|
+
const after = await db.select().from(session);
|
|
294
|
+
expect(after).toHaveLength(1);
|
|
295
|
+
expect(after[0]?.token).not.toBe(before[0]?.token);
|
|
296
|
+
expect(await auth.api.getSession({ headers: current })).toBeNull();
|
|
297
|
+
|
|
298
|
+
// Consuming the code lifts the resend cooldown for the next flow (e.g.
|
|
299
|
+
// opening the 2FA-enable dialog right after a re-auth).
|
|
300
|
+
const signIn = await auth.api.signInEmail({
|
|
301
|
+
body: { email: EMAIL, password: OLD_PW },
|
|
302
|
+
returnHeaders: true,
|
|
303
|
+
});
|
|
304
|
+
expect(await requestReauthOtp(auth, cookieHeaders(signIn.headers))).toEqual(
|
|
305
|
+
{ ok: true, retryAfterSeconds: 30 },
|
|
306
|
+
);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("without 2FA: rejects a wrong emailed code and mints nothing", async () => {
|
|
310
|
+
const { auth, db } = await makeAuth();
|
|
311
|
+
const current = await signUpWithSession(auth, db);
|
|
312
|
+
await requestReauthOtp(auth, current);
|
|
313
|
+
|
|
314
|
+
expect(await reauthenticate(auth, current, { code: "000000" })).toEqual({
|
|
315
|
+
ok: false,
|
|
316
|
+
code: "invalid_code",
|
|
317
|
+
});
|
|
318
|
+
const { session } = await import("@/db/auth.schema");
|
|
319
|
+
expect(await db.select().from(session)).toHaveLength(1);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("with 2FA on: refuses to email a code — the authenticator is the factor", async () => {
|
|
323
|
+
const { auth, otps, db } = await makeAuth();
|
|
324
|
+
const signUpHeaders = await signUpWithSession(auth, db);
|
|
325
|
+
const { headers } = await enrollTotp(auth, signUpHeaders);
|
|
326
|
+
const sent = otps.length;
|
|
327
|
+
|
|
328
|
+
expect(await requestReauthOtp(auth, headers)).toEqual({
|
|
329
|
+
ok: false,
|
|
330
|
+
code: "totp_required",
|
|
331
|
+
});
|
|
332
|
+
expect(otps).toHaveLength(sent);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("with 2FA on: a valid authenticator code mints a fresh session without emailing", async () => {
|
|
336
|
+
const { auth, otps, db } = await makeAuth();
|
|
337
|
+
const signUpHeaders = await signUpWithSession(auth, db);
|
|
338
|
+
const { secret, headers } = await enrollTotp(auth, signUpHeaders);
|
|
339
|
+
const { session } = await import("@/db/auth.schema");
|
|
340
|
+
// Enrollment replaced the sign-up session, so exactly one row remains.
|
|
341
|
+
const before = await db.select().from(session);
|
|
342
|
+
expect(before).toHaveLength(1);
|
|
343
|
+
const sent = otps.length;
|
|
344
|
+
|
|
345
|
+
expect(await reauthenticate(auth, headers, { code: totp(secret) })).toEqual(
|
|
346
|
+
{ ok: true },
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
// Same replacement semantics as the email arm: new session, old one dead.
|
|
350
|
+
const after = await db.select().from(session);
|
|
351
|
+
expect(after).toHaveLength(1);
|
|
352
|
+
expect(after[0]?.token).not.toBe(before[0]?.token);
|
|
353
|
+
// The internal one-shot OTP that mints the session must never be emailed.
|
|
354
|
+
expect(otps).toHaveLength(sent);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("with 2FA on: rejects a wrong authenticator code", async () => {
|
|
358
|
+
const { auth, db } = await makeAuth();
|
|
359
|
+
const signUpHeaders = await signUpWithSession(auth, db);
|
|
360
|
+
const { secret, headers } = await enrollTotp(auth, signUpHeaders);
|
|
361
|
+
const wrong = totp(secret) === "000000" ? "111111" : "000000";
|
|
362
|
+
|
|
363
|
+
expect(await reauthenticate(auth, headers, { code: wrong })).toEqual({
|
|
364
|
+
ok: false,
|
|
365
|
+
code: "invalid_code",
|
|
366
|
+
});
|
|
367
|
+
const { session } = await import("@/db/auth.schema");
|
|
368
|
+
expect(await db.select().from(session)).toHaveLength(1);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("with 2FA on: locks after 5 failed codes, even for a then-correct one", async () => {
|
|
372
|
+
const { auth, db } = await makeAuth();
|
|
373
|
+
const signUpHeaders = await signUpWithSession(auth, db);
|
|
374
|
+
const { secret, headers } = await enrollTotp(auth, signUpHeaders);
|
|
375
|
+
const wrong = totp(secret) === "000000" ? "111111" : "000000";
|
|
376
|
+
|
|
377
|
+
for (let i = 0; i < 5; i++) {
|
|
378
|
+
expect(await reauthenticate(auth, headers, { code: wrong })).toEqual({
|
|
379
|
+
ok: false,
|
|
380
|
+
code: "invalid_code",
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
expect(await reauthenticate(auth, headers, { code: totp(secret) })).toEqual(
|
|
384
|
+
{ ok: false, code: "too_many_attempts" },
|
|
385
|
+
);
|
|
386
|
+
const { session } = await import("@/db/auth.schema");
|
|
387
|
+
expect(await db.select().from(session)).toHaveLength(1);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("with 2FA on: accepts a backup code once, then never again", async () => {
|
|
391
|
+
const { auth, otps, db } = await makeAuth();
|
|
392
|
+
const signUpHeaders = await signUpWithSession(auth, db);
|
|
393
|
+
const { backupCodes, headers } = await enrollTotp(auth, signUpHeaders);
|
|
394
|
+
const code = backupCodes[0] ?? "";
|
|
395
|
+
|
|
396
|
+
expect(
|
|
397
|
+
await reauthenticate(auth, headers, { code, backupCode: true }),
|
|
398
|
+
).toEqual({ ok: true });
|
|
399
|
+
const { session } = await import("@/db/auth.schema");
|
|
400
|
+
expect(await db.select().from(session)).toHaveLength(1);
|
|
401
|
+
|
|
402
|
+
// Backup codes are single-use: replay from a fresh session must fail.
|
|
403
|
+
// (The fresh session comes via the server-side OTP sign-in channel —
|
|
404
|
+
// password sign-in would stop at the 2FA interstitial.)
|
|
405
|
+
await auth.api.sendVerificationOTP({
|
|
406
|
+
body: { email: EMAIL, type: "sign-in" },
|
|
407
|
+
});
|
|
408
|
+
const signIn = await auth.api.signInEmailOTP({
|
|
409
|
+
body: { email: EMAIL, otp: otps.at(-1)?.otp ?? "" },
|
|
410
|
+
returnHeaders: true,
|
|
411
|
+
});
|
|
412
|
+
expect(
|
|
413
|
+
await reauthenticate(auth, cookieHeaders(signIn.headers), {
|
|
414
|
+
code,
|
|
415
|
+
backupCode: true,
|
|
416
|
+
}),
|
|
417
|
+
).toEqual({ ok: false, code: "invalid_code" });
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("requires a session", async () => {
|
|
421
|
+
const { auth } = await makeAuth();
|
|
422
|
+
expect(await requestReauthOtp(auth, new Headers())).toEqual({
|
|
423
|
+
ok: false,
|
|
424
|
+
code: "unauthorized",
|
|
425
|
+
});
|
|
426
|
+
expect(await reauthenticate(auth, new Headers(), { code: "x" })).toEqual({
|
|
427
|
+
ok: false,
|
|
428
|
+
code: "unauthorized",
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
describe.skipIf(!hasTestDatabase)(
|
|
434
|
+
"two-factor management via OTP (no password)",
|
|
435
|
+
() => {
|
|
436
|
+
it("enables 2FA with an emailed code, and the result round-trips through Better Auth's own verify", async () => {
|
|
437
|
+
const { auth, otps, db } = await makeAuth();
|
|
438
|
+
const current = await signUpWithSession(auth, db);
|
|
439
|
+
await requestReauthOtp(auth, current);
|
|
440
|
+
const otp = otps.at(-1)?.otp ?? "";
|
|
441
|
+
|
|
442
|
+
const result = await enableTwoFactorWithOtp(auth, current, { otp });
|
|
443
|
+
if (!result.ok) throw new Error(`enable failed: ${result.code}`);
|
|
444
|
+
expect(result.backupCodes).toHaveLength(10);
|
|
445
|
+
const secret = new URL(result.totpURI).searchParams.get("secret") ?? "";
|
|
446
|
+
expect(secret.length).toBeGreaterThan(0);
|
|
447
|
+
|
|
448
|
+
// Not enabled yet — enrollment completes through the OFFICIAL verify
|
|
449
|
+
// endpoint, which decrypts our stored secret (format compatibility).
|
|
450
|
+
const verified = await auth.api.verifyTOTP({
|
|
451
|
+
body: { code: totp(secret) },
|
|
452
|
+
headers: current,
|
|
453
|
+
returnHeaders: true,
|
|
454
|
+
});
|
|
455
|
+
const fresh = cookieHeaders(verified.headers);
|
|
456
|
+
const session = await auth.api.getSession({ headers: fresh });
|
|
457
|
+
expect(session?.user.twoFactorEnabled).toBe(true);
|
|
458
|
+
|
|
459
|
+
// The backup codes we stored verify through the official path too.
|
|
460
|
+
expect(
|
|
461
|
+
await reauthenticate(auth, fresh, {
|
|
462
|
+
code: result.backupCodes[0] ?? "",
|
|
463
|
+
backupCode: true,
|
|
464
|
+
}),
|
|
465
|
+
).toEqual({ ok: true });
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it("rejects a wrong emailed code, and refuses when already enabled", async () => {
|
|
469
|
+
const { auth, otps, db } = await makeAuth();
|
|
470
|
+
const current = await signUpWithSession(auth, db);
|
|
471
|
+
await requestReauthOtp(auth, current);
|
|
472
|
+
const otp = otps.at(-1)?.otp ?? "";
|
|
473
|
+
const wrong = otp === "000000" ? "111111" : "000000";
|
|
474
|
+
|
|
475
|
+
expect(
|
|
476
|
+
await enableTwoFactorWithOtp(auth, current, { otp: wrong }),
|
|
477
|
+
).toEqual({ ok: false, code: "invalid_otp" });
|
|
478
|
+
const { twoFactor: twoFactorTable } = await import("@/db/auth.schema");
|
|
479
|
+
expect(await db.select().from(twoFactorTable)).toHaveLength(0);
|
|
480
|
+
|
|
481
|
+
const { headers } = await enrollTotp(auth, current);
|
|
482
|
+
expect(await enableTwoFactorWithOtp(auth, headers, { otp })).toEqual({
|
|
483
|
+
ok: false,
|
|
484
|
+
code: "already_enabled",
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("disables 2FA with an authenticator code: row gone, trust revoked, session swapped", async () => {
|
|
489
|
+
const { auth, db } = await makeAuth();
|
|
490
|
+
const signUpHeaders = await signUpWithSession(auth, db);
|
|
491
|
+
const { secret, headers } = await enrollTotp(auth, signUpHeaders);
|
|
492
|
+
const { user, session, twoFactor, verification } = await import(
|
|
493
|
+
"@/db/auth.schema"
|
|
494
|
+
);
|
|
495
|
+
const userId = (await db.select().from(user))[0]?.id ?? "";
|
|
496
|
+
// A trusted device from the 2FA era must not survive the disable.
|
|
497
|
+
await db.insert(verification).values({
|
|
498
|
+
id: crypto.randomUUID(),
|
|
499
|
+
identifier: "trust-device-e2e-fixture",
|
|
500
|
+
value: userId,
|
|
501
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
expect(
|
|
505
|
+
await disableTwoFactorWithCode(auth, headers, { code: totp(secret) }),
|
|
506
|
+
).toEqual({ ok: true });
|
|
507
|
+
|
|
508
|
+
expect((await db.select().from(user))[0]?.twoFactorEnabled).toBe(false);
|
|
509
|
+
expect(await db.select().from(twoFactor)).toHaveLength(0);
|
|
510
|
+
const trustRows = await db
|
|
511
|
+
.select()
|
|
512
|
+
.from(verification)
|
|
513
|
+
.where(like(verification.identifier, "trust-device-%"));
|
|
514
|
+
expect(trustRows).toHaveLength(0);
|
|
515
|
+
// Session swapped: exactly one, and the old cookie is dead.
|
|
516
|
+
expect(await db.select().from(session)).toHaveLength(1);
|
|
517
|
+
expect(await auth.api.getSession({ headers })).toBeNull();
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it("keeps 2FA on when the disable code is wrong", async () => {
|
|
521
|
+
const { auth, db } = await makeAuth();
|
|
522
|
+
const signUpHeaders = await signUpWithSession(auth, db);
|
|
523
|
+
const { secret, headers } = await enrollTotp(auth, signUpHeaders);
|
|
524
|
+
const wrong = totp(secret) === "000000" ? "111111" : "000000";
|
|
525
|
+
|
|
526
|
+
expect(
|
|
527
|
+
await disableTwoFactorWithCode(auth, headers, { code: wrong }),
|
|
528
|
+
).toEqual({ ok: false, code: "invalid_code" });
|
|
529
|
+
const { user, twoFactor } = await import("@/db/auth.schema");
|
|
530
|
+
expect((await db.select().from(user))[0]?.twoFactorEnabled).toBe(true);
|
|
531
|
+
expect(await db.select().from(twoFactor)).toHaveLength(1);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it("regenerates backup codes with an authenticator code; old codes die, new ones work", async () => {
|
|
535
|
+
const { auth, db } = await makeAuth();
|
|
536
|
+
const signUpHeaders = await signUpWithSession(auth, db);
|
|
537
|
+
const { secret, backupCodes, headers } = await enrollTotp(
|
|
538
|
+
auth,
|
|
539
|
+
signUpHeaders,
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
const result = await regenerateBackupCodesWithCode(auth, headers, {
|
|
543
|
+
code: totp(secret),
|
|
544
|
+
});
|
|
545
|
+
if (!result.ok) throw new Error(`regenerate failed: ${result.code}`);
|
|
546
|
+
expect(result.backupCodes).toHaveLength(10);
|
|
547
|
+
|
|
548
|
+
// An old code no longer verifies; a new one does.
|
|
549
|
+
expect(
|
|
550
|
+
await reauthenticate(auth, headers, {
|
|
551
|
+
code: backupCodes[0] ?? "",
|
|
552
|
+
backupCode: true,
|
|
553
|
+
}),
|
|
554
|
+
).toEqual({ ok: false, code: "invalid_code" });
|
|
555
|
+
expect(
|
|
556
|
+
await reauthenticate(auth, headers, {
|
|
557
|
+
code: result.backupCodes[0] ?? "",
|
|
558
|
+
backupCode: true,
|
|
559
|
+
}),
|
|
560
|
+
).toEqual({ ok: true });
|
|
561
|
+
});
|
|
562
|
+
},
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
describe.skipIf(!hasTestDatabase)("otp resend cooldown", () => {
|
|
566
|
+
it("refuses a resend inside the 30s window, per flow, until it expires", async () => {
|
|
567
|
+
const { auth, otps, db } = await makeAuth();
|
|
568
|
+
const current = await signUpWithSession(auth, db);
|
|
569
|
+
|
|
570
|
+
expect(await requestReauthOtp(auth, current)).toEqual({
|
|
571
|
+
ok: true,
|
|
572
|
+
retryAfterSeconds: 30,
|
|
573
|
+
});
|
|
574
|
+
const blocked = await requestReauthOtp(auth, current);
|
|
575
|
+
expect(blocked.ok).toBe(false);
|
|
576
|
+
if (!blocked.ok) {
|
|
577
|
+
expect(blocked.code).toBe("cooldown");
|
|
578
|
+
expect(blocked.retryAfterSeconds).toBeGreaterThan(0);
|
|
579
|
+
expect(blocked.retryAfterSeconds).toBeLessThanOrEqual(30);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// The password flow has its own independent window.
|
|
583
|
+
expect(await requestPasswordChangeOtp(auth, current)).toEqual({
|
|
584
|
+
ok: true,
|
|
585
|
+
retryAfterSeconds: 30,
|
|
586
|
+
});
|
|
587
|
+
expect((await requestPasswordChangeOtp(auth, current)).ok).toBe(false);
|
|
588
|
+
|
|
589
|
+
// Only two emails went out despite four requests.
|
|
590
|
+
expect(otps).toHaveLength(2);
|
|
591
|
+
|
|
592
|
+
await expireCooldowns(db);
|
|
593
|
+
expect(await requestReauthOtp(auth, current)).toEqual({
|
|
594
|
+
ok: true,
|
|
595
|
+
retryAfterSeconds: 30,
|
|
596
|
+
});
|
|
597
|
+
expect(otps).toHaveLength(3);
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
describe.skipIf(!hasTestDatabase)("checkPasswordChangeOtp", () => {
|
|
602
|
+
it("verifies the code without consuming it — the change still works after", async () => {
|
|
603
|
+
const { auth, otps, db } = await makeAuth();
|
|
604
|
+
const current = await signUpWithSession(auth, db);
|
|
605
|
+
await requestPasswordChangeOtp(auth, current);
|
|
606
|
+
const otp = otps.at(-1)?.otp ?? "";
|
|
607
|
+
|
|
608
|
+
expect(await checkPasswordChangeOtp(auth, current, { otp })).toEqual({
|
|
609
|
+
ok: true,
|
|
610
|
+
});
|
|
611
|
+
// Not consumed: the very same code then performs the actual change.
|
|
612
|
+
expect(
|
|
613
|
+
await changePasswordWithOtp(auth, current, {
|
|
614
|
+
otp,
|
|
615
|
+
newPassword: NEW_PW,
|
|
616
|
+
}),
|
|
617
|
+
).toEqual({ ok: true });
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it("counts wrong guesses against the shared attempt budget", async () => {
|
|
621
|
+
const { auth, otps, db } = await makeAuth();
|
|
622
|
+
const current = await signUpWithSession(auth, db);
|
|
623
|
+
await requestPasswordChangeOtp(auth, current);
|
|
624
|
+
const otp = otps.at(-1)?.otp ?? "";
|
|
625
|
+
const wrong = otp === "000000" ? "111111" : "000000";
|
|
626
|
+
|
|
627
|
+
for (let i = 0; i < 3; i++) {
|
|
628
|
+
expect(
|
|
629
|
+
await checkPasswordChangeOtp(auth, current, { otp: wrong }),
|
|
630
|
+
).toEqual({ ok: false, code: "invalid_otp" });
|
|
631
|
+
}
|
|
632
|
+
// Budget exhausted: even the right code is now refused.
|
|
633
|
+
expect(await checkPasswordChangeOtp(auth, current, { otp })).toEqual({
|
|
634
|
+
ok: false,
|
|
635
|
+
code: "too_many_attempts",
|
|
636
|
+
});
|
|
637
|
+
});
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
// The gate's deny codes are the error contract with the sign-in page: they
|
|
641
|
+
// arrive as /sign-in?error=<code> via Better Auth's callback redirectOnError.
|
|
642
|
+
describe("evaluateGithubSignup", () => {
|
|
643
|
+
it("allows a GitHub-verified email", () => {
|
|
644
|
+
expect(
|
|
645
|
+
evaluateGithubSignup({ email: "dev@example.com", emailVerified: true }),
|
|
646
|
+
).toEqual({ ok: true });
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it("denies a missing email with the built-in email_not_found code", () => {
|
|
650
|
+
expect(
|
|
651
|
+
evaluateGithubSignup({ email: undefined, emailVerified: false }),
|
|
652
|
+
).toEqual({ ok: false, code: "email_not_found" });
|
|
653
|
+
expect(evaluateGithubSignup({ email: "", emailVerified: true })).toEqual({
|
|
654
|
+
ok: false,
|
|
655
|
+
code: "email_not_found",
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
it("denies an email GitHub has not verified", () => {
|
|
660
|
+
expect(
|
|
661
|
+
evaluateGithubSignup({ email: "dev@example.com", emailVerified: false }),
|
|
662
|
+
).toEqual({ ok: false, code: "github_email_unverified" });
|
|
663
|
+
});
|
|
664
|
+
});
|