Skip to content

Commit bfd748a

Browse files
committed
feat(security): enforce Better-Auth 6-digit OTP, Magic Link, and Google OAuth on Studio Portals
1 parent 7de05f7 commit bfd748a

16 files changed

Lines changed: 1376 additions & 585 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/home/alex/codingcat.dev/skills/codingcat-excalidraw-diagrams

‎apps/site/src/lib/auth.ts‎

Lines changed: 248 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { env } from "cloudflare:workers";
22
import { mcp } from "@better-auth/mcp";
33
import { betterAuth } from "better-auth";
4-
import { jwt } from "better-auth/plugins";
4+
import { emailOTP, jwt, magicLink } from "better-auth/plugins";
5+
import { sendCodingCatEmail } from "@/lib/email";
56

67
export const BETTER_AUTH_SECRET =
78
(env as unknown as { BETTER_AUTH_SECRET?: string })?.BETTER_AUTH_SECRET ||
9+
(env as unknown as { GUEST_PORTAL_SECRET?: string })?.GUEST_PORTAL_SECRET ||
810
"codingcat-mcp-oauth2-secret-secure-random-2026-key-better-auth";
911

1012
export const MCP_DEFAULT_SCOPES = [
@@ -47,34 +49,232 @@ export async function hashToken(token: string): Promise<string> {
4749
}
4850

4951
/**
50-
* Ensures a default guest/explorer user and active session exist in Cloudflare D1
51-
* for automated MCP server account linking.
52+
* Ensures core Better-Auth tables (`user`, `session`, `account`, `verification`)
53+
* and the default MCP explorer user exist in Cloudflare D1.
5254
*/
5355
export async function ensureDefaultUserAndSession(db: D1Database) {
5456
const now = Date.now();
55-
await db
56-
.prepare(
57-
`INSERT OR IGNORE INTO user (id, name, email, emailVerified, createdAt, updatedAt)
58-
VALUES ('user_codingcat_explorer', 'CodingCat Dev Explorer', 'explorer@codingcat.dev', 1, ?, ?)`,
59-
)
60-
.bind(now, now)
61-
.run();
62-
63-
await db
64-
.prepare(
65-
`INSERT OR IGNORE INTO session (id, expiresAt, token, createdAt, updatedAt, userId)
66-
VALUES ('sess_codingcat_default', 2147483647000, 'cc_mcp_session_token', ?, ?, 'user_codingcat_explorer')`,
67-
)
68-
.bind(now, now)
69-
.run();
57+
await db.batch([
58+
db.prepare(
59+
`CREATE TABLE IF NOT EXISTS "user" (
60+
"id" text PRIMARY KEY NOT NULL,
61+
"name" text NOT NULL,
62+
"email" text NOT NULL UNIQUE,
63+
"emailVerified" integer NOT NULL DEFAULT 0,
64+
"image" text,
65+
"createdAt" integer NOT NULL,
66+
"updatedAt" integer NOT NULL
67+
)`,
68+
),
69+
db.prepare(
70+
`CREATE TABLE IF NOT EXISTS "session" (
71+
"id" text PRIMARY KEY NOT NULL,
72+
"expiresAt" integer NOT NULL,
73+
"token" text NOT NULL UNIQUE,
74+
"createdAt" integer NOT NULL,
75+
"updatedAt" integer NOT NULL,
76+
"ipAddress" text,
77+
"userAgent" text,
78+
"userId" text NOT NULL REFERENCES "user"("id") ON DELETE CASCADE
79+
)`,
80+
),
81+
db.prepare(
82+
`CREATE TABLE IF NOT EXISTS "account" (
83+
"id" text PRIMARY KEY NOT NULL,
84+
"accountId" text NOT NULL,
85+
"providerId" text NOT NULL,
86+
"userId" text NOT NULL REFERENCES "user"("id") ON DELETE CASCADE,
87+
"accessToken" text,
88+
"refreshToken" text,
89+
"idToken" text,
90+
"accessTokenExpiresAt" integer,
91+
"refreshTokenExpiresAt" integer,
92+
"scope" text,
93+
"password" text,
94+
"createdAt" integer NOT NULL,
95+
"updatedAt" integer NOT NULL
96+
)`,
97+
),
98+
db.prepare(
99+
`CREATE TABLE IF NOT EXISTS "verification" (
100+
"id" text PRIMARY KEY NOT NULL,
101+
"identifier" text NOT NULL,
102+
"value" text NOT NULL,
103+
"expiresAt" integer NOT NULL,
104+
"createdAt" integer NOT NULL,
105+
"updatedAt" integer NOT NULL
106+
)`,
107+
),
108+
db
109+
.prepare(
110+
`INSERT OR IGNORE INTO user (id, name, email, emailVerified, createdAt, updatedAt)
111+
VALUES ('user_codingcat_explorer', 'CodingCat Dev Explorer', 'explorer@codingcat.dev', 1, ?, ?)`,
112+
)
113+
.bind(now, now),
114+
db
115+
.prepare(
116+
`INSERT OR IGNORE INTO session (id, expiresAt, token, createdAt, updatedAt, userId)
117+
VALUES ('sess_codingcat_default', 2147483647000, 'cc_mcp_session_token', ?, ?, 'user_codingcat_explorer')`,
118+
)
119+
.bind(now, now),
120+
]);
121+
}
122+
123+
function resolveSocialProviders() {
124+
const workerEnv = (env ?? {}) as unknown as Record<
125+
string,
126+
string | undefined
127+
>;
128+
const googleClientId =
129+
workerEnv.GOOGLE_CLIENT_ID || process.env.GOOGLE_CLIENT_ID;
130+
const googleClientSecret =
131+
workerEnv.GOOGLE_CLIENT_SECRET || process.env.GOOGLE_CLIENT_SECRET;
132+
const githubClientId =
133+
workerEnv.GITHUB_CLIENT_ID || process.env.GITHUB_CLIENT_ID;
134+
const githubClientSecret =
135+
workerEnv.GITHUB_CLIENT_SECRET || process.env.GITHUB_CLIENT_SECRET;
136+
137+
const providers: Record<string, any> = {};
138+
if (googleClientId && googleClientSecret) {
139+
providers.google = {
140+
clientId: googleClientId,
141+
clientSecret: googleClientSecret,
142+
prompt: "select_account",
143+
};
144+
}
145+
if (githubClientId && githubClientSecret) {
146+
providers.github = {
147+
clientId: githubClientId,
148+
clientSecret: githubClientSecret,
149+
};
150+
}
151+
return providers;
152+
}
153+
154+
function buildAuthPlugins(origin: string) {
155+
return [
156+
jwt(),
157+
emailOTP({
158+
otpLength: 6,
159+
expiresIn: 300,
160+
allowedAttempts: 3,
161+
storeOTP: "hashed",
162+
async sendVerificationOTP({ email, otp, type }) {
163+
const subject = `CodingCat.dev Studio Sign-In Code: ${otp}`;
164+
const text = [
165+
"CodingCat.dev Studio & Deal Room Verification",
166+
"==============================================",
167+
"",
168+
`Your 6-digit One-Time Password (OTP) for ${email} is:`,
169+
"",
170+
` ${otp}`,
171+
"",
172+
`This code expires in 5 minutes (Verification type: ${type}).`,
173+
"If you did not request this code, you can safely ignore this email.",
174+
"",
175+
"Alex Patterson",
176+
"Founder, CodingCat.dev",
177+
].join("\n");
178+
179+
const html = `
180+
<div style="font-family: Inter, system-ui, sans-serif; max-width: 520px; margin: 0 auto; padding: 28px; background: #0B1326; color: #F8FAFC; border-radius: 16px; border: 2px solid #7C3AED;">
181+
<div style="font-family: monospace; font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: #C084FC; font-weight: 700; margin-bottom: 8px;">
182+
CodingCat.dev Studio &middot; Better-Auth OTP
183+
</div>
184+
<h1 style="font-size: 22px; font-weight: 800; margin: 0 0 12px; color: #FFFFFF;">
185+
Your 6-Digit Studio Sign-In Code
186+
</h1>
187+
<p style="font-size: 14px; line-height: 1.6; color: #CBD5E1; margin: 0 0 20px;">
188+
Enter this One-Time Password on your CodingCat.dev Studio Portal to verify <strong>${email}</strong>:
189+
</p>
190+
<div style="background: #131B2E; border: 2px solid #C084FC; border-radius: 12px; padding: 18px; text-align: center; font-family: monospace; font-size: 32px; font-weight: 800; letter-spacing: 0.25em; color: #86EFAC; margin-bottom: 20px;">
191+
${otp}
192+
</div>
193+
<p style="font-size: 12px; color: #94A3B8; margin: 0;">
194+
This code expires in 5 minutes and can only be used once.
195+
</p>
196+
</div>
197+
`;
198+
199+
await sendCodingCatEmail({
200+
to: email,
201+
subject,
202+
text,
203+
html,
204+
});
205+
},
206+
}),
207+
magicLink({
208+
expiresIn: 300,
209+
storeToken: "hashed",
210+
async sendMagicLink({ email, url }) {
211+
const subject = "Sign in to CodingCat.dev Studio Portal (Magic Link)";
212+
const text = [
213+
"CodingCat.dev Studio & Deal Room Magic Link",
214+
"===========================================",
215+
"",
216+
`Click the single-use link below to sign in as ${email}:`,
217+
"",
218+
url,
219+
"",
220+
"This link expires in 5 minutes.",
221+
"",
222+
"Alex Patterson",
223+
"Founder, CodingCat.dev",
224+
].join("\n");
225+
226+
const html = `
227+
<div style="font-family: Inter, system-ui, sans-serif; max-width: 520px; margin: 0 auto; padding: 28px; background: #0B1326; color: #F8FAFC; border-radius: 16px; border: 2px solid #7C3AED;">
228+
<div style="font-family: monospace; font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: #C084FC; font-weight: 700; margin-bottom: 8px;">
229+
CodingCat.dev Studio &middot; Better-Auth Magic Link
230+
</div>
231+
<h1 style="font-size: 22px; font-weight: 800; margin: 0 0 12px; color: #FFFFFF;">
232+
1-Click Studio Portal Sign-In
233+
</h1>
234+
<p style="font-size: 14px; line-height: 1.6; color: #CBD5E1; margin: 0 0 22px;">
235+
Click the button below to verify <strong>${email}</strong> and unlock your CodingCat.dev Studio Portal:
236+
</p>
237+
<div style="margin-bottom: 22px;">
238+
<a href="${url}" style="display: inline-block; background: #7C3AED; color: #FFFFFF; font-weight: 700; font-size: 14px; text-decoration: none; padding: 12px 24px; border-radius: 10px;">
239+
Unlock Studio Portal &rarr;
240+
</a>
241+
</div>
242+
<p style="font-size: 11px; color: #94A3B8; word-break: break-all; margin: 0;">
243+
Or copy and paste this URL into your browser:<br>${url}
244+
</p>
245+
</div>
246+
`;
247+
248+
await sendCodingCatEmail({
249+
to: email,
250+
subject,
251+
text,
252+
html,
253+
});
254+
},
255+
}),
256+
mcp({
257+
resource: `${origin}/mcp`,
258+
resources: [`${origin}/mcp`, origin],
259+
clientRegistrationDefaultResources: [`${origin}/mcp`, origin],
260+
loginPage: "/sign-in",
261+
consentPage: "/oauth2/consent",
262+
allowDynamicClientRegistration: true,
263+
allowUnauthenticatedClientRegistration: true,
264+
clientRegistrationRequirePKCE: false,
265+
clientRegistrationDefaultScopes: [...MCP_DEFAULT_SCOPES],
266+
enforcePerClientResources: false,
267+
scopes: [...MCP_DEFAULT_SCOPES],
268+
}),
269+
];
70270
}
71271

72272
let cachedAuth: any = null;
73273
let cachedOrigin = "";
74274

75275
/**
76276
* Creates or retrieves the Better Auth instance configured for
77-
* Model Context Protocol (MCP) and OAuth 2.1 authorization server
277+
* Model Context Protocol (MCP) and OAuth 2.1 authorization server (`basePath: ""`)
78278
* backed by Cloudflare D1.
79279
*/
80280
export function getAuth(origin: string, d1?: D1Database) {
@@ -88,23 +288,14 @@ export function getAuth(origin: string, d1?: D1Database) {
88288
baseURL: origin,
89289
basePath: "",
90290
secret: BETTER_AUTH_SECRET,
91-
...(db ? { database: db } : {}),
92-
plugins: [
93-
jwt(),
94-
mcp({
95-
resource: `${origin}/mcp`,
96-
resources: [`${origin}/mcp`, origin],
97-
clientRegistrationDefaultResources: [`${origin}/mcp`, origin],
98-
loginPage: "/sign-in",
99-
consentPage: "/oauth2/consent",
100-
allowDynamicClientRegistration: true,
101-
allowUnauthenticatedClientRegistration: true,
102-
clientRegistrationRequirePKCE: false,
103-
clientRegistrationDefaultScopes: [...MCP_DEFAULT_SCOPES],
104-
enforcePerClientResources: false,
105-
scopes: [...MCP_DEFAULT_SCOPES],
106-
}),
291+
trustedOrigins: [
292+
"https://codingcat.dev",
293+
"https://dev.codingcat.dev",
294+
origin,
107295
],
296+
...(db ? { database: db } : {}),
297+
socialProviders: resolveSocialProviders(),
298+
plugins: buildAuthPlugins(origin),
108299
});
109300

110301
if (!db) {
@@ -114,3 +305,25 @@ export function getAuth(origin: string, d1?: D1Database) {
114305

115306
return instance;
116307
}
308+
309+
/**
310+
* Creates the Better Auth instance mounted at `/api/auth` (`basePath: "/api/auth"`)
311+
* for Studio Portal, Email OTP, Magic Link, and Google/GitHub Social OAuth.
312+
*/
313+
export function getPortalAuth(origin: string, d1?: D1Database) {
314+
const db = d1 ?? (env as unknown as { DB?: D1Database })?.DB;
315+
316+
return betterAuth({
317+
baseURL: origin,
318+
basePath: "/api/auth",
319+
secret: BETTER_AUTH_SECRET,
320+
trustedOrigins: [
321+
"https://codingcat.dev",
322+
"https://dev.codingcat.dev",
323+
origin,
324+
],
325+
...(db ? { database: db } : {}),
326+
socialProviders: resolveSocialProviders(),
327+
plugins: buildAuthPlugins(origin),
328+
});
329+
}

0 commit comments

Comments
 (0)