From 714399cf411c62fd75d977aa0b97b3fa7123739b Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Sat, 19 Jul 2025 16:44:15 +0530 Subject: [PATCH] Feat: OAuth support for external apps (#22) * Feat: OAuth support for external apps * Fix: OAuth screen --------- Co-authored-by: Manoj K --- .../conversation/conversation-list.tsx | 38 +- apps/webapp/app/components/icons/arrows.tsx | 19 + apps/webapp/app/components/icons/index.ts | 1 + .../integrations/integration-grid.tsx | 6 +- .../app/components/logs/logs-filters.tsx | 8 +- .../app/components/sidebar/nav-main.tsx | 1 - .../app/components/sidebar/nav-user.tsx | 13 +- .../routes/api.oauth.clients.$clientId.tsx | 186 ++++++++++ apps/webapp/app/routes/api.oauth.clients.tsx | 148 ++++++++ apps/webapp/app/routes/api.profile.tsx | 33 ++ apps/webapp/app/routes/api.v1.mcp.memory.tsx | 43 ++- apps/webapp/app/routes/home.logs.activity.tsx | 2 +- apps/webapp/app/routes/home.logs.all.tsx | 2 +- apps/webapp/app/routes/login.magic.tsx | 4 +- apps/webapp/app/routes/oauth.authorize.tsx | 279 +++++++++++++++ apps/webapp/app/routes/oauth.token.tsx | 143 ++++++++ apps/webapp/app/routes/oauth.userinfo.tsx | 44 +++ apps/webapp/app/services/apiAuth.server.ts | 28 +- .../app/services/authorization.server.ts | 7 +- apps/webapp/app/services/oauth2.server.ts | 336 ++++++++++++++++++ apps/webapp/app/tailwind.css | 2 +- apps/webapp/app/trigger/chat/memory-utils.ts | 3 +- apps/webapp/app/utils/auth-helper.ts | 109 ++++++ apps/webapp/app/utils/oauth2-middleware.ts | 92 +++++ packages/core-cli/README.md | 24 +- packages/core-cli/src/commands/init.ts | 46 +-- packages/core-cli/src/commands/start.ts | 38 +- packages/core-cli/src/commands/stop.ts | 38 +- .../migration.sql | 106 ++++++ packages/database/prisma/schema.prisma | 111 ++++++ 30 files changed, 1742 insertions(+), 168 deletions(-) create mode 100644 apps/webapp/app/components/icons/arrows.tsx create mode 100644 apps/webapp/app/routes/api.oauth.clients.$clientId.tsx create mode 100644 apps/webapp/app/routes/api.oauth.clients.tsx create mode 100644 apps/webapp/app/routes/api.profile.tsx create mode 100644 apps/webapp/app/routes/oauth.authorize.tsx create mode 100644 apps/webapp/app/routes/oauth.token.tsx create mode 100644 apps/webapp/app/routes/oauth.userinfo.tsx create mode 100644 apps/webapp/app/services/oauth2.server.ts create mode 100644 apps/webapp/app/utils/auth-helper.ts create mode 100644 apps/webapp/app/utils/oauth2-middleware.ts create mode 100644 packages/database/prisma/migrations/20250716103033_add_oauth_models/migration.sql diff --git a/apps/webapp/app/components/conversation/conversation-list.tsx b/apps/webapp/app/components/conversation/conversation-list.tsx index 0c81a1b..8760bd4 100644 --- a/apps/webapp/app/components/conversation/conversation-list.tsx +++ b/apps/webapp/app/components/conversation/conversation-list.tsx @@ -3,6 +3,7 @@ import { useEffect, useState, useCallback, useRef } from "react"; import { AutoSizer, List, type ListRowRenderer } from "react-virtualized"; import { cn } from "~/lib/utils"; import { Button } from "../ui"; +import { LoaderCircle } from "lucide-react"; type ConversationItem = { id: string; @@ -179,28 +180,27 @@ export const ConversationList = ({ return (
-
- - {({ height, width }) => ( - - )} - -
+ {!isLoading && conversations.length > 0 && ( +
+ + {({ height, width }) => ( + + )} + +
+ )} {isLoading && conversations.length === 0 && (
-
-
- - Loading conversations... - +
+
)} diff --git a/apps/webapp/app/components/icons/arrows.tsx b/apps/webapp/app/components/icons/arrows.tsx new file mode 100644 index 0000000..2dbe269 --- /dev/null +++ b/apps/webapp/app/components/icons/arrows.tsx @@ -0,0 +1,19 @@ +import type { IconProps } from "./types"; + +export function Arrows({ size = 18, className }: IconProps) { + return ( + + ); +} diff --git a/apps/webapp/app/components/icons/index.ts b/apps/webapp/app/components/icons/index.ts index b5a7e4c..e43963c 100644 --- a/apps/webapp/app/components/icons/index.ts +++ b/apps/webapp/app/components/icons/index.ts @@ -1,2 +1,3 @@ export * from "./slack-icon"; export * from "./linear-icon"; +export * from "./arrows"; diff --git a/apps/webapp/app/components/integrations/integration-grid.tsx b/apps/webapp/app/components/integrations/integration-grid.tsx index f196af9..92107cb 100644 --- a/apps/webapp/app/components/integrations/integration-grid.tsx +++ b/apps/webapp/app/components/integrations/integration-grid.tsx @@ -1,5 +1,5 @@ import React, { useMemo } from "react"; -import { Search } from "lucide-react"; +import { Layout, LayoutGrid, Search } from "lucide-react"; import { IntegrationCard } from "./integration-card"; interface IntegrationGridProps { @@ -25,8 +25,8 @@ export function IntegrationGrid({ if (integrations.length === 0) { return (
- -

No integrations found

+ +

No integrations found

); } diff --git a/apps/webapp/app/components/logs/logs-filters.tsx b/apps/webapp/app/components/logs/logs-filters.tsx index 5930e44..f64dd13 100644 --- a/apps/webapp/app/components/logs/logs-filters.tsx +++ b/apps/webapp/app/components/logs/logs-filters.tsx @@ -1,11 +1,5 @@ import { useState } from "react"; -import { - ChevronsUpDown, - Filter, - FilterIcon, - ListFilter, - X, -} from "lucide-react"; +import { ListFilter, X } from "lucide-react"; import { Button } from "~/components/ui/button"; import { Popover, diff --git a/apps/webapp/app/components/sidebar/nav-main.tsx b/apps/webapp/app/components/sidebar/nav-main.tsx index 6874338..18a1eb6 100644 --- a/apps/webapp/app/components/sidebar/nav-main.tsx +++ b/apps/webapp/app/components/sidebar/nav-main.tsx @@ -3,7 +3,6 @@ import { SidebarGroup, SidebarGroupContent, SidebarMenu, - SidebarMenuButton, SidebarMenuItem, } from "../ui/sidebar"; import { useLocation, useNavigate } from "@remix-run/react"; diff --git a/apps/webapp/app/components/sidebar/nav-user.tsx b/apps/webapp/app/components/sidebar/nav-user.tsx index faaf0b0..8a14d7f 100644 --- a/apps/webapp/app/components/sidebar/nav-user.tsx +++ b/apps/webapp/app/components/sidebar/nav-user.tsx @@ -8,20 +8,13 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "../ui/dropdown-menu"; -import { - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - useSidebar, -} from "../ui/sidebar"; +import { SidebarMenu, SidebarMenuItem, useSidebar } from "../ui/sidebar"; import type { User } from "~/models/user.server"; import { Button } from "../ui"; -import { cn } from "~/lib/utils"; -import { useLocation, useNavigate } from "@remix-run/react"; +import { useNavigate } from "@remix-run/react"; export function NavUser({ user }: { user: User }) { const { isMobile } = useSidebar(); - const location = useLocation(); const navigate = useNavigate(); return ( @@ -55,7 +48,7 @@ export function NavUser({ user }: { user: User }) { navigate("/settings")} + onClick={() => navigate("/settings/api")} > Settings diff --git a/apps/webapp/app/routes/api.oauth.clients.$clientId.tsx b/apps/webapp/app/routes/api.oauth.clients.$clientId.tsx new file mode 100644 index 0000000..a3402f4 --- /dev/null +++ b/apps/webapp/app/routes/api.oauth.clients.$clientId.tsx @@ -0,0 +1,186 @@ +import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/node"; +import { PrismaClient } from "@prisma/client"; +import { requireAuth } from "~/utils/auth-helper"; +import crypto from "crypto"; + +const prisma = new PrismaClient(); + +// GET /api/oauth/clients/:clientId - Get specific OAuth client +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const user = await requireAuth(request); + + const { clientId } = params; + + if (!clientId) { + return json({ error: "Client ID is required" }, { status: 400 }); + } + + try { + // Get user's workspace + const userRecord = await prisma.user.findUnique({ + where: { id: user.id }, + include: { Workspace: true }, + }); + + if (!userRecord?.Workspace) { + return json({ error: "No workspace found" }, { status: 404 }); + } + + const client = await prisma.oAuthClient.findFirst({ + where: { + id: clientId, + workspaceId: userRecord.Workspace.id, + }, + select: { + id: true, + clientId: true, + name: true, + description: true, + redirectUris: true, + allowedScopes: true, + requirePkce: true, + logoUrl: true, + homepageUrl: true, + isActive: true, + createdAt: true, + updatedAt: true, + createdBy: { + select: { + id: true, + email: true, + name: true, + }, + }, + }, + }); + + if (!client) { + return json({ error: "OAuth client not found" }, { status: 404 }); + } + + return json({ client }); + } catch (error) { + console.error("Error fetching OAuth client:", error); + return json({ error: "Internal server error" }, { status: 500 }); + } +}; + +export const action = async ({ request, params }: ActionFunctionArgs) => { + const user = await requireAuth(request); + + const { clientId } = params; + const method = request.method; + + if (!clientId) { + return json({ error: "Client ID is required" }, { status: 400 }); + } + + try { + // Get user's workspace + const userRecord = await prisma.user.findUnique({ + where: { id: user.id }, + include: { Workspace: true }, + }); + + if (!userRecord?.Workspace) { + return json({ error: "No workspace found" }, { status: 404 }); + } + + // Verify client exists and belongs to user's workspace + const existingClient = await prisma.oAuthClient.findFirst({ + where: { + id: clientId, + workspaceId: userRecord.Workspace.id, + }, + }); + + if (!existingClient) { + return json({ error: "OAuth client not found" }, { status: 404 }); + } + + // PATCH - Update OAuth client + if (method === "PATCH") { + const body = await request.json(); + const { name, description, redirectUris, allowedScopes, requirePkce, logoUrl, homepageUrl, isActive } = body; + + const updateData: any = {}; + if (name !== undefined) updateData.name = name; + if (description !== undefined) updateData.description = description; + if (redirectUris !== undefined) { + updateData.redirectUris = Array.isArray(redirectUris) ? redirectUris.join(',') : redirectUris; + } + if (allowedScopes !== undefined) { + updateData.allowedScopes = Array.isArray(allowedScopes) ? allowedScopes.join(',') : allowedScopes; + } + if (requirePkce !== undefined) updateData.requirePkce = requirePkce; + if (logoUrl !== undefined) updateData.logoUrl = logoUrl; + if (homepageUrl !== undefined) updateData.homepageUrl = homepageUrl; + if (isActive !== undefined) updateData.isActive = isActive; + + const updatedClient = await prisma.oAuthClient.update({ + where: { id: clientId }, + data: updateData, + select: { + id: true, + clientId: true, + name: true, + description: true, + redirectUris: true, + allowedScopes: true, + requirePkce: true, + logoUrl: true, + homepageUrl: true, + isActive: true, + createdAt: true, + updatedAt: true, + }, + }); + + return json({ success: true, client: updatedClient }); + } + + // POST - Regenerate client secret + if (method === "POST") { + const body = await request.json(); + const { action } = body; + + if (action === "regenerate_secret") { + const newClientSecret = crypto.randomBytes(32).toString('hex'); + + const updatedClient = await prisma.oAuthClient.update({ + where: { id: clientId }, + data: { clientSecret: newClientSecret }, + select: { + id: true, + clientId: true, + clientSecret: true, + name: true, + }, + }); + + return json({ + success: true, + client: updatedClient, + message: "Client secret regenerated successfully. Save it securely - it won't be shown again." + }); + } + + return json({ error: "Invalid action" }, { status: 400 }); + } + + // DELETE - Delete OAuth client + if (method === "DELETE") { + await prisma.oAuthClient.delete({ + where: { id: clientId }, + }); + + return json({ success: true, message: "OAuth client deleted successfully" }); + } + + return json({ error: "Method not allowed" }, { status: 405 }); + + } catch (error) { + console.error("Error managing OAuth client:", error); + return json({ error: "Internal server error" }, { status: 500 }); + } +}; \ No newline at end of file diff --git a/apps/webapp/app/routes/api.oauth.clients.tsx b/apps/webapp/app/routes/api.oauth.clients.tsx new file mode 100644 index 0000000..d38151a --- /dev/null +++ b/apps/webapp/app/routes/api.oauth.clients.tsx @@ -0,0 +1,148 @@ +import { + type ActionFunctionArgs, + type LoaderFunctionArgs, + json, +} from "@remix-run/node"; +import { PrismaClient } from "@prisma/client"; +import { requireAuth } from "~/utils/auth-helper"; +import crypto from "crypto"; + +const prisma = new PrismaClient(); + +// GET /api/oauth/clients - List OAuth clients for user's workspace +export const loader = async ({ request }: LoaderFunctionArgs) => { + const user = await requireAuth(request); + + try { + // Get user's workspace + const userRecord = await prisma.user.findUnique({ + where: { id: user.id }, + include: { Workspace: true }, + }); + + if (!userRecord?.Workspace) { + return json({ error: "No workspace found" }, { status: 404 }); + } + + const clients = await prisma.oAuthClient.findMany({ + where: { workspaceId: userRecord.Workspace.id }, + select: { + id: true, + clientId: true, + name: true, + description: true, + redirectUris: true, + allowedScopes: true, + requirePkce: true, + logoUrl: true, + homepageUrl: true, + isActive: true, + createdAt: true, + updatedAt: true, + createdBy: { + select: { + id: true, + email: true, + name: true, + }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + + return json({ clients }); + } catch (error) { + console.error("Error fetching OAuth clients:", error); + return json({ error: "Internal server error" }, { status: 500 }); + } +}; + +// POST /api/oauth/clients - Create new OAuth client +export const action = async ({ request }: ActionFunctionArgs) => { + if (request.method !== "POST") { + return json({ error: "Method not allowed" }, { status: 405 }); + } + + const user = await requireAuth(request); + + try { + const body = await request.json(); + + const { + name, + description, + redirectUris, + allowedScopes, + requirePkce, + logoUrl, + homepageUrl, + } = body; + + // Validate required fields + if (!name || !redirectUris) { + return json( + { error: "Name and redirectUris are required" }, + { status: 400 }, + ); + } + + // Get user's workspace + const userRecord = await prisma.user.findUnique({ + where: { id: user.id }, + include: { Workspace: true }, + }); + + if (!userRecord?.Workspace) { + return json({ error: "No workspace found" }, { status: 404 }); + } + + // Generate client credentials + const clientId = crypto.randomUUID(); + const clientSecret = crypto.randomBytes(32).toString("hex"); + + // Create OAuth client + const client = await prisma.oAuthClient.create({ + data: { + clientId, + clientSecret, + name, + description: description || null, + redirectUris: Array.isArray(redirectUris) + ? redirectUris.join(",") + : redirectUris, + allowedScopes: Array.isArray(allowedScopes) + ? allowedScopes.join(",") + : allowedScopes || "read", + requirePkce: requirePkce || false, + logoUrl: logoUrl || null, + homepageUrl: homepageUrl || null, + workspaceId: userRecord.Workspace.id, + createdById: user.id, + }, + select: { + id: true, + clientId: true, + clientSecret: true, + name: true, + description: true, + redirectUris: true, + allowedScopes: true, + requirePkce: true, + logoUrl: true, + homepageUrl: true, + isActive: true, + createdAt: true, + }, + }); + + return json({ + success: true, + client, + message: + "OAuth client created successfully. Save the client_secret securely - it won't be shown again.", + }); + } catch (error) { + console.error("Error creating OAuth client:", error); + return json({ error: "Internal server error" }, { status: 500 }); + } +}; diff --git a/apps/webapp/app/routes/api.profile.tsx b/apps/webapp/app/routes/api.profile.tsx new file mode 100644 index 0000000..f6710ee --- /dev/null +++ b/apps/webapp/app/routes/api.profile.tsx @@ -0,0 +1,33 @@ +import { type LoaderFunctionArgs, json } from "@remix-run/node"; +import { requireOAuth2, requireScope } from "~/utils/oauth2-middleware"; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + try { + // Require OAuth2 authentication + const oauth2Context = await requireOAuth2(request); + + // Require 'read' scope + requireScope(oauth2Context, 'read'); + + // Return user profile information + return json({ + user: oauth2Context.user, + client: { + name: oauth2Context.client.name, + id: oauth2Context.client.clientId, + }, + scope: oauth2Context.token.scope, + }); + } catch (error) { + // Error responses are already formatted by the middleware + throw error; + } +}; + +// This endpoint only supports GET +export const action = () => { + return json( + { error: "method_not_allowed", error_description: "Only GET method is allowed" }, + { status: 405 } + ); +}; \ No newline at end of file diff --git a/apps/webapp/app/routes/api.v1.mcp.memory.tsx b/apps/webapp/app/routes/api.v1.mcp.memory.tsx index 42ad0ff..83ebb70 100644 --- a/apps/webapp/app/routes/api.v1.mcp.memory.tsx +++ b/apps/webapp/app/routes/api.v1.mcp.memory.tsx @@ -8,7 +8,6 @@ import { createHybridActionApiRoute } from "~/services/routeBuilders/apiBuilder. import { addToQueue } from "~/lib/ingest.server"; import { SearchService } from "~/services/search.server"; import { handleTransport } from "~/utils/mcp"; -import { IngestBodyRequest } from "~/trigger/ingest/ingest"; // Map to store transports by session ID with cleanup tracking const transports: { @@ -39,16 +38,14 @@ const MCPRequestSchema = z.object({}).passthrough(); // Search parameters schema for MCP tool const SearchParamsSchema = z.object({ - query: z.string(), - startTime: z.string().optional(), - endTime: z.string().optional(), - spaceId: z.string().optional(), - limit: z.number().optional(), - maxBfsDepth: z.number().optional(), - includeInvalidated: z.boolean().optional(), - entityTypes: z.array(z.string()).optional(), - scoreThreshold: z.number().optional(), - minResults: z.number().optional(), + query: z.string().describe("The search query in third person perspective"), + validAt: z.string().optional().describe("The valid at time in ISO format"), + startTime: z.string().optional().describe("The start time in ISO format"), + endTime: z.string().optional().describe("The end time in ISO format"), +}); + +const IngestSchema = z.object({ + message: z.string().describe("The data to ingest in text format"), }); const searchService = new SearchService(); @@ -60,6 +57,22 @@ const handleMCPRequest = async ( authentication: any, ) => { const sessionId = request.headers.get("mcp-session-id") as string | undefined; + const source = request.headers.get("source") as string | undefined; + + if (!source) { + return json( + { + jsonrpc: "2.0", + error: { + code: -32601, + message: "No source found", + }, + id: null, + }, + { status: 400 }, + ); + } + let transport: StreamableHTTPServerTransport; try { @@ -104,14 +117,18 @@ const handleMCPRequest = async ( { title: "Ingest Data", description: "Ingest data into the memory system", - inputSchema: IngestBodyRequest.shape, + inputSchema: IngestSchema.shape, }, async (args) => { try { const userId = authentication.userId; const response = addToQueue( - args as z.infer, + { + episodeBody: args.message, + referenceTime: new Date().toISOString(), + source, + }, userId, ); return { diff --git a/apps/webapp/app/routes/home.logs.activity.tsx b/apps/webapp/app/routes/home.logs.activity.tsx index 0570a80..c2e17e8 100644 --- a/apps/webapp/app/routes/home.logs.activity.tsx +++ b/apps/webapp/app/routes/home.logs.activity.tsx @@ -57,7 +57,7 @@ export default function LogsActivity() { }, ]} /> -
+
{isInitialLoad ? ( <> {" "} diff --git a/apps/webapp/app/routes/home.logs.all.tsx b/apps/webapp/app/routes/home.logs.all.tsx index 2797967..2074ef9 100644 --- a/apps/webapp/app/routes/home.logs.all.tsx +++ b/apps/webapp/app/routes/home.logs.all.tsx @@ -45,7 +45,7 @@ export default function LogsAll() { }, ]} /> -
+
{isInitialLoad ? ( <> {" "} diff --git a/apps/webapp/app/routes/login.magic.tsx b/apps/webapp/app/routes/login.magic.tsx index 9ec386d..1c836c0 100644 --- a/apps/webapp/app/routes/login.magic.tsx +++ b/apps/webapp/app/routes/login.magic.tsx @@ -13,7 +13,7 @@ import { CardTitle, } from "~/components/ui/card"; import { Form, useNavigation } from "@remix-run/react"; -import { Inbox, Loader, Mail } from "lucide-react"; +import { Inbox, Loader, LoaderCircle, Mail } from "lucide-react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { LoginPageLayout } from "~/components/layout/login-page-layout"; @@ -203,7 +203,7 @@ export default function LoginMagicLinkPage() { data-action="send a magic link" > {isLoading ? ( - + ) : ( )} diff --git a/apps/webapp/app/routes/oauth.authorize.tsx b/apps/webapp/app/routes/oauth.authorize.tsx new file mode 100644 index 0000000..4a95168 --- /dev/null +++ b/apps/webapp/app/routes/oauth.authorize.tsx @@ -0,0 +1,279 @@ +import { + type ActionFunctionArgs, + type LoaderFunctionArgs, + redirect, +} from "@remix-run/node"; +import { Form, useLoaderData } from "@remix-run/react"; +import { getUser } from "~/services/session.server"; +import { + oauth2Service, + OAuth2Errors, + type OAuth2AuthorizeRequest, +} from "~/services/oauth2.server"; +import { Button } from "~/components/ui/button"; +import { Card, CardContent } from "~/components/ui/card"; +import { Arrows } from "~/components/icons"; +import Logo from "~/components/logo/logo"; +import { AlignLeft, LayoutGrid, Pen } from "lucide-react"; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + // Check if user is authenticated + const user = await getUser(request); + + if (!user) { + // Redirect to login with return URL + const url = new URL(request.url); + const loginUrl = new URL("/login", request.url); + loginUrl.searchParams.set("redirectTo", url.pathname + url.search); + return redirect(loginUrl.toString()); + } + + const url = new URL(request.url); + let scopeParam = url.searchParams.get("scope") || undefined; + + // If scope is present, remove spaces after commas (e.g., "read, write" -> "read,write") + if (scopeParam) { + scopeParam = scopeParam + .split(",") + .map((s) => s.trim()) + .join(","); + } else { + throw new Error("Scope is not found"); + } + + const params: OAuth2AuthorizeRequest = { + client_id: url.searchParams.get("client_id") || "", + redirect_uri: url.searchParams.get("redirect_uri") || "", + response_type: url.searchParams.get("response_type") || "", + scope: scopeParam, + state: url.searchParams.get("state") || undefined, + code_challenge: url.searchParams.get("code_challenge") || undefined, + code_challenge_method: + url.searchParams.get("code_challenge_method") || undefined, + }; + + // Validate required parameters + if (!params.client_id || !params.redirect_uri || !params.response_type) { + return redirect( + `${params.redirect_uri}?error=${OAuth2Errors.INVALID_REQUEST}&error_description=Missing required parameters${params.state ? `&state=${params.state}` : ""}`, + ); + } + + // Only support authorization code flow + if (params.response_type !== "code") { + return redirect( + `${params.redirect_uri}?error=${OAuth2Errors.UNSUPPORTED_RESPONSE_TYPE}&error_description=Only authorization code flow is supported${params.state ? `&state=${params.state}` : ""}`, + ); + } + + try { + // Validate client + const client = await oauth2Service.validateClient(params.client_id); + + // Validate redirect URI + if (!oauth2Service.validateRedirectUri(client, params.redirect_uri)) { + return redirect( + `${params.redirect_uri}?error=${OAuth2Errors.INVALID_REQUEST}&error_description=Invalid redirect URI${params.state ? `&state=${params.state}` : ""}`, + ); + } + + return { + user, + client, + params, + }; + } catch (error) { + return redirect( + `${params.redirect_uri}?error=${OAuth2Errors.INVALID_CLIENT}&error_description=Invalid client${params.state ? `&state=${params.state}` : ""}`, + ); + } +}; + +export const action = async ({ request }: ActionFunctionArgs) => { + const user = await getUser(request); + + if (!user) { + return redirect("/login"); + } + + const formData = await request.formData(); + const action = formData.get("action"); + + const params: OAuth2AuthorizeRequest = { + client_id: formData.get("client_id") as string, + redirect_uri: formData.get("redirect_uri") as string, + response_type: formData.get("response_type") as string, + scope: (formData.get("scope") as string) || undefined, + state: (formData.get("state") as string) || undefined, + code_challenge: (formData.get("code_challenge") as string) || undefined, + code_challenge_method: + (formData.get("code_challenge_method") as string) || undefined, + }; + + if (action === "deny") { + return redirect( + `${params.redirect_uri}?error=${OAuth2Errors.ACCESS_DENIED}&error_description=User denied access${params.state ? `&state=${params.state}` : ""}`, + ); + } + + if (action === "allow") { + try { + // Validate client again + const client = await oauth2Service.validateClient(params.client_id); + + if (!oauth2Service.validateRedirectUri(client, params.redirect_uri)) { + return redirect( + `${params.redirect_uri}?error=${OAuth2Errors.INVALID_REQUEST}&error_description=Invalid redirect URI${params.state ? `&state=${params.state}` : ""}`, + ); + } + + // Create authorization code + const authCode = await oauth2Service.createAuthorizationCode({ + clientId: params.client_id, + userId: user.id, + redirectUri: params.redirect_uri, + scope: params.scope, + state: params.state, + codeChallenge: params.code_challenge, + codeChallengeMethod: params.code_challenge_method, + }); + // Redirect back to client with authorization code + const redirectUrl = new URL(params.redirect_uri); + redirectUrl.searchParams.set("code", authCode); + if (params.state) { + redirectUrl.searchParams.set("state", params.state); + } + + return redirect(redirectUrl.toString()); + } catch (error) { + return redirect( + `${params.redirect_uri}?error=${OAuth2Errors.SERVER_ERROR}&error_description=Failed to create authorization code${params.state ? `&state=${params.state}` : ""}`, + ); + } + } + + return redirect( + `${params.redirect_uri}?error=${OAuth2Errors.INVALID_REQUEST}&error_description=Invalid action${params.state ? `&state=${params.state}` : ""}`, + ); +}; + +export default function OAuthAuthorize() { + const { user, client, params } = useLoaderData(); + + const getIcon = (scope: string) => { + if (scope === "read") { + return ; + } + + return ; + }; + + return ( +
+ + +
+ {client.logoUrl ? ( + {client.name} + ) : ( + + )} + + +
+
+
+
+

+ {client.name} is requesting access +

+

+ Authenticating with your {user.name} workspace +

+
+
+ +

Permissions

+
    + {params.scope?.split(",").map((scope, index, arr) => { + const isFirst = index === 0; + const isLast = index === arr.length - 1; + return ( +
  • +
    {getIcon(scope)}
    +
    + {scope.charAt(0).toUpperCase() + scope.slice(1)} access to + your workspace +
    +
  • + ); + })} +
+ +
+ + + + {params.scope && ( + + )} + {params.state && ( + + )} + {params.code_challenge && ( + + )} + {params.code_challenge_method && ( + + )} + +
+ + +
+
+
+
+
+
+ ); +} diff --git a/apps/webapp/app/routes/oauth.token.tsx b/apps/webapp/app/routes/oauth.token.tsx new file mode 100644 index 0000000..ee90d63 --- /dev/null +++ b/apps/webapp/app/routes/oauth.token.tsx @@ -0,0 +1,143 @@ +import { type ActionFunctionArgs, json } from "@remix-run/node"; +import { oauth2Service, OAuth2Errors, type OAuth2TokenRequest } from "~/services/oauth2.server"; + +export const action = async ({ request }: ActionFunctionArgs) => { + if (request.method !== "POST") { + return json( + { error: OAuth2Errors.INVALID_REQUEST, error_description: "Only POST method is allowed" }, + { status: 405 } + ); + } + + try { + const contentType = request.headers.get("content-type"); + let body: any; + let tokenRequest: OAuth2TokenRequest; + + // Support both JSON and form-encoded data + if (contentType?.includes("application/json")) { + body = await request.json(); + tokenRequest = { + grant_type: body.grant_type, + code: body.code || undefined, + redirect_uri: body.redirect_uri || undefined, + client_id: body.client_id, + client_secret: body.client_secret || undefined, + code_verifier: body.code_verifier || undefined, + }; + } else { + // Fall back to form data for compatibility + const formData = await request.formData(); + body = Object.fromEntries(formData); + tokenRequest = { + grant_type: formData.get("grant_type") as string, + code: formData.get("code") as string || undefined, + redirect_uri: formData.get("redirect_uri") as string || undefined, + client_id: formData.get("client_id") as string, + client_secret: formData.get("client_secret") as string || undefined, + code_verifier: formData.get("code_verifier") as string || undefined, + }; + } + + // Validate required parameters + if (!tokenRequest.grant_type || !tokenRequest.client_id) { + return json( + { error: OAuth2Errors.INVALID_REQUEST, error_description: "Missing required parameters" }, + { status: 400 } + ); + } + + // Handle authorization code grant + if (tokenRequest.grant_type === "authorization_code") { + if (!tokenRequest.code || !tokenRequest.redirect_uri) { + return json( + { error: OAuth2Errors.INVALID_REQUEST, error_description: "Missing code or redirect_uri" }, + { status: 400 } + ); + } + + // Validate client + try { + await oauth2Service.validateClient(tokenRequest.client_id, tokenRequest.client_secret); + } catch (error) { + return json( + { error: OAuth2Errors.INVALID_CLIENT, error_description: "Invalid client credentials" }, + { status: 401 } + ); + } + + // Exchange code for tokens + try { + const tokens = await oauth2Service.exchangeCodeForTokens({ + code: tokenRequest.code, + clientId: tokenRequest.client_id, + redirectUri: tokenRequest.redirect_uri, + codeVerifier: tokenRequest.code_verifier, + }); + + return json(tokens); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + return json( + { error: errorMessage, error_description: "Failed to exchange code for tokens" }, + { status: 400 } + ); + } + } + + // Handle refresh token grant + if (tokenRequest.grant_type === "refresh_token") { + const refreshToken = body.refresh_token; + + if (!refreshToken) { + return json( + { error: OAuth2Errors.INVALID_REQUEST, error_description: "Missing refresh_token" }, + { status: 400 } + ); + } + + // Validate client + try { + await oauth2Service.validateClient(tokenRequest.client_id, tokenRequest.client_secret); + } catch (error) { + return json( + { error: OAuth2Errors.INVALID_CLIENT, error_description: "Invalid client credentials" }, + { status: 401 } + ); + } + + // Refresh access token + try { + const tokens = await oauth2Service.refreshAccessToken(refreshToken, tokenRequest.client_id); + return json(tokens); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + return json( + { error: errorMessage, error_description: "Failed to refresh access token" }, + { status: 400 } + ); + } + } + + // Unsupported grant type + return json( + { error: OAuth2Errors.UNSUPPORTED_GRANT_TYPE, error_description: "Unsupported grant type" }, + { status: 400 } + ); + + } catch (error) { + console.error("OAuth2 token endpoint error:", error); + return json( + { error: OAuth2Errors.SERVER_ERROR, error_description: "Internal server error" }, + { status: 500 } + ); + } +}; + +// This endpoint only supports POST +export const loader = () => { + return json( + { error: OAuth2Errors.INVALID_REQUEST, error_description: "Only POST method is allowed" }, + { status: 405 } + ); +}; \ No newline at end of file diff --git a/apps/webapp/app/routes/oauth.userinfo.tsx b/apps/webapp/app/routes/oauth.userinfo.tsx new file mode 100644 index 0000000..0988bff --- /dev/null +++ b/apps/webapp/app/routes/oauth.userinfo.tsx @@ -0,0 +1,44 @@ +import { type LoaderFunctionArgs, json } from "@remix-run/node"; +import { oauth2Service } from "~/services/oauth2.server"; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + try { + // Get authorization header + const authHeader = request.headers.get("authorization"); + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return json( + { error: "invalid_token", error_description: "Missing or invalid authorization header" }, + { status: 401 } + ); + } + + const token = authHeader.substring(7); // Remove "Bearer " prefix + + // Validate token and get user info + try { + const userInfo = await oauth2Service.getUserInfo(token); + return json(userInfo); + } catch (error) { + return json( + { error: "invalid_token", error_description: "Invalid or expired access token" }, + { status: 401 } + ); + } + + } catch (error) { + console.error("OAuth2 userinfo endpoint error:", error); + return json( + { error: "server_error", error_description: "Internal server error" }, + { status: 500 } + ); + } +}; + +// This endpoint only supports GET +export const action = () => { + return json( + { error: "invalid_request", error_description: "Only GET method is allowed" }, + { status: 405 } + ); +}; \ No newline at end of file diff --git a/apps/webapp/app/services/apiAuth.server.ts b/apps/webapp/app/services/apiAuth.server.ts index 08cb0ba..1ee0165 100644 --- a/apps/webapp/app/services/apiAuth.server.ts +++ b/apps/webapp/app/services/apiAuth.server.ts @@ -1,4 +1,5 @@ import { findUserByToken } from "~/models/personal-token.server"; +import { oauth2Service } from "~/services/oauth2.server"; // See this for more: https://twitter.com/mattpocockuk/status/1653403198885904387?s=20 export type Prettify = { @@ -12,10 +13,14 @@ export type ApiAuthenticationResult = export type ApiAuthenticationResultSuccess = { ok: true; apiKey: string; - type: "PRIVATE"; + type: "PRIVATE" | "OAUTH2"; userId: string; scopes?: string[]; oneTimeUse?: boolean; + oauth2?: { + clientId: string; + scope: string | null; + }; }; export type ApiAuthenticationResultFailure = { @@ -53,6 +58,27 @@ export async function authenticateApiKeyWithFailure( apiKey: string, options: { allowPublicKey?: boolean; allowJWT?: boolean } = {}, ): Promise { + // First try OAuth2 access token + try { + const accessToken = await oauth2Service.validateAccessToken(apiKey); + if (accessToken) { + return { + ok: true, + apiKey, + type: "OAUTH2", + userId: accessToken.user.id, + scopes: accessToken.scope ? accessToken.scope.split(' ') : undefined, + oauth2: { + clientId: accessToken.client.clientId, + scope: accessToken.scope, + }, + }; + } + } catch (error) { + // If OAuth2 token validation fails, continue to PAT validation + } + + // Fall back to PAT authentication const result = getApiKeyResult(apiKey); if (!result) { diff --git a/apps/webapp/app/services/authorization.server.ts b/apps/webapp/app/services/authorization.server.ts index f1b9732..833f77f 100644 --- a/apps/webapp/app/services/authorization.server.ts +++ b/apps/webapp/app/services/authorization.server.ts @@ -7,7 +7,7 @@ export type AuthorizationResources = { }; export type AuthorizationEntity = { - type: "PRIVATE"; + type: "PRIVATE" | "OAUTH2"; scopes?: string[]; }; @@ -26,5 +26,10 @@ export function checkAuthorization( return { authorized: true }; } + // "OAUTH2" tokens are also authorized (scope-based authorization can be added later) + if (entity.type === "OAUTH2") { + return { authorized: true }; + } + return { authorized: false, reason: "No key" }; } diff --git a/apps/webapp/app/services/oauth2.server.ts b/apps/webapp/app/services/oauth2.server.ts new file mode 100644 index 0000000..08f345e --- /dev/null +++ b/apps/webapp/app/services/oauth2.server.ts @@ -0,0 +1,336 @@ +import { PrismaClient } from "@prisma/client"; +import crypto from "crypto"; + +const prisma = new PrismaClient(); + +export interface OAuth2AuthorizeRequest { + client_id: string; + redirect_uri: string; + response_type: string; + scope?: string; + state?: string; + code_challenge?: string; + code_challenge_method?: string; +} + +export interface OAuth2TokenRequest { + grant_type: string; + code?: string; + redirect_uri?: string; + client_id: string; + client_secret?: string; + code_verifier?: string; +} + +export interface OAuth2TokenResponse { + access_token: string; + token_type: string; + expires_in: number; + refresh_token?: string; + scope?: string; +} + +export interface OAuth2ErrorResponse { + error: string; + error_description?: string; + error_uri?: string; + state?: string; +} + +// OAuth2 Error types +export const OAuth2Errors = { + INVALID_REQUEST: "invalid_request", + INVALID_CLIENT: "invalid_client", + INVALID_GRANT: "invalid_grant", + UNAUTHORIZED_CLIENT: "unauthorized_client", + UNSUPPORTED_GRANT_TYPE: "unsupported_grant_type", + INVALID_SCOPE: "invalid_scope", + ACCESS_DENIED: "access_denied", + UNSUPPORTED_RESPONSE_TYPE: "unsupported_response_type", + SERVER_ERROR: "server_error", + TEMPORARILY_UNAVAILABLE: "temporarily_unavailable", +} as const; + +export class OAuth2Service { + // Generate secure random string + private generateSecureToken(length: number = 32): string { + return crypto.randomBytes(length).toString("hex"); + } + + // Validate OAuth2 client + async validateClient(clientId: string, clientSecret?: string): Promise { + const client = await prisma.oAuthClient.findUnique({ + where: { + clientId, + isActive: true, + }, + include: { + workspace: true, + }, + }); + + if (!client) { + throw new Error(OAuth2Errors.INVALID_CLIENT); + } + + // If client secret is provided, validate it + if (clientSecret && client.clientSecret !== clientSecret) { + throw new Error(OAuth2Errors.INVALID_CLIENT); + } + + return client; + } + + // Validate redirect URI + validateRedirectUri(client: any, redirectUri: string): boolean { + const allowedUris = client.redirectUris + .split(",") + .map((uri: string) => uri.trim()); + return allowedUris.includes(redirectUri); + } + + // Validate PKCE challenge + validatePkceChallenge( + codeVerifier: string, + codeChallenge: string, + method: string = "S256", + ): boolean { + if (method === "S256") { + const hash = crypto.createHash("sha256").update(codeVerifier).digest(); + const challenge = hash.toString("base64url"); + return challenge === codeChallenge; + } else if (method === "plain") { + return codeVerifier === codeChallenge; + } + return false; + } + + // Create authorization code + async createAuthorizationCode(params: { + clientId: string; + userId: string; + redirectUri: string; + scope?: string; + state?: string; + codeChallenge?: string; + codeChallengeMethod?: string; + }): Promise { + const code = this.generateSecureToken(32); + const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes + + // Find the client to get the internal database ID + const client = await prisma.oAuthClient.findUnique({ + where: { clientId: params.clientId }, + select: { id: true }, + }); + + if (!client) { + throw new Error(OAuth2Errors.INVALID_CLIENT); + } + + await prisma.oAuthAuthorizationCode.create({ + data: { + code, + clientId: client.id, // Use internal database ID + userId: params.userId, + redirectUri: params.redirectUri, + scope: params.scope, + state: params.state, + codeChallenge: params.codeChallenge, + codeChallengeMethod: params.codeChallengeMethod, + expiresAt, + }, + }); + + return code; + } + + // Exchange authorization code for tokens + async exchangeCodeForTokens(params: { + code: string; + clientId: string; + redirectUri: string; + codeVerifier?: string; + }): Promise { + // Find the client first to get the internal database ID + const client = await prisma.oAuthClient.findUnique({ + where: { clientId: params.clientId }, + select: { id: true }, + }); + + if (!client) { + throw new Error(OAuth2Errors.INVALID_CLIENT); + } + + // Find and validate authorization code + const authCode = await prisma.oAuthAuthorizationCode.findFirst({ + where: { + code: params.code, + clientId: client.id, // Use internal database ID + redirectUri: params.redirectUri, + used: false, + expiresAt: { gt: new Date() }, + }, + include: { + client: true, + user: true, + }, + }); + + if (!authCode) { + throw new Error(OAuth2Errors.INVALID_GRANT); + } + + // Validate PKCE if required + if (authCode.codeChallenge) { + if (!params.codeVerifier) { + throw new Error(OAuth2Errors.INVALID_REQUEST); + } + if ( + !this.validatePkceChallenge( + params.codeVerifier, + authCode.codeChallenge, + authCode.codeChallengeMethod || "S256", + ) + ) { + throw new Error(OAuth2Errors.INVALID_GRANT); + } + } + + // Mark code as used + await prisma.oAuthAuthorizationCode.update({ + where: { id: authCode.id }, + data: { used: true }, + }); + + // Generate access token + const accessToken = this.generateSecureToken(64); + const refreshToken = this.generateSecureToken(64); + const expiresIn = 3600; // 1 hour + const accessTokenExpiresAt = new Date(Date.now() + expiresIn * 1000); + const refreshTokenExpiresAt = new Date( + Date.now() + 30 * 24 * 60 * 60 * 1000, + ); // 30 days + + // Store tokens + await prisma.oAuthAccessToken.create({ + data: { + token: accessToken, + clientId: client.id, // Use internal database ID + userId: authCode.userId, + scope: authCode.scope, + expiresAt: accessTokenExpiresAt, + }, + }); + + await prisma.oAuthRefreshToken.create({ + data: { + token: refreshToken, + clientId: client.id, // Use internal database ID + userId: authCode.userId, + scope: authCode.scope, + expiresAt: refreshTokenExpiresAt, + }, + }); + + return { + access_token: accessToken, + token_type: "Bearer", + expires_in: expiresIn, + refresh_token: refreshToken, + scope: authCode.scope || undefined, + }; + } + + // Validate access token + async validateAccessToken(token: string): Promise { + const accessToken = await prisma.oAuthAccessToken.findFirst({ + where: { + token, + revoked: false, + expiresAt: { gt: new Date() }, + }, + include: { + client: true, + user: true, + }, + }); + + if (!accessToken) { + throw new Error("Invalid or expired token"); + } + + return accessToken; + } + + // Get user info from access token + async getUserInfo(token: string): Promise { + const accessToken = await this.validateAccessToken(token); + + return { + sub: accessToken.user.id, + email: accessToken.user.email, + name: accessToken.user.name, + display_name: accessToken.user.displayName, + avatar_url: accessToken.user.avatarUrl, + email_verified: true, // Assuming email is verified if user exists + }; + } + + // Refresh access token + async refreshAccessToken( + refreshToken: string, + clientId: string, + ): Promise { + // Find the client first to get the internal database ID + const client = await prisma.oAuthClient.findUnique({ + where: { clientId }, + select: { id: true }, + }); + + if (!client) { + throw new Error(OAuth2Errors.INVALID_CLIENT); + } + + const storedRefreshToken = await prisma.oAuthRefreshToken.findFirst({ + where: { + token: refreshToken, + clientId: client.id, // Use internal database ID + revoked: false, + expiresAt: { gt: new Date() }, + }, + include: { + client: true, + user: true, + }, + }); + + if (!storedRefreshToken) { + throw new Error(OAuth2Errors.INVALID_GRANT); + } + + // Generate new access token + const accessToken = this.generateSecureToken(64); + const expiresIn = 3600; // 1 hour + const accessTokenExpiresAt = new Date(Date.now() + expiresIn * 1000); + + await prisma.oAuthAccessToken.create({ + data: { + token: accessToken, + clientId: client.id, // Use internal database ID + userId: storedRefreshToken.userId, + scope: storedRefreshToken.scope, + expiresAt: accessTokenExpiresAt, + }, + }); + + return { + access_token: accessToken, + token_type: "Bearer", + expires_in: expiresIn, + scope: storedRefreshToken.scope || undefined, + }; + } +} + +export const oauth2Service = new OAuth2Service(); diff --git a/apps/webapp/app/tailwind.css b/apps/webapp/app/tailwind.css index 85ebd48..d14eb46 100644 --- a/apps/webapp/app/tailwind.css +++ b/apps/webapp/app/tailwind.css @@ -305,7 +305,7 @@ --radius-full: 9999px; --shadow: 0px 6px 20px 0px rgba(0, 0, 0, 0.15), 0px 0px 2px 0px rgba(0, 0, 0, 0.2); - --shadow-1: 0px 6px 20px 0px rgba(0, 0, 0, 0.15), 0px 0px 2px 0px rgba(0, 0, 0, 0.2); + --shadow-1: lch(0 0 0 / 0.022) 0px 3px 6px -2px, lch(0 0 0 / 0.044) 0px 1px 1px; --font-sans: "Geist Variable", "Helvetica Neue", "Helvetica", "Arial", sans-serif; --font-mono: "Geist Mono Variable", monaco, Consolas, "Lucida Console", monospace; diff --git a/apps/webapp/app/trigger/chat/memory-utils.ts b/apps/webapp/app/trigger/chat/memory-utils.ts index afb2791..613cf21 100644 --- a/apps/webapp/app/trigger/chat/memory-utils.ts +++ b/apps/webapp/app/trigger/chat/memory-utils.ts @@ -12,7 +12,6 @@ export interface SearchMemoryParams { export interface AddMemoryParams { message: string; referenceTime?: string; - source?: string; spaceId?: string; sessionId?: string; metadata?: any; @@ -38,7 +37,7 @@ export const addMemory = async (params: AddMemoryParams) => { ...params, episodeBody: params.message, referenceTime: params.referenceTime || new Date().toISOString(), - source: params.source || "CORE", + source: "CORE", }; const response = await axios.post( diff --git a/apps/webapp/app/utils/auth-helper.ts b/apps/webapp/app/utils/auth-helper.ts new file mode 100644 index 0000000..2211863 --- /dev/null +++ b/apps/webapp/app/utils/auth-helper.ts @@ -0,0 +1,109 @@ +import { json, redirect } from "@remix-run/node"; +import { getUser } from "~/services/session.server"; +import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { oauth2Service } from "~/services/oauth2.server"; +import { getUserById } from "~/models/user.server"; + +export type AuthenticatedUser = { + id: string; + email: string; + name: string | null; + displayName: string | null; + avatarUrl: string | null; + admin: boolean; + createdAt: Date; + updatedAt: Date; + confirmedBasicDetails: boolean; + authMethod: 'session' | 'pat' | 'oauth2'; + oauth2?: { + clientId: string; + scope: string | null; + }; +}; + +/** + * Authenticates a request using session, PAT, or OAuth2 access token + * Returns the authenticated user or throws an error response + */ +export async function requireAuth(request: Request): Promise { + const authHeader = request.headers.get("authorization"); + + // Try OAuth2 access token authentication + if (authHeader && authHeader.startsWith("Bearer ")) { + const token = authHeader.substring(7); + + // Check if it's a PAT token first + const patAuth = await authenticateApiRequestWithPersonalAccessToken(request); + if (patAuth) { + const user = await getUserById(patAuth.userId); + if (user) { + return { + id: user.id, + email: user.email, + name: user.name, + displayName: user.displayName, + avatarUrl: user.avatarUrl, + admin: user.admin, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + confirmedBasicDetails: user.confirmedBasicDetails, + authMethod: 'pat', + }; + } + } + + // Try OAuth2 access token + try { + const accessToken = await oauth2Service.validateAccessToken(token); + return { + id: accessToken.user.id, + email: accessToken.user.email, + name: accessToken.user.name, + displayName: accessToken.user.displayName, + avatarUrl: accessToken.user.avatarUrl, + admin: accessToken.user.admin, + createdAt: accessToken.user.createdAt, + updatedAt: accessToken.user.updatedAt, + confirmedBasicDetails: accessToken.user.confirmedBasicDetails, + authMethod: 'oauth2', + oauth2: { + clientId: accessToken.client.clientId, + scope: accessToken.scope, + }, + }; + } catch (error) { + // OAuth2 token validation failed, continue to session auth + } + } + + // Try session authentication + const sessionUser = await getUser(request); + if (sessionUser) { + return { + id: sessionUser.id, + email: sessionUser.email, + name: sessionUser.name, + displayName: sessionUser.displayName, + avatarUrl: sessionUser.avatarUrl, + admin: sessionUser.admin, + createdAt: sessionUser.createdAt, + updatedAt: sessionUser.updatedAt, + confirmedBasicDetails: sessionUser.confirmedBasicDetails, + authMethod: 'session', + }; + } + + // If no authentication method worked, return 401 + throw json({ error: "Unauthorized" }, { status: 401 }); +} + +/** + * Optional authentication - returns user if authenticated, null otherwise + */ +export async function getAuthenticatedUser(request: Request): Promise { + try { + return await requireAuth(request); + } catch (error) { + return null; + } +} \ No newline at end of file diff --git a/apps/webapp/app/utils/oauth2-middleware.ts b/apps/webapp/app/utils/oauth2-middleware.ts new file mode 100644 index 0000000..2131262 --- /dev/null +++ b/apps/webapp/app/utils/oauth2-middleware.ts @@ -0,0 +1,92 @@ +import { json } from "@remix-run/node"; +import { oauth2Service } from "~/services/oauth2.server"; + +export interface OAuth2Context { + user: { + id: string; + email: string; + name: string | null; + displayName: string | null; + avatarUrl: string | null; + }; + client: { + id: string; + clientId: string; + name: string; + }; + token: { + id: string; + token: string; + scope: string | null; + expiresAt: Date; + }; +} + +export async function requireOAuth2(request: Request): Promise { + const authHeader = request.headers.get("authorization"); + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + throw json( + { error: "invalid_token", error_description: "Missing or invalid authorization header" }, + { status: 401 } + ); + } + + const token = authHeader.substring(7); // Remove "Bearer " prefix + + try { + const accessToken = await oauth2Service.validateAccessToken(token); + + return { + user: { + id: accessToken.user.id, + email: accessToken.user.email, + name: accessToken.user.name, + displayName: accessToken.user.displayName, + avatarUrl: accessToken.user.avatarUrl, + }, + client: { + id: accessToken.client.id, + clientId: accessToken.client.clientId, + name: accessToken.client.name, + }, + token: { + id: accessToken.id, + token: accessToken.token, + scope: accessToken.scope, + expiresAt: accessToken.expiresAt, + }, + }; + } catch (error) { + throw json( + { error: "invalid_token", error_description: "Invalid or expired access token" }, + { status: 401 } + ); + } +} + +export async function getOAuth2Context(request: Request): Promise { + try { + return await requireOAuth2(request); + } catch (error) { + return null; + } +} + +export function hasScope(context: OAuth2Context, requiredScope: string): boolean { + if (!context.token.scope) { + return false; + } + + const scopes = context.token.scope.split(' '); + return scopes.includes(requiredScope); +} + +export function requireScope(context: OAuth2Context, requiredScope: string): void { + if (!hasScope(context, requiredScope)) { + throw json( + { error: "insufficient_scope", error_description: `Required scope: ${requiredScope}` }, + { status: 403 } + ); + } +} \ No newline at end of file diff --git a/packages/core-cli/README.md b/packages/core-cli/README.md index 21451fd..9d6d85d 100644 --- a/packages/core-cli/README.md +++ b/packages/core-cli/README.md @@ -35,19 +35,23 @@ npm install -g @redplanethq/core ### Initial Setup -1. **Run the initialization command:** +1. **Clone the Core repository:** + ```bash + git clone https://github.com/redplanethq/core.git + cd core + ``` +2. **Run the initialization command:** ```bash core init ``` -2. **The CLI will guide you through the complete setup process:** +3. **The CLI will guide you through the complete setup process:** -#### Step 1: Repository Validation - -- The CLI checks if you're in the Core repository -- If not, it offers to clone the repository for you -- Choose **Yes** to clone automatically, or **No** to clone manually +#### Step 1: Prerequisites Check +- The CLI shows a checklist of required tools +- Confirms you're in the Core repository directory +- Exits with instructions if prerequisites aren't met #### Step 2: Environment Configuration @@ -130,9 +134,9 @@ After setup, these services will be available: If you run commands outside the Core repository: -- The CLI will offer to clone the repository automatically -- Choose **Yes** to clone in the current directory -- Or navigate to the Core repository manually +- The CLI will ask you to confirm you're in the Core repository +- If not, it provides instructions to clone the repository +- Navigate to the Core repository directory before running commands again ### Docker Issues diff --git a/packages/core-cli/src/commands/init.ts b/packages/core-cli/src/commands/init.ts index 5c790b3..87b34e8 100644 --- a/packages/core-cli/src/commands/init.ts +++ b/packages/core-cli/src/commands/init.ts @@ -1,5 +1,4 @@ import { intro, outro, text, confirm, spinner, note, log } from "@clack/prompts"; -import { isValidCoreRepo } from "../utils/git.js"; import { fileExists, updateEnvFile } from "../utils/file.js"; import { checkPostgresHealth } from "../utils/docker.js"; import { executeDockerCommandInteractive } from "../utils/docker-interactive.js"; @@ -14,42 +13,17 @@ export async function initCommand() { intro("šŸš€ Core Development Environment Setup"); - // Step 1: Validate repository - if (!isValidCoreRepo()) { - log.warning("This directory is not a Core repository"); - note( - "The Core repository is required to run the development environment.\nWould you like to clone it in the current directory?", - "šŸ” Repository Not Found" - ); + // Step 1: Confirm this is the Core repository + note("Please ensure you have:\n• Docker and Docker Compose installed\n• Git installed\n• pnpm package manager installed\n• You are in the Core repository directory", "šŸ“‹ Prerequisites"); + + const isCoreRepo = await confirm({ + message: "Are you currently in the Core repository directory?", + }); - const shouldClone = await confirm({ - message: "Clone the Core repository here?", - }); - - if (!shouldClone) { - outro("āŒ Setup cancelled. Please navigate to the Core repository or clone it first."); - process.exit(1); - } - - // Clone the repository - try { - await executeDockerCommandInteractive("git clone https://github.com/redplanethq/core.git .", { - cwd: process.cwd(), - message: "Cloning Core repository...", - showOutput: true, - }); - - log.success("Core repository cloned successfully!"); - note( - 'Please run "core init" again to initialize the development environment.', - "āœ… Repository Ready" - ); - outro("šŸŽ‰ Core repository is now available!"); - process.exit(0); - } catch (error: any) { - outro(`āŒ Failed to clone repository: ${error.message}`); - process.exit(1); - } + if (!isCoreRepo) { + note("Please clone the Core repository first:\n\ngit clone https://github.com/redplanethq/core.git\ncd core\n\nThen run 'core init' again.", "šŸ“„ Clone Repository"); + outro("āŒ Setup cancelled. Please navigate to the Core repository first."); + process.exit(1); } const rootDir = process.cwd(); diff --git a/packages/core-cli/src/commands/start.ts b/packages/core-cli/src/commands/start.ts index f1378d8..2d061ae 100644 --- a/packages/core-cli/src/commands/start.ts +++ b/packages/core-cli/src/commands/start.ts @@ -1,5 +1,4 @@ import { intro, outro, note, log, confirm } from '@clack/prompts'; -import { isValidCoreRepo } from '../utils/git.js'; import { executeDockerCommandInteractive } from '../utils/docker-interactive.js'; import { printCoreBrainLogo } from '../utils/ascii.js'; import path from 'path'; @@ -10,36 +9,15 @@ export async function startCommand() { intro('šŸš€ Starting Core Development Environment'); - // Step 1: Validate repository - if (!isValidCoreRepo()) { - log.warning('This directory is not a Core repository'); - note('The Core repository is required to run the development environment.\nWould you like to clone it in the current directory?', 'šŸ” Repository Not Found'); - - const shouldClone = await confirm({ - message: 'Clone the Core repository here?', - }); + // Step 1: Confirm this is the Core repository + const isCoreRepo = await confirm({ + message: 'Are you currently in the Core repository directory?', + }); - if (!shouldClone) { - outro('āŒ Setup cancelled. Please navigate to the Core repository or clone it first.'); - process.exit(1); - } - - // Clone the repository - try { - await executeDockerCommandInteractive('git clone https://github.com/redplanethq/core.git .', { - cwd: process.cwd(), - message: 'Cloning Core repository...', - showOutput: true - }); - - log.success('Core repository cloned successfully!'); - note('You can now run "core start" to start the development environment.', 'āœ… Repository Ready'); - outro('šŸŽ‰ Core repository is now available!'); - process.exit(0); - } catch (error: any) { - outro(`āŒ Failed to clone repository: ${error.message}`); - process.exit(1); - } + if (!isCoreRepo) { + note('Please navigate to the Core repository first:\n\ngit clone https://github.com/redplanethq/core.git\ncd core\n\nThen run "core start" again.', 'šŸ“„ Core Repository Required'); + outro('āŒ Please navigate to the Core repository first.'); + process.exit(1); } const rootDir = process.cwd(); diff --git a/packages/core-cli/src/commands/stop.ts b/packages/core-cli/src/commands/stop.ts index 5fef1d1..aa1b2a6 100644 --- a/packages/core-cli/src/commands/stop.ts +++ b/packages/core-cli/src/commands/stop.ts @@ -1,5 +1,4 @@ import { intro, outro, log, confirm, note } from '@clack/prompts'; -import { isValidCoreRepo } from '../utils/git.js'; import { executeDockerCommandInteractive } from '../utils/docker-interactive.js'; import { printCoreBrainLogo } from '../utils/ascii.js'; import path from 'path'; @@ -10,36 +9,15 @@ export async function stopCommand() { intro('šŸ›‘ Stopping Core Development Environment'); - // Step 1: Validate repository - if (!isValidCoreRepo()) { - log.warning('This directory is not a Core repository'); - note('The Core repository is required to stop the development environment.\nWould you like to clone it in the current directory?', 'šŸ” Repository Not Found'); - - const shouldClone = await confirm({ - message: 'Clone the Core repository here?', - }); + // Step 1: Confirm this is the Core repository + const isCoreRepo = await confirm({ + message: 'Are you currently in the Core repository directory?', + }); - if (!shouldClone) { - outro('āŒ Setup cancelled. Please navigate to the Core repository or clone it first.'); - process.exit(1); - } - - // Clone the repository - try { - await executeDockerCommandInteractive('git clone https://github.com/redplanethq/core.git .', { - cwd: process.cwd(), - message: 'Cloning Core repository...', - showOutput: true - }); - - log.success('Core repository cloned successfully!'); - note('You can now run "core stop" to stop the development environment.', 'āœ… Repository Ready'); - outro('šŸŽ‰ Core repository is now available!'); - process.exit(0); - } catch (error: any) { - outro(`āŒ Failed to clone repository: ${error.message}`); - process.exit(1); - } + if (!isCoreRepo) { + note('Please navigate to the Core repository first:\n\ngit clone https://github.com/redplanethq/core.git\ncd core\n\nThen run "core stop" again.', 'šŸ“„ Core Repository Required'); + outro('āŒ Please navigate to the Core repository first.'); + process.exit(1); } const rootDir = process.cwd(); diff --git a/packages/database/prisma/migrations/20250716103033_add_oauth_models/migration.sql b/packages/database/prisma/migrations/20250716103033_add_oauth_models/migration.sql new file mode 100644 index 0000000..659c9f1 --- /dev/null +++ b/packages/database/prisma/migrations/20250716103033_add_oauth_models/migration.sql @@ -0,0 +1,106 @@ +-- CreateTable +CREATE TABLE "OAuthAuthorizationCode" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "clientId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "redirectUri" TEXT NOT NULL, + "scope" TEXT, + "state" TEXT, + "codeChallenge" TEXT, + "codeChallengeMethod" TEXT, + "expiresAt" TIMESTAMP(3) NOT NULL, + "used" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "OAuthAuthorizationCode_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OAuthClient" ( + "id" TEXT NOT NULL, + "clientId" TEXT NOT NULL, + "clientSecret" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "redirectUris" TEXT NOT NULL, + "allowedScopes" TEXT NOT NULL DEFAULT 'read', + "grantTypes" TEXT NOT NULL DEFAULT 'authorization_code', + "requirePkce" BOOLEAN NOT NULL DEFAULT false, + "logoUrl" TEXT, + "homepageUrl" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "workspaceId" TEXT NOT NULL, + "createdById" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "OAuthClient_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OAuthAccessToken" ( + "id" TEXT NOT NULL, + "token" TEXT NOT NULL, + "clientId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "scope" TEXT, + "expiresAt" TIMESTAMP(3) NOT NULL, + "revoked" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "OAuthAccessToken_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OAuthRefreshToken" ( + "id" TEXT NOT NULL, + "token" TEXT NOT NULL, + "clientId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "scope" TEXT, + "expiresAt" TIMESTAMP(3) NOT NULL, + "revoked" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "OAuthRefreshToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "OAuthAuthorizationCode_code_key" ON "OAuthAuthorizationCode"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "OAuthClient_clientId_key" ON "OAuthClient"("clientId"); + +-- CreateIndex +CREATE UNIQUE INDEX "OAuthAccessToken_token_key" ON "OAuthAccessToken"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "OAuthRefreshToken_token_key" ON "OAuthRefreshToken"("token"); + +-- AddForeignKey +ALTER TABLE "OAuthAuthorizationCode" ADD CONSTRAINT "OAuthAuthorizationCode_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "OAuthClient"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OAuthAuthorizationCode" ADD CONSTRAINT "OAuthAuthorizationCode_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OAuthClient" ADD CONSTRAINT "OAuthClient_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OAuthClient" ADD CONSTRAINT "OAuthClient_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OAuthAccessToken" ADD CONSTRAINT "OAuthAccessToken_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "OAuthClient"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OAuthAccessToken" ADD CONSTRAINT "OAuthAccessToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OAuthRefreshToken" ADD CONSTRAINT "OAuthRefreshToken_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "OAuthClient"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OAuthRefreshToken" ADD CONSTRAINT "OAuthRefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 621128e..28a816a 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -46,6 +46,110 @@ model AuthorizationCode { updatedAt DateTime @updatedAt } +model OAuthAuthorizationCode { + id String @id @default(cuid()) + + code String @unique + + // OAuth2 specific fields + clientId String + userId String + redirectUri String + scope String? + state String? + codeChallenge String? + codeChallengeMethod String? + expiresAt DateTime + used Boolean @default(false) + + // Relations + client OAuthClient @relation(fields: [clientId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model OAuthClient { + id String @id @default(cuid()) + + clientId String @unique + clientSecret String + name String + description String? + + // Redirect URIs (comma-separated for simplicity) + redirectUris String + + // Allowed scopes (comma-separated) + allowedScopes String @default("read") + + // Grant types allowed + grantTypes String @default("authorization_code") + + // PKCE support + requirePkce Boolean @default(false) + + // Client metadata + logoUrl String? + homepageUrl String? + + // GitHub-style features + isActive Boolean @default(true) + + // Workspace relationship (like GitHub orgs) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + workspaceId String + + // Created by user (for audit trail) + createdBy User @relation(fields: [createdById], references: [id]) + createdById String + + // Relations + oauthAuthorizationCodes OAuthAuthorizationCode[] + accessTokens OAuthAccessToken[] + refreshTokens OAuthRefreshToken[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model OAuthAccessToken { + id String @id @default(cuid()) + + token String @unique + clientId String + userId String + scope String? + expiresAt DateTime + revoked Boolean @default(false) + + // Relations + client OAuthClient @relation(fields: [clientId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model OAuthRefreshToken { + id String @id @default(cuid()) + + token String @unique + clientId String + userId String + scope String? + expiresAt DateTime + revoked Boolean @default(false) + + // Relations + client OAuthClient @relation(fields: [clientId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + model Conversation { id String @id @default(uuid()) createdAt DateTime @default(now()) @@ -319,6 +423,12 @@ model User { Conversation Conversation[] ConversationHistory ConversationHistory[] IngestionRule IngestionRule[] + + // OAuth2 relations + oauthAuthorizationCodes OAuthAuthorizationCode[] + oauthAccessTokens OAuthAccessToken[] + oauthRefreshTokens OAuthRefreshToken[] + oauthClientsCreated OAuthClient[] } model WebhookConfiguration { @@ -375,6 +485,7 @@ model Workspace { WebhookConfiguration WebhookConfiguration[] Conversation Conversation[] IngestionRule IngestionRule[] + OAuthClient OAuthClient[] } enum AuthenticationMethod {