diff --git a/app/api/auth/email/register/route.ts b/app/api/auth/email/register/route.ts new file mode 100644 index 000000000..c34865eb5 --- /dev/null +++ b/app/api/auth/email/register/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server" + +import { registerUserEmailPassword } from "@/lib/neo4j/services/auth" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +export async function POST(req: Request) { + const body = await req.json().catch(() => ({})) + const email = String(body?.email ?? "").trim() + const password = String(body?.password ?? "") + const confirmPassword = String(body?.confirmPassword ?? "") + + if (!email || !password || !confirmPassword) { + return NextResponse.json( + { error: "Email, password and confirmPassword are required" }, + { status: 400 } + ) + } + if (password !== confirmPassword) { + return NextResponse.json( + { error: "Passwords do not match" }, + { status: 400 } + ) + } + + const res = await registerUserEmailPassword({ email, password }) + if (!res.ok) { + const status = res.error === "User already exists" ? 409 : 400 + return NextResponse.json({ error: res.error }, { status }) + } + return NextResponse.json({ ok: true }) +} + diff --git a/auth.ts b/auth.ts index e304ffa64..d48db3044 100644 --- a/auth.ts +++ b/auth.ts @@ -2,9 +2,11 @@ import { NextResponse } from "next/server" import NextAuth from "next-auth" import { JWT } from "next-auth/jwt" import GithubProvider from "next-auth/providers/github" +import Credentials from "next-auth/providers/credentials" import { AUTH_CONFIG } from "@/lib/auth/config" import { redis } from "@/lib/redis" +import { verifyEmailPassword } from "@/lib/neo4j/services/auth" import { refreshTokenWithLock } from "@/lib/utils/auth" export const runtime = "nodejs" @@ -12,14 +14,14 @@ export const runtime = "nodejs" declare module "next-auth" { interface Session { token?: JWT - authMethod?: "github-app" + authMethod?: "github-app" | "email-password" profile?: { login: string } } } declare module "next-auth/jwt" { interface JWT { - authMethod?: "github-app" + authMethod?: "github-app" | "email-password" profile?: { login: string } } } @@ -51,55 +53,98 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ } }, }), + Credentials({ + id: "email-password", + name: "Email & Password", + credentials: { + email: { label: "Email", type: "email" }, + password: { label: "Password", type: "password" }, + }, + authorize: async (credentials) => { + const email = String(credentials?.email ?? "").trim() + const password = String(credentials?.password ?? "") + if (!email || !password) return null + const res = await verifyEmailPassword({ email, password }) + if (!res.ok) return null + return { + id: res.user.id, // use email as ID + name: res.user.name, + email: res.user.email, + username: res.user.email, + } + }, + }), ], callbacks: { async jwt({ token, account, user, profile, trigger, session }) { - // TODO: Should test on `trigger` instead of `account` + // If signing in (account exists), attach provider-specific info if (account) { - console.log("Auth info:", { - provider: account.provider, - type: account.type, - tokenType: account.token_type, - accessToken: !!account.access_token, - scope: account.scope, - }) - const newToken = { - ...token, - ...account, - profile: { login: profile?.login }, - // Store which auth method was used - authMethod: "github-app", - } - if (account.expires_in) { - newToken.expires_at = - Math.floor(Date.now() / 1000) + account.expires_in + if (account.provider === "email-password") { + const newToken = { + ...token, + sub: user?.id ?? token.sub, + email: user?.email ?? token.email, + profile: { login: (user as any)?.email ?? token.email ?? "" }, + authMethod: "email-password" as const, + } + await redis.set(`token_${newToken.sub}`, JSON.stringify(newToken), { + ex: AUTH_CONFIG.tokenCacheTtlSeconds, + }) + return newToken } + // GitHub app provider + if (account.provider === "github-app") { + console.log("Auth info:", { + provider: account.provider, + type: account.type, + tokenType: (account as any).token_type, + accessToken: !!(account as any).access_token, + scope: (account as any).scope, + }) + const newToken = { + ...token, + ...account, + profile: { login: profile?.login }, + // Store which auth method was used + authMethod: "github-app" as const, + } + if ((account as any).expires_in) { + ;(newToken as any).expires_at = + Math.floor(Date.now() / 1000) + (account as any).expires_in + } - await redis.set(`token_${token.sub}`, JSON.stringify(newToken), { - ex: account.expires_in || AUTH_CONFIG.tokenCacheTtlSeconds, - }) - return newToken + await redis.set(`token_${token.sub}`, JSON.stringify(newToken), { + ex: (account as any).expires_in || AUTH_CONFIG.tokenCacheTtlSeconds, + }) + return newToken + } } - // Check if this is an old OAuth App token (migration cleanup) - if (token.authMethod !== "github-app") { + // For existing sessions, invalidate only legacy OAuth tokens without our supported authMethod + if ( + token.authMethod && + token.authMethod !== "github-app" && + token.authMethod !== "email-password" + ) { console.log( - "Invalidating old OAuth App token, forcing re-authentication" + "Invalidating unsupported auth token, forcing re-authentication" ) - throw new Error("OAuth App token detected - please sign in again") + throw new Error("Unsupported auth token detected - please sign in again") } + // Handle refresh for providers that support it (GitHub App) if ( + token.authMethod === "github-app" && token.expires_at && (token.expires_at as number) < Date.now() / 1000 ) { // Try to refresh when we have a refresh token available - if (token.refresh_token) { + if ((token as any).refresh_token) { try { console.log("Refreshing token", { - provider: token.provider, + provider: (token as any).provider, sub: token.sub, - expires_at: token.expires_at, + expires_at: (token as any).expires_at, }) return await refreshTokenWithLock(token) } catch (error) { @@ -125,3 +170,4 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ }, }, }) + diff --git a/lib/neo4j/repositories/userAuth.ts b/lib/neo4j/repositories/userAuth.ts new file mode 100644 index 000000000..b253728d9 --- /dev/null +++ b/lib/neo4j/repositories/userAuth.ts @@ -0,0 +1,49 @@ +import { Integer, ManagedTransaction, Node } from "neo4j-driver" + +import { Labels } from "@/lib/neo4j/labels" + +export type Neo4jUserAuth = { + username: string + email: string + passwordHash: string + createdAt?: unknown +} + +export async function findUserByEmail( + tx: ManagedTransaction, + email: string +): Promise { + const res = await tx.run<{ u: Node }>( + ` + MATCH (u:${Labels.User} {username: $email}) + RETURN u + LIMIT 1 + `, + { email } + ) + const node = res.records[0]?.get("u") + return node?.properties ?? null +} + +export async function createUserWithEmailPassword( + tx: ManagedTransaction, + email: string, + passwordHash: string +): Promise { + const res = await tx.run<{ u: Node }>( + ` + CREATE (u:${Labels.User} { + username: $email, + email: $email, + passwordHash: $passwordHash, + createdAt: datetime() + }) + RETURN u + `, + { email, passwordHash } + ) + const node = res.records[0]?.get("u") + if (!node) throw new Error("Failed to create user") + return node.properties +} + diff --git a/lib/neo4j/services/auth.ts b/lib/neo4j/services/auth.ts new file mode 100644 index 000000000..d6ba22d5c --- /dev/null +++ b/lib/neo4j/services/auth.ts @@ -0,0 +1,70 @@ +"use server" + +import bcrypt from "bcryptjs" + +import { n4j } from "@/lib/neo4j/client" +import * as userAuthRepo from "@/lib/neo4j/repositories/userAuth" + +export async function registerUserEmailPassword(params: { + email: string + password: string +}): Promise<{ ok: true } | { ok: false; error: string }> { + const email = params.email.trim().toLowerCase() + const password = params.password + + if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return { ok: false, error: "Invalid email" } + } + if (!password || password.length < 8) { + return { ok: false, error: "Password must be at least 8 characters" } + } + + const session = await n4j.getSession() + try { + const existing = await session.executeRead((tx) => + userAuthRepo.findUserByEmail(tx, email) + ) + if (existing) { + return { ok: false, error: "User already exists" } + } + + const passwordHash = await bcrypt.hash(password, 10) + + await session.executeWrite((tx) => + userAuthRepo.createUserWithEmailPassword(tx, email, passwordHash) + ) + + return { ok: true } + } finally { + await session.close() + } +} + +export async function verifyEmailPassword(params: { + email: string + password: string +}): Promise< + | { ok: true; user: { id: string; email: string; name: string } } + | { ok: false; error: string } +> { + const email = params.email.trim().toLowerCase() + const password = params.password + + const session = await n4j.getSession() + try { + const existing = await session.executeRead((tx) => + userAuthRepo.findUserByEmail(tx, email) + ) + if (!existing) { + return { ok: false, error: "Invalid credentials" } + } + const match = await bcrypt.compare(password, existing.passwordHash) + if (!match) { + return { ok: false, error: "Invalid credentials" } + } + return { ok: true, user: { id: email, email, name: email } } + } finally { + await session.close() + } +} + diff --git a/package.json b/package.json index dac7cfa18..ad903eb6c 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "@types/dockerode": "^3.3.42", "@upstash/redis": "^1.34.4", "@vercel/otel": "^1.13.0", + "bcryptjs": "^2.4.3", "bullmq": "~5.56.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", @@ -150,3 +151,4 @@ "rootDir": "." } } +