From 0e0b454404a2cad7de89eccf2029c1ebc7ec25a5 Mon Sep 17 00:00:00 2001 From: Andrew Qu Date: Tue, 6 May 2025 20:46:12 -0700 Subject: [PATCH] app/(oauth)/.well-known/oauth-authorization-server/route.ts app/(oauth)/api/auth/login/route.ts app/(oauth)/api/auth/verify/route.ts app/(oauth)/authorize/login/page.tsx app/(oauth)/authorize/route.ts app/(oauth)/oauth/register/route.ts app/(oauth)/token/route.ts app/[transport]/route.ts app/globals.css app/layout.tsx lib/auth.ts package.json pnpm-lock.yaml postcss.config.js tailwind.config.js --- .../oauth-authorization-server/route.ts | 25 ++ app/(oauth)/api/auth/login/route.ts | 51 ++++ app/(oauth)/api/auth/verify/route.ts | 33 +++ app/(oauth)/authorize/login/page.tsx | 141 +++++++++++ app/(oauth)/authorize/route.ts | 138 ++++++++++ app/(oauth)/oauth/register/route.ts | 140 +++++++++++ app/(oauth)/token/route.ts | 179 +++++++++++++ app/[transport]/route.ts | 65 +++-- app/globals.css | 3 + app/layout.tsx | 16 ++ lib/auth.ts | 48 ++++ package.json | 6 + pnpm-lock.yaml | 238 +++++++++++++++++- postcss.config.js | 6 + tailwind.config.js | 12 + 15 files changed, 1072 insertions(+), 29 deletions(-) create mode 100644 app/(oauth)/.well-known/oauth-authorization-server/route.ts create mode 100644 app/(oauth)/api/auth/login/route.ts create mode 100644 app/(oauth)/api/auth/verify/route.ts create mode 100644 app/(oauth)/authorize/login/page.tsx create mode 100644 app/(oauth)/authorize/route.ts create mode 100644 app/(oauth)/oauth/register/route.ts create mode 100644 app/(oauth)/token/route.ts create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 lib/auth.ts create mode 100644 postcss.config.js create mode 100644 tailwind.config.js diff --git a/app/(oauth)/.well-known/oauth-authorization-server/route.ts b/app/(oauth)/.well-known/oauth-authorization-server/route.ts new file mode 100644 index 0000000..d2772cc --- /dev/null +++ b/app/(oauth)/.well-known/oauth-authorization-server/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; + +export async function GET(request: Request) { + const metadata = { + issuer: "http://localhost:3000", + authorization_endpoint: "http://localhost:3000/authorize", + token_endpoint: "http://localhost:3000/token", + registration_endpoint: "http://localhost:3000/oauth/register", + scopes_supported: ["read_write"], + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + token_endpoint_auth_methods_supported: ["client_secret_basic"], + code_challenge_methods_supported: ["S256"], + service_documentation: + "https://docs.stripe.com/stripe-apps/api-authentication/oauth", + }; + + return new NextResponse(JSON.stringify(metadata), { + status: 200, + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + }, + }); +} diff --git a/app/(oauth)/api/auth/login/route.ts b/app/(oauth)/api/auth/login/route.ts new file mode 100644 index 0000000..44b887f --- /dev/null +++ b/app/(oauth)/api/auth/login/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { SignJWT } from "jose"; + +// In a real app, you would validate against a database +const VALID_CREDENTIALS = { + email: "test@example.com", + password: "password123", +}; + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { email, password } = body; + + // Validate credentials + if ( + email !== VALID_CREDENTIALS.email || + password !== VALID_CREDENTIALS.password + ) { + return new NextResponse( + JSON.stringify({ error: "Invalid credentials" }), + { status: 401 } + ); + } + + // Generate a JWT token + const secret = new TextEncoder().encode( + process.env.JWT_SECRET || "your-secret-key" + ); + + const token = await new SignJWT({ email }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime("24h") + .sign(secret); + + // Return the token + return new NextResponse(JSON.stringify({ token }), { + status: 200, + headers: { + "Content-Type": "application/json", + }, + }); + } catch (error) { + return new NextResponse( + JSON.stringify({ error: "Internal server error" }), + { status: 500 } + ); + } +} diff --git a/app/(oauth)/api/auth/verify/route.ts b/app/(oauth)/api/auth/verify/route.ts new file mode 100644 index 0000000..c904502 --- /dev/null +++ b/app/(oauth)/api/auth/verify/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { email, password } = body; + + // Accept any non-empty email and password + if (!email || !password) { + return new NextResponse( + JSON.stringify({ error: "Email and password are required" }), + { status: 400 } + ); + } + + // Set the session cookie with the email + const cookieStore = await cookies(); + cookieStore.set("auth_session", email, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 60 * 60 * 24, // 24 hours + }); + + return new NextResponse(JSON.stringify({ success: true }), { status: 200 }); + } catch (error) { + return new NextResponse( + JSON.stringify({ error: "Internal server error" }), + { status: 500 } + ); + } +} diff --git a/app/(oauth)/authorize/login/page.tsx b/app/(oauth)/authorize/login/page.tsx new file mode 100644 index 0000000..d4ff619 --- /dev/null +++ b/app/(oauth)/authorize/login/page.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { useRouter, useSearchParams } from "next/navigation"; +import { useState } from "react"; + +export default function AuthorizeLoginPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setIsLoading(true); + + try { + const response = await fetch("/api/auth/verify", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email, password }), + }); + + if (!response.ok) { + throw new Error("Invalid credentials"); + } + + // Get all the original OAuth parameters + const client_id = searchParams.get("client_id"); + const redirect_uri = searchParams.get("redirect_uri"); + const response_type = searchParams.get("response_type"); + const code_challenge = searchParams.get("code_challenge"); + const code_challenge_method = searchParams.get("code_challenge_method"); + const state = searchParams.get("state"); + + // Redirect back to authorize with all parameters + const authorizeUrl = new URL("/authorize", window.location.origin); + authorizeUrl.searchParams.set("client_id", client_id || ""); + authorizeUrl.searchParams.set("redirect_uri", redirect_uri || ""); + authorizeUrl.searchParams.set("response_type", response_type || ""); + if (code_challenge) + authorizeUrl.searchParams.set("code_challenge", code_challenge); + if (code_challenge_method) + authorizeUrl.searchParams.set( + "code_challenge_method", + code_challenge_method + ); + if (state) authorizeUrl.searchParams.set("state", state); + authorizeUrl.searchParams.set("authenticated", "true"); + + // Use window.location.href for a full page navigation + window.location.href = authorizeUrl.toString(); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+
+

+ Welcome Back +

+

+ Please sign in to continue +

+
+ +
+
+ {error && ( +
+ {error} +
+ )} + +
+
+ + setEmail(e.target.value)} + disabled={isLoading} + /> +
+
+ + setPassword(e.target.value)} + disabled={isLoading} + /> +
+
+ +
+ +
+
+
+
+
+
+ ); +} diff --git a/app/(oauth)/authorize/route.ts b/app/(oauth)/authorize/route.ts new file mode 100644 index 0000000..35977ca --- /dev/null +++ b/app/(oauth)/authorize/route.ts @@ -0,0 +1,138 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { SignJWT } from "jose"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + + // Required OAuth parameters + const client_id = searchParams.get("client_id"); + const redirect_uri = searchParams.get("redirect_uri"); + const response_type = searchParams.get("response_type"); + const code_challenge = searchParams.get("code_challenge"); + const code_challenge_method = searchParams.get("code_challenge_method"); + const state = "123"; + const authenticated = searchParams.get("authenticated"); + + // Check which parameters are missing + const missingParams = []; + if (!client_id) missingParams.push("client_id"); + if (!redirect_uri) missingParams.push("redirect_uri"); + if (!response_type) missingParams.push("response_type"); + + // Validate required parameters + if (missingParams.length > 0) { + return new NextResponse( + JSON.stringify({ + error: "invalid_request", + error_description: `Missing required parameters: ${missingParams.join( + ", " + )}`, + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + + // Validate response_type + if (response_type !== "code") { + return new NextResponse( + JSON.stringify({ + error: "unsupported_response_type", + error_description: "Only code response type is supported", + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + + // Validate PKCE if provided + if (code_challenge && code_challenge_method !== "S256") { + return new NextResponse( + JSON.stringify({ + error: "invalid_request", + error_description: "Only S256 code challenge method is supported", + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + + // Check for authentication + if (authenticated !== "true") { + // Redirect to login page with all parameters + const loginUrl = new URL("/authorize/login", request.url); + searchParams.forEach((value, key) => { + if (value) { + loginUrl.searchParams.set(key, value); + } + }); + return NextResponse.redirect(loginUrl.toString()); + } + + // At this point, we know the user is authenticated + // Get the email from the session cookie + const cookieStore = await cookies(); + const authSession = cookieStore.get("auth_session"); + if (!authSession?.value) { + return new NextResponse( + JSON.stringify({ + error: "unauthorized", + error_description: "No authenticated session found", + }), + { status: 401, headers: { "Content-Type": "application/json" } } + ); + } + + // Generate authorization code + const code = await generateAuthorizationCode( + client_id, + "read_write", // Default scope + code_challenge, + authSession.value + ); + + // Store the code and its details in a secure way (e.g., database) + // For this example, we'll use cookies + cookieStore.set("auth_code", code, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 60 * 10, // 10 minutes + }); + + // Redirect to the client's redirect URI with the authorization code + if (!redirect_uri) { + throw new Error("Redirect URI is required"); + } + + const redirectUrl = new URL(redirect_uri); + redirectUrl.searchParams.set("code", code); + redirectUrl.searchParams.set("state", state); + + return NextResponse.redirect(redirectUrl.toString()); +} + +async function generateAuthorizationCode( + client_id: string | null, + scope: string, + code_challenge?: string | null, + email?: string +) { + if (!client_id) { + throw new Error("Client ID is required"); + } + + const secret = new TextEncoder().encode( + process.env.JWT_SECRET || "your-secret-key" + ); + + return new SignJWT({ + client_id, + scope, + code_challenge, + email, + timestamp: Date.now(), + }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime("10m") + .sign(secret); +} diff --git a/app/(oauth)/oauth/register/route.ts b/app/(oauth)/oauth/register/route.ts new file mode 100644 index 0000000..34b864b --- /dev/null +++ b/app/(oauth)/oauth/register/route.ts @@ -0,0 +1,140 @@ +import { NextResponse } from "next/server"; +import { SignJWT } from "jose"; + +interface OAuthClient { + client_id: string; + client_secret: string; + client_name: string; + redirect_uris: string[]; + grant_types: string[]; + response_types: string[]; + token_endpoint_auth_method: string; + created_at: number; +} + +// In a real application, you would store this in a database +const clients = new Map(); + +// Add OPTIONS handler for CORS preflight requests +export async function OPTIONS() { + return new NextResponse(null, { + status: 204, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Max-Age": "86400", + }, + }); +} + +export async function POST(request: Request) { + try { + const body = await request.json(); + + // Required registration parameters + const { + client_name, + redirect_uris, + grant_types, + response_types, + token_endpoint_auth_method, + } = body; + + // Validate required parameters + if (!client_name || !redirect_uris || !grant_types || !response_types) { + return new NextResponse( + JSON.stringify({ + error: "invalid_request", + error_description: "Missing required parameters", + }), + { + status: 400, + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + }, + } + ); + } + + // Generate client credentials + const client_id = await generateClientId(); + const client_secret = await generateClientSecret(); + + // Store client information + clients.set(client_id, { + client_id, + client_secret, + client_name, + redirect_uris, + grant_types, + response_types, + token_endpoint_auth_method: + token_endpoint_auth_method || "client_secret_basic", + created_at: Date.now(), + }); + + // Return client credentials + return new NextResponse( + JSON.stringify({ + client_id, + client_secret, + client_id_issued_at: Math.floor(Date.now() / 1000), + client_secret_expires_at: 0, // 0 means it never expires + redirect_uris, + grant_types, + response_types, + token_endpoint_auth_method: + token_endpoint_auth_method || "client_secret_basic", + }), + { + status: 201, + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + }, + } + ); + } catch (error) { + return new NextResponse( + JSON.stringify({ + error: "server_error", + error_description: "Internal server error", + }), + { + status: 500, + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + }, + } + ); + } +} + +async function generateClientId() { + const secret = new TextEncoder().encode( + process.env.JWT_SECRET || "your-secret-key" + ); + return new SignJWT({ type: "client_id" }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .sign(secret); +} + +async function generateClientSecret() { + const secret = new TextEncoder().encode( + process.env.JWT_SECRET || "your-secret-key" + ); + return new SignJWT({ type: "client_secret" }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .sign(secret); +} diff --git a/app/(oauth)/token/route.ts b/app/(oauth)/token/route.ts new file mode 100644 index 0000000..7e869b5 --- /dev/null +++ b/app/(oauth)/token/route.ts @@ -0,0 +1,179 @@ +import { NextResponse } from "next/server"; +import { SignJWT, jwtVerify } from "jose"; +import { cookies } from "next/headers"; + +// Add OPTIONS handler for CORS preflight requests +export async function OPTIONS() { + return new NextResponse(null, { + status: 204, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Max-Age": "86400", + }, + }); +} + +export async function POST(request: Request) { + try { + const formData = await request.formData(); + + // Required OAuth parameters + const grant_type = formData.get("grant_type"); + const code = formData.get("code"); + const redirect_uri = formData.get("redirect_uri"); + const client_id = formData.get("client_id"); + const code_verifier = formData.get("code_verifier"); + + // Validate required parameters + if (!grant_type || !code || !redirect_uri || !client_id) { + return new NextResponse( + JSON.stringify({ + error: "invalid_request", + error_description: "Missing required parameters", + }), + { + status: 400, + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + }, + } + ); + } + + // Validate grant type + if (grant_type !== "authorization_code") { + return new NextResponse( + JSON.stringify({ + error: "unsupported_grant_type", + error_description: "Only authorization_code grant type is supported", + }), + { + status: 400, + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + }, + } + ); + } + + // Verify the authorization code + const secret = new TextEncoder().encode( + process.env.JWT_SECRET || "your-secret-key" + ); + + // biome-ignore lint/suspicious/noExplicitAny: decrypt + let codePayload: any; + try { + const { payload } = await jwtVerify(code as string, secret); + codePayload = payload; + } catch (error) { + return new NextResponse( + JSON.stringify({ + error: "invalid_grant", + error_description: "Invalid authorization code", + }), + { + status: 400, + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + }, + } + ); + } + + // Verify PKCE if code challenge was provided + if (codePayload.code_challenge && !code_verifier) { + return new NextResponse( + JSON.stringify({ + error: "invalid_request", + error_description: "Code verifier required", + }), + { + status: 400, + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + }, + } + ); + } + + // Generate access token + const accessToken = await generateAccessToken( + client_id as string, + codePayload.scope as string, + codePayload.email as string + ); + + // Return the access token + return new NextResponse( + JSON.stringify({ + access_token: accessToken, + token_type: "Bearer", + expires_in: 3600, // 1 hour + scope: codePayload.scope, + }), + { + status: 200, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + Pragma: "no-cache", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + }, + } + ); + } catch (error) { + return new NextResponse( + JSON.stringify({ + error: "server_error", + error_description: "Internal server error", + }), + { + status: 500, + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + }, + } + ); + } +} + +async function generateAccessToken( + client_id: string, + scope: string, + email: string +) { + const secret = new TextEncoder().encode( + process.env.JWT_SECRET || "your-secret-key" + ); + + return new SignJWT({ + client_id, + scope, + email, + timestamp: Date.now(), + }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime("1h") + .sign(secret); +} diff --git a/app/[transport]/route.ts b/app/[transport]/route.ts index c0d3bc4..afe7b20 100644 --- a/app/[transport]/route.ts +++ b/app/[transport]/route.ts @@ -1,33 +1,48 @@ import { createMcpHandler } from "@vercel/mcp-adapter"; import { z } from "zod"; +import { withMcpAuth } from "@/lib/auth"; -const handler = createMcpHandler( - (server) => { - server.tool( - "echo", - "Echo a message", - { message: z.string() }, - async ({ message }) => ({ - content: [{ type: "text", text: `Tool echo: ${message}` }], - }) - ); - }, - { - capabilities: { - tools: { - echo: { - description: "Echo a message", +const createHandler = (req: Request) => { + console.log("auth", req.headers.get("x-user-email")); + + return createMcpHandler( + (server) => { + server.tool( + "echo", + "Echo a message", + { message: z.string() }, + async ({ message }) => { + return { + content: [ + { + type: "text", + text: `Tool echo: ${message}`, + }, + ], + }; + } + ); + }, + { + capabilities: { + tools: { + echo: { + description: "Echo a message", + }, }, }, }, - }, - { - redisUrl: process.env.REDIS_URL, - sseEndpoint: "/sse", - streamableHttpEndpoint: "/mcp", - verboseLogs: true, - maxDuration: 60, - } -); + { + redisUrl: process.env.REDIS_URL, + sseEndpoint: "/sse", + streamableHttpEndpoint: "/mcp", + verboseLogs: false, + maxDuration: 60, + } + )(req); +}; + +// Create and wrap the handler with auth +const handler = withMcpAuth(createHandler); export { handler as GET, handler as POST, handler as DELETE }; diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..a14e64f --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,16 @@ +export const metadata = { + title: 'Next.js', + description: 'Generated by Next.js', +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/lib/auth.ts b/lib/auth.ts new file mode 100644 index 0000000..686cabf --- /dev/null +++ b/lib/auth.ts @@ -0,0 +1,48 @@ +import { createMcpHandler } from "@vercel/mcp-adapter"; +import { cookies } from "next/headers"; +import { jwtVerify } from "jose"; + +// Wrapper function for MCP handler with auth +export const withMcpAuth = (handler: (req: Request) => Promise) => { + return async (req: Request) => { + const authHeader = req.headers.get("authorization"); + const cookieStore = await cookies(); + const authSession = cookieStore.get("auth_session"); + + let email: string | undefined; + + // Check for bearer token + if (authHeader?.startsWith("Bearer ")) { + try { + const token = authHeader.split(" ")[1]; + const secret = new TextEncoder().encode( + process.env.JWT_SECRET || "your-secret-key" + ); + const { payload } = await jwtVerify(token, secret); + email = payload.email as string; + } catch (error) { + // Token verification failed, try session cookie + email = authSession?.value; + } + } else { + // No bearer token, try session cookie + email = authSession?.value; + } + + if (!email) { + // Redirect to login page if no valid token or session + return new Response(null, { + status: 401, + headers: { + Location: "/authorize/login", + }, + }); + } + + // Add email to request headers + req.headers.set("x-user-email", email); + + // If authenticated, proceed with the MCP handler + return handler(req); + }; +}; diff --git a/package.json b/package.json index 1aca2a7..8c2ee66 100644 --- a/package.json +++ b/package.json @@ -8,15 +8,21 @@ "start": "next start" }, "dependencies": { + "@auth/core": "^0.39.0", "@modelcontextprotocol/sdk": "^1.10.2", "@vercel/mcp-adapter": "^0.2.1", + "jose": "^6.0.11", "next": "15.2.4", + "next-auth": "^4.24.11", "redis": "^4.7.0", "zod": "^3.24.2" }, "devDependencies": { "@types/node": "^20", "@types/react": "^19", + "autoprefixer": "^10.4.21", + "postcss": "^8.5.3", + "tailwindcss": "^4.1.5", "typescript": "^5" }, "packageManager": "pnpm@8.15.7+sha512.c85cd21b6da10332156b1ca2aa79c0a61ee7ad2eb0453b88ab299289e9e8ca93e6091232b25c07cbf61f6df77128d9c849e5c9ac6e44854dbd211c49f3a67adc" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4429383..9fc7cde 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,15 +5,24 @@ settings: excludeLinksFromLockfile: false dependencies: + '@auth/core': + specifier: ^0.39.0 + version: 0.39.0 '@modelcontextprotocol/sdk': specifier: ^1.10.2 version: 1.10.2 '@vercel/mcp-adapter': specifier: ^0.2.1 version: 0.2.1(@modelcontextprotocol/sdk@1.10.2)(next@15.2.4)(redis@4.7.0) + jose: + specifier: ^6.0.11 + version: 6.0.11 next: specifier: 15.2.4 version: 15.2.4(react-dom@19.1.0)(react@19.1.0) + next-auth: + specifier: ^4.24.11 + version: 4.24.11(@auth/core@0.39.0)(next@15.2.4)(react-dom@19.1.0)(react@19.1.0) redis: specifier: ^4.7.0 version: 4.7.0 @@ -28,12 +37,47 @@ devDependencies: '@types/react': specifier: ^19 version: 19.1.0 + autoprefixer: + specifier: ^10.4.21 + version: 10.4.21(postcss@8.5.3) + postcss: + specifier: ^8.5.3 + version: 8.5.3 + tailwindcss: + specifier: ^4.1.5 + version: 4.1.5 typescript: specifier: ^5 version: 5.8.3 packages: + /@auth/core@0.39.0: + resolution: {integrity: sha512-jusviw/sUSfAh6S/wjY5tRmJOq0Itd3ImF+c/b4HB9DfmfChtcfVJTNJeqCeExeCG8oh4PBKRsMQJsn2W6NhFQ==} + peerDependencies: + '@simplewebauthn/browser': ^9.0.1 + '@simplewebauthn/server': ^9.0.2 + nodemailer: ^6.8.0 + peerDependenciesMeta: + '@simplewebauthn/browser': + optional: true + '@simplewebauthn/server': + optional: true + nodemailer: + optional: true + dependencies: + '@panva/hkdf': 1.2.1 + jose: 6.0.11 + oauth4webapi: 3.5.1 + preact: 10.24.3 + preact-render-to-string: 6.5.11(preact@10.24.3) + dev: false + + /@babel/runtime@7.27.1: + resolution: {integrity: sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==} + engines: {node: '>=6.9.0'} + dev: false + /@emnapi/runtime@1.4.0: resolution: {integrity: sha512-64WYIf4UYcdLnbKn/umDlNjQDSS8AgZrI/R9+x5ilkUVFxXcA1Ebl+gQLc/6mERA4407Xof0R7wEyEuj091CVw==} requiresBuild: true @@ -316,6 +360,10 @@ packages: dev: false optional: true + /@panva/hkdf@1.2.1: + resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} + dev: false + /@redis/bloom@1.2.0(@redis/client@1.6.0): resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} peerDependencies: @@ -407,6 +455,22 @@ packages: negotiator: 1.0.0 dev: false + /autoprefixer@10.4.21(postcss@8.5.3): + resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + dependencies: + browserslist: 4.24.5 + caniuse-lite: 1.0.30001712 + fraction.js: 4.3.7 + normalize-range: 0.1.2 + picocolors: 1.1.1 + postcss: 8.5.3 + postcss-value-parser: 4.2.0 + dev: true + /body-parser@2.2.0: resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} engines: {node: '>=18'} @@ -424,6 +488,17 @@ packages: - supports-color dev: false + /browserslist@4.24.5: + resolution: {integrity: sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + dependencies: + caniuse-lite: 1.0.30001717 + electron-to-chromium: 1.5.150 + node-releases: 2.0.19 + update-browserslist-db: 1.1.3(browserslist@4.24.5) + dev: true + /busboy@1.6.0: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} @@ -454,7 +529,10 @@ packages: /caniuse-lite@1.0.30001712: resolution: {integrity: sha512-MBqPpGYYdQ7/hfKiet9SCI+nmN5/hp4ZzveOJubl5DTAMa5oggjAuoi0Z4onBpKPFI2ePGnQuQIzF3VxDjDJig==} - dev: false + + /caniuse-lite@1.0.30001717: + resolution: {integrity: sha512-auPpttCq6BDEG8ZAuHJIplGw6GODhjw+/11e7IjpnYCxZcW/ONgPs0KVBJ0d1bY3e2+7PRe5RCLyP+PfwVgkYw==} + dev: true /client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} @@ -579,6 +657,10 @@ packages: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} dev: false + /electron-to-chromium@1.5.150: + resolution: {integrity: sha512-rOOkP2ZUMx1yL4fCxXQKDHQ8ZXwisb2OycOQVKHgvB3ZI4CvehOd4y2tfnnLDieJ3Zs1RL1Dlp3cMkyIn7nnXA==} + dev: true + /encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -601,6 +683,11 @@ packages: es-errors: 1.3.0 dev: false + /escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + dev: true + /escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} dev: false @@ -685,6 +772,10 @@ packages: engines: {node: '>= 0.6'} dev: false + /fraction.js@4.3.7: + resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + dev: true + /fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -781,6 +872,21 @@ packages: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} dev: false + /jose@4.15.9: + resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + dev: false + + /jose@6.0.11: + resolution: {integrity: sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg==} + dev: false + + /lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + dependencies: + yallist: 4.0.0 + dev: false + /math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -816,13 +922,41 @@ packages: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - dev: false /negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} dev: false + /next-auth@4.24.11(@auth/core@0.39.0)(next@15.2.4)(react-dom@19.1.0)(react@19.1.0): + resolution: {integrity: sha512-pCFXzIDQX7xmHFs4KVH4luCjaCbuPRtZ9oBUjUhOk84mZ9WVPf94n87TxYI4rSRf9HmfHEF8Yep3JrYDVOo3Cw==} + peerDependencies: + '@auth/core': 0.34.2 + next: ^12.2.5 || ^13 || ^14 || ^15 + nodemailer: ^6.6.5 + react: ^17.0.2 || ^18 || ^19 + react-dom: ^17.0.2 || ^18 || ^19 + peerDependenciesMeta: + '@auth/core': + optional: true + nodemailer: + optional: true + dependencies: + '@auth/core': 0.39.0 + '@babel/runtime': 7.27.1 + '@panva/hkdf': 1.2.1 + cookie: 0.7.2 + jose: 4.15.9 + next: 15.2.4(react-dom@19.1.0)(react@19.1.0) + oauth: 0.9.15 + openid-client: 5.7.1 + preact: 10.26.6 + preact-render-to-string: 5.2.6(preact@10.26.6) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + uuid: 8.3.2 + dev: false + /next@15.2.4(react-dom@19.1.0)(react@19.1.0): resolution: {integrity: sha512-VwL+LAaPSxEkd3lU2xWbgEOtrM8oedmyhBqaVNmgKB+GvZlCy9rgaEc+y2on0wv+l0oSFqLtYD6dcC1eAedUaQ==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} @@ -868,16 +1002,43 @@ packages: - babel-plugin-macros dev: false + /node-releases@2.0.19: + resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + dev: true + + /normalize-range@0.1.2: + resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} + engines: {node: '>=0.10.0'} + dev: true + + /oauth4webapi@3.5.1: + resolution: {integrity: sha512-txg/jZQwcbaF7PMJgY7aoxc9QuCxHVFMiEkDIJ60DwDz3PbtXPQnrzo+3X4IRYGChIwWLabRBRpf1k9hO9+xrQ==} + dev: false + + /oauth@0.9.15: + resolution: {integrity: sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==} + dev: false + /object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} dev: false + /object-hash@2.2.0: + resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==} + engines: {node: '>= 6'} + dev: false + /object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} dev: false + /oidc-token-hash@5.1.0: + resolution: {integrity: sha512-y0W+X7Ppo7oZX6eovsRkuzcSM40Bicg2JEJkDJ4irIt1wsYAP5MLSNv+QAogO8xivMffw/9OvV3um1pxXgt1uA==} + engines: {node: ^10.13.0 || >=12.0.0} + dev: false + /on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -891,6 +1052,15 @@ packages: wrappy: 1.0.2 dev: false + /openid-client@5.7.1: + resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==} + dependencies: + jose: 4.15.9 + lru-cache: 6.0.0 + object-hash: 2.2.0 + oidc-token-hash: 5.1.0 + dev: false + /parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -908,13 +1078,16 @@ packages: /picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - dev: false /pkce-challenge@5.0.0: resolution: {integrity: sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==} engines: {node: '>=16.20.0'} dev: false + /postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + dev: true + /postcss@8.4.31: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} @@ -924,6 +1097,44 @@ packages: source-map-js: 1.2.1 dev: false + /postcss@8.5.3: + resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==} + engines: {node: ^10 || ^12 || >=14} + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + dev: true + + /preact-render-to-string@5.2.6(preact@10.26.6): + resolution: {integrity: sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==} + peerDependencies: + preact: '>=10' + dependencies: + preact: 10.26.6 + pretty-format: 3.8.0 + dev: false + + /preact-render-to-string@6.5.11(preact@10.24.3): + resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==} + peerDependencies: + preact: '>=10' + dependencies: + preact: 10.24.3 + dev: false + + /preact@10.24.3: + resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==} + dev: false + + /preact@10.26.6: + resolution: {integrity: sha512-5SRRBinwpwkaD+OqlBDeITlRgvd8I8QlxHJw9AxSdMNV6O+LodN9nUyYGpSF7sadHjs6RzeFShMexC6DbtWr9g==} + dev: false + + /pretty-format@3.8.0: + resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==} + dev: false + /proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -1141,7 +1352,6 @@ packages: /source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - dev: false /statuses@2.0.1: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} @@ -1170,6 +1380,10 @@ packages: react: 19.1.0 dev: false + /tailwindcss@4.1.5: + resolution: {integrity: sha512-nYtSPfWGDiWgCkwQG/m+aX83XCwf62sBgg3bIlNiiOcggnS1x3uVRDAuyelBFL+vJdOPPCGElxv9DjHJjRHiVA==} + dev: true + /toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -1203,6 +1417,22 @@ packages: engines: {node: '>= 0.8'} dev: false + /update-browserslist-db@1.1.3(browserslist@4.24.5): + resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + dependencies: + browserslist: 4.24.5 + escalade: 3.2.0 + picocolors: 1.1.1 + dev: true + + /uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + dev: false + /vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..3de3845 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,12 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + "./app/**/*.{js,ts,jsx,tsx,mdx}", + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: {}, + }, + plugins: [], +};