diff --git a/apps/web/app/api/extension/generate-session/route.ts b/apps/web/app/api/extension/generate-session/route.ts new file mode 100644 index 0000000000..274511df78 --- /dev/null +++ b/apps/web/app/api/extension/generate-session/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import { generateSecureToken } from "@/utils/api-key"; +import prisma from "@/utils/prisma"; +import { withError } from "@/utils/middleware"; + +export const POST = withError(async (request) => { + const session = await auth(); + + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Clean up any existing tokens for this user + await prisma.extensionSession.deleteMany({ + where: { + userId: session.user.id, + }, + }); + + // Generate a secure session token + const sessionToken = generateSecureToken(); + + // Store the session token with user info and expiration + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours + + await prisma.extensionSession.create({ + data: { + token: sessionToken, + userId: session.user.id, + expiresAt, + }, + }); + + return NextResponse.json({ sessionToken }); +}); diff --git a/apps/web/app/api/extension/verify-session/route.ts b/apps/web/app/api/extension/verify-session/route.ts new file mode 100644 index 0000000000..6c695dd5e6 --- /dev/null +++ b/apps/web/app/api/extension/verify-session/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from "next/server"; +import prisma from "@/utils/prisma"; +import { withError } from "@/utils/middleware"; + +export const POST = withError(async (request) => { + const authHeader = request.headers.get("Authorization"); + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return NextResponse.json( + { error: "Missing or invalid authorization header" }, + { status: 401 }, + ); + } + + const token = authHeader.substring(7); // Remove "Bearer " prefix + + // Find the session token and check if it's valid + const session = await prisma.extensionSession.findUnique({ + where: { token }, + include: { + user: { + select: { + id: true, + email: true, + }, + }, + }, + }); + + if (!session) { + return NextResponse.json( + { error: "Invalid session token" }, + { status: 401 }, + ); + } + + // Check if the session has expired + if (session.expiresAt < new Date()) { + // Clean up expired session + await prisma.extensionSession.delete({ + where: { id: session.id }, + }); + + return NextResponse.json({ error: "Session expired" }, { status: 401 }); + } + + return NextResponse.json({ + valid: true, + userId: session.user.id, + email: session.user.email, + }); +}); diff --git a/apps/web/app/extension-auth/page.tsx b/apps/web/app/extension-auth/page.tsx new file mode 100644 index 0000000000..c36c3f356c --- /dev/null +++ b/apps/web/app/extension-auth/page.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { useEffect, useState, useRef } from "react"; +import { signIn, useSession } from "next-auth/react"; +import { useSearchParams, useRouter } from "next/navigation"; +import { createScopedLogger } from "@/utils/logger"; + +const logger = createScopedLogger("extension-auth"); + +export default function ExtensionAuthPage() { + const { data: session, status } = useSession(); + const [isGeneratingToken, setIsGeneratingToken] = useState(false); + const [error, setError] = useState(null); + const searchParams = useSearchParams(); + const router = useRouter(); + const hasGeneratedToken = useRef(false); + + useEffect(() => { + // If not authenticated, redirect to sign in + if (status === "unauthenticated") { + signIn("google", { callbackUrl: "/extension-auth" }); + return; + } + + // If authenticated and we haven't generated a token yet, generate session token + if (status === "authenticated" && session && !hasGeneratedToken.current) { + hasGeneratedToken.current = true; + generateSessionToken(); + } + }, [status, session]); + + const generateSessionToken = async () => { + try { + setIsGeneratingToken(true); + setError(null); + + const response = await fetch("/api/extension/generate-session", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error("Failed to generate session token"); + } + + const { sessionToken } = await response.json(); + + // Redirect back to extension with session token + const redirectUrl = `chrome-extension://${searchParams.get("extensionId") || ""}?sessionToken=${sessionToken}`; + + // For development, we'll use a different approach since we can't redirect to chrome-extension:// + // We'll use a custom protocol or just show the token + if (process.env.NODE_ENV === "development") { + // In development, we'll redirect to a page that shows the token + router.push(`/extension-auth/success?sessionToken=${sessionToken}`); + } else { + // In production, redirect to the extension + window.location.href = redirectUrl; + } + } catch (error) { + logger.error("Error generating session token:", { error }); + setError("Failed to generate session token"); + // Reset the flag so user can try again + hasGeneratedToken.current = false; + } finally { + setIsGeneratingToken(false); + } + }; + + if (status === "loading" || isGeneratingToken) { + return ( +
+
+
+

+ {status === "loading" + ? "Loading..." + : "Generating session token..."} +

+
+
+ ); + } + + if (error) { + return ( +
+
+
+ {error} +
+ +
+
+ ); + } + + return ( +
+
+
+

Setting up your session...

+
+
+ ); +} diff --git a/apps/web/app/extension/page.tsx b/apps/web/app/extension/page.tsx new file mode 100644 index 0000000000..386c6de626 --- /dev/null +++ b/apps/web/app/extension/page.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; +import { createScopedLogger } from "@/utils/logger"; + +const logger = createScopedLogger("extension"); + +export default function ExtensionPage() { + const searchParams = useSearchParams(); + const token = searchParams.get("token"); + const [isValidating, setIsValidating] = useState(true); + const [isValid, setIsValid] = useState(false); + const [error, setError] = useState(null); + const [userData, setUserData] = useState(null); + + useEffect(() => { + if (token) { + validateSessionToken(); + } else { + setError("No session token provided"); + setIsValidating(false); + } + }, [token]); + + const validateSessionToken = async () => { + if (!token) return; + + try { + const response = await fetch("/api/extension/verify-session", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + }); + + if (response.ok) { + const data = await response.json(); + setIsValid(true); + setUserData(data); + // Store the token for the main app to use + if (typeof window !== "undefined") { + sessionStorage.setItem("extensionToken", token); + sessionStorage.setItem("extensionUser", JSON.stringify(data)); + } + } else { + const errorData = await response.json(); + setError(errorData.error || "Invalid session token"); + } + } catch (error) { + logger.error("Error validating session token:", { error }); + setError("Failed to validate session token"); + } finally { + setIsValidating(false); + } + }; + + if (isValidating) { + return ( +
+
+
+

Validating session...

+
+
+ ); + } + + if (!isValid || error) { + return ( +
+
+
+ {error || "Invalid session"} +
+

+ Please re-authenticate in the extension. +

+
+
+ ); + } + + // If we have a valid session token, load the main app content + if (isValid && token && userData) { + return ( +
+