mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-11 18:38:27 +00:00
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:
parent
b0ff41823e
commit
c80303a851
@ -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
|
// Get user's workspace
|
||||||
const userRecord = await prisma.user.findUnique({
|
const userRecord = await prisma.user.findUnique({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
@ -96,6 +122,10 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
return json({ error: "No workspace found" }, { status: 404 });
|
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
|
// Generate client credentials
|
||||||
const clientId = crypto.randomUUID();
|
const clientId = crypto.randomUUID();
|
||||||
const clientSecret = crypto.randomBytes(32).toString("hex");
|
const clientSecret = crypto.randomBytes(32).toString("hex");
|
||||||
@ -110,9 +140,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
redirectUris: Array.isArray(redirectUris)
|
redirectUris: Array.isArray(redirectUris)
|
||||||
? redirectUris.join(",")
|
? redirectUris.join(",")
|
||||||
: redirectUris,
|
: redirectUris,
|
||||||
allowedScopes: Array.isArray(allowedScopes)
|
allowedScopes: requestedScopes.join(","),
|
||||||
? allowedScopes.join(",")
|
|
||||||
: allowedScopes || "read",
|
|
||||||
requirePkce: requirePkce || false,
|
requirePkce: requirePkce || false,
|
||||||
logoUrl: logoUrl || null,
|
logoUrl: logoUrl || null,
|
||||||
homepageUrl: homepageUrl || null,
|
homepageUrl: homepageUrl || null,
|
||||||
@ -138,8 +166,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
return json({
|
return json({
|
||||||
success: true,
|
success: true,
|
||||||
client,
|
client,
|
||||||
message:
|
message: "OAuth client created successfully",
|
||||||
"OAuth client created successfully. Save the client_secret securely - it won't be shown again.",
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating OAuth client:", error);
|
console.error("Error creating OAuth client:", error);
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { requireUserId } from "~/services/session.server";
|
|||||||
|
|
||||||
import { logger } from "~/services/logger.service";
|
import { logger } from "~/services/logger.service";
|
||||||
import { prisma } from "~/db.server";
|
import { prisma } from "~/db.server";
|
||||||
|
import { triggerIntegrationWebhook } from "~/trigger/webhooks/integration-webhook-delivery";
|
||||||
|
|
||||||
export async function action({ request }: ActionFunctionArgs) {
|
export async function action({ request }: ActionFunctionArgs) {
|
||||||
if (request.method !== "POST") {
|
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)", {
|
logger.info("Integration account disconnected (soft deleted)", {
|
||||||
integrationAccountId,
|
integrationAccountId,
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { requireUserId } from "~/services/session.server";
|
|||||||
|
|
||||||
import { logger } from "~/services/logger.service";
|
import { logger } from "~/services/logger.service";
|
||||||
import { prisma } from "~/db.server";
|
import { prisma } from "~/db.server";
|
||||||
|
import { triggerIntegrationWebhook } from "~/trigger/webhooks/integration-webhook-delivery";
|
||||||
|
|
||||||
export async function action({ request }: ActionFunctionArgs) {
|
export async function action({ request }: ActionFunctionArgs) {
|
||||||
if (request.method !== "POST") {
|
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", {
|
logger.info("MCP configuration disconnected", {
|
||||||
integrationAccountId,
|
integrationAccountId,
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
57
apps/webapp/app/routes/api.v1.integrations.tsx
Normal file
57
apps/webapp/app/routes/api.v1.integrations.tsx
Normal 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 }
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -4,7 +4,7 @@ import {
|
|||||||
redirect,
|
redirect,
|
||||||
} from "@remix-run/node";
|
} from "@remix-run/node";
|
||||||
import { Form, useLoaderData } from "@remix-run/react";
|
import { Form, useLoaderData } from "@remix-run/react";
|
||||||
import { getUser } from "~/services/session.server";
|
import { getUser, requireWorkpace } from "~/services/session.server";
|
||||||
import {
|
import {
|
||||||
oauth2Service,
|
oauth2Service,
|
||||||
OAuth2Errors,
|
OAuth2Errors,
|
||||||
@ -14,7 +14,8 @@ import { Button } from "~/components/ui/button";
|
|||||||
import { Card, CardContent } from "~/components/ui/card";
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
import { Arrows } from "~/components/icons";
|
import { Arrows } from "~/components/icons";
|
||||||
import Logo from "~/components/logo/logo";
|
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) => {
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
// Check if user is authenticated
|
// Check if user is authenticated
|
||||||
@ -31,12 +32,18 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
let scopeParam = url.searchParams.get("scope") || undefined;
|
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) {
|
if (scopeParam) {
|
||||||
scopeParam = scopeParam
|
// First, try splitting by spaces (common in OAuth2 URLs)
|
||||||
.split(",")
|
let scopes = scopeParam.split(/\s+/).filter(s => s.length > 0);
|
||||||
.map((s) => s.trim())
|
|
||||||
.join(",");
|
// 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 {
|
} else {
|
||||||
throw new Error("Scope is not found");
|
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 {
|
return {
|
||||||
user,
|
user,
|
||||||
client,
|
client,
|
||||||
@ -91,6 +104,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
|
|
||||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
const user = await getUser(request);
|
const user = await getUser(request);
|
||||||
|
const workspace = await requireWorkpace(request);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return redirect("/login");
|
return redirect("/login");
|
||||||
@ -136,6 +150,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
state: params.state,
|
state: params.state,
|
||||||
codeChallenge: params.code_challenge,
|
codeChallenge: params.code_challenge,
|
||||||
codeChallengeMethod: params.code_challenge_method,
|
codeChallengeMethod: params.code_challenge_method,
|
||||||
|
workspaceId: workspace.id,
|
||||||
});
|
});
|
||||||
// Redirect back to client with authorization code
|
// Redirect back to client with authorization code
|
||||||
const redirectUrl = new URL(params.redirect_uri);
|
const redirectUrl = new URL(params.redirect_uri);
|
||||||
@ -160,12 +175,43 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
export default function OAuthAuthorize() {
|
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") {
|
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 <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 (
|
return (
|
||||||
@ -192,14 +238,15 @@ export default function OAuthAuthorize() {
|
|||||||
{client.name} is requesting access
|
{client.name} is requesting access
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
Authenticating with your {user.name} workspace
|
Authenticating with your {user.name} account
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-muted-foreground mb-2 text-sm">Permissions</p>
|
<p className="text-muted-foreground mb-2 text-sm">Permissions</p>
|
||||||
<ul className="text-muted-foreground text-sm">
|
<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 isFirst = index === 0;
|
||||||
const isLast = index === arr.length - 1;
|
const isLast = index === arr.length - 1;
|
||||||
return (
|
return (
|
||||||
@ -207,10 +254,9 @@ export default function OAuthAuthorize() {
|
|||||||
key={index}
|
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" : ""} `}
|
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>
|
<div>
|
||||||
{scope.charAt(0).toUpperCase() + scope.slice(1)} access to
|
{getScopeDescription(trimmedScope)}
|
||||||
your workspace
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
24
apps/webapp/app/routes/oauth.tokeninfo.tsx
Normal file
24
apps/webapp/app/routes/oauth.tokeninfo.tsx
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -67,7 +67,7 @@ export async function authenticateApiKeyWithFailure(
|
|||||||
apiKey,
|
apiKey,
|
||||||
type: "OAUTH2",
|
type: "OAUTH2",
|
||||||
userId: accessToken.user.id,
|
userId: accessToken.user.id,
|
||||||
scopes: accessToken.scope ? accessToken.scope.split(' ') : undefined,
|
scopes: accessToken.scope ? accessToken.scope.split(" ") : undefined,
|
||||||
oauth2: {
|
oauth2: {
|
||||||
clientId: accessToken.client.clientId,
|
clientId: accessToken.client.clientId,
|
||||||
scope: accessToken.scope,
|
scope: accessToken.scope,
|
||||||
@ -130,3 +130,48 @@ export function getApiKeyResult(apiKey: string): {
|
|||||||
} {
|
} {
|
||||||
return { apiKey, type: "PRIVATE" };
|
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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -4,10 +4,10 @@ import { prisma } from "~/db.server";
|
|||||||
* Get all integration definitions available to a workspace.
|
* Get all integration definitions available to a workspace.
|
||||||
* Returns both global (workspaceId: null) and workspace-specific definitions.
|
* 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({
|
return prisma.integrationDefinitionV2.findMany({
|
||||||
where: {
|
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.
|
* Get a single integration definition by its slug.
|
||||||
*/
|
*/
|
||||||
export async function getIntegrationDefinitionWithSlug(
|
export async function getIntegrationDefinitionWithSlug(slug: string) {
|
||||||
slug: string,
|
|
||||||
) {
|
|
||||||
return prisma.integrationDefinitionV2.findFirst({
|
return prisma.integrationDefinitionV2.findFirst({
|
||||||
where: { slug },
|
where: { slug },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
|
import { env } from "~/env.server";
|
||||||
|
import { type JWTPayload, jwtVerify, SignJWT } from "jose";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@ -28,6 +30,7 @@ export interface OAuth2TokenResponse {
|
|||||||
expires_in: number;
|
expires_in: number;
|
||||||
refresh_token?: string;
|
refresh_token?: string;
|
||||||
scope?: string;
|
scope?: string;
|
||||||
|
id_token?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OAuth2ErrorResponse {
|
export interface OAuth2ErrorResponse {
|
||||||
@ -37,6 +40,19 @@ export interface OAuth2ErrorResponse {
|
|||||||
state?: string;
|
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
|
// OAuth2 Error types
|
||||||
export const OAuth2Errors = {
|
export const OAuth2Errors = {
|
||||||
INVALID_REQUEST: "invalid_request",
|
INVALID_REQUEST: "invalid_request",
|
||||||
@ -52,9 +68,149 @@ export const OAuth2Errors = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export class OAuth2Service {
|
export class OAuth2Service {
|
||||||
// Generate secure random string
|
private generateAccessToken(params: {
|
||||||
private generateSecureToken(length: number = 32): string {
|
userId: string;
|
||||||
return crypto.randomBytes(length).toString("hex");
|
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
|
// Validate OAuth2 client
|
||||||
@ -105,46 +261,147 @@ export class OAuth2Service {
|
|||||||
return false;
|
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
|
// Create authorization code
|
||||||
async createAuthorizationCode(params: {
|
async createAuthorizationCode(params: {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
redirectUri: string;
|
redirectUri: string;
|
||||||
|
workspaceId: string;
|
||||||
scope?: string;
|
scope?: string;
|
||||||
state?: string;
|
state?: string;
|
||||||
codeChallenge?: string;
|
codeChallenge?: string;
|
||||||
codeChallengeMethod?: string;
|
codeChallengeMethod?: string;
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
const code = this.generateSecureToken(32);
|
const code = this.generateAuthorizationCode(params);
|
||||||
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
|
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
|
||||||
|
|
||||||
// Find the client to get the internal database ID
|
// Find the client to get the internal database ID
|
||||||
const client = await prisma.oAuthClient.findUnique({
|
const client = await prisma.oAuthClient.findUnique({
|
||||||
where: { clientId: params.clientId },
|
where: { clientId: params.clientId },
|
||||||
select: { id: true },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error(OAuth2Errors.INVALID_CLIENT);
|
throw new Error(OAuth2Errors.INVALID_CLIENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
await prisma.oAuthAuthorizationCode.create({
|
await prisma.oAuthAuthorizationCode.create({
|
||||||
data: {
|
data: {
|
||||||
code,
|
code,
|
||||||
clientId: client.id, // Use internal database ID
|
clientId: client.id,
|
||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
redirectUri: params.redirectUri,
|
redirectUri: params.redirectUri,
|
||||||
scope: params.scope,
|
scope: params.scope,
|
||||||
state: params.state,
|
state: params.state,
|
||||||
codeChallenge: params.codeChallenge,
|
codeChallenge: params.codeChallenge,
|
||||||
codeChallengeMethod: params.codeChallengeMethod,
|
codeChallengeMethod: params.codeChallengeMethod,
|
||||||
|
workspaceId: params.workspaceId,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error("Failed to create authorization code");
|
||||||
|
}
|
||||||
|
|
||||||
return 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,
|
||||||
|
workspaceId: tokenPayload.workspace_id,
|
||||||
|
expiresAt: { gt: new Date() },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
client: true,
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!authorizationCode) {
|
||||||
|
throw new Error("Invalid or expired token");
|
||||||
|
}
|
||||||
|
|
||||||
|
return authorizationCode;
|
||||||
|
}
|
||||||
|
|
||||||
// Exchange authorization code for tokens
|
// Exchange authorization code for tokens
|
||||||
async exchangeCodeForTokens(params: {
|
async exchangeCodeForTokens(params: {
|
||||||
code: string;
|
code: string;
|
||||||
@ -155,27 +412,13 @@ export class OAuth2Service {
|
|||||||
// Find the client first to get the internal database ID
|
// Find the client first to get the internal database ID
|
||||||
const client = await prisma.oAuthClient.findUnique({
|
const client = await prisma.oAuthClient.findUnique({
|
||||||
where: { clientId: params.clientId },
|
where: { clientId: params.clientId },
|
||||||
select: { id: true },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error(OAuth2Errors.INVALID_CLIENT);
|
throw new Error(OAuth2Errors.INVALID_CLIENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find and validate authorization code
|
const authCode = await this.validateAuthorizationCode(params.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) {
|
if (!authCode) {
|
||||||
throw new Error(OAuth2Errors.INVALID_GRANT);
|
throw new Error(OAuth2Errors.INVALID_GRANT);
|
||||||
@ -204,9 +447,20 @@ export class OAuth2Service {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Generate access token
|
// Generate access token
|
||||||
const accessToken = this.generateSecureToken(64);
|
const accessToken = this.generateAccessToken({
|
||||||
const refreshToken = this.generateSecureToken(64);
|
userId: authCode.userId,
|
||||||
const expiresIn = 3600; // 1 hour
|
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 accessTokenExpiresAt = new Date(Date.now() + expiresIn * 1000);
|
||||||
const refreshTokenExpiresAt = new Date(
|
const refreshTokenExpiresAt = new Date(
|
||||||
Date.now() + 30 * 24 * 60 * 60 * 1000,
|
Date.now() + 30 * 24 * 60 * 60 * 1000,
|
||||||
@ -216,39 +470,97 @@ export class OAuth2Service {
|
|||||||
await prisma.oAuthAccessToken.create({
|
await prisma.oAuthAccessToken.create({
|
||||||
data: {
|
data: {
|
||||||
token: accessToken,
|
token: accessToken,
|
||||||
clientId: client.id, // Use internal database ID
|
clientId: client.id,
|
||||||
userId: authCode.userId,
|
userId: authCode.userId,
|
||||||
scope: authCode.scope,
|
scope: authCode.scope,
|
||||||
expiresAt: accessTokenExpiresAt,
|
expiresAt: accessTokenExpiresAt,
|
||||||
|
workspaceId: authCode.workspaceId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await prisma.oAuthRefreshToken.create({
|
await prisma.oAuthRefreshToken.create({
|
||||||
data: {
|
data: {
|
||||||
token: refreshToken,
|
token: refreshToken,
|
||||||
clientId: client.id, // Use internal database ID
|
clientId: client.id,
|
||||||
userId: authCode.userId,
|
userId: authCode.userId,
|
||||||
scope: authCode.scope,
|
scope: authCode.scope,
|
||||||
expiresAt: refreshTokenExpiresAt,
|
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 {
|
return {
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
token_type: "Bearer",
|
token_type: "Bearer",
|
||||||
expires_in: expiresIn,
|
expires_in: expiresIn,
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
scope: authCode.scope || undefined,
|
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
|
// 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({
|
const accessToken = await prisma.oAuthAccessToken.findFirst({
|
||||||
where: {
|
where: {
|
||||||
token,
|
token,
|
||||||
revoked: false,
|
revoked: false,
|
||||||
expiresAt: { gt: new Date() },
|
expiresAt: { gt: new Date() },
|
||||||
|
userId: tokenPayload.user_id,
|
||||||
|
workspaceId: tokenPayload.workspace_id,
|
||||||
|
...(scopes ? { scope: { contains: scopes.join(",") } } : {}),
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
client: true,
|
client: true,
|
||||||
@ -273,10 +585,32 @@ export class OAuth2Service {
|
|||||||
name: accessToken.user.name,
|
name: accessToken.user.name,
|
||||||
display_name: accessToken.user.displayName,
|
display_name: accessToken.user.displayName,
|
||||||
avatar_url: accessToken.user.avatarUrl,
|
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
|
// Refresh access token
|
||||||
async refreshAccessToken(
|
async refreshAccessToken(
|
||||||
refreshToken: string,
|
refreshToken: string,
|
||||||
@ -285,7 +619,6 @@ export class OAuth2Service {
|
|||||||
// Find the client first to get the internal database ID
|
// Find the client first to get the internal database ID
|
||||||
const client = await prisma.oAuthClient.findUnique({
|
const client = await prisma.oAuthClient.findUnique({
|
||||||
where: { clientId },
|
where: { clientId },
|
||||||
select: { id: true },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
@ -295,7 +628,7 @@ export class OAuth2Service {
|
|||||||
const storedRefreshToken = await prisma.oAuthRefreshToken.findFirst({
|
const storedRefreshToken = await prisma.oAuthRefreshToken.findFirst({
|
||||||
where: {
|
where: {
|
||||||
token: refreshToken,
|
token: refreshToken,
|
||||||
clientId: client.id, // Use internal database ID
|
clientId: client.id,
|
||||||
revoked: false,
|
revoked: false,
|
||||||
expiresAt: { gt: new Date() },
|
expiresAt: { gt: new Date() },
|
||||||
},
|
},
|
||||||
@ -309,18 +642,44 @@ export class OAuth2Service {
|
|||||||
throw new Error(OAuth2Errors.INVALID_GRANT);
|
throw new Error(OAuth2Errors.INVALID_GRANT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const newRefreshToken = this.generateRefreshToken({
|
||||||
|
userId: storedRefreshToken.userId,
|
||||||
|
clientId: client.clientId,
|
||||||
|
workspaceId: storedRefreshToken.workspaceId,
|
||||||
|
});
|
||||||
|
|
||||||
// Generate new access token
|
// Generate new access token
|
||||||
const accessToken = this.generateSecureToken(64);
|
const accessToken = this.generateAccessToken({
|
||||||
const expiresIn = 3600; // 1 hour
|
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 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({
|
await prisma.oAuthAccessToken.create({
|
||||||
data: {
|
data: {
|
||||||
token: accessToken,
|
token: accessToken,
|
||||||
clientId: client.id, // Use internal database ID
|
clientId: client.id,
|
||||||
userId: storedRefreshToken.userId,
|
userId: storedRefreshToken.userId,
|
||||||
scope: storedRefreshToken.scope,
|
scope: storedRefreshToken.scope,
|
||||||
expiresAt: accessTokenExpiresAt,
|
expiresAt: accessTokenExpiresAt,
|
||||||
|
workspaceId: storedRefreshToken.workspaceId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -328,6 +687,7 @@ export class OAuth2Service {
|
|||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
token_type: "Bearer",
|
token_type: "Bearer",
|
||||||
expires_in: expiresIn,
|
expires_in: expiresIn,
|
||||||
|
refresh_token: newRefreshToken,
|
||||||
scope: storedRefreshToken.scope || undefined,
|
scope: storedRefreshToken.scope || undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
171
apps/webapp/app/services/oauthIntegration.server.ts
Normal file
171
apps/webapp/app/services/oauthIntegration.server.ts
Normal 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();
|
||||||
@ -22,6 +22,7 @@ import {
|
|||||||
saveIntegrationAccountState,
|
saveIntegrationAccountState,
|
||||||
saveMCPConfig,
|
saveMCPConfig,
|
||||||
} from "../utils/message-utils";
|
} from "../utils/message-utils";
|
||||||
|
import { triggerIntegrationWebhook } from "../webhooks/integration-webhook-delivery";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines if a string is a URL.
|
* Determines if a string is a URL.
|
||||||
@ -223,17 +224,23 @@ async function handleAccountMessage(
|
|||||||
const mcp = message.data.mcp;
|
const mcp = message.data.mcp;
|
||||||
|
|
||||||
if (mcp) {
|
if (mcp) {
|
||||||
return await saveMCPConfig({
|
const config = await saveMCPConfig({
|
||||||
integrationAccountId,
|
integrationAccountId,
|
||||||
config: message.data.config,
|
config: message.data.config,
|
||||||
});
|
});
|
||||||
|
await triggerIntegrationWebhook(
|
||||||
|
integrationAccountId,
|
||||||
|
userId,
|
||||||
|
"mcp.connected",
|
||||||
|
);
|
||||||
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle only one messages since account gets created only for one
|
// Handle only one messages since account gets created only for one
|
||||||
const {
|
const {
|
||||||
data: { settings, config, accountId },
|
data: { settings, config, accountId },
|
||||||
} = messages[0];
|
} = messages[0];
|
||||||
return await createIntegrationAccount({
|
const integrationAccount = await createIntegrationAccount({
|
||||||
integrationDefinitionId: integrationDefinition.id,
|
integrationDefinitionId: integrationDefinition.id,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
settings,
|
settings,
|
||||||
@ -241,6 +248,24 @@ async function handleAccountMessage(
|
|||||||
accountId,
|
accountId,
|
||||||
userId,
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
170
apps/webapp/app/trigger/webhooks/integration-webhook-delivery.ts
Normal file
170
apps/webapp/app/trigger/webhooks/integration-webhook-delivery.ts
Normal 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) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
168
apps/webapp/app/trigger/webhooks/webhook-delivery-utils.ts
Normal file
168
apps/webapp/app/trigger/webhooks/webhook-delivery-utils.ts
Normal 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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@ -1,9 +1,11 @@
|
|||||||
import { queue, task } from "@trigger.dev/sdk";
|
import { queue, task } from "@trigger.dev/sdk";
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
import { logger } from "~/services/logger.service";
|
import { logger } from "~/services/logger.service";
|
||||||
import { WebhookDeliveryStatus } from "@core/database";
|
import { WebhookDeliveryStatus } from "@core/database";
|
||||||
import crypto from "crypto";
|
import {
|
||||||
|
deliverWebhook,
|
||||||
|
prepareWebhookTargets,
|
||||||
|
} from "./webhook-delivery-utils";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@ -84,117 +86,60 @@ export const webhookDeliveryTask = task({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const payloadString = JSON.stringify(webhookPayload);
|
// Convert webhooks to targets using common utils
|
||||||
const deliveryResults = [];
|
const targets = prepareWebhookTargets(webhooks);
|
||||||
|
|
||||||
// Deliver to each webhook
|
// Use common delivery function
|
||||||
for (const webhook of webhooks) {
|
const result = await deliverWebhook({
|
||||||
const deliveryId = crypto.randomUUID();
|
payload: webhookPayload,
|
||||||
|
targets,
|
||||||
|
eventType: "activity.created",
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
// Log delivery results to database using createMany for better performance
|
||||||
// Create delivery log entry
|
const logEntries = webhooks
|
||||||
const deliveryLog = await prisma.webhookDeliveryLog.create({
|
.map((webhook, index) => {
|
||||||
data: {
|
const deliveryResult = result.deliveryResults[index];
|
||||||
|
if (!deliveryResult) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
webhookConfigurationId: webhook.id,
|
webhookConfigurationId: webhook.id,
|
||||||
activityId: activity.id,
|
activityId: activity.id,
|
||||||
status: WebhookDeliveryStatus.FAILED, // Will update if successful
|
status: deliveryResult.success
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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",
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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
|
|
||||||
});
|
|
||||||
|
|
||||||
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.SUCCESS
|
||||||
: WebhookDeliveryStatus.FAILED,
|
: WebhookDeliveryStatus.FAILED,
|
||||||
responseStatusCode: response.status,
|
responseStatusCode: deliveryResult.status,
|
||||||
responseBody: responseBody.slice(0, 1000), // Limit response body length
|
responseBody: deliveryResult.responseBody?.slice(0, 1000),
|
||||||
error: response.ok
|
error: deliveryResult.error,
|
||||||
? null
|
};
|
||||||
: `HTTP ${response.status}: ${response.statusText}`,
|
})
|
||||||
},
|
.filter((entry): entry is NonNullable<typeof entry> => entry !== null);
|
||||||
});
|
|
||||||
|
|
||||||
deliveryResults.push({
|
if (logEntries.length > 0) {
|
||||||
webhookId: webhook.id,
|
try {
|
||||||
success: response.ok,
|
await prisma.webhookDeliveryLog.createMany({
|
||||||
statusCode: response.status,
|
data: logEntries,
|
||||||
error: response.ok
|
|
||||||
? null
|
|
||||||
: `HTTP ${response.status}: ${response.statusText}`,
|
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
logger.log(`Webhook delivery to ${webhook.url}: ${response.status}`);
|
logger.error("Failed to log webhook deliveries", {
|
||||||
} catch (error: any) {
|
error,
|
||||||
// Update delivery log with error
|
count: logEntries.length,
|
||||||
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 successCount = result.summary.successful;
|
||||||
const totalCount = deliveryResults.length;
|
const totalCount = result.summary.total;
|
||||||
|
|
||||||
logger.log(
|
logger.log(
|
||||||
`Webhook delivery completed: ${successCount}/${totalCount} successful`,
|
`Webhook delivery completed: ${successCount}/${totalCount} successful`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: result.success,
|
||||||
delivered: successCount,
|
delivered: successCount,
|
||||||
total: totalCount,
|
total: totalCount,
|
||||||
results: deliveryResults,
|
results: result.deliveryResults,
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|||||||
@ -46,110 +46,6 @@ 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())
|
||||||
@ -290,6 +186,7 @@ model IntegrationAccount {
|
|||||||
workspace Workspace @relation(references: [id], fields: [workspaceId])
|
workspace Workspace @relation(references: [id], fields: [workspaceId])
|
||||||
workspaceId String
|
workspaceId String
|
||||||
Activity Activity[]
|
Activity Activity[]
|
||||||
|
oauthIntegrationGrants OAuthIntegrationGrant[]
|
||||||
|
|
||||||
@@unique([accountId, integrationDefinitionId, workspaceId])
|
@@unique([accountId, integrationDefinitionId, workspaceId])
|
||||||
}
|
}
|
||||||
@ -324,6 +221,179 @@ model InvitationCode {
|
|||||||
createdAt DateTime @default(now())
|
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 {
|
model PersonalAccessToken {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
|
||||||
@ -429,6 +499,8 @@ model User {
|
|||||||
oauthAccessTokens OAuthAccessToken[]
|
oauthAccessTokens OAuthAccessToken[]
|
||||||
oauthRefreshTokens OAuthRefreshToken[]
|
oauthRefreshTokens OAuthRefreshToken[]
|
||||||
oauthClientsCreated OAuthClient[]
|
oauthClientsCreated OAuthClient[]
|
||||||
|
oauthIntegrationGrants OAuthIntegrationGrant[]
|
||||||
|
oAuthClientInstallation OAuthClientInstallation[]
|
||||||
UserUsage UserUsage?
|
UserUsage UserUsage?
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -500,6 +572,10 @@ model Workspace {
|
|||||||
Conversation Conversation[]
|
Conversation Conversation[]
|
||||||
IngestionRule IngestionRule[]
|
IngestionRule IngestionRule[]
|
||||||
OAuthClient OAuthClient[]
|
OAuthClient OAuthClient[]
|
||||||
|
OAuthClientInstallation OAuthClientInstallation[]
|
||||||
|
OAuthAuthorizationCode OAuthAuthorizationCode[]
|
||||||
|
OAuthAccessToken OAuthAccessToken[]
|
||||||
|
OAuthRefreshToken OAuthRefreshToken[]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AuthenticationMethod {
|
enum AuthenticationMethod {
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -46,110 +46,6 @@ 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())
|
||||||
@ -290,6 +186,7 @@ model IntegrationAccount {
|
|||||||
workspace Workspace @relation(references: [id], fields: [workspaceId])
|
workspace Workspace @relation(references: [id], fields: [workspaceId])
|
||||||
workspaceId String
|
workspaceId String
|
||||||
Activity Activity[]
|
Activity Activity[]
|
||||||
|
oauthIntegrationGrants OAuthIntegrationGrant[]
|
||||||
|
|
||||||
@@unique([accountId, integrationDefinitionId, workspaceId])
|
@@unique([accountId, integrationDefinitionId, workspaceId])
|
||||||
}
|
}
|
||||||
@ -324,6 +221,179 @@ model InvitationCode {
|
|||||||
createdAt DateTime @default(now())
|
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 {
|
model PersonalAccessToken {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
|
||||||
@ -429,6 +499,8 @@ model User {
|
|||||||
oauthAccessTokens OAuthAccessToken[]
|
oauthAccessTokens OAuthAccessToken[]
|
||||||
oauthRefreshTokens OAuthRefreshToken[]
|
oauthRefreshTokens OAuthRefreshToken[]
|
||||||
oauthClientsCreated OAuthClient[]
|
oauthClientsCreated OAuthClient[]
|
||||||
|
oauthIntegrationGrants OAuthIntegrationGrant[]
|
||||||
|
oAuthClientInstallation OAuthClientInstallation[]
|
||||||
UserUsage UserUsage?
|
UserUsage UserUsage?
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -500,6 +572,10 @@ model Workspace {
|
|||||||
Conversation Conversation[]
|
Conversation Conversation[]
|
||||||
IngestionRule IngestionRule[]
|
IngestionRule IngestionRule[]
|
||||||
OAuthClient OAuthClient[]
|
OAuthClient OAuthClient[]
|
||||||
|
OAuthClientInstallation OAuthClientInstallation[]
|
||||||
|
OAuthAuthorizationCode OAuthAuthorizationCode[]
|
||||||
|
OAuthAccessToken OAuthAccessToken[]
|
||||||
|
OAuthRefreshToken OAuthRefreshToken[]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AuthenticationMethod {
|
enum AuthenticationMethod {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user