Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 34 additions & 13 deletions packages/auth/src/adapter.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { db, users, accounts, sessions, verificationTokens } from "@query/db";
import type { Adapter } from "next-auth/adapters";
import { sql } from "drizzle-orm";
import type { Adapter, VerificationToken } from "next-auth/adapters";

// Only create adapter if database is available and properly initialized
function createAdapter(): Adapter | undefined {
// Check both that db exists and that DATABASE_URL was set
if (!db || !process.env.DATABASE_URL) {
console.warn("Auth adapter: No database connection, using JWT sessions");
return undefined;
Expand All @@ -18,20 +17,42 @@ function createAdapter(): Adapter | undefined {
verificationTokensTable: verificationTokens,
});

// Override token methods — our custom sendVerificationRequest (config.ts)
// stores its own token with a "custom:" prefix, and our custom
// /api/auth/verify-email route consumes it directly.
// NextAuth's default flow creates a *hashed* token that our custom
// verify route can never match, causing Verification_Failed errors.
// Override BOTH token methods with raw SQL to avoid Drizzle "boolin"
// type errors that affect all verificationToken queries in our
// deployment environment when using the pgTable compound primary key.
return {
...baseAdapter,
createVerificationToken: async (token) => {
// No-op: our sendVerificationRequest handles token creation
createVerificationToken: async (
token: VerificationToken
): Promise<VerificationToken> => {
if (!db) throw new Error("Database not available");
// Convert expires to ISO string for reliable Postgres timestamp handling
const expiresISO = token.expires.toISOString();
await db.execute(sql`
INSERT INTO "verificationToken" ("identifier", "token", "expires")
VALUES (${token.identifier}, ${token.token}, ${expiresISO}::timestamp)
`);
return token;
},
useVerificationToken: async (params) => {
// No-op: our /api/auth/verify-email route handles token consumption
return null;
useVerificationToken: async (params: {
identifier: string;
token: string;
}): Promise<VerificationToken | null> => {
if (!db) return null;
const result = await db.execute(sql`
DELETE FROM "verificationToken"
WHERE "identifier" = ${params.identifier} AND "token" = ${params.token}
RETURNING *
`);
if (result.rowCount === 0) {
return null;
}
const row = result.rows[0] as any;
return {
identifier: row.identifier,
token: row.token,
expires: new Date(row.expires),
};
},
};
} catch (error) {
Expand Down
114 changes: 53 additions & 61 deletions packages/auth/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
import type { NextAuthConfig } from "next-auth";
import GoogleProvider from "next-auth/providers/google";
import EmailProvider from "next-auth/providers/nodemailer";
import { db, verificationTokens } from "@query/db";
import { randomBytes } from "crypto";
import { db } from "@query/db";
import { sql } from "drizzle-orm";

function html(params: { url: string; host: string }) {
const { url, host } = params;
function html(params: { code: string; host: string }) {
const { code, host } = params;

// Liquid Glass Design with Teal/Emerald Gradient
const mainColor = "#10b981"; // Emerald-500
const backgroundColor = "#0f172a"; // Slate-900
const textColor = "#f8fafc"; // Slate-50
const mainColor = "#10b981";
const backgroundColor = "#0f172a";
const textColor = "#f8fafc";

return `
<body style="background: ${backgroundColor}; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; padding: 20px; margin: 0;">
Expand All @@ -20,33 +19,35 @@ function html(params: { url: string; host: string }) {
<td style="background: linear-gradient(90deg, #10b981 0%, #0ea5e9 100%); height: 4px;"></td>
</tr>

<!-- Content Area with Glass Effect -->
<!-- Content Area -->
<tr>
<td style="padding: 40px 20px; text-align: center; background: rgba(255, 255, 255, 0.03); backdrop-filter: blur(10px); border: 1px solid rgba(255,255,255,0.05);">
<td style="padding: 40px 20px; text-align: center; background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255,255,255,0.05);">

<!-- Logo / Icon -->
<div style="margin-bottom: 24px;">
<h1 style="color: ${textColor}; font-size: 24px; font-weight: 300; letter-spacing: 1px; margin: 0;">DataScience<span style="font-weight: 600; color: ${mainColor};">GT</span></h1>
</div>

<h2 style="color: ${textColor}; font-size: 20px; font-weight: 400; margin-bottom: 16px;">Secure Sign In</h2>
<h2 style="color: ${textColor}; font-size: 20px; font-weight: 400; margin-bottom: 16px;">Your Verification Code</h2>

<p style="color: #94a3b8; font-size: 15px; line-height: 1.6; margin-bottom: 32px;">
Click the button below to authenticate your access to <strong>${host}</strong>. This link expires in 24 hours.
<p style="color: #94a3b8; font-size: 15px; line-height: 1.6; margin-bottom: 24px;">
Enter this code on <strong>${host}</strong> to sign in. It expires in 10 minutes.
</p>

<!-- Primary Button -->
<table border="0" cellspacing="0" cellpadding="0" style="margin: auto;">
<tr>
<td align="center" style="border-radius: 8px; background: linear-gradient(135deg, ${mainColor} 0%, #059669 100%); box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);">
<a href="${url}" target="_blank" style="font-size: 16px; font-family: sans-serif; color: #ffffff; text-decoration: none; padding: 12px 32px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.2); display: inline-block; font-weight: 600;">
Sign In Now
</a>
</td>
</tr>
</table>

<!-- Security Note -->
<!-- Code Display -->
<div style="margin: 32px auto; max-width: 320px;">
<table border="0" cellspacing="0" cellpadding="0" style="margin: auto;">
<tr>
${code
.split("")
.map(
(d) =>
`<td style="padding: 0 4px;"><div style="width: 44px; height: 56px; background: rgba(16, 185, 129, 0.1); border: 2px solid rgba(16, 185, 129, 0.3); border-radius: 8px; font-size: 28px; font-weight: 700; color: ${mainColor}; line-height: 56px; text-align: center; font-family: 'Courier New', monospace;">${d}</div></td>`
)
.join("")}
</tr>
</table>
</div>

<p style="color: #64748b; font-size: 12px; margin-top: 40px; border-top: 1px solid rgba(255,255,255,0.05); padding-top: 20px;">
If you didn't request this email, you can safely ignore it.
</p>
Expand All @@ -70,9 +71,7 @@ export const authConfig: NextAuthConfig = {
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
// Allows Google login to "claim" the pre-seeded user record via email match
allowDangerousEmailAccountLinking: true,
// Disable all checks for Firebase proxy (cookies don't transfer)
checks: [],
authorization: {
params: {
Expand All @@ -93,46 +92,41 @@ export const authConfig: NextAuthConfig = {
pool: true,
},
from: process.env.EMAIL_FROM || "noreply@datasciencegt.org",
// 6-digit code flow — no magic link, user types the code.
sendVerificationRequest: async ({ identifier, url, provider }) => {
// Generate a 6-digit numeric code
const code = Math.floor(100000 + Math.random() * 900000).toString();
const customToken = `custom:${code}`;
const expires = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes

// Store code in DB
if (db) {
const expiresISO = expires.toISOString();
await db.execute(sql`
INSERT INTO "verificationToken" ("identifier", "token", "expires")
VALUES (${identifier}, ${customToken}, ${expiresISO}::timestamp)
`);
console.log(`[sendVerificationRequest] Stored code for ${identifier}`);
} else {
console.error(`[sendVerificationRequest] No DB — code NOT stored for ${identifier}`);
}

// @ts-ignore
const { createTransport } = await import("nodemailer");
const transport = createTransport(provider.server);

const parsedUrl = new URL(url);
const host = parsedUrl.host;
const callbackUrl = parsedUrl.searchParams.get("callbackUrl") || "/dashboard";

// Generate our own random token and store it directly in the DB.
// This bypasses NextAuth's internal token hashing which causes
// Verification_Failed errors in our deployment environment.
const customToken = randomBytes(32).toString("hex");
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours

if (!db) {
throw new Error("Database not available — cannot store verification token");
}

await db.insert(verificationTokens).values({
identifier,
token: `custom:${customToken}`,
expires,
});

console.log(`[sendVerificationRequest] Token stored for ${identifier}`);

// Build /verify URL with our custom token
const verifyUrl = new URL("/verify", parsedUrl.origin);
verifyUrl.searchParams.set("token", customToken);
verifyUrl.searchParams.set("email", identifier);
verifyUrl.searchParams.set("callbackUrl", callbackUrl);
const safeUrl = verifyUrl.toString();
const baseUrl =
process.env.NEXTAUTH_URL ||
process.env.AUTH_URL ||
"https://datasciencegt.org";
const host = new URL(baseUrl).host;

const result = await transport.sendMail({
to: identifier,
from: provider.from,
subject: `Sign in to ${host}`,
text: `Sign in to ${host}\n${safeUrl}\n\n`,
html: html({ url: safeUrl, host }),
subject: `${code} — Your sign-in code for ${host}`,
text: `Your sign-in code is: ${code}\n\nEnter this code on ${host} to sign in. It expires in 10 minutes.\n\nIf you didn't request this, you can safely ignore this email.\n`,
html: html({ code, host }),
});

const failed = result.rejected.concat(result.pending).filter(Boolean);
Expand All @@ -150,13 +144,11 @@ export const authConfig: NextAuthConfig = {
callbacks: {
async session({ session, user }) {
if (user && session.user) {
// Ensures the ID generated during seeding is the ID used in the session
session.user.id = user.id;
}
return session;
},
async redirect({ url, baseUrl }) {
// Handle callback URLs
if (url.startsWith("/")) {
return `${baseUrl}${url}`;
} else if (new URL(url).origin === baseUrl) {
Expand Down
8 changes: 7 additions & 1 deletion sites/mainweb/app/(portal)/api/auth/[...nextauth]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,10 @@ import { handlers } from "@query/auth";
const { GET: _GET, POST: _POST } = handlers;

export const GET = _GET as any;
export const POST = _POST as any;
export const POST = _POST as any;

// Return 200 for HEAD requests (email client link preview/prefetch)
// to prevent UnknownAction errors from cluttering logs
export function HEAD() {
return new Response(null, { status: 200 });
}
Loading
Loading