Skip to content
Open
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
34 changes: 34 additions & 0 deletions app/api/auth/email/register/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}

108 changes: 77 additions & 31 deletions auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,26 @@ 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"

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 }
}
}
Expand Down Expand Up @@ -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) {
Expand All @@ -125,3 +170,4 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
},
},
})

49 changes: 49 additions & 0 deletions lib/neo4j/repositories/userAuth.ts
Original file line number Diff line number Diff line change
@@ -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<Neo4jUserAuth | null> {
const res = await tx.run<{ u: Node<Integer, Neo4jUserAuth, "User"> }>(
`
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<Neo4jUserAuth> {
const res = await tx.run<{ u: Node<Integer, Neo4jUserAuth, "User"> }>(
`
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
}

70 changes: 70 additions & 0 deletions lib/neo4j/services/auth.ts
Original file line number Diff line number Diff line change
@@ -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()
}
}

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -150,3 +151,4 @@
"rootDir": "."
}
}

Loading