mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-23 07:28:31 +00:00
Feat: OAuth support for external apps (#22)
* Feat: OAuth support for external apps * Fix: OAuth screen --------- Co-authored-by: Manoj K <saimanoj58@gmail.com>
This commit is contained in:
parent
b7b24aecdd
commit
714399cf41
@ -3,6 +3,7 @@ import { useEffect, useState, useCallback, useRef } from "react";
|
|||||||
import { AutoSizer, List, type ListRowRenderer } from "react-virtualized";
|
import { AutoSizer, List, type ListRowRenderer } from "react-virtualized";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import { Button } from "../ui";
|
import { Button } from "../ui";
|
||||||
|
import { LoaderCircle } from "lucide-react";
|
||||||
|
|
||||||
type ConversationItem = {
|
type ConversationItem = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -179,28 +180,27 @@ export const ConversationList = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col pt-1 pl-1">
|
<div className="flex h-full flex-col pt-1 pl-1">
|
||||||
<div className="group grow overflow-hidden">
|
{!isLoading && conversations.length > 0 && (
|
||||||
<AutoSizer>
|
<div className="group grow overflow-hidden">
|
||||||
{({ height, width }) => (
|
<AutoSizer>
|
||||||
<List
|
{({ height, width }) => (
|
||||||
height={height}
|
<List
|
||||||
width={width}
|
height={height}
|
||||||
rowCount={rowCount}
|
width={width}
|
||||||
rowHeight={32} // Slightly taller for better click area
|
rowCount={rowCount}
|
||||||
rowRenderer={rowRenderer}
|
rowHeight={32} // Slightly taller for better click area
|
||||||
overscanRowCount={5}
|
rowRenderer={rowRenderer}
|
||||||
/>
|
overscanRowCount={5}
|
||||||
)}
|
/>
|
||||||
</AutoSizer>
|
)}
|
||||||
</div>
|
</AutoSizer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isLoading && conversations.length === 0 && (
|
{isLoading && conversations.length === 0 && (
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center p-8">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<div className="border-primary h-4 w-4 animate-spin rounded-full border-2 border-t-transparent" />
|
<LoaderCircle className="text-primary h-4 w-4 animate-spin" />
|
||||||
<span className="text-muted-foreground text-sm">
|
|
||||||
Loading conversations...
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
19
apps/webapp/app/components/icons/arrows.tsx
Normal file
19
apps/webapp/app/components/icons/arrows.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import type { IconProps } from "./types";
|
||||||
|
|
||||||
|
export function Arrows({ size = 18, className }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={className}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="lch(38.893% 1 282.863 / 1)"
|
||||||
|
role="img"
|
||||||
|
focusable="false"
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path d="M5.378 7.5a.6.6 0 0 1 .6.6l-.002 1.774c2.375.224 8.027.917 8.027 1.328 0 .311-2.675.796-8.024 1.453l-.001 1.747a.6.6 0 0 1-.992.454L1.14 11.548a.4.4 0 0 1 0-.607l3.848-3.297a.6.6 0 0 1 .39-.144Zm4.79-6.291a.6.6 0 0 1 .846-.064l3.847 3.309a.4.4 0 0 1 0 .607l-3.848 3.296a.6.6 0 0 1-.99-.455V6.128C7.65 5.904 1.998 5.21 1.998 4.799c0-.31 2.675-.795 8.024-1.452l.001-1.747a.6.6 0 0 1 .145-.391Z"></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,2 +1,3 @@
|
|||||||
export * from "./slack-icon";
|
export * from "./slack-icon";
|
||||||
export * from "./linear-icon";
|
export * from "./linear-icon";
|
||||||
|
export * from "./arrows";
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
import { Search } from "lucide-react";
|
import { Layout, LayoutGrid, Search } from "lucide-react";
|
||||||
import { IntegrationCard } from "./integration-card";
|
import { IntegrationCard } from "./integration-card";
|
||||||
|
|
||||||
interface IntegrationGridProps {
|
interface IntegrationGridProps {
|
||||||
@ -25,8 +25,8 @@ export function IntegrationGrid({
|
|||||||
if (integrations.length === 0) {
|
if (integrations.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-20 flex flex-col items-center justify-center">
|
<div className="mt-20 flex flex-col items-center justify-center">
|
||||||
<Search className="text-muted-foreground mb-2 h-12 w-12" />
|
<LayoutGrid className="text-muted-foreground mb-2 h-10 w-10" />
|
||||||
<h3 className="text-lg font-medium">No integrations found</h3>
|
<h3 className="text-lg">No integrations found</h3>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import { ListFilter, X } from "lucide-react";
|
||||||
ChevronsUpDown,
|
|
||||||
Filter,
|
|
||||||
FilterIcon,
|
|
||||||
ListFilter,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import {
|
|||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
SidebarGroupContent,
|
SidebarGroupContent,
|
||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuButton,
|
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "../ui/sidebar";
|
} from "../ui/sidebar";
|
||||||
import { useLocation, useNavigate } from "@remix-run/react";
|
import { useLocation, useNavigate } from "@remix-run/react";
|
||||||
|
|||||||
@ -8,20 +8,13 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "../ui/dropdown-menu";
|
} from "../ui/dropdown-menu";
|
||||||
import {
|
import { SidebarMenu, SidebarMenuItem, useSidebar } from "../ui/sidebar";
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuButton,
|
|
||||||
SidebarMenuItem,
|
|
||||||
useSidebar,
|
|
||||||
} from "../ui/sidebar";
|
|
||||||
import type { User } from "~/models/user.server";
|
import type { User } from "~/models/user.server";
|
||||||
import { Button } from "../ui";
|
import { Button } from "../ui";
|
||||||
import { cn } from "~/lib/utils";
|
import { useNavigate } from "@remix-run/react";
|
||||||
import { useLocation, useNavigate } from "@remix-run/react";
|
|
||||||
|
|
||||||
export function NavUser({ user }: { user: User }) {
|
export function NavUser({ user }: { user: User }) {
|
||||||
const { isMobile } = useSidebar();
|
const { isMobile } = useSidebar();
|
||||||
const location = useLocation();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -55,7 +48,7 @@ export function NavUser({ user }: { user: User }) {
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="flex gap-2"
|
className="flex gap-2"
|
||||||
onClick={() => navigate("/settings")}
|
onClick={() => navigate("/settings/api")}
|
||||||
>
|
>
|
||||||
<Settings size={16} />
|
<Settings size={16} />
|
||||||
Settings
|
Settings
|
||||||
|
|||||||
186
apps/webapp/app/routes/api.oauth.clients.$clientId.tsx
Normal file
186
apps/webapp/app/routes/api.oauth.clients.$clientId.tsx
Normal file
@ -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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
148
apps/webapp/app/routes/api.oauth.clients.tsx
Normal file
148
apps/webapp/app/routes/api.oauth.clients.tsx
Normal file
@ -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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
33
apps/webapp/app/routes/api.profile.tsx
Normal file
33
apps/webapp/app/routes/api.profile.tsx
Normal file
@ -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 }
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -8,7 +8,6 @@ import { createHybridActionApiRoute } from "~/services/routeBuilders/apiBuilder.
|
|||||||
import { addToQueue } from "~/lib/ingest.server";
|
import { addToQueue } from "~/lib/ingest.server";
|
||||||
import { SearchService } from "~/services/search.server";
|
import { SearchService } from "~/services/search.server";
|
||||||
import { handleTransport } from "~/utils/mcp";
|
import { handleTransport } from "~/utils/mcp";
|
||||||
import { IngestBodyRequest } from "~/trigger/ingest/ingest";
|
|
||||||
|
|
||||||
// Map to store transports by session ID with cleanup tracking
|
// Map to store transports by session ID with cleanup tracking
|
||||||
const transports: {
|
const transports: {
|
||||||
@ -39,16 +38,14 @@ const MCPRequestSchema = z.object({}).passthrough();
|
|||||||
|
|
||||||
// Search parameters schema for MCP tool
|
// Search parameters schema for MCP tool
|
||||||
const SearchParamsSchema = z.object({
|
const SearchParamsSchema = z.object({
|
||||||
query: z.string(),
|
query: z.string().describe("The search query in third person perspective"),
|
||||||
startTime: z.string().optional(),
|
validAt: z.string().optional().describe("The valid at time in ISO format"),
|
||||||
endTime: z.string().optional(),
|
startTime: z.string().optional().describe("The start time in ISO format"),
|
||||||
spaceId: z.string().optional(),
|
endTime: z.string().optional().describe("The end time in ISO format"),
|
||||||
limit: z.number().optional(),
|
});
|
||||||
maxBfsDepth: z.number().optional(),
|
|
||||||
includeInvalidated: z.boolean().optional(),
|
const IngestSchema = z.object({
|
||||||
entityTypes: z.array(z.string()).optional(),
|
message: z.string().describe("The data to ingest in text format"),
|
||||||
scoreThreshold: z.number().optional(),
|
|
||||||
minResults: z.number().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const searchService = new SearchService();
|
const searchService = new SearchService();
|
||||||
@ -60,6 +57,22 @@ const handleMCPRequest = async (
|
|||||||
authentication: any,
|
authentication: any,
|
||||||
) => {
|
) => {
|
||||||
const sessionId = request.headers.get("mcp-session-id") as string | undefined;
|
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;
|
let transport: StreamableHTTPServerTransport;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -104,14 +117,18 @@ const handleMCPRequest = async (
|
|||||||
{
|
{
|
||||||
title: "Ingest Data",
|
title: "Ingest Data",
|
||||||
description: "Ingest data into the memory system",
|
description: "Ingest data into the memory system",
|
||||||
inputSchema: IngestBodyRequest.shape,
|
inputSchema: IngestSchema.shape,
|
||||||
},
|
},
|
||||||
async (args) => {
|
async (args) => {
|
||||||
try {
|
try {
|
||||||
const userId = authentication.userId;
|
const userId = authentication.userId;
|
||||||
|
|
||||||
const response = addToQueue(
|
const response = addToQueue(
|
||||||
args as z.infer<typeof IngestBodyRequest>,
|
{
|
||||||
|
episodeBody: args.message,
|
||||||
|
referenceTime: new Date().toISOString(),
|
||||||
|
source,
|
||||||
|
},
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -57,7 +57,7 @@ export default function LogsActivity() {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<div className="flex h-[calc(100vh_-_56px)] flex-col space-y-6 p-4 px-5">
|
<div className="flex h-[calc(100vh_-_56px)] flex-col items-center space-y-6 p-4 px-5">
|
||||||
{isInitialLoad ? (
|
{isInitialLoad ? (
|
||||||
<>
|
<>
|
||||||
<LoaderCircle className="text-primary h-4 w-4 animate-spin" />{" "}
|
<LoaderCircle className="text-primary h-4 w-4 animate-spin" />{" "}
|
||||||
|
|||||||
@ -45,7 +45,7 @@ export default function LogsAll() {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<div className="h-[calc(100vh_-_56px)] space-y-6 p-4 px-5">
|
<div className="flex h-[calc(100vh_-_56px)] flex-col items-center space-y-6 p-4 px-5">
|
||||||
{isInitialLoad ? (
|
{isInitialLoad ? (
|
||||||
<>
|
<>
|
||||||
<LoaderCircle className="text-primary h-4 w-4 animate-spin" />{" "}
|
<LoaderCircle className="text-primary h-4 w-4 animate-spin" />{" "}
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "~/components/ui/card";
|
} from "~/components/ui/card";
|
||||||
import { Form, useNavigation } from "@remix-run/react";
|
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 { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { LoginPageLayout } from "~/components/layout/login-page-layout";
|
import { LoginPageLayout } from "~/components/layout/login-page-layout";
|
||||||
@ -203,7 +203,7 @@ export default function LoginMagicLinkPage() {
|
|||||||
data-action="send a magic link"
|
data-action="send a magic link"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Loader className="mr-2 size-5" color="white" />
|
<LoaderCircle className="text-primary h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Mail className="text-text-bright mr-2 size-5" />
|
<Mail className="text-text-bright mr-2 size-5" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
279
apps/webapp/app/routes/oauth.authorize.tsx
Normal file
279
apps/webapp/app/routes/oauth.authorize.tsx
Normal file
@ -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<typeof loader>();
|
||||||
|
|
||||||
|
const getIcon = (scope: string) => {
|
||||||
|
if (scope === "read") {
|
||||||
|
return <AlignLeft size={16} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Pen size={16} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-background-2 flex min-h-screen items-center justify-center">
|
||||||
|
<Card className="bg-background-3 shadow-1 w-full max-w-md rounded-lg p-5">
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-center gap-4">
|
||||||
|
{client.logoUrl ? (
|
||||||
|
<img
|
||||||
|
src={client.logoUrl}
|
||||||
|
alt={client.name}
|
||||||
|
className="h-[40px] w-[40px] rounded"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<LayoutGrid size={40} />
|
||||||
|
)}
|
||||||
|
<Arrows size={16} />
|
||||||
|
<Logo width={40} height={40} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<div className="flex items-center justify-center space-x-3 text-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-normal">
|
||||||
|
{client.name} is requesting access
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Authenticating with your {user.name} workspace
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mb-2 text-sm">Permissions</p>
|
||||||
|
<ul className="text-muted-foreground text-sm">
|
||||||
|
{params.scope?.split(",").map((scope, index, arr) => {
|
||||||
|
const isFirst = index === 0;
|
||||||
|
const isLast = index === arr.length - 1;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={index}
|
||||||
|
className={`border-border flex items-center gap-2 border-x border-t p-2 ${isLast ? "border-b" : ""} ${isFirst ? "rounded-tl-md rounded-tr-md" : ""} ${isLast ? "rounded-br-md rounded-bl-md" : ""} `}
|
||||||
|
>
|
||||||
|
<div>{getIcon(scope)}</div>
|
||||||
|
<div>
|
||||||
|
{scope.charAt(0).toUpperCase() + scope.slice(1)} access to
|
||||||
|
your workspace
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<Form method="post" className="space-y-3">
|
||||||
|
<input type="hidden" name="client_id" value={params.client_id} />
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="redirect_uri"
|
||||||
|
value={params.redirect_uri}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="response_type"
|
||||||
|
value={params.response_type}
|
||||||
|
/>
|
||||||
|
{params.scope && (
|
||||||
|
<input type="hidden" name="scope" value={params.scope} />
|
||||||
|
)}
|
||||||
|
{params.state && (
|
||||||
|
<input type="hidden" name="state" value={params.state} />
|
||||||
|
)}
|
||||||
|
{params.code_challenge && (
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="code_challenge"
|
||||||
|
value={params.code_challenge}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{params.code_challenge_method && (
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="code_challenge_method"
|
||||||
|
value={params.code_challenge_method}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-3">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
name="action"
|
||||||
|
value="deny"
|
||||||
|
size="lg"
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
Deny
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
name="action"
|
||||||
|
value="allow"
|
||||||
|
size="lg"
|
||||||
|
className="shadow-none"
|
||||||
|
>
|
||||||
|
Allow Access
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
apps/webapp/app/routes/oauth.token.tsx
Normal file
143
apps/webapp/app/routes/oauth.token.tsx
Normal file
@ -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 }
|
||||||
|
);
|
||||||
|
};
|
||||||
44
apps/webapp/app/routes/oauth.userinfo.tsx
Normal file
44
apps/webapp/app/routes/oauth.userinfo.tsx
Normal file
@ -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 }
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { findUserByToken } from "~/models/personal-token.server";
|
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
|
// See this for more: https://twitter.com/mattpocockuk/status/1653403198885904387?s=20
|
||||||
export type Prettify<T> = {
|
export type Prettify<T> = {
|
||||||
@ -12,10 +13,14 @@ export type ApiAuthenticationResult =
|
|||||||
export type ApiAuthenticationResultSuccess = {
|
export type ApiAuthenticationResultSuccess = {
|
||||||
ok: true;
|
ok: true;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
type: "PRIVATE";
|
type: "PRIVATE" | "OAUTH2";
|
||||||
userId: string;
|
userId: string;
|
||||||
scopes?: string[];
|
scopes?: string[];
|
||||||
oneTimeUse?: boolean;
|
oneTimeUse?: boolean;
|
||||||
|
oauth2?: {
|
||||||
|
clientId: string;
|
||||||
|
scope: string | null;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ApiAuthenticationResultFailure = {
|
export type ApiAuthenticationResultFailure = {
|
||||||
@ -53,6 +58,27 @@ export async function authenticateApiKeyWithFailure(
|
|||||||
apiKey: string,
|
apiKey: string,
|
||||||
options: { allowPublicKey?: boolean; allowJWT?: boolean } = {},
|
options: { allowPublicKey?: boolean; allowJWT?: boolean } = {},
|
||||||
): Promise<ApiAuthenticationResult> {
|
): Promise<ApiAuthenticationResult> {
|
||||||
|
// 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);
|
const result = getApiKeyResult(apiKey);
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
|
|||||||
@ -7,7 +7,7 @@ export type AuthorizationResources = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type AuthorizationEntity = {
|
export type AuthorizationEntity = {
|
||||||
type: "PRIVATE";
|
type: "PRIVATE" | "OAUTH2";
|
||||||
scopes?: string[];
|
scopes?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -26,5 +26,10 @@ export function checkAuthorization(
|
|||||||
return { authorized: true };
|
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" };
|
return { authorized: false, reason: "No key" };
|
||||||
}
|
}
|
||||||
|
|||||||
336
apps/webapp/app/services/oauth2.server.ts
Normal file
336
apps/webapp/app/services/oauth2.server.ts
Normal file
@ -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<any> {
|
||||||
|
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<string> {
|
||||||
|
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<OAuth2TokenResponse> {
|
||||||
|
// 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<any> {
|
||||||
|
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<any> {
|
||||||
|
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<OAuth2TokenResponse> {
|
||||||
|
// 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();
|
||||||
@ -305,7 +305,7 @@
|
|||||||
--radius-full: 9999px;
|
--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: 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-sans: "Geist Variable", "Helvetica Neue", "Helvetica", "Arial", sans-serif;
|
||||||
--font-mono: "Geist Mono Variable", monaco, Consolas, "Lucida Console", monospace;
|
--font-mono: "Geist Mono Variable", monaco, Consolas, "Lucida Console", monospace;
|
||||||
|
|||||||
@ -12,7 +12,6 @@ export interface SearchMemoryParams {
|
|||||||
export interface AddMemoryParams {
|
export interface AddMemoryParams {
|
||||||
message: string;
|
message: string;
|
||||||
referenceTime?: string;
|
referenceTime?: string;
|
||||||
source?: string;
|
|
||||||
spaceId?: string;
|
spaceId?: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
metadata?: any;
|
metadata?: any;
|
||||||
@ -38,7 +37,7 @@ export const addMemory = async (params: AddMemoryParams) => {
|
|||||||
...params,
|
...params,
|
||||||
episodeBody: params.message,
|
episodeBody: params.message,
|
||||||
referenceTime: params.referenceTime || new Date().toISOString(),
|
referenceTime: params.referenceTime || new Date().toISOString(),
|
||||||
source: params.source || "CORE",
|
source: "CORE",
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
|
|||||||
109
apps/webapp/app/utils/auth-helper.ts
Normal file
109
apps/webapp/app/utils/auth-helper.ts
Normal file
@ -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<AuthenticatedUser> {
|
||||||
|
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<AuthenticatedUser | null> {
|
||||||
|
try {
|
||||||
|
return await requireAuth(request);
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
92
apps/webapp/app/utils/oauth2-middleware.ts
Normal file
92
apps/webapp/app/utils/oauth2-middleware.ts
Normal file
@ -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<OAuth2Context> {
|
||||||
|
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<OAuth2Context | null> {
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -35,19 +35,23 @@ npm install -g @redplanethq/core
|
|||||||
|
|
||||||
### Initial Setup
|
### 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
|
```bash
|
||||||
core init
|
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
|
#### Step 1: Prerequisites Check
|
||||||
|
- The CLI shows a checklist of required tools
|
||||||
- The CLI checks if you're in the Core repository
|
- Confirms you're in the Core repository directory
|
||||||
- If not, it offers to clone the repository for you
|
- Exits with instructions if prerequisites aren't met
|
||||||
- Choose **Yes** to clone automatically, or **No** to clone manually
|
|
||||||
|
|
||||||
#### Step 2: Environment Configuration
|
#### Step 2: Environment Configuration
|
||||||
|
|
||||||
@ -130,9 +134,9 @@ After setup, these services will be available:
|
|||||||
|
|
||||||
If you run commands outside the Core repository:
|
If you run commands outside the Core repository:
|
||||||
|
|
||||||
- The CLI will offer to clone the repository automatically
|
- The CLI will ask you to confirm you're in the Core repository
|
||||||
- Choose **Yes** to clone in the current directory
|
- If not, it provides instructions to clone the repository
|
||||||
- Or navigate to the Core repository manually
|
- Navigate to the Core repository directory before running commands again
|
||||||
|
|
||||||
### Docker Issues
|
### Docker Issues
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { intro, outro, text, confirm, spinner, note, log } from "@clack/prompts";
|
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 { fileExists, updateEnvFile } from "../utils/file.js";
|
||||||
import { checkPostgresHealth } from "../utils/docker.js";
|
import { checkPostgresHealth } from "../utils/docker.js";
|
||||||
import { executeDockerCommandInteractive } from "../utils/docker-interactive.js";
|
import { executeDockerCommandInteractive } from "../utils/docker-interactive.js";
|
||||||
@ -14,42 +13,17 @@ export async function initCommand() {
|
|||||||
|
|
||||||
intro("🚀 Core Development Environment Setup");
|
intro("🚀 Core Development Environment Setup");
|
||||||
|
|
||||||
// Step 1: Validate repository
|
// Step 1: Confirm this is the Core repository
|
||||||
if (!isValidCoreRepo()) {
|
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");
|
||||||
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({
|
const isCoreRepo = await confirm({
|
||||||
message: "Clone the Core repository here?",
|
message: "Are you currently in the Core repository directory?",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!shouldClone) {
|
if (!isCoreRepo) {
|
||||||
outro("❌ Setup cancelled. Please navigate to the Core repository or clone it first.");
|
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");
|
||||||
process.exit(1);
|
outro("❌ Setup cancelled. Please navigate to the Core repository 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootDir = process.cwd();
|
const rootDir = process.cwd();
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { intro, outro, note, log, confirm } from '@clack/prompts';
|
import { intro, outro, note, log, confirm } from '@clack/prompts';
|
||||||
import { isValidCoreRepo } from '../utils/git.js';
|
|
||||||
import { executeDockerCommandInteractive } from '../utils/docker-interactive.js';
|
import { executeDockerCommandInteractive } from '../utils/docker-interactive.js';
|
||||||
import { printCoreBrainLogo } from '../utils/ascii.js';
|
import { printCoreBrainLogo } from '../utils/ascii.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@ -10,36 +9,15 @@ export async function startCommand() {
|
|||||||
|
|
||||||
intro('🚀 Starting Core Development Environment');
|
intro('🚀 Starting Core Development Environment');
|
||||||
|
|
||||||
// Step 1: Validate repository
|
// Step 1: Confirm this is the Core repository
|
||||||
if (!isValidCoreRepo()) {
|
const isCoreRepo = await confirm({
|
||||||
log.warning('This directory is not a Core repository');
|
message: 'Are you currently in the Core repository directory?',
|
||||||
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({
|
if (!isCoreRepo) {
|
||||||
message: 'Clone the Core repository here?',
|
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);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootDir = process.cwd();
|
const rootDir = process.cwd();
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { intro, outro, log, confirm, note } from '@clack/prompts';
|
import { intro, outro, log, confirm, note } from '@clack/prompts';
|
||||||
import { isValidCoreRepo } from '../utils/git.js';
|
|
||||||
import { executeDockerCommandInteractive } from '../utils/docker-interactive.js';
|
import { executeDockerCommandInteractive } from '../utils/docker-interactive.js';
|
||||||
import { printCoreBrainLogo } from '../utils/ascii.js';
|
import { printCoreBrainLogo } from '../utils/ascii.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@ -10,36 +9,15 @@ export async function stopCommand() {
|
|||||||
|
|
||||||
intro('🛑 Stopping Core Development Environment');
|
intro('🛑 Stopping Core Development Environment');
|
||||||
|
|
||||||
// Step 1: Validate repository
|
// Step 1: Confirm this is the Core repository
|
||||||
if (!isValidCoreRepo()) {
|
const isCoreRepo = await confirm({
|
||||||
log.warning('This directory is not a Core repository');
|
message: 'Are you currently in the Core repository directory?',
|
||||||
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({
|
if (!isCoreRepo) {
|
||||||
message: 'Clone the Core repository here?',
|
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);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootDir = process.cwd();
|
const rootDir = process.cwd();
|
||||||
|
|||||||
@ -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;
|
||||||
@ -46,6 +46,110 @@ model AuthorizationCode {
|
|||||||
updatedAt DateTime @updatedAt
|
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 {
|
model Conversation {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@ -319,6 +423,12 @@ model User {
|
|||||||
Conversation Conversation[]
|
Conversation Conversation[]
|
||||||
ConversationHistory ConversationHistory[]
|
ConversationHistory ConversationHistory[]
|
||||||
IngestionRule IngestionRule[]
|
IngestionRule IngestionRule[]
|
||||||
|
|
||||||
|
// OAuth2 relations
|
||||||
|
oauthAuthorizationCodes OAuthAuthorizationCode[]
|
||||||
|
oauthAccessTokens OAuthAccessToken[]
|
||||||
|
oauthRefreshTokens OAuthRefreshToken[]
|
||||||
|
oauthClientsCreated OAuthClient[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model WebhookConfiguration {
|
model WebhookConfiguration {
|
||||||
@ -375,6 +485,7 @@ model Workspace {
|
|||||||
WebhookConfiguration WebhookConfiguration[]
|
WebhookConfiguration WebhookConfiguration[]
|
||||||
Conversation Conversation[]
|
Conversation Conversation[]
|
||||||
IngestionRule IngestionRule[]
|
IngestionRule IngestionRule[]
|
||||||
|
OAuthClient OAuthClient[]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AuthenticationMethod {
|
enum AuthenticationMethod {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user