OAuth for core (#28)

* Feat: add integrations access to OAuth apps

* Fix: generalize OAuth flow

---------

Co-authored-by: Manoj K <saimanoj58@gmail.com>
This commit is contained in:
Harshith Mullapudi 2025-07-23 13:03:13 +05:30 committed by GitHub
parent b0ff41823e
commit c80303a851
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1665 additions and 379 deletions

View File

@ -86,6 +86,32 @@ export const action = async ({ request }: ActionFunctionArgs) => {
);
}
// Validate scopes
const validScopes = [
// Authentication scopes (Google-style)
"profile",
"email",
"openid",
// Integration scope
"integration",
];
const requestedScopes = Array.isArray(allowedScopes)
? allowedScopes
: [allowedScopes || "read"];
const invalidScopes = requestedScopes.filter(
(scope) => !validScopes.includes(scope),
);
if (invalidScopes.length > 0) {
return json(
{
error: `Invalid scopes: ${invalidScopes.join(", ")}. Valid scopes are: ${validScopes.join(", ")}`,
},
{ status: 400 },
);
}
// Get user's workspace
const userRecord = await prisma.user.findUnique({
where: { id: user.id },
@ -96,6 +122,10 @@ export const action = async ({ request }: ActionFunctionArgs) => {
return json({ error: "No workspace found" }, { status: 404 });
}
if (!userRecord?.admin) {
return json({ error: "No access to create OAuth app" }, { status: 404 });
}
// Generate client credentials
const clientId = crypto.randomUUID();
const clientSecret = crypto.randomBytes(32).toString("hex");
@ -110,9 +140,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
redirectUris: Array.isArray(redirectUris)
? redirectUris.join(",")
: redirectUris,
allowedScopes: Array.isArray(allowedScopes)
? allowedScopes.join(",")
: allowedScopes || "read",
allowedScopes: requestedScopes.join(","),
requirePkce: requirePkce || false,
logoUrl: logoUrl || null,
homepageUrl: homepageUrl || null,
@ -138,8 +166,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
return json({
success: true,
client,
message:
"OAuth client created successfully. Save the client_secret securely - it won't be shown again.",
message: "OAuth client created successfully",
});
} catch (error) {
console.error("Error creating OAuth client:", error);

View File

@ -3,6 +3,7 @@ import { requireUserId } from "~/services/session.server";
import { logger } from "~/services/logger.service";
import { prisma } from "~/db.server";
import { triggerIntegrationWebhook } from "~/trigger/webhooks/integration-webhook-delivery";
export async function action({ request }: ActionFunctionArgs) {
if (request.method !== "POST") {
@ -29,6 +30,12 @@ export async function action({ request }: ActionFunctionArgs) {
},
});
await triggerIntegrationWebhook(
integrationAccountId,
userId,
"integration.disconnected",
);
logger.info("Integration account disconnected (soft deleted)", {
integrationAccountId,
userId,

View File

@ -3,6 +3,7 @@ import { requireUserId } from "~/services/session.server";
import { logger } from "~/services/logger.service";
import { prisma } from "~/db.server";
import { triggerIntegrationWebhook } from "~/trigger/webhooks/integration-webhook-delivery";
export async function action({ request }: ActionFunctionArgs) {
if (request.method !== "POST") {
@ -52,6 +53,12 @@ export async function action({ request }: ActionFunctionArgs) {
},
});
await triggerIntegrationWebhook(
integrationAccountId,
userId,
"mcp.disconnected",
);
logger.info("MCP configuration disconnected", {
integrationAccountId,
userId,

View File

@ -0,0 +1,57 @@
import { type LoaderFunctionArgs, json } from "@remix-run/node";
import { oauthIntegrationService } from "~/services/oauthIntegration.server";
import { authenticateOAuthRequest } from "~/services/apiAuth.server";
/**
* API endpoint for OAuth apps to get their connected integrations
* GET /api/oauth/integrations
* Authorization: Bearer <oauth_access_token>
*/
export const loader = async ({ request }: LoaderFunctionArgs) => {
try {
// Authenticate OAuth request and verify integration scope
const authResult = await authenticateOAuthRequest(request, ["integration"]);
if (!authResult.success) {
return json(
{
error: "unauthorized",
error_description: authResult.error
},
{ status: 401 }
);
}
// Get connected integrations for this client and user
const integrations = await oauthIntegrationService.getConnectedIntegrations({
clientId: authResult.clientId!,
userId: authResult.user!.id,
});
return json({
integrations,
count: integrations.length,
});
} catch (error) {
console.error("Error fetching OAuth integrations:", error);
return json(
{
error: "server_error",
error_description: "Internal server error"
},
{ status: 500 }
);
}
};
// Method not allowed for non-GET requests
export const action = async () => {
return json(
{
error: "method_not_allowed",
error_description: "Only GET requests are allowed"
},
{ status: 405 }
);
};

View File

@ -4,7 +4,7 @@ import {
redirect,
} from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
import { getUser } from "~/services/session.server";
import { getUser, requireWorkpace } from "~/services/session.server";
import {
oauth2Service,
OAuth2Errors,
@ -14,7 +14,8 @@ 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";
import { AlignLeft, LayoutGrid, Pen, User, Mail, Shield, Database } from "lucide-react";
export const loader = async ({ request }: LoaderFunctionArgs) => {
// Check if user is authenticated
@ -31,12 +32,18 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
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 scope is present, normalize it to comma-separated format
// Handle both space-separated (from URL encoding) and comma-separated scopes
if (scopeParam) {
scopeParam = scopeParam
.split(",")
.map((s) => s.trim())
.join(",");
// First, try splitting by spaces (common in OAuth2 URLs)
let scopes = scopeParam.split(/\s+/).filter(s => s.length > 0);
// If no spaces found, try splitting by commas
if (scopes.length === 1) {
scopes = scopeParam.split(",").map(s => s.trim()).filter(s => s.length > 0);
}
scopeParam = scopes.join(",");
} else {
throw new Error("Scope is not found");
}
@ -77,6 +84,12 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
);
}
// Validate scopes
if (!oauth2Service.validateScopes(client, params.scope || '')) {
return redirect(
`${params.redirect_uri}?error=${OAuth2Errors.INVALID_SCOPE}&error_description=Invalid scope${params.state ? `&state=${params.state}` : ""}`,
);
}
return {
user,
client,
@ -91,6 +104,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
export const action = async ({ request }: ActionFunctionArgs) => {
const user = await getUser(request);
const workspace = await requireWorkpace(request);
if (!user) {
return redirect("/login");
@ -136,7 +150,8 @@ export const action = async ({ request }: ActionFunctionArgs) => {
state: params.state,
codeChallenge: params.code_challenge,
codeChallengeMethod: params.code_challenge_method,
});
workspaceId: workspace.id,
});
// Redirect back to client with authorization code
const redirectUrl = new URL(params.redirect_uri);
redirectUrl.searchParams.set("code", authCode);
@ -158,14 +173,45 @@ export const action = async ({ request }: ActionFunctionArgs) => {
};
export default function OAuthAuthorize() {
const { user, client, params } = useLoaderData<typeof loader>();
const { user, client, params } = useLoaderData<typeof loader>();
const getIcon = (scope: string) => {
if (scope === "read") {
return <AlignLeft size={16} />;
const getScopeIcon = (scope: string) => {
switch (scope) {
case "profile":
return <User size={16} />;
case "email":
return <Mail size={16} />;
case "openid":
return <Shield size={16} />;
case "integration":
return <Database size={16} />;
case "read":
return <Pen size={16} />;
case "write":
return <Pen size={16} />;
default:
return <AlignLeft size={16} />;
}
};
return <Pen size={16} />;
const getScopeDescription = (scope: string) => {
switch (scope) {
case "profile":
return "View your basic profile information";
case "email":
return "View your email address";
case "openid":
return "Verify your identity using OpenID Connect";
case "integration":
return "Access and manage your workspace integrations";
case "read":
return "Read access to your account";
case "write":
return "Write access to your account";
default:
return `Access to ${scope}`;
}
};
return (
@ -192,14 +238,15 @@ export default function OAuthAuthorize() {
{client.name} is requesting access
</p>
<p className="text-muted-foreground text-sm">
Authenticating with your {user.name} workspace
Authenticating with your {user.name} account
</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) => {
{params.scope?.split(",").map((scope, index, arr) => {
const trimmedScope = scope.trim();
const isFirst = index === 0;
const isLast = index === arr.length - 1;
return (
@ -207,10 +254,9 @@ export default function OAuthAuthorize() {
key={index}
className={`flex items-center gap-2 border-x border-t border-gray-300 p-2 ${isLast ? "border-b" : ""} ${isFirst ? "rounded-tl-md rounded-tr-md" : ""} ${isLast ? "rounded-br-md rounded-bl-md" : ""} `}
>
<div>{getIcon(scope)}</div>
<div>{getScopeIcon(trimmedScope)}</div>
<div>
{scope.charAt(0).toUpperCase() + scope.slice(1)} access to
your workspace
{getScopeDescription(trimmedScope)}
</div>
</li>
);
@ -248,7 +294,7 @@ export default function OAuthAuthorize() {
name="code_challenge_method"
value={params.code_challenge_method}
/>
)}
)}
<div className="flex justify-end space-x-3">
<Button

View File

@ -0,0 +1,24 @@
import { type LoaderFunctionArgs, json } from "@remix-run/node";
import { oauth2Service } from "~/services/oauth2.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
const idToken = url.searchParams.get("id_token");
if (!idToken) {
return json(
{ error: "invalid_request", error_description: "Missing id_token parameter" },
{ status: 400 }
);
}
try {
const userInfo = await oauth2Service.getUserInfoFromIdToken(idToken);
return json(userInfo);
} catch (error) {
return json(
{ error: "invalid_token", error_description: "Invalid or expired ID token" },
{ status: 401 }
);
}
};

View File

@ -67,7 +67,7 @@ export async function authenticateApiKeyWithFailure(
apiKey,
type: "OAUTH2",
userId: accessToken.user.id,
scopes: accessToken.scope ? accessToken.scope.split(' ') : undefined,
scopes: accessToken.scope ? accessToken.scope.split(" ") : undefined,
oauth2: {
clientId: accessToken.client.clientId,
scope: accessToken.scope,
@ -130,3 +130,48 @@ export function getApiKeyResult(apiKey: string): {
} {
return { apiKey, type: "PRIVATE" };
}
/**
* Authenticate OAuth2 requests specifically
* Returns structured result for OAuth endpoints
*/
export async function authenticateOAuthRequest(
request: Request,
scopes?: string[],
): Promise<{
success: boolean;
user?: { id: string };
clientId?: string;
error?: string;
}> {
const apiKey = getApiKeyFromRequest(request);
if (!apiKey) {
return {
success: false,
error: "Missing authorization header",
};
}
// Only allow OAuth2 tokens for OAuth API endpoints
try {
const accessToken = await oauth2Service.validateAccessToken(apiKey, scopes);
if (accessToken) {
return {
success: true,
user: { id: accessToken.user.id },
clientId: accessToken.client.clientId,
};
}
} catch (error) {
return {
success: false,
error: "Invalid or expired access token",
};
}
return {
success: false,
error: "Invalid access token",
};
}

View File

@ -4,10 +4,10 @@ import { prisma } from "~/db.server";
* Get all integration definitions available to a workspace.
* Returns both global (workspaceId: null) and workspace-specific definitions.
*/
export async function getIntegrationDefinitions(workspaceId: string) {
export async function getIntegrationDefinitions(workspaceId?: string) {
return prisma.integrationDefinitionV2.findMany({
where: {
OR: [{ workspaceId: null }, { workspaceId }],
OR: [{ workspaceId: null }, ...(workspaceId ? [{ workspaceId }] : [])],
},
});
}
@ -26,9 +26,7 @@ export async function getIntegrationDefinitionWithId(
/**
* Get a single integration definition by its slug.
*/
export async function getIntegrationDefinitionWithSlug(
slug: string,
) {
export async function getIntegrationDefinitionWithSlug(slug: string) {
return prisma.integrationDefinitionV2.findFirst({
where: { slug },
});

View File

@ -1,5 +1,7 @@
import { PrismaClient } from "@prisma/client";
import crypto from "crypto";
import { env } from "~/env.server";
import { type JWTPayload, jwtVerify, SignJWT } from "jose";
const prisma = new PrismaClient();
@ -28,6 +30,7 @@ export interface OAuth2TokenResponse {
expires_in: number;
refresh_token?: string;
scope?: string;
id_token?: string;
}
export interface OAuth2ErrorResponse {
@ -37,6 +40,19 @@ export interface OAuth2ErrorResponse {
state?: string;
}
export interface IDTokenClaims {
iss: string; // Issuer
aud: string; // Audience (client_id)
sub: string; // Subject (user ID)
exp: number; // Expiration time
iat: number; // Issued at
email?: string;
email_verified?: boolean;
name?: string;
picture?: string;
installation_id?: string;
}
// OAuth2 Error types
export const OAuth2Errors = {
INVALID_REQUEST: "invalid_request",
@ -52,9 +68,149 @@ export const OAuth2Errors = {
} as const;
export class OAuth2Service {
// Generate secure random string
private generateSecureToken(length: number = 32): string {
return crypto.randomBytes(length).toString("hex");
private generateAccessToken(params: {
userId: string;
clientId: string;
workspaceId: string;
scope?: string;
}): string {
const payload = {
type: "access_token",
user_id: params.userId,
client_id: params.clientId,
workspace_id: params.workspaceId,
scope: params.scope,
jti: crypto.randomBytes(16).toString("hex"),
iat: Math.floor(Date.now() / 1000),
};
const encoded = Buffer.from(JSON.stringify(payload)).toString("base64url");
return `at_${encoded}`;
}
private generateRefreshToken(params: {
userId: string;
clientId: string;
workspaceId: string;
}): string {
const payload = {
type: "refresh_token",
user_id: params.userId,
client_id: params.clientId,
workspace_id: params.workspaceId,
jti: crypto.randomBytes(16).toString("hex"),
iat: Math.floor(Date.now() / 1000),
};
const encoded = Buffer.from(JSON.stringify(payload)).toString("base64url");
return `rt_${encoded}`;
}
private generateAuthorizationCode(params: {
clientId: string;
userId: string;
workspaceId: string;
}): string {
const payload = {
type: "authorization_code",
client_id: params.clientId,
user_id: params.userId,
workspace_id: params.workspaceId,
jti: crypto.randomBytes(12).toString("hex"),
iat: Math.floor(Date.now() / 1000),
};
const encoded = Buffer.from(JSON.stringify(payload)).toString("base64url");
return `ac_${encoded}`;
}
private async generateIdToken(params: {
userId: string;
clientId: string;
workspaceId: string;
email?: string;
name?: string;
avatarUrl?: string;
installationId?: string;
scopes?: string[];
}): Promise<string> {
const now = Math.floor(Date.now() / 1000);
const exp = now + 3600; // 1 hour
const claims: IDTokenClaims = {
iss: env.LOGIN_ORIGIN,
aud: params.clientId,
sub: params.userId,
exp,
iat: now,
};
// Add optional claims based on scopes
if (params.scopes?.includes("email") && params.email) {
claims.email = params.email;
claims.email_verified = true; // Assuming all CORE emails are verified
}
if (params.scopes?.includes("profile")) {
if (params.name) claims.name = params.name;
if (params.avatarUrl) claims.picture = params.avatarUrl;
}
if (params.installationId) {
claims.installation_id = params.installationId;
}
// Sign JWT with secret
const secret = new TextEncoder().encode(env.SESSION_SECRET);
return await new SignJWT(claims as JWTPayload)
.setProtectedHeader({ alg: "HS256" })
.sign(secret);
}
private extractTokenPayload(token: string): any {
try {
const parts = token.split("_");
if (parts.length !== 2) return null;
const encoded = parts[1];
const decoded = Buffer.from(encoded, "base64url").toString();
return JSON.parse(decoded);
} catch {
return null;
}
}
private validateTokenFormat(
token: string,
expectedType: "access_token" | "refresh_token" | "authorization_code",
): any {
try {
const prefixMap = {
access_token: "at_",
refresh_token: "rt_",
authorization_code: "ac_",
};
const expectedPrefix = prefixMap[expectedType];
if (!token.startsWith(expectedPrefix)) {
return null;
}
const payload = this.extractTokenPayload(token);
if (!payload || payload.type !== expectedType) {
return null;
}
return payload;
} catch {
return null;
}
}
// Validate OAuth2 client
@ -105,44 +261,145 @@ export class OAuth2Service {
return false;
}
// Validate scopes against client's allowed scopes
validateScopes(client: any, requestedScopes: string): boolean {
const allowedScopes = client.allowedScopes
.split(",")
.map((s: string) => s.trim());
const requestedScopeArray = requestedScopes
.split(",")
.map((s: string) => s.trim());
return requestedScopeArray.every((scope) => allowedScopes.includes(scope));
}
async verifyIdToken(idToken: string): Promise<IDTokenClaims> {
try {
const secret = new TextEncoder().encode(env.SESSION_SECRET);
const { payload } = await jwtVerify(idToken, secret);
return payload as IDTokenClaims;
} catch (error) {
throw new Error("Invalid ID token");
}
}
// Determine scope type for routing (simplified)
getScopeType(scope: string): "auth" | "integration" | "mixed" {
const scopes = scope.split(",").map((s) => s.trim());
// Google-style auth scopes
const authScopes = ["profile", "email", "openid"];
// Single integration scope
const integrationScopes = ["integration"];
const hasAuthScopes = scopes.some((s) => authScopes.includes(s));
const hasIntegrationScopes = scopes.some((s) =>
integrationScopes.includes(s),
);
if (hasAuthScopes && hasIntegrationScopes) {
return "mixed";
} else if (hasAuthScopes) {
return "auth";
} else if (hasIntegrationScopes) {
return "integration";
}
// Default to auth for unknown scopes
return "auth";
}
// Get scope descriptions for UI
getScopeDescriptions(
scopes: string[],
): Array<{ scope: string; description: string; icon: string }> {
const scopeMap: Record<string, { description: string; icon: string }> = {
profile: {
description: "Access your profile information",
icon: "user",
},
email: { description: "Access your email address", icon: "mail" },
openid: { description: "Verify your identity", icon: "shield" },
integration: {
description: "Access your workspace integrations",
icon: "database",
},
};
return scopes.map((scope) => ({
scope,
description: scopeMap[scope]?.description || `Access to ${scope}`,
icon: scopeMap[scope]?.icon || "align-left",
}));
}
// Create authorization code
async createAuthorizationCode(params: {
clientId: string;
userId: string;
redirectUri: string;
workspaceId: string;
scope?: string;
state?: string;
codeChallenge?: string;
codeChallengeMethod?: string;
}): Promise<string> {
const code = this.generateSecureToken(32);
const code = this.generateAuthorizationCode(params);
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: {
try {
await prisma.oAuthAuthorizationCode.create({
data: {
code,
clientId: client.id,
userId: params.userId,
redirectUri: params.redirectUri,
scope: params.scope,
state: params.state,
codeChallenge: params.codeChallenge,
codeChallengeMethod: params.codeChallengeMethod,
workspaceId: params.workspaceId,
expiresAt,
},
});
} catch (error) {
throw new Error("Failed to create authorization code");
}
return code;
}
async validateAuthorizationCode(code: string): Promise<any> {
const tokenPayload = this.validateTokenFormat(code, "authorization_code");
if (!tokenPayload) {
throw new Error("Invalid or expired token");
}
const authorizationCode = await prisma.oAuthAuthorizationCode.findFirst({
where: {
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,
workspaceId: tokenPayload.workspace_id,
expiresAt: { gt: new Date() },
},
include: {
client: true,
user: true,
},
});
return code;
if (!authorizationCode) {
throw new Error("Invalid or expired token");
}
return authorizationCode;
}
// Exchange authorization code for tokens
@ -155,27 +412,13 @@ export class OAuth2Service {
// 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,
},
});
const authCode = await this.validateAuthorizationCode(params.code);
if (!authCode) {
throw new Error(OAuth2Errors.INVALID_GRANT);
@ -204,9 +447,20 @@ export class OAuth2Service {
});
// Generate access token
const accessToken = this.generateSecureToken(64);
const refreshToken = this.generateSecureToken(64);
const expiresIn = 3600; // 1 hour
const accessToken = this.generateAccessToken({
userId: authCode.userId,
clientId: client.clientId,
workspaceId: authCode.workspaceId,
scope: authCode.scope || undefined,
});
const refreshToken = this.generateRefreshToken({
userId: authCode.userId,
clientId: client.clientId,
workspaceId: authCode.workspaceId,
});
const expiresIn = 86400; // 1 day
const accessTokenExpiresAt = new Date(Date.now() + expiresIn * 1000);
const refreshTokenExpiresAt = new Date(
Date.now() + 30 * 24 * 60 * 60 * 1000,
@ -216,39 +470,97 @@ export class OAuth2Service {
await prisma.oAuthAccessToken.create({
data: {
token: accessToken,
clientId: client.id, // Use internal database ID
clientId: client.id,
userId: authCode.userId,
scope: authCode.scope,
expiresAt: accessTokenExpiresAt,
workspaceId: authCode.workspaceId,
},
});
await prisma.oAuthRefreshToken.create({
data: {
token: refreshToken,
clientId: client.id, // Use internal database ID
clientId: client.id,
userId: authCode.userId,
scope: authCode.scope,
expiresAt: refreshTokenExpiresAt,
workspaceId: authCode.workspaceId,
},
});
const installation = await prisma.oAuthClientInstallation.upsert({
where: {
oauthClientId_workspaceId: {
oauthClientId: client.id,
workspaceId: authCode.workspaceId,
},
},
update: {
oauthClientId: client.id,
workspaceId: authCode.workspaceId,
installedById: authCode.userId,
isActive: true,
grantedScopes: authCode.scope,
},
create: {
oauthClientId: client.id,
workspaceId: authCode.workspaceId,
installedById: authCode.userId,
isActive: true,
grantedScopes: authCode.scope,
},
});
const idToken = await this.generateIdToken({
userId: authCode.userId,
clientId: client.clientId,
workspaceId: authCode.workspaceId,
email: authCode.user.email,
name: authCode.user.name || null,
avatarUrl: authCode.user.avatarUrl || null,
installationId: installation.id,
scopes: authCode.scope?.split(","),
});
return {
access_token: accessToken,
token_type: "Bearer",
expires_in: expiresIn,
refresh_token: refreshToken,
scope: authCode.scope || undefined,
id_token: idToken,
};
}
async getUserInfoFromIdToken(idToken: string): Promise<any> {
const claims = await this.verifyIdToken(idToken);
return {
sub: claims.sub,
email: claims.email,
email_verified: claims.email_verified,
name: claims.name,
picture: claims.picture,
installation_id: claims.installation_id,
};
}
// Validate access token
async validateAccessToken(token: string): Promise<any> {
async validateAccessToken(token: string, scopes?: string[]): Promise<any> {
const tokenPayload = this.validateTokenFormat(token, "access_token");
if (!tokenPayload) {
throw new Error("Invalid or expired token");
}
const accessToken = await prisma.oAuthAccessToken.findFirst({
where: {
token,
revoked: false,
expiresAt: { gt: new Date() },
userId: tokenPayload.user_id,
workspaceId: tokenPayload.workspace_id,
...(scopes ? { scope: { contains: scopes.join(",") } } : {}),
},
include: {
client: true,
@ -273,10 +585,32 @@ export class OAuth2Service {
name: accessToken.user.name,
display_name: accessToken.user.displayName,
avatar_url: accessToken.user.avatarUrl,
email_verified: true, // Assuming email is verified if user exists
email_verified: true,
};
}
async validateRefreshToken(token: string): Promise<any> {
const tokenPayload = await this.validateTokenFormat(token, "refresh_token");
if (!tokenPayload) {
throw new Error("Invalid or expired token");
}
const refreshToken = await prisma.oAuthRefreshToken.findFirst({
where: {
token,
clientId: tokenPayload.client_id,
revoked: false,
expiresAt: { gt: new Date() },
},
});
if (!refreshToken) {
throw new Error("Invalid or expired token");
}
return refreshToken;
}
// Refresh access token
async refreshAccessToken(
refreshToken: string,
@ -285,7 +619,6 @@ export class OAuth2Service {
// Find the client first to get the internal database ID
const client = await prisma.oAuthClient.findUnique({
where: { clientId },
select: { id: true },
});
if (!client) {
@ -295,7 +628,7 @@ export class OAuth2Service {
const storedRefreshToken = await prisma.oAuthRefreshToken.findFirst({
where: {
token: refreshToken,
clientId: client.id, // Use internal database ID
clientId: client.id,
revoked: false,
expiresAt: { gt: new Date() },
},
@ -309,18 +642,44 @@ export class OAuth2Service {
throw new Error(OAuth2Errors.INVALID_GRANT);
}
const newRefreshToken = this.generateRefreshToken({
userId: storedRefreshToken.userId,
clientId: client.clientId,
workspaceId: storedRefreshToken.workspaceId,
});
// Generate new access token
const accessToken = this.generateSecureToken(64);
const expiresIn = 3600; // 1 hour
const accessToken = this.generateAccessToken({
userId: storedRefreshToken.userId,
clientId: client.clientId,
workspaceId: storedRefreshToken.workspaceId,
scope: storedRefreshToken.scope || undefined,
});
const expiresIn = 86400; // 1 day
const accessTokenExpiresAt = new Date(Date.now() + expiresIn * 1000);
const newRefreshTokenExpiresAt = new Date(
Date.now() + 30 * 24 * 60 * 60 * 1000,
);
await prisma.oAuthRefreshToken.create({
data: {
token: newRefreshToken,
clientId: client.id,
userId: storedRefreshToken.userId,
scope: storedRefreshToken.scope,
expiresAt: newRefreshTokenExpiresAt,
workspaceId: storedRefreshToken.workspaceId,
},
});
await prisma.oAuthAccessToken.create({
data: {
token: accessToken,
clientId: client.id, // Use internal database ID
clientId: client.id,
userId: storedRefreshToken.userId,
scope: storedRefreshToken.scope,
expiresAt: accessTokenExpiresAt,
workspaceId: storedRefreshToken.workspaceId,
},
});
@ -328,6 +687,7 @@ export class OAuth2Service {
access_token: accessToken,
token_type: "Bearer",
expires_in: expiresIn,
refresh_token: newRefreshToken,
scope: storedRefreshToken.scope || undefined,
};
}

View File

@ -0,0 +1,171 @@
import { prisma } from "~/db.server";
import { env } from "~/env.server";
/**
* Service for managing OAuth integration grants and webhooks
*/
export class OAuthIntegrationService {
/**
* Create integration grants for OAuth client when user authorizes
*/
async createIntegrationGrants(params: {
clientId: string;
userId: string;
integrationAccountIds: string[];
}): Promise<void> {
// Get internal client ID
const client = await prisma.oAuthClient.findUnique({
where: { clientId: params.clientId },
select: { id: true, webhookUrl: true, webhookSecret: true },
});
if (!client) {
throw new Error("Invalid OAuth client");
}
// Create grants for each selected integration
const grants = params.integrationAccountIds.map((integrationAccountId) => ({
clientId: client.id,
userId: params.userId,
integrationAccountId,
isActive: true,
}));
await prisma.oAuthIntegrationGrant.createMany({
data: grants,
skipDuplicates: true, // Avoid conflicts if grant already exists
});
// Send webhook notification if webhook URL is configured
if (client.webhookUrl) {
await this.sendIntegrationWebhooks({
clientId: params.clientId,
userId: params.userId,
integrationAccountIds: params.integrationAccountIds,
eventType: "integration.connected",
webhookUrl: client.webhookUrl,
webhookSecret: client.webhookSecret ?? undefined,
});
}
}
/**
* Revoke integration grants for OAuth client
*/
async revokeIntegrationGrants(params: {
clientId: string;
userId: string;
integrationAccountIds?: string[]; // If not provided, revoke all
}): Promise<void> {
// Get internal client ID
const client = await prisma.oAuthClient.findUnique({
where: { clientId: params.clientId },
select: { id: true, webhookUrl: true, webhookSecret: true },
});
if (!client) {
throw new Error("Invalid OAuth client");
}
const whereClause: any = {
clientId: client.id,
userId: params.userId,
isActive: true,
};
if (params.integrationAccountIds) {
whereClause.integrationAccountId = {
in: params.integrationAccountIds,
};
}
// Get the grants being revoked for webhook notification
const grantsToRevoke = await prisma.oAuthIntegrationGrant.findMany({
where: whereClause,
include: {
integrationAccount: true,
},
});
// Revoke the grants
await prisma.oAuthIntegrationGrant.updateMany({
where: whereClause,
data: {
isActive: false,
revokedAt: new Date(),
},
});
// Send webhook notification if webhook URL is configured
if (client.webhookUrl && grantsToRevoke.length > 0) {
await this.sendIntegrationWebhooks({
clientId: params.clientId,
userId: params.userId,
integrationAccountIds: grantsToRevoke.map(
(g) => g.integrationAccountId,
),
eventType: "integration.disconnected",
webhookUrl: client.webhookUrl,
webhookSecret: client.webhookSecret ?? undefined,
});
}
}
/**
* Get connected integrations for OAuth client
*/
async getConnectedIntegrations(params: { clientId: string; userId: string }) {
// Get internal client ID
const client = await prisma.oAuthClient.findUnique({
where: { clientId: params.clientId },
select: { id: true },
});
if (!client) {
throw new Error("Invalid OAuth client");
}
const integrationAccounts = await prisma.integrationAccount.findMany({
where: {
workspace: {
userId: params.userId,
},
isActive: true,
},
include: {
integrationDefinition: true,
},
});
return integrationAccounts.map((integrationAccount) => {
const integrationConfig =
integrationAccount.integrationConfiguration as any;
return {
id: integrationAccount.id,
provider: integrationAccount.integrationDefinition.slug,
mcpEndpoint: integrationConfig.mcp
? `${env.LOGIN_ORIGIN}/api/v1/mcp/${integrationAccount.integrationDefinition.slug}`
: undefined,
connectedAt: integrationAccount.createdAt,
name: integrationAccount.integrationDefinition.name,
icon: integrationAccount.integrationDefinition.icon,
};
});
}
/**
* Send webhook notifications for integration events
*/
private async sendIntegrationWebhooks(params: {
clientId: string;
userId: string;
integrationAccountIds: string[];
eventType: "integration.connected" | "integration.disconnected";
webhookUrl: string;
webhookSecret?: string;
}) {
return params;
}
}
export const oauthIntegrationService = new OAuthIntegrationService();

View File

@ -22,6 +22,7 @@ import {
saveIntegrationAccountState,
saveMCPConfig,
} from "../utils/message-utils";
import { triggerIntegrationWebhook } from "../webhooks/integration-webhook-delivery";
/**
* Determines if a string is a URL.
@ -223,17 +224,23 @@ async function handleAccountMessage(
const mcp = message.data.mcp;
if (mcp) {
return await saveMCPConfig({
const config = await saveMCPConfig({
integrationAccountId,
config: message.data.config,
});
await triggerIntegrationWebhook(
integrationAccountId,
userId,
"mcp.connected",
);
return config;
}
// Handle only one messages since account gets created only for one
const {
data: { settings, config, accountId },
} = messages[0];
return await createIntegrationAccount({
const integrationAccount = await createIntegrationAccount({
integrationDefinitionId: integrationDefinition.id,
workspaceId,
settings,
@ -241,6 +248,24 @@ async function handleAccountMessage(
accountId,
userId,
});
// Trigger OAuth integration webhook notifications
try {
await triggerIntegrationWebhook(
integrationAccount.id,
userId,
"integration.connected",
);
} catch (error) {
logger.error("Failed to trigger OAuth integration webhook", {
integrationAccountId: integrationAccount.id,
userId,
error: error instanceof Error ? error.message : String(error),
});
// Don't fail the integration creation if webhook delivery fails
}
return integrationAccount;
}
/**

View File

@ -0,0 +1,170 @@
import { queue, task } from "@trigger.dev/sdk";
import { PrismaClient } from "@prisma/client";
import { logger } from "~/services/logger.service";
import {
deliverWebhook,
type WebhookEventType,
type WebhookTarget,
} from "./webhook-delivery-utils";
const prisma = new PrismaClient();
const integrationWebhookQueue = queue({
name: "integration-webhook-queue",
});
interface OAuthIntegrationWebhookPayload {
integrationAccountId: string;
eventType: WebhookEventType;
userId: string;
}
export const integrationWebhookTask = task({
id: "integration-webhook-delivery",
queue: integrationWebhookQueue,
run: async (payload: OAuthIntegrationWebhookPayload) => {
try {
logger.log(
`Processing OAuth integration webhook delivery for integration account ${payload.integrationAccountId}`,
);
// Get the integration account details
const integrationAccount = await prisma.integrationAccount.findUnique({
where: { id: payload.integrationAccountId },
include: {
integrationDefinition: true,
},
});
if (!integrationAccount) {
logger.error(
`Integration account ${payload.integrationAccountId} not found`,
);
return { success: false, error: "Integration account not found" };
}
// Get all OAuth clients that:
// 1. Have integration scope granted for this user
// 2. Have webhook URLs configured
const oauthClients = await prisma.oAuthClientInstallation.findMany({
where: {
workspaceId: integrationAccount.workspaceId,
installedById: payload.userId,
isActive: true,
// Check if client has integration scope in allowedScopes
grantedScopes: {
contains: "integration",
},
},
select: {
id: true,
oauthClient: {
select: {
clientId: true,
webhookUrl: true,
webhookSecret: true,
},
},
},
});
logger.log(`Found ${oauthClients.length} OAuth clients`);
if (oauthClients.length === 0) {
logger.log(
`No OAuth clients with integration scope found for user ${payload.userId}`,
);
return { success: true, message: "No OAuth clients to notify" };
}
const integrationConfig =
integrationAccount.integrationConfiguration as any;
// Prepare webhook payload
const webhookPayload = {
event: payload.eventType,
user_id: payload.userId,
integration: {
id: integrationAccount.id,
provider: integrationAccount.integrationDefinition.slug,
mcp_endpoint: integrationConfig.mcp
? `${process.env.API_BASE_URL}/api/v1/mcp/${integrationAccount.integrationDefinition.slug}`
: undefined,
name: integrationAccount.integrationDefinition.name,
icon: integrationAccount.integrationDefinition.icon,
},
timestamp: new Date().toISOString(),
};
// Convert OAuth clients to targets
const targets: WebhookTarget[] = oauthClients
.filter((client) => client.oauthClient?.webhookUrl)
.map((client) => ({
url: `${client.oauthClient?.webhookUrl}`,
secret: client.oauthClient?.webhookSecret,
accountId: client.id,
}));
// Use common delivery function
const result = await deliverWebhook({
payload: webhookPayload,
targets,
eventType: payload.eventType,
});
const successfulDeliveries = result.summary.successful;
const totalDeliveries = result.summary.total;
logger.log(
`OAuth integration webhook delivery completed: ${successfulDeliveries}/${totalDeliveries} successful`,
{
integrationId: integrationAccount.id,
integrationProvider: integrationAccount.integrationDefinition.slug,
userId: payload.userId,
},
);
return {
success: result.success,
deliveryResults: result.deliveryResults,
summary: {
total: totalDeliveries,
successful: successfulDeliveries,
failed: totalDeliveries - successfulDeliveries,
},
};
} catch (error) {
logger.error(
`Failed to process OAuth integration webhook delivery for integration account ${payload.integrationAccountId}:`,
{ error: error instanceof Error ? error.message : String(error) },
);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
},
});
// Helper function to trigger OAuth integration webhook delivery
export async function triggerIntegrationWebhook(
integrationAccountId: string,
userId: string,
eventType: WebhookEventType,
) {
try {
await integrationWebhookTask.trigger({
integrationAccountId,
userId,
eventType,
});
logger.log(
`Triggered OAuth integration webhook delivery for integration account ${integrationAccountId}`,
);
} catch (error: any) {
logger.error(
`Failed to trigger OAuth integration webhook delivery for integration account ${integrationAccountId}:`,
{ error: error instanceof Error ? error.message : String(error) },
);
}
}

View File

@ -0,0 +1,168 @@
import { logger } from "~/services/logger.service";
import crypto from "crypto";
// Common webhook delivery types
export type WebhookEventType =
| "activity.created"
| "integration.connected"
| "integration.disconnected"
| "mcp.connected"
| "mcp.disconnected";
// Webhook target configuration
export interface WebhookTarget {
url: string;
secret?: string | null;
headers?: Record<string, string>;
accountId?: string;
}
// Delivery result
export interface DeliveryResult {
url: string;
status: number;
success: boolean;
responseBody?: string;
error?: string;
}
// Generic webhook delivery parameters
export interface WebhookDeliveryParams {
payload: any; // Can be any webhook payload structure
targets: WebhookTarget[];
userAgent?: string;
eventType: WebhookEventType;
}
/**
* Common webhook delivery function that handles HTTP delivery logic
*/
export async function deliverWebhook(params: WebhookDeliveryParams): Promise<{
success: boolean;
deliveryResults: DeliveryResult[];
summary: {
total: number;
successful: number;
failed: number;
};
}> {
const {
payload,
targets,
userAgent = "Core-Webhooks/1.0",
eventType,
} = params;
const payloadString = JSON.stringify({
...payload,
accountId: payload.accountId,
});
const deliveryResults: DeliveryResult[] = [];
logger.log(`Delivering ${eventType} webhook to ${targets.length} targets`);
// Send webhook to each target
for (const target of targets) {
const deliveryId = crypto.randomUUID();
try {
// Prepare headers
const headers: Record<string, string> = {
"Content-Type": "application/json",
"User-Agent": userAgent,
"X-Webhook-Delivery": deliveryId,
"X-Webhook-Event": eventType,
...target.headers,
};
// Add HMAC signature if secret is configured
if (target.secret) {
const signature = crypto
.createHmac("sha256", target.secret)
.update(payloadString)
.digest("hex");
// Use different header names for different webhook types
if (eventType === "activity.created") {
headers["X-Hub-Signature-256"] = `sha256=${signature}`;
} else {
headers["X-Webhook-Secret"] = signature;
}
}
// Make the HTTP request
const response = await fetch(target.url, {
method: "POST",
headers,
body: payloadString,
signal: AbortSignal.timeout(30000), // 30 second timeout
});
const responseBody = await response.text().catch(() => "");
const result: DeliveryResult = {
url: target.url,
status: response.status,
success: response.ok,
responseBody: responseBody.slice(0, 500), // Limit response body length
error: response.ok
? undefined
: `HTTP ${response.status}: ${response.statusText}`,
};
deliveryResults.push(result);
logger.log(`Webhook delivered to ${target.url}:`, {
status: response.status,
event: eventType,
success: response.ok,
});
} catch (error) {
const result: DeliveryResult = {
url: target.url,
status: 0,
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
deliveryResults.push(result);
logger.error(`Failed to deliver webhook to ${target.url}:`, {
error,
event: eventType,
});
}
}
const successfulDeliveries = deliveryResults.filter((r) => r.success).length;
const totalDeliveries = deliveryResults.length;
logger.log(
`Webhook delivery completed: ${successfulDeliveries}/${totalDeliveries} successful`,
{
event: eventType,
},
);
return {
success: successfulDeliveries > 0,
deliveryResults,
summary: {
total: totalDeliveries,
successful: successfulDeliveries,
failed: totalDeliveries - successfulDeliveries,
},
};
}
/**
* Helper function to prepare webhook targets from basic URL/secret pairs
*/
export function prepareWebhookTargets(
webhooks: Array<{ url: string; secret?: string | null; id: string }>,
): WebhookTarget[] {
return webhooks.map((webhook) => ({
url: webhook.url,
secret: webhook.secret,
accountId: webhook.id,
}));
}

View File

@ -1,9 +1,11 @@
import { queue, task } from "@trigger.dev/sdk";
import { PrismaClient } from "@prisma/client";
import { logger } from "~/services/logger.service";
import { WebhookDeliveryStatus } from "@core/database";
import crypto from "crypto";
import {
deliverWebhook,
prepareWebhookTargets,
} from "./webhook-delivery-utils";
const prisma = new PrismaClient();
@ -84,117 +86,60 @@ export const webhookDeliveryTask = task({
},
};
const payloadString = JSON.stringify(webhookPayload);
const deliveryResults = [];
// Convert webhooks to targets using common utils
const targets = prepareWebhookTargets(webhooks);
// Deliver to each webhook
for (const webhook of webhooks) {
const deliveryId = crypto.randomUUID();
// Use common delivery function
const result = await deliverWebhook({
payload: webhookPayload,
targets,
eventType: "activity.created",
});
try {
// Create delivery log entry
const deliveryLog = await prisma.webhookDeliveryLog.create({
data: {
webhookConfigurationId: webhook.id,
activityId: activity.id,
status: WebhookDeliveryStatus.FAILED, // Will update if successful
},
});
// Log delivery results to database using createMany for better performance
const logEntries = webhooks
.map((webhook, index) => {
const deliveryResult = result.deliveryResults[index];
if (!deliveryResult) return null;
// Prepare headers
const headers: Record<string, string> = {
"Content-Type": "application/json",
"User-Agent": "Echo-Webhooks/1.0",
"X-Webhook-Delivery": deliveryId,
"X-Webhook-Event": "activity.created",
return {
webhookConfigurationId: webhook.id,
activityId: activity.id,
status: deliveryResult.success
? WebhookDeliveryStatus.SUCCESS
: WebhookDeliveryStatus.FAILED,
responseStatusCode: deliveryResult.status,
responseBody: deliveryResult.responseBody?.slice(0, 1000),
error: deliveryResult.error,
};
})
.filter((entry): entry is NonNullable<typeof entry> => entry !== null);
// Add HMAC signature if secret is configured
if (webhook.secret) {
const signature = crypto
.createHmac("sha256", webhook.secret)
.update(payloadString)
.digest("hex");
headers["X-Hub-Signature-256"] = `sha256=${signature}`;
}
// Make the HTTP request
const response = await fetch(webhook.url, {
method: "POST",
headers,
body: payloadString,
signal: AbortSignal.timeout(30000), // 30 second timeout
if (logEntries.length > 0) {
try {
await prisma.webhookDeliveryLog.createMany({
data: logEntries,
});
const responseBody = await response.text().catch(() => "");
// Update delivery log with results
await prisma.webhookDeliveryLog.update({
where: { id: deliveryLog.id },
data: {
status: response.ok
? WebhookDeliveryStatus.SUCCESS
: WebhookDeliveryStatus.FAILED,
responseStatusCode: response.status,
responseBody: responseBody.slice(0, 1000), // Limit response body length
error: response.ok
? null
: `HTTP ${response.status}: ${response.statusText}`,
},
} catch (error) {
logger.error("Failed to log webhook deliveries", {
error,
count: logEntries.length,
});
deliveryResults.push({
webhookId: webhook.id,
success: response.ok,
statusCode: response.status,
error: response.ok
? null
: `HTTP ${response.status}: ${response.statusText}`,
});
logger.log(`Webhook delivery to ${webhook.url}: ${response.status}`);
} catch (error: any) {
// Update delivery log with error
const deliveryLog = await prisma.webhookDeliveryLog.findFirst({
where: {
webhookConfigurationId: webhook.id,
activityId: activity.id,
},
orderBy: { createdAt: "desc" },
});
if (deliveryLog) {
await prisma.webhookDeliveryLog.update({
where: { id: deliveryLog.id },
data: {
status: WebhookDeliveryStatus.FAILED,
error: error.message,
},
});
}
deliveryResults.push({
webhookId: webhook.id,
success: false,
error: error.message,
});
logger.error(`Error delivering webhook to ${webhook.url}:`, error);
}
}
const successCount = deliveryResults.filter((r) => r.success).length;
const totalCount = deliveryResults.length;
const successCount = result.summary.successful;
const totalCount = result.summary.total;
logger.log(
`Webhook delivery completed: ${successCount}/${totalCount} successful`,
);
return {
success: true,
success: result.success,
delivered: successCount,
total: totalCount,
results: deliveryResults,
results: result.deliveryResults,
};
} catch (error: any) {
logger.error(

View File

@ -46,110 +46,6 @@ 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())
@ -290,6 +186,7 @@ model IntegrationAccount {
workspace Workspace @relation(references: [id], fields: [workspaceId])
workspaceId String
Activity Activity[]
oauthIntegrationGrants OAuthIntegrationGrant[]
@@unique([accountId, integrationDefinitionId, workspaceId])
}
@ -324,6 +221,179 @@ model InvitationCode {
createdAt DateTime @default(now())
}
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)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
workspaceId String
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)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
workspaceId String
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?
// Integration hub webhook support
webhookUrl String?
webhookSecret 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[]
integrationGrants OAuthIntegrationGrant[]
oAuthClientInstallation OAuthClientInstallation[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model OAuthClientInstallation {
id String @id @default(cuid())
// The OAuth client being installed
oauthClient OAuthClient @relation(fields: [oauthClientId], references: [id], onDelete: Cascade)
oauthClientId String
// The workspace where it's installed
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
workspaceId String
// Installation metadata
installedBy User @relation(fields: [installedById], references: [id])
installedById String
installedAt DateTime @default(now())
uninstalledAt DateTime?
// Installation status
isActive Boolean @default(true)
// Installation-specific settings
settings Json?
grantedScopes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([oauthClientId, workspaceId])
}
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)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
workspaceId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model OAuthIntegrationGrant {
id String @id @default(cuid())
// OAuth client that has access
client OAuthClient @relation(fields: [clientId], references: [id], onDelete: Cascade)
clientId String
// User who granted access
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
// Integration account that was granted
integrationAccount IntegrationAccount @relation(fields: [integrationAccountId], references: [id], onDelete: Cascade)
integrationAccountId String
// When access was granted/revoked
grantedAt DateTime @default(now())
revokedAt DateTime?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([clientId, userId, integrationAccountId])
}
model PersonalAccessToken {
id String @id @default(cuid())
@ -429,6 +499,8 @@ model User {
oauthAccessTokens OAuthAccessToken[]
oauthRefreshTokens OAuthRefreshToken[]
oauthClientsCreated OAuthClient[]
oauthIntegrationGrants OAuthIntegrationGrant[]
oAuthClientInstallation OAuthClientInstallation[]
UserUsage UserUsage?
}
@ -500,6 +572,10 @@ model Workspace {
Conversation Conversation[]
IngestionRule IngestionRule[]
OAuthClient OAuthClient[]
OAuthClientInstallation OAuthClientInstallation[]
OAuthAuthorizationCode OAuthAuthorizationCode[]
OAuthAccessToken OAuthAccessToken[]
OAuthRefreshToken OAuthRefreshToken[]
}
enum AuthenticationMethod {

View File

@ -0,0 +1,30 @@
-- AlterTable
ALTER TABLE "OAuthClient" ADD COLUMN "webhookSecret" TEXT,
ADD COLUMN "webhookUrl" TEXT;
-- CreateTable
CREATE TABLE "OAuthIntegrationGrant" (
"id" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"integrationAccountId" TEXT NOT NULL,
"grantedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OAuthIntegrationGrant_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "OAuthIntegrationGrant_clientId_userId_integrationAccountId_key" ON "OAuthIntegrationGrant"("clientId", "userId", "integrationAccountId");
-- AddForeignKey
ALTER TABLE "OAuthIntegrationGrant" ADD CONSTRAINT "OAuthIntegrationGrant_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "OAuthClient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OAuthIntegrationGrant" ADD CONSTRAINT "OAuthIntegrationGrant_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OAuthIntegrationGrant" ADD CONSTRAINT "OAuthIntegrationGrant_integrationAccountId_fkey" FOREIGN KEY ("integrationAccountId") REFERENCES "IntegrationAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,54 @@
/*
Warnings:
- Added the required column `workspaceId` to the `OAuthAccessToken` table without a default value. This is not possible if the table is not empty.
- Added the required column `workspaceId` to the `OAuthAuthorizationCode` table without a default value. This is not possible if the table is not empty.
- Added the required column `workspaceId` to the `OAuthRefreshToken` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "OAuthAccessToken" ADD COLUMN "workspaceId" TEXT NOT NULL;
-- AlterTable
ALTER TABLE "OAuthAuthorizationCode" ADD COLUMN "workspaceId" TEXT NOT NULL;
-- AlterTable
ALTER TABLE "OAuthRefreshToken" ADD COLUMN "workspaceId" TEXT NOT NULL;
-- CreateTable
CREATE TABLE "OAuthClientInstallation" (
"id" TEXT NOT NULL,
"oauthClientId" TEXT NOT NULL,
"workspaceId" TEXT NOT NULL,
"installedById" TEXT NOT NULL,
"installedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"uninstalledAt" TIMESTAMP(3),
"isActive" BOOLEAN NOT NULL DEFAULT true,
"settings" JSONB,
"grantedScopes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OAuthClientInstallation_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "OAuthClientInstallation_oauthClientId_workspaceId_key" ON "OAuthClientInstallation"("oauthClientId", "workspaceId");
-- AddForeignKey
ALTER TABLE "OAuthAuthorizationCode" ADD CONSTRAINT "OAuthAuthorizationCode_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OAuthAccessToken" ADD CONSTRAINT "OAuthAccessToken_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OAuthClientInstallation" ADD CONSTRAINT "OAuthClientInstallation_oauthClientId_fkey" FOREIGN KEY ("oauthClientId") REFERENCES "OAuthClient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OAuthClientInstallation" ADD CONSTRAINT "OAuthClientInstallation_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OAuthClientInstallation" ADD CONSTRAINT "OAuthClientInstallation_installedById_fkey" FOREIGN KEY ("installedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OAuthRefreshToken" ADD CONSTRAINT "OAuthRefreshToken_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -46,110 +46,6 @@ 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())
@ -290,6 +186,7 @@ model IntegrationAccount {
workspace Workspace @relation(references: [id], fields: [workspaceId])
workspaceId String
Activity Activity[]
oauthIntegrationGrants OAuthIntegrationGrant[]
@@unique([accountId, integrationDefinitionId, workspaceId])
}
@ -324,6 +221,179 @@ model InvitationCode {
createdAt DateTime @default(now())
}
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)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
workspaceId String
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)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
workspaceId String
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?
// Integration hub webhook support
webhookUrl String?
webhookSecret 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[]
integrationGrants OAuthIntegrationGrant[]
oAuthClientInstallation OAuthClientInstallation[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model OAuthClientInstallation {
id String @id @default(cuid())
// The OAuth client being installed
oauthClient OAuthClient @relation(fields: [oauthClientId], references: [id], onDelete: Cascade)
oauthClientId String
// The workspace where it's installed
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
workspaceId String
// Installation metadata
installedBy User @relation(fields: [installedById], references: [id])
installedById String
installedAt DateTime @default(now())
uninstalledAt DateTime?
// Installation status
isActive Boolean @default(true)
// Installation-specific settings
settings Json?
grantedScopes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([oauthClientId, workspaceId])
}
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)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
workspaceId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model OAuthIntegrationGrant {
id String @id @default(cuid())
// OAuth client that has access
client OAuthClient @relation(fields: [clientId], references: [id], onDelete: Cascade)
clientId String
// User who granted access
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
// Integration account that was granted
integrationAccount IntegrationAccount @relation(fields: [integrationAccountId], references: [id], onDelete: Cascade)
integrationAccountId String
// When access was granted/revoked
grantedAt DateTime @default(now())
revokedAt DateTime?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([clientId, userId, integrationAccountId])
}
model PersonalAccessToken {
id String @id @default(cuid())
@ -429,6 +499,8 @@ model User {
oauthAccessTokens OAuthAccessToken[]
oauthRefreshTokens OAuthRefreshToken[]
oauthClientsCreated OAuthClient[]
oauthIntegrationGrants OAuthIntegrationGrant[]
oAuthClientInstallation OAuthClientInstallation[]
UserUsage UserUsage?
}
@ -500,6 +572,10 @@ model Workspace {
Conversation Conversation[]
IngestionRule IngestionRule[]
OAuthClient OAuthClient[]
OAuthClientInstallation OAuthClientInstallation[]
OAuthAuthorizationCode OAuthAuthorizationCode[]
OAuthAccessToken OAuthAccessToken[]
OAuthRefreshToken OAuthRefreshToken[]
}
enum AuthenticationMethod {