@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,893 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Account-security domain logic (CONTEXT.md "Account security").
|
|
3
|
+
*
|
|
4
|
+
* Verified-email invariant: every account's email is verified. GitHub
|
|
5
|
+
* sign-up may only proceed when GitHub supplies an email it has itself
|
|
6
|
+
* verified — otherwise the attempt fails outright (no user, no session).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { APIError } from "better-auth";
|
|
10
|
+
import {
|
|
11
|
+
generateRandomString,
|
|
12
|
+
type SecretConfig,
|
|
13
|
+
symmetricEncrypt,
|
|
14
|
+
} from "better-auth/crypto";
|
|
15
|
+
|
|
16
|
+
/** Adapter where-clause slice: no operator means equality. */
|
|
17
|
+
type SecurityWhere = {
|
|
18
|
+
field: string;
|
|
19
|
+
value: string | Date;
|
|
20
|
+
operator?: "starts_with" | "gt";
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The slice of a Better Auth instance this service needs. Structural on
|
|
25
|
+
* purpose: production `createAuth(db)` satisfies it, and tests can build a
|
|
26
|
+
* minimal instance without the full plugin stack. `$context` is used for
|
|
27
|
+
* the second-factor attempt counter, the OTP resend cooldown, the
|
|
28
|
+
* stale-session replacement and the password-free two-factor mutations —
|
|
29
|
+
* none of which have public api endpoints.
|
|
30
|
+
*/
|
|
31
|
+
export type SecurityAuth = {
|
|
32
|
+
api: {
|
|
33
|
+
// `disableCookieCache` forces a database read. Every credential-change
|
|
34
|
+
// gate below must pass it: the signed cookie-cache blob keeps answering
|
|
35
|
+
// for up to `cookieCache.maxAge` (60s) after the session row is deleted,
|
|
36
|
+
// so a plain read lets a caller whose session was just evicted from
|
|
37
|
+
// another browser — or wiped by `revokeSessionsOnPasswordReset` mid-flow —
|
|
38
|
+
// pass the gate, mutate the credential, and only then hit the 401 that
|
|
39
|
+
// `revokeOtherSessions`/`replaceSession` (authoritative reads) raise.
|
|
40
|
+
getSession(ctx: {
|
|
41
|
+
headers: Headers;
|
|
42
|
+
query?: { disableCookieCache?: boolean };
|
|
43
|
+
}): Promise<{
|
|
44
|
+
user: { id: string; email: string; twoFactorEnabled?: boolean | null };
|
|
45
|
+
session: { token: string; createdAt: Date | string };
|
|
46
|
+
} | null>;
|
|
47
|
+
requestPasswordResetEmailOTP(ctx: {
|
|
48
|
+
body: { email: string };
|
|
49
|
+
}): Promise<unknown>;
|
|
50
|
+
resetPasswordEmailOTP(ctx: {
|
|
51
|
+
body: { email: string; otp: string; password: string };
|
|
52
|
+
}): Promise<unknown>;
|
|
53
|
+
checkVerificationOTP(ctx: {
|
|
54
|
+
body: { email: string; type: "forget-password" | "sign-in"; otp: string };
|
|
55
|
+
}): Promise<unknown>;
|
|
56
|
+
revokeOtherSessions(ctx: { headers: Headers }): Promise<unknown>;
|
|
57
|
+
sendVerificationOTP(ctx: {
|
|
58
|
+
body: { email: string; type: "sign-in" };
|
|
59
|
+
}): Promise<unknown>;
|
|
60
|
+
verifyTOTP(ctx: {
|
|
61
|
+
body: { code: string };
|
|
62
|
+
headers: Headers;
|
|
63
|
+
}): Promise<unknown>;
|
|
64
|
+
verifyBackupCode(ctx: {
|
|
65
|
+
body: { code: string };
|
|
66
|
+
headers: Headers;
|
|
67
|
+
}): Promise<unknown>;
|
|
68
|
+
createVerificationOTP(ctx: {
|
|
69
|
+
body: { email: string; type: "sign-in" };
|
|
70
|
+
}): Promise<string>;
|
|
71
|
+
signInEmailOTP(ctx: {
|
|
72
|
+
body: { email: string; otp: string };
|
|
73
|
+
headers: Headers;
|
|
74
|
+
}): Promise<unknown>;
|
|
75
|
+
};
|
|
76
|
+
$context: Promise<{
|
|
77
|
+
secretConfig: string | SecretConfig;
|
|
78
|
+
adapter: {
|
|
79
|
+
count(args: { model: string; where: SecurityWhere[] }): Promise<number>;
|
|
80
|
+
create(args: {
|
|
81
|
+
model: string;
|
|
82
|
+
data: Record<string, unknown>;
|
|
83
|
+
}): Promise<unknown>;
|
|
84
|
+
update(args: {
|
|
85
|
+
model: string;
|
|
86
|
+
where: SecurityWhere[];
|
|
87
|
+
update: Record<string, unknown>;
|
|
88
|
+
}): Promise<unknown>;
|
|
89
|
+
deleteMany(args: {
|
|
90
|
+
model: string;
|
|
91
|
+
where: SecurityWhere[];
|
|
92
|
+
}): Promise<unknown>;
|
|
93
|
+
};
|
|
94
|
+
internalAdapter: {
|
|
95
|
+
createVerificationValue(value: {
|
|
96
|
+
identifier: string;
|
|
97
|
+
value: string;
|
|
98
|
+
expiresAt: Date;
|
|
99
|
+
}): Promise<unknown>;
|
|
100
|
+
findVerificationValue(
|
|
101
|
+
identifier: string,
|
|
102
|
+
): Promise<{ expiresAt: Date | string } | null | undefined>;
|
|
103
|
+
deleteVerificationByIdentifier(identifier: string): Promise<unknown>;
|
|
104
|
+
deleteSession(token: string): Promise<unknown>;
|
|
105
|
+
updateUser(
|
|
106
|
+
userId: string,
|
|
107
|
+
data: Record<string, unknown>,
|
|
108
|
+
): Promise<unknown>;
|
|
109
|
+
};
|
|
110
|
+
}>;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// -------------------------------------------------------- session eviction
|
|
114
|
+
|
|
115
|
+
/** Everything `evictOtherSessions` needs — satisfied by every auth slice here. */
|
|
116
|
+
type RevocableAuth = {
|
|
117
|
+
api: { revokeOtherSessions(ctx: { headers: Headers }): Promise<unknown> };
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The account-security invariant (CONTEXT.md "Account security"): a change to
|
|
122
|
+
* a credential or to a second factor signs out every session on the account
|
|
123
|
+
* except the caller's own. Written down once so a seventh credential-changing
|
|
124
|
+
* flow cannot silently omit it.
|
|
125
|
+
*
|
|
126
|
+
* Two ordering rules, both load-bearing:
|
|
127
|
+
* - it authenticates by the caller's *current* cookie, so it must run before
|
|
128
|
+
* `replaceSession` — afterwards that cookie names a deleted row;
|
|
129
|
+
* - it is an `auth.api.*` call on a different pooled connection, so in any
|
|
130
|
+
* handler that also writes `context.db` it comes first (AGENTS.md).
|
|
131
|
+
*/
|
|
132
|
+
async function evictOtherSessions(
|
|
133
|
+
auth: RevocableAuth,
|
|
134
|
+
headers: Headers,
|
|
135
|
+
): Promise<void> {
|
|
136
|
+
await auth.api.revokeOtherSessions({ headers });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ------------------------------------------------------------ send cooldown
|
|
140
|
+
|
|
141
|
+
const OTP_RESEND_COOLDOWN_SECONDS = 30;
|
|
142
|
+
|
|
143
|
+
export type OtpSendResult =
|
|
144
|
+
| { ok: true; retryAfterSeconds: number }
|
|
145
|
+
| {
|
|
146
|
+
ok: false;
|
|
147
|
+
code: "unauthorized" | "totp_required" | "cooldown";
|
|
148
|
+
retryAfterSeconds?: number;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Server-side resend throttle shared by every OTP email flow: one send per
|
|
153
|
+
* user per flow per 30s window. The UI countdown mirrors
|
|
154
|
+
* `retryAfterSeconds`, but this gate is the authority — closing the modal
|
|
155
|
+
* or reloading the page cannot sidestep it.
|
|
156
|
+
*/
|
|
157
|
+
async function otpSendGate(
|
|
158
|
+
auth: SecurityAuth,
|
|
159
|
+
kind: "password" | "reauth",
|
|
160
|
+
userId: string,
|
|
161
|
+
): Promise<OtpSendResult> {
|
|
162
|
+
const { internalAdapter } = await auth.$context;
|
|
163
|
+
const identifier = `otp-cooldown-${kind}-${userId}`;
|
|
164
|
+
const existing = await internalAdapter.findVerificationValue(identifier);
|
|
165
|
+
if (existing) {
|
|
166
|
+
const remainingMs = new Date(existing.expiresAt).getTime() - Date.now();
|
|
167
|
+
if (remainingMs > 0)
|
|
168
|
+
return {
|
|
169
|
+
ok: false,
|
|
170
|
+
code: "cooldown",
|
|
171
|
+
retryAfterSeconds: Math.ceil(remainingMs / 1000),
|
|
172
|
+
};
|
|
173
|
+
await internalAdapter.deleteVerificationByIdentifier(identifier);
|
|
174
|
+
}
|
|
175
|
+
await internalAdapter.createVerificationValue({
|
|
176
|
+
identifier,
|
|
177
|
+
value: "1",
|
|
178
|
+
expiresAt: new Date(Date.now() + OTP_RESEND_COOLDOWN_SECONDS * 1000),
|
|
179
|
+
});
|
|
180
|
+
return { ok: true, retryAfterSeconds: OTP_RESEND_COOLDOWN_SECONDS };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The cooldown throttles sends while a code is in flight — once the code is
|
|
185
|
+
* consumed, the next flow may send immediately (e.g. opening the 2FA-enable
|
|
186
|
+
* dialog right after a re-auth spent the previous code).
|
|
187
|
+
*/
|
|
188
|
+
async function clearOtpCooldown(
|
|
189
|
+
auth: SecurityAuth,
|
|
190
|
+
kind: "password" | "reauth",
|
|
191
|
+
userId: string,
|
|
192
|
+
): Promise<void> {
|
|
193
|
+
const { internalAdapter } = await auth.$context;
|
|
194
|
+
await internalAdapter.deleteVerificationByIdentifier(
|
|
195
|
+
`otp-cooldown-${kind}-${userId}`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// -------------------------------------------------------- password change
|
|
200
|
+
|
|
201
|
+
export type PasswordChangeResult =
|
|
202
|
+
| { ok: true }
|
|
203
|
+
| {
|
|
204
|
+
ok: false;
|
|
205
|
+
code:
|
|
206
|
+
| "unauthorized"
|
|
207
|
+
| "invalid_otp"
|
|
208
|
+
| "invalid_password"
|
|
209
|
+
| "too_many_attempts";
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Email a password-change OTP to the calling session's own verified email.
|
|
214
|
+
* The email is taken from the session, never from client input.
|
|
215
|
+
*/
|
|
216
|
+
export async function requestPasswordChangeOtp(
|
|
217
|
+
auth: SecurityAuth,
|
|
218
|
+
headers: Headers,
|
|
219
|
+
): Promise<OtpSendResult> {
|
|
220
|
+
const session = await auth.api.getSession({ headers });
|
|
221
|
+
if (!session) return { ok: false, code: "unauthorized" };
|
|
222
|
+
const gate = await otpSendGate(auth, "password", session.user.id);
|
|
223
|
+
if (!gate.ok) return gate;
|
|
224
|
+
await auth.api.requestPasswordResetEmailOTP({
|
|
225
|
+
body: { email: session.user.email },
|
|
226
|
+
});
|
|
227
|
+
return gate;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Step-1 verification for the two-modal password flow: proves the code is
|
|
232
|
+
* right WITHOUT consuming it (Better Auth's check endpoint only burns
|
|
233
|
+
* attempts on wrong guesses), so the same code still authorizes the actual
|
|
234
|
+
* change in step 2. Wrong guesses here count against the shared 3-attempt
|
|
235
|
+
* budget of the code.
|
|
236
|
+
*/
|
|
237
|
+
export async function checkPasswordChangeOtp(
|
|
238
|
+
auth: SecurityAuth,
|
|
239
|
+
headers: Headers,
|
|
240
|
+
input: { otp: string },
|
|
241
|
+
): Promise<PasswordChangeResult> {
|
|
242
|
+
const session = await auth.api.getSession({ headers });
|
|
243
|
+
if (!session) return { ok: false, code: "unauthorized" };
|
|
244
|
+
try {
|
|
245
|
+
await auth.api.checkVerificationOTP({
|
|
246
|
+
body: {
|
|
247
|
+
email: session.user.email,
|
|
248
|
+
type: "forget-password",
|
|
249
|
+
otp: input.otp,
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
} catch (err) {
|
|
253
|
+
if (err instanceof APIError) {
|
|
254
|
+
const status = (err as { statusCode?: number }).statusCode;
|
|
255
|
+
return {
|
|
256
|
+
ok: false,
|
|
257
|
+
code: status === 403 ? "too_many_attempts" : "invalid_otp",
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
throw err;
|
|
261
|
+
}
|
|
262
|
+
return { ok: true };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* OTP-gated password change (CONTEXT.md "Account security"): verify the OTP,
|
|
267
|
+
* set the new password (creating the credential for passwordless users),
|
|
268
|
+
* then force every session except the caller's off.
|
|
269
|
+
*/
|
|
270
|
+
export async function changePasswordWithOtp(
|
|
271
|
+
auth: SecurityAuth,
|
|
272
|
+
headers: Headers,
|
|
273
|
+
input: { otp: string; newPassword: string },
|
|
274
|
+
): Promise<PasswordChangeResult> {
|
|
275
|
+
const session = await auth.api.getSession({ headers });
|
|
276
|
+
if (!session) return { ok: false, code: "unauthorized" };
|
|
277
|
+
try {
|
|
278
|
+
await auth.api.resetPasswordEmailOTP({
|
|
279
|
+
body: {
|
|
280
|
+
email: session.user.email,
|
|
281
|
+
otp: input.otp,
|
|
282
|
+
password: input.newPassword,
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
} catch (err) {
|
|
286
|
+
if (err instanceof APIError) {
|
|
287
|
+
const status = (err as { statusCode?: number }).statusCode;
|
|
288
|
+
if (status === 403) return { ok: false, code: "too_many_attempts" };
|
|
289
|
+
return {
|
|
290
|
+
ok: false,
|
|
291
|
+
code:
|
|
292
|
+
status === 400 && /password/i.test(err.message ?? "")
|
|
293
|
+
? "invalid_password"
|
|
294
|
+
: "invalid_otp",
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
throw err;
|
|
298
|
+
}
|
|
299
|
+
// Two shapes, one outcome: the caller signed in and nobody else. Which one
|
|
300
|
+
// applies depends on `revokeSessionsOnPasswordReset`, which the reset-by-link
|
|
301
|
+
// arm needs ON and which this OTP endpoint honours too — when it fires, the
|
|
302
|
+
// account-wide wipe has already taken the caller's own session with it, so
|
|
303
|
+
// they get a replacement rather than an eviction they can no longer authorize.
|
|
304
|
+
// The probe must bypass the cookie cache, or under that flag it reports the
|
|
305
|
+
// just-deleted session as alive and takes the eviction branch, which then
|
|
306
|
+
// 401s with the password already changed and no session left.
|
|
307
|
+
if (
|
|
308
|
+
await auth.api.getSession({ headers, query: { disableCookieCache: true } })
|
|
309
|
+
) {
|
|
310
|
+
await evictOtherSessions(auth, headers);
|
|
311
|
+
} else {
|
|
312
|
+
await replaceSession(
|
|
313
|
+
auth,
|
|
314
|
+
session.user.email,
|
|
315
|
+
headers,
|
|
316
|
+
session.session.token,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
await clearOtpCooldown(auth, "password", session.user.id);
|
|
320
|
+
return { ok: true };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// -------------------------------------------------- fresh-session re-auth
|
|
324
|
+
|
|
325
|
+
export type ReauthResult =
|
|
326
|
+
| { ok: true }
|
|
327
|
+
| {
|
|
328
|
+
ok: false;
|
|
329
|
+
code:
|
|
330
|
+
| "unauthorized"
|
|
331
|
+
| "totp_required"
|
|
332
|
+
| "invalid_code"
|
|
333
|
+
| "too_many_attempts";
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const REAUTH_ATTEMPT_PREFIX = "reauth-totp-attempt-";
|
|
337
|
+
const REAUTH_ATTEMPT_LIMIT = 5;
|
|
338
|
+
const REAUTH_ATTEMPT_WINDOW_MS = 15 * 60 * 1000;
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Email a re-auth code to the calling session's own email — the no-2FA arm
|
|
342
|
+
* of the fresh-session challenge. Refused when 2FA is on: the authenticator
|
|
343
|
+
* (or a backup code) is the factor then, and a sign-in OTP would sidestep
|
|
344
|
+
* it.
|
|
345
|
+
*/
|
|
346
|
+
export async function requestReauthOtp(
|
|
347
|
+
auth: SecurityAuth,
|
|
348
|
+
headers: Headers,
|
|
349
|
+
): Promise<OtpSendResult> {
|
|
350
|
+
const session = await auth.api.getSession({ headers });
|
|
351
|
+
if (!session) return { ok: false, code: "unauthorized" };
|
|
352
|
+
if (session.user.twoFactorEnabled)
|
|
353
|
+
return { ok: false, code: "totp_required" };
|
|
354
|
+
const gate = await otpSendGate(auth, "reauth", session.user.id);
|
|
355
|
+
if (!gate.ok) return gate;
|
|
356
|
+
await auth.api.sendVerificationOTP({
|
|
357
|
+
body: { email: session.user.email, type: "sign-in" },
|
|
358
|
+
});
|
|
359
|
+
return gate;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Fresh-session re-auth (sudo mode). Passkey registration and account
|
|
364
|
+
* unlinking sit behind Better Auth's freshSessionMiddleware, which compares
|
|
365
|
+
* `session.createdAt` against `freshAge` — the only way through is a newly
|
|
366
|
+
* minted session. The caller re-proves identity (TOTP or a single-use
|
|
367
|
+
* backup code when 2FA is on, the emailed code otherwise) and on success a
|
|
368
|
+
* one-shot sign-in OTP is created server-side (never emailed) and redeemed
|
|
369
|
+
* via `signInEmailOTP`, whose fresh session cookie reaches the browser
|
|
370
|
+
* through tanstackStartCookies.
|
|
371
|
+
*
|
|
372
|
+
* `/sign-in/email-otp` bypassing the 2FA hook is safe here: the HTTP path is
|
|
373
|
+
* in disabledPaths, and this server-side call happens only after the factor
|
|
374
|
+
* appropriate to the account was just verified.
|
|
375
|
+
*/
|
|
376
|
+
export async function reauthenticate(
|
|
377
|
+
auth: SecurityAuth,
|
|
378
|
+
headers: Headers,
|
|
379
|
+
input: { code: string; backupCode?: boolean },
|
|
380
|
+
): Promise<ReauthResult> {
|
|
381
|
+
const session = await auth.api.getSession({ headers });
|
|
382
|
+
if (!session) return { ok: false, code: "unauthorized" };
|
|
383
|
+
const email = session.user.email;
|
|
384
|
+
|
|
385
|
+
if (session.user.twoFactorEnabled) {
|
|
386
|
+
const verified = await verifySecondFactor(auth, headers, session.user.id, {
|
|
387
|
+
code: input.code,
|
|
388
|
+
backupCode: input.backupCode,
|
|
389
|
+
});
|
|
390
|
+
if (!verified.ok) return verified;
|
|
391
|
+
await replaceSession(auth, email, headers, session.session.token);
|
|
392
|
+
return { ok: true };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
try {
|
|
396
|
+
// Wrong-code budget is the plugin's own allowedAttempts (3 per code).
|
|
397
|
+
// Redeeming the code IS the fresh-session mint for this arm.
|
|
398
|
+
await auth.api.signInEmailOTP({
|
|
399
|
+
body: { email, otp: input.code },
|
|
400
|
+
headers,
|
|
401
|
+
});
|
|
402
|
+
} catch (err) {
|
|
403
|
+
if (err instanceof APIError) {
|
|
404
|
+
const status = (err as { statusCode?: number }).statusCode;
|
|
405
|
+
return {
|
|
406
|
+
ok: false,
|
|
407
|
+
code: status === 403 ? "too_many_attempts" : "invalid_code",
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
throw err;
|
|
411
|
+
}
|
|
412
|
+
// The browser just overwrote its cookie with the fresh session's, so the
|
|
413
|
+
// stale token must not stay redeemable.
|
|
414
|
+
const { internalAdapter } = await auth.$context;
|
|
415
|
+
await internalAdapter.deleteSession(session.session.token);
|
|
416
|
+
await clearOtpCooldown(auth, "reauth", session.user.id);
|
|
417
|
+
return { ok: true };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Mint a brand-new session for the caller and revoke their current one.
|
|
422
|
+
* Mechanism: a one-shot sign-in OTP created server-side (never emailed) and
|
|
423
|
+
* redeemed immediately — the only sanctioned session-minting door reachable
|
|
424
|
+
* from here. The fresh cookie reaches the browser via tanstackStartCookies;
|
|
425
|
+
* with the cookie session cache enabled it also refreshes the cached user
|
|
426
|
+
* snapshot (e.g. after twoFactorEnabled flips).
|
|
427
|
+
*/
|
|
428
|
+
async function replaceSession(
|
|
429
|
+
auth: SecurityAuth,
|
|
430
|
+
email: string,
|
|
431
|
+
headers: Headers,
|
|
432
|
+
staleToken: string,
|
|
433
|
+
): Promise<void> {
|
|
434
|
+
const otp = await auth.api.createVerificationOTP({
|
|
435
|
+
body: { email, type: "sign-in" },
|
|
436
|
+
});
|
|
437
|
+
await auth.api.signInEmailOTP({ body: { email, otp }, headers });
|
|
438
|
+
const { internalAdapter } = await auth.$context;
|
|
439
|
+
await internalAdapter.deleteSession(staleToken);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Prove the caller's second factor (TOTP, or a single-use backup code) under
|
|
444
|
+
* the shared attempt gate. Used by every 2FA-sensitive mutation.
|
|
445
|
+
*/
|
|
446
|
+
async function verifySecondFactor(
|
|
447
|
+
auth: SecurityAuth,
|
|
448
|
+
headers: Headers,
|
|
449
|
+
userId: string,
|
|
450
|
+
input: { code: string; backupCode?: boolean },
|
|
451
|
+
): Promise<
|
|
452
|
+
{ ok: true } | { ok: false; code: "invalid_code" | "too_many_attempts" }
|
|
453
|
+
> {
|
|
454
|
+
const gate = await recordSecondFactorAttempt(auth, userId);
|
|
455
|
+
if (!gate.ok) return gate;
|
|
456
|
+
try {
|
|
457
|
+
if (input.backupCode)
|
|
458
|
+
await auth.api.verifyBackupCode({ body: { code: input.code }, headers });
|
|
459
|
+
else await auth.api.verifyTOTP({ body: { code: input.code }, headers });
|
|
460
|
+
} catch (err) {
|
|
461
|
+
if (err instanceof APIError) return { ok: false, code: "invalid_code" };
|
|
462
|
+
throw err;
|
|
463
|
+
}
|
|
464
|
+
await gate.clear();
|
|
465
|
+
return { ok: true };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Attempt gate for the 2FA arm (TOTP and backup codes): the session-scoped
|
|
470
|
+
* verify endpoints have no built-in throttle, and server-side api calls
|
|
471
|
+
* skip HTTP rate limiting — without this, a stolen session cookie could
|
|
472
|
+
* brute-force the 6-digit space. Insert-then-count so parallel bursts see
|
|
473
|
+
* each other's rows (a read-modify-write counter would let a flood
|
|
474
|
+
* through). Rows expire with the window; a successful re-auth clears them.
|
|
475
|
+
*/
|
|
476
|
+
async function recordSecondFactorAttempt(
|
|
477
|
+
auth: SecurityAuth,
|
|
478
|
+
userId: string,
|
|
479
|
+
): Promise<
|
|
480
|
+
| { ok: false; code: "too_many_attempts" }
|
|
481
|
+
| { ok: true; clear: () => Promise<unknown> }
|
|
482
|
+
> {
|
|
483
|
+
const ctx = await auth.$context;
|
|
484
|
+
const prefix = `${REAUTH_ATTEMPT_PREFIX}${userId}-`;
|
|
485
|
+
await ctx.internalAdapter.createVerificationValue({
|
|
486
|
+
identifier: `${prefix}${crypto.randomUUID()}`,
|
|
487
|
+
value: "1",
|
|
488
|
+
expiresAt: new Date(Date.now() + REAUTH_ATTEMPT_WINDOW_MS),
|
|
489
|
+
});
|
|
490
|
+
const attempts = await ctx.adapter.count({
|
|
491
|
+
model: "verification",
|
|
492
|
+
where: [
|
|
493
|
+
{ field: "identifier", operator: "starts_with", value: prefix },
|
|
494
|
+
{ field: "expiresAt", operator: "gt", value: new Date() },
|
|
495
|
+
],
|
|
496
|
+
});
|
|
497
|
+
if (attempts > REAUTH_ATTEMPT_LIMIT)
|
|
498
|
+
return { ok: false, code: "too_many_attempts" };
|
|
499
|
+
return {
|
|
500
|
+
ok: true,
|
|
501
|
+
clear: () =>
|
|
502
|
+
ctx.adapter.deleteMany({
|
|
503
|
+
model: "verification",
|
|
504
|
+
where: [
|
|
505
|
+
{ field: "identifier", operator: "starts_with", value: prefix },
|
|
506
|
+
],
|
|
507
|
+
}),
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// ---------------------------------------------------- passkey removal (fresh)
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Focused slice for passkey removal — the only operation here that needs the
|
|
515
|
+
* delete endpoint and the freshness config. Kept separate from SecurityAuth
|
|
516
|
+
* so callers/tests that never touch passkeys aren't forced to include the
|
|
517
|
+
* passkey plugin.
|
|
518
|
+
*/
|
|
519
|
+
/** All the freshness gate itself needs — no passkey plugin required. */
|
|
520
|
+
export type FreshSessionAuth = {
|
|
521
|
+
api: {
|
|
522
|
+
getSession(ctx: {
|
|
523
|
+
headers: Headers;
|
|
524
|
+
query?: { disableCookieCache?: boolean };
|
|
525
|
+
}): Promise<{
|
|
526
|
+
session: { createdAt: Date | string };
|
|
527
|
+
} | null>;
|
|
528
|
+
};
|
|
529
|
+
$context: Promise<{ sessionConfig: { freshAge: number } }>;
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
export type PasskeyRemovalAuth = FreshSessionAuth &
|
|
533
|
+
RevocableAuth & {
|
|
534
|
+
api: {
|
|
535
|
+
deletePasskey(ctx: {
|
|
536
|
+
body: { id: string };
|
|
537
|
+
headers: Headers;
|
|
538
|
+
}): Promise<unknown>;
|
|
539
|
+
};
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
export type PasskeyRemoveResult =
|
|
543
|
+
| { ok: true }
|
|
544
|
+
| { ok: false; code: "unauthorized" | "not_fresh" };
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Whether the session clears Better Auth's freshness bar, replicating
|
|
548
|
+
* `freshSessionMiddleware` exactly (`sessionConfig.freshAge`, 0 = disabled).
|
|
549
|
+
* The fresh check must be identical to the one guarding passkey ADD so the
|
|
550
|
+
* two operations demand the same proof of a recent login.
|
|
551
|
+
*/
|
|
552
|
+
async function isSessionFresh(
|
|
553
|
+
auth: FreshSessionAuth,
|
|
554
|
+
createdAt: Date | string,
|
|
555
|
+
maxAgeSeconds?: number,
|
|
556
|
+
): Promise<boolean> {
|
|
557
|
+
const { sessionConfig } = await auth.$context;
|
|
558
|
+
const freshAge = maxAgeSeconds ?? sessionConfig.freshAge;
|
|
559
|
+
if (freshAge === 0) return true;
|
|
560
|
+
const age = Date.now() - new Date(createdAt).getTime();
|
|
561
|
+
return age < freshAge * 1000;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* How recently a session must have proved itself for high-risk product
|
|
566
|
+
* mutations (short “sudo” window; see ADR 0008).
|
|
567
|
+
*
|
|
568
|
+
* Deliberately far shorter than Better Auth's `freshAge` (24h), which the
|
|
569
|
+
* passkey gates use: a cookie stolen from someone who signed in this morning
|
|
570
|
+
* clears 24h without proving anything, which is exactly the theft this is meant
|
|
571
|
+
* to stop. `reauthenticate` mints a new session in both arms, so the retried
|
|
572
|
+
* operation lands inside this window. The Starter documents the constant;
|
|
573
|
+
* product pages that use it live in clones.
|
|
574
|
+
*/
|
|
575
|
+
export const MONEY_REAUTH_MAX_AGE_SECONDS = 5 * 60;
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* The shared gate behind every operation that demands a recent proof of
|
|
579
|
+
* identity. Defaults to Better Auth's own freshness bar, so the passkey paths
|
|
580
|
+
* keep asking for exactly what they asked for before; high-risk product
|
|
581
|
+
* operations may pass the short sudo window above.
|
|
582
|
+
*
|
|
583
|
+
* A refusal is a *code*, not a throw: the UI turns `not_fresh` into the
|
|
584
|
+
* re-authentication dialog and replays the original call, and an operation the
|
|
585
|
+
* user abandons at that prompt has changed nothing.
|
|
586
|
+
*/
|
|
587
|
+
export async function requireFreshSession(
|
|
588
|
+
auth: FreshSessionAuth,
|
|
589
|
+
headers: Headers,
|
|
590
|
+
maxAgeSeconds: number = MONEY_REAUTH_MAX_AGE_SECONDS,
|
|
591
|
+
): Promise<{ ok: true } | { ok: false; code: "unauthorized" | "not_fresh" }> {
|
|
592
|
+
// Authoritative read: a freshness gate must never trust the cookie cache,
|
|
593
|
+
// which would report a just-evicted session as still present (and fresh).
|
|
594
|
+
const session = await auth.api.getSession({
|
|
595
|
+
headers,
|
|
596
|
+
query: { disableCookieCache: true },
|
|
597
|
+
});
|
|
598
|
+
if (!session) return { ok: false, code: "unauthorized" };
|
|
599
|
+
if (!(await isSessionFresh(auth, session.session.createdAt, maxAgeSeconds))) {
|
|
600
|
+
return { ok: false, code: "not_fresh" };
|
|
601
|
+
}
|
|
602
|
+
return { ok: true };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Remove a passkey behind the SAME fresh-session gate as adding one. Better
|
|
607
|
+
* Auth's `/passkey/delete-passkey` uses only `sessionMiddleware`, so a stolen
|
|
608
|
+
* stale cookie could otherwise strip a victim's passkeys (a security
|
|
609
|
+
* downgrade / lock-out) even though it cannot add one. A stale session gets
|
|
610
|
+
* `not_fresh`, which the UI turns into the re-auth dialog (TOTP for 2FA
|
|
611
|
+
* users) and retries — matching passkey addition and account unlink.
|
|
612
|
+
*
|
|
613
|
+
* Retiring a sign-in credential evicts every other session: the device whose
|
|
614
|
+
* passkey this was must not keep a live session on the account.
|
|
615
|
+
*/
|
|
616
|
+
export async function removePasskeyWithFreshSession(
|
|
617
|
+
auth: PasskeyRemovalAuth,
|
|
618
|
+
headers: Headers,
|
|
619
|
+
input: { passkeyId: string },
|
|
620
|
+
): Promise<PasskeyRemoveResult> {
|
|
621
|
+
const { sessionConfig } = await auth.$context;
|
|
622
|
+
const fresh = await requireFreshSession(
|
|
623
|
+
auth,
|
|
624
|
+
headers,
|
|
625
|
+
sessionConfig.freshAge,
|
|
626
|
+
);
|
|
627
|
+
if (!fresh.ok) return fresh;
|
|
628
|
+
await auth.api.deletePasskey({ body: { id: input.passkeyId }, headers });
|
|
629
|
+
await evictOtherSessions(auth, headers);
|
|
630
|
+
return { ok: true };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// ------------------------------------------- two-factor management (no password)
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Single source for the TOTP issuer — `twoFactor({ issuer })` in the auth
|
|
637
|
+
* config must use this same value so enrollment URIs stay consistent.
|
|
638
|
+
*/
|
|
639
|
+
export const TWO_FACTOR_ISSUER = "Starter";
|
|
640
|
+
|
|
641
|
+
export type TwoFactorEnableResult =
|
|
642
|
+
| { ok: true; totpURI: string; backupCodes: string[] }
|
|
643
|
+
| {
|
|
644
|
+
ok: false;
|
|
645
|
+
code:
|
|
646
|
+
| "unauthorized"
|
|
647
|
+
| "invalid_otp"
|
|
648
|
+
| "too_many_attempts"
|
|
649
|
+
| "already_enabled";
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
export type TwoFactorManageResult =
|
|
653
|
+
| { ok: true }
|
|
654
|
+
| {
|
|
655
|
+
ok: false;
|
|
656
|
+
code:
|
|
657
|
+
| "unauthorized"
|
|
658
|
+
| "not_enabled"
|
|
659
|
+
| "invalid_code"
|
|
660
|
+
| "too_many_attempts";
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
export type BackupCodesResult =
|
|
664
|
+
| { ok: true; backupCodes: string[] }
|
|
665
|
+
| Extract<TwoFactorManageResult, { ok: false }>;
|
|
666
|
+
|
|
667
|
+
/** RFC 4648 base32 (unpadded) — how otpauth URIs carry the raw secret. */
|
|
668
|
+
function base32Encode(input: string): string {
|
|
669
|
+
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
670
|
+
let bits = 0;
|
|
671
|
+
let value = 0;
|
|
672
|
+
let out = "";
|
|
673
|
+
for (const byte of new TextEncoder().encode(input)) {
|
|
674
|
+
value = (value << 8) | byte;
|
|
675
|
+
bits += 8;
|
|
676
|
+
while (bits >= 5) {
|
|
677
|
+
out += alphabet[(value >>> (bits - 5)) & 31];
|
|
678
|
+
bits -= 5;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
if (bits > 0) out += alphabet[(value << (5 - bits)) & 31];
|
|
682
|
+
return out;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** Same shape as Better Auth's default backup codes: 10 × `xxxxx-xxxxx`. */
|
|
686
|
+
function newBackupCodes(): string[] {
|
|
687
|
+
return Array.from({ length: 10 }, () => {
|
|
688
|
+
const code = generateRandomString(10, "a-z", "0-9", "A-Z");
|
|
689
|
+
return `${code.slice(0, 5)}-${code.slice(5)}`;
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** Better Auth's default at-rest format for the twoFactor.backupCodes
|
|
694
|
+
* column: the JSON array encrypted with the auth secret (pinned by the
|
|
695
|
+
* round-trip tests against the official verify endpoint). */
|
|
696
|
+
function encodeBackupCodes(
|
|
697
|
+
secretConfig: string | SecretConfig,
|
|
698
|
+
codes: string[],
|
|
699
|
+
): Promise<string> {
|
|
700
|
+
return symmetricEncrypt({ key: secretConfig, data: JSON.stringify(codes) });
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Enable 2FA gated by an emailed one-time code — never a password (the
|
|
705
|
+
* password may be exactly what the user no longer trusts, and GitHub-only
|
|
706
|
+
* users have none). Better Auth's own `/two-factor/enable` cannot skip the
|
|
707
|
+
* password check for credential users, so this replicates its mutation
|
|
708
|
+
* (same secret encryption via `symmetricEncrypt`, same plaintext-JSON
|
|
709
|
+
* backup-code storage as our config) and the round-trip is pinned by tests
|
|
710
|
+
* against the official verify endpoints. The HTTP management endpoints are
|
|
711
|
+
* in disabledPaths; this is the only enable path.
|
|
712
|
+
*
|
|
713
|
+
* `twoFactorEnabled` stays false until the user confirms a code from their
|
|
714
|
+
* app (the official enrollment completion via `/two-factor/verify-totp`).
|
|
715
|
+
*
|
|
716
|
+
* The other sessions go at this point rather than at that confirmation: this
|
|
717
|
+
* is the step that proves control of the verified email, and it is the only
|
|
718
|
+
* enable path, so the invariant cannot be reached from the plugin's own
|
|
719
|
+
* `/two-factor/verify-totp` — which also serves the routine sign-in challenge
|
|
720
|
+
* and must never sign a returning user out of their other devices. The cost
|
|
721
|
+
* is that abandoning enrolment after the QR code still evicts them.
|
|
722
|
+
*/
|
|
723
|
+
export async function enableTwoFactorWithOtp(
|
|
724
|
+
auth: SecurityAuth,
|
|
725
|
+
headers: Headers,
|
|
726
|
+
input: { otp: string },
|
|
727
|
+
): Promise<TwoFactorEnableResult> {
|
|
728
|
+
// Authoritative: the flow evicts every other session after writing the
|
|
729
|
+
// twoFactor row, so a caller whose own row is already gone must be refused
|
|
730
|
+
// here — before the write — not left with a written row and a thrown 401.
|
|
731
|
+
const session = await auth.api.getSession({
|
|
732
|
+
headers,
|
|
733
|
+
query: { disableCookieCache: true },
|
|
734
|
+
});
|
|
735
|
+
if (!session) return { ok: false, code: "unauthorized" };
|
|
736
|
+
if (session.user.twoFactorEnabled)
|
|
737
|
+
return { ok: false, code: "already_enabled" };
|
|
738
|
+
try {
|
|
739
|
+
await auth.api.checkVerificationOTP({
|
|
740
|
+
body: { email: session.user.email, type: "sign-in", otp: input.otp },
|
|
741
|
+
});
|
|
742
|
+
} catch (err) {
|
|
743
|
+
if (err instanceof APIError) {
|
|
744
|
+
const status = (err as { statusCode?: number }).statusCode;
|
|
745
|
+
return {
|
|
746
|
+
ok: false,
|
|
747
|
+
code: status === 403 ? "too_many_attempts" : "invalid_otp",
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
throw err;
|
|
751
|
+
}
|
|
752
|
+
const ctx = await auth.$context;
|
|
753
|
+
const secret = generateRandomString(32);
|
|
754
|
+
const backupCodes = newBackupCodes();
|
|
755
|
+
// Replaces any abandoned enrollment for this user.
|
|
756
|
+
await ctx.adapter.deleteMany({
|
|
757
|
+
model: "twoFactor",
|
|
758
|
+
where: [{ field: "userId", value: session.user.id }],
|
|
759
|
+
});
|
|
760
|
+
await ctx.adapter.create({
|
|
761
|
+
model: "twoFactor",
|
|
762
|
+
data: {
|
|
763
|
+
secret: await symmetricEncrypt({ key: ctx.secretConfig, data: secret }),
|
|
764
|
+
backupCodes: await encodeBackupCodes(ctx.secretConfig, backupCodes),
|
|
765
|
+
userId: session.user.id,
|
|
766
|
+
verified: false,
|
|
767
|
+
},
|
|
768
|
+
});
|
|
769
|
+
await evictOtherSessions(auth, headers);
|
|
770
|
+
const issuer = encodeURIComponent(TWO_FACTOR_ISSUER);
|
|
771
|
+
const totpURI = `otpauth://totp/${issuer}:${encodeURIComponent(
|
|
772
|
+
session.user.email,
|
|
773
|
+
)}?secret=${base32Encode(secret)}&issuer=${issuer}&digits=6&period=30`;
|
|
774
|
+
return { ok: true, totpURI, backupCodes };
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Disable 2FA gated by the factor itself (TOTP or a backup code) — a stolen
|
|
779
|
+
* password must not be enough to strip the account's second factor. Mirrors
|
|
780
|
+
* the official endpoint's effects: flag off, row deleted, session swapped
|
|
781
|
+
* (so the cookie cache stops saying twoFactorEnabled), and trusted-device
|
|
782
|
+
* records revoked — for ALL devices, since we can't expire the calling
|
|
783
|
+
* device's cookie from here; without its verification row a trust cookie is
|
|
784
|
+
* inert.
|
|
785
|
+
*/
|
|
786
|
+
export async function disableTwoFactorWithCode(
|
|
787
|
+
auth: SecurityAuth,
|
|
788
|
+
headers: Headers,
|
|
789
|
+
input: { code: string; backupCode?: boolean },
|
|
790
|
+
): Promise<TwoFactorManageResult> {
|
|
791
|
+
// Authoritative: this deletes the twoFactor row and evicts other sessions,
|
|
792
|
+
// so a caller whose own row is already gone is refused before either write.
|
|
793
|
+
const session = await auth.api.getSession({
|
|
794
|
+
headers,
|
|
795
|
+
query: { disableCookieCache: true },
|
|
796
|
+
});
|
|
797
|
+
if (!session) return { ok: false, code: "unauthorized" };
|
|
798
|
+
if (!session.user.twoFactorEnabled) return { ok: false, code: "not_enabled" };
|
|
799
|
+
const verified = await verifySecondFactor(
|
|
800
|
+
auth,
|
|
801
|
+
headers,
|
|
802
|
+
session.user.id,
|
|
803
|
+
input,
|
|
804
|
+
);
|
|
805
|
+
if (!verified.ok) return verified;
|
|
806
|
+
|
|
807
|
+
const ctx = await auth.$context;
|
|
808
|
+
await ctx.internalAdapter.updateUser(session.user.id, {
|
|
809
|
+
twoFactorEnabled: false,
|
|
810
|
+
});
|
|
811
|
+
await ctx.adapter.deleteMany({
|
|
812
|
+
model: "twoFactor",
|
|
813
|
+
where: [{ field: "userId", value: session.user.id }],
|
|
814
|
+
});
|
|
815
|
+
await ctx.adapter.deleteMany({
|
|
816
|
+
model: "verification",
|
|
817
|
+
where: [
|
|
818
|
+
{ field: "identifier", operator: "starts_with", value: "trust-device-" },
|
|
819
|
+
{ field: "value", value: session.user.id },
|
|
820
|
+
],
|
|
821
|
+
});
|
|
822
|
+
// Before the swap: `headers` still names a live session here, and after it
|
|
823
|
+
// they would not.
|
|
824
|
+
await evictOtherSessions(auth, headers);
|
|
825
|
+
await replaceSession(
|
|
826
|
+
auth,
|
|
827
|
+
session.user.email,
|
|
828
|
+
headers,
|
|
829
|
+
session.session.token,
|
|
830
|
+
);
|
|
831
|
+
return { ok: true };
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* Rotate backup codes, gated like disable (the old codes are invalidated,
|
|
836
|
+
* which is 2FA-sensitive). Storage format matches enable.
|
|
837
|
+
*/
|
|
838
|
+
export async function regenerateBackupCodesWithCode(
|
|
839
|
+
auth: SecurityAuth,
|
|
840
|
+
headers: Headers,
|
|
841
|
+
input: { code: string; backupCode?: boolean },
|
|
842
|
+
): Promise<BackupCodesResult> {
|
|
843
|
+
// Authoritative: this rotates the backup codes and evicts other sessions,
|
|
844
|
+
// so a caller whose own row is already gone is refused before the rotation
|
|
845
|
+
// overwrites the codes.
|
|
846
|
+
const session = await auth.api.getSession({
|
|
847
|
+
headers,
|
|
848
|
+
query: { disableCookieCache: true },
|
|
849
|
+
});
|
|
850
|
+
if (!session) return { ok: false, code: "unauthorized" };
|
|
851
|
+
if (!session.user.twoFactorEnabled) return { ok: false, code: "not_enabled" };
|
|
852
|
+
const verified = await verifySecondFactor(
|
|
853
|
+
auth,
|
|
854
|
+
headers,
|
|
855
|
+
session.user.id,
|
|
856
|
+
input,
|
|
857
|
+
);
|
|
858
|
+
if (!verified.ok) return verified;
|
|
859
|
+
|
|
860
|
+
const ctx = await auth.$context;
|
|
861
|
+
const backupCodes = newBackupCodes();
|
|
862
|
+
await ctx.adapter.update({
|
|
863
|
+
model: "twoFactor",
|
|
864
|
+
where: [{ field: "userId", value: session.user.id }],
|
|
865
|
+
update: {
|
|
866
|
+
backupCodes: await encodeBackupCodes(ctx.secretConfig, backupCodes),
|
|
867
|
+
},
|
|
868
|
+
});
|
|
869
|
+
await evictOtherSessions(auth, headers);
|
|
870
|
+
return { ok: true, backupCodes };
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// ------------------------------------------------------------ github gate
|
|
874
|
+
|
|
875
|
+
export type GithubSignupVerdict =
|
|
876
|
+
| { ok: true }
|
|
877
|
+
| { ok: false; code: "email_not_found" | "github_email_unverified" };
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Gate for creating a user from a GitHub profile. Deny codes double as the
|
|
881
|
+
* `/sign-in?error=<code>` redirect contract: `email_not_found` matches the
|
|
882
|
+
* code Better Auth's callback already emits when the profile has no email
|
|
883
|
+
* (this gate is the defense-in-depth backstop behind that check).
|
|
884
|
+
*/
|
|
885
|
+
export function evaluateGithubSignup(profile: {
|
|
886
|
+
email: string | null | undefined;
|
|
887
|
+
emailVerified: boolean;
|
|
888
|
+
}): GithubSignupVerdict {
|
|
889
|
+
if (!profile.email) return { ok: false, code: "email_not_found" };
|
|
890
|
+
if (!profile.emailVerified)
|
|
891
|
+
return { ok: false, code: "github_email_unverified" };
|
|
892
|
+
return { ok: true };
|
|
893
|
+
}
|