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
36 changes: 36 additions & 0 deletions apps/web/app/api/extension/generate-session/route.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Copy link
Owner

@elie222 elie222 Jun 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can do: withAuth() and then drop the next 2 lines

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 });
});
52 changes: 52 additions & 0 deletions apps/web/app/api/extension/verify-session/route.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
113 changes: 113 additions & 0 deletions apps/web/app/extension-auth/page.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="text-center">
<div className="mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-b-2 border-blue-600"></div>
<p className="text-gray-600">
{status === "loading"
? "Loading..."
: "Generating session token..."}
</p>
</div>
</div>
);
}

if (error) {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="text-center">
<div className="mb-4 rounded border border-red-400 bg-red-100 px-4 py-3 text-red-700">
{error}
</div>
<button
onClick={() => window.close()}
className="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
>
Close
</button>
</div>
</div>
);
}

return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="text-center">
<div className="mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-b-2 border-blue-600"></div>
<p className="text-gray-600">Setting up your session...</p>
</div>
</div>
);
}
107 changes: 107 additions & 0 deletions apps/web/app/extension/page.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);
const [userData, setUserData] = useState<any>(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 (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="text-center">
<div className="mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-b-2 border-blue-600"></div>
<p className="text-gray-600">Validating session...</p>
</div>
</div>
);
}

if (!isValid || error) {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="text-center">
<div className="mb-4 rounded border border-red-400 bg-red-100 px-4 py-3 text-red-700">
{error || "Invalid session"}
</div>
<p className="text-gray-600">
Please re-authenticate in the extension.
</p>
</div>
</div>
);
}

// If we have a valid session token, load the main app content
if (isValid && token && userData) {
return (
<div className="h-screen w-full">
<iframe
src="/"
className="h-full w-full border-0"
title="Inbox Zero"
allow="clipboard-read; clipboard-write"
/>
</div>
);
}

return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="text-center">
<div className="mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-b-2 border-blue-600"></div>
<p className="text-gray-600">Loading...</p>
</div>
</div>
);
}
52 changes: 52 additions & 0 deletions apps/web/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,58 @@ const nextConfig: NextConfig = {
},
],
},
// Extension routes - allow iframe embedding
{
source: "/extension/:path*",
headers: [
{
key: "X-Frame-Options",
value: "ALLOWALL",
},
{
key: "Content-Security-Policy",
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https:",
"style-src 'self' 'unsafe-inline' https:",
"font-src 'self' data: https:",
"img-src 'self' data: https: blob: https://image.mux.com https://*.litix.io",
"media-src 'self' blob: https://*.mux.com",
"worker-src 'self' blob:",
"connect-src 'self' https: wss: https://*.mux.com https://*.litix.io",
"frame-src 'self' https: chrome-extension://*",
// Allow iframe embedding from chrome extensions
"frame-ancestors 'self' chrome-extension://*",
].join("; "),
},
],
},
// Main app routes when accessed from extension
{
source: "/",
headers: [
{
key: "X-Frame-Options",
value: "ALLOWALL",
},
{
key: "Content-Security-Policy",
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https:",
"style-src 'self' 'unsafe-inline' https:",
"font-src 'self' data: https:",
"img-src 'self' data: https: blob: https://image.mux.com https://*.litix.io",
"media-src 'self' blob: https://*.mux.com",
"worker-src 'self' blob:",
"connect-src 'self' https: wss: https://*.mux.com https://*.litix.io",
"frame-src 'self' https: chrome-extension://*",
// Allow iframe embedding from chrome extensions
"frame-ancestors 'self' chrome-extension://*",
].join("; "),
},
],
},
{
source: "/sw.js",
headers: [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "ExtensionSession" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"token" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "ExtensionSession_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "ExtensionSession_token_key" ON "ExtensionSession"("token");

-- CreateIndex
CREATE INDEX "ExtensionSession_userId_idx" ON "ExtensionSession"("userId");

-- CreateIndex
CREATE INDEX "ExtensionSession_expiresAt_idx" ON "ExtensionSession"("expiresAt");

-- AddForeignKey
ALTER TABLE "ExtensionSession" ADD CONSTRAINT "ExtensionSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
Loading