Feat: OAuth support for external apps

This commit is contained in:
Manoj K 2025-07-16 20:05:45 +05:30 committed by Harshith Mullapudi
parent b7b24aecdd
commit e50c5a7c64
13 changed files with 1525 additions and 2 deletions

View File

@ -0,0 +1,186 @@
import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/node";
import { PrismaClient } from "@prisma/client";
import { requireAuth } from "~/utils/auth-helper";
import crypto from "crypto";
const prisma = new PrismaClient();
// GET /api/oauth/clients/:clientId - Get specific OAuth client
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireAuth(request);
const { clientId } = params;
if (!clientId) {
return json({ error: "Client ID is required" }, { status: 400 });
}
try {
// Get user's workspace
const userRecord = await prisma.user.findUnique({
where: { id: user.id },
include: { Workspace: true },
});
if (!userRecord?.Workspace) {
return json({ error: "No workspace found" }, { status: 404 });
}
const client = await prisma.oAuthClient.findFirst({
where: {
id: clientId,
workspaceId: userRecord.Workspace.id,
},
select: {
id: true,
clientId: true,
name: true,
description: true,
redirectUris: true,
allowedScopes: true,
requirePkce: true,
logoUrl: true,
homepageUrl: true,
isActive: true,
createdAt: true,
updatedAt: true,
createdBy: {
select: {
id: true,
email: true,
name: true,
},
},
},
});
if (!client) {
return json({ error: "OAuth client not found" }, { status: 404 });
}
return json({ client });
} catch (error) {
console.error("Error fetching OAuth client:", error);
return json({ error: "Internal server error" }, { status: 500 });
}
};
export const action = async ({ request, params }: ActionFunctionArgs) => {
const user = await requireAuth(request);
const { clientId } = params;
const method = request.method;
if (!clientId) {
return json({ error: "Client ID is required" }, { status: 400 });
}
try {
// Get user's workspace
const userRecord = await prisma.user.findUnique({
where: { id: user.id },
include: { Workspace: true },
});
if (!userRecord?.Workspace) {
return json({ error: "No workspace found" }, { status: 404 });
}
// Verify client exists and belongs to user's workspace
const existingClient = await prisma.oAuthClient.findFirst({
where: {
id: clientId,
workspaceId: userRecord.Workspace.id,
},
});
if (!existingClient) {
return json({ error: "OAuth client not found" }, { status: 404 });
}
// PATCH - Update OAuth client
if (method === "PATCH") {
const body = await request.json();
const { name, description, redirectUris, allowedScopes, requirePkce, logoUrl, homepageUrl, isActive } = body;
const updateData: any = {};
if (name !== undefined) updateData.name = name;
if (description !== undefined) updateData.description = description;
if (redirectUris !== undefined) {
updateData.redirectUris = Array.isArray(redirectUris) ? redirectUris.join(',') : redirectUris;
}
if (allowedScopes !== undefined) {
updateData.allowedScopes = Array.isArray(allowedScopes) ? allowedScopes.join(',') : allowedScopes;
}
if (requirePkce !== undefined) updateData.requirePkce = requirePkce;
if (logoUrl !== undefined) updateData.logoUrl = logoUrl;
if (homepageUrl !== undefined) updateData.homepageUrl = homepageUrl;
if (isActive !== undefined) updateData.isActive = isActive;
const updatedClient = await prisma.oAuthClient.update({
where: { id: clientId },
data: updateData,
select: {
id: true,
clientId: true,
name: true,
description: true,
redirectUris: true,
allowedScopes: true,
requirePkce: true,
logoUrl: true,
homepageUrl: true,
isActive: true,
createdAt: true,
updatedAt: true,
},
});
return json({ success: true, client: updatedClient });
}
// POST - Regenerate client secret
if (method === "POST") {
const body = await request.json();
const { action } = body;
if (action === "regenerate_secret") {
const newClientSecret = crypto.randomBytes(32).toString('hex');
const updatedClient = await prisma.oAuthClient.update({
where: { id: clientId },
data: { clientSecret: newClientSecret },
select: {
id: true,
clientId: true,
clientSecret: true,
name: true,
},
});
return json({
success: true,
client: updatedClient,
message: "Client secret regenerated successfully. Save it securely - it won't be shown again."
});
}
return json({ error: "Invalid action" }, { status: 400 });
}
// DELETE - Delete OAuth client
if (method === "DELETE") {
await prisma.oAuthClient.delete({
where: { id: clientId },
});
return json({ success: true, message: "OAuth client deleted successfully" });
}
return json({ error: "Method not allowed" }, { status: 405 });
} catch (error) {
console.error("Error managing OAuth client:", error);
return json({ error: "Internal server error" }, { status: 500 });
}
};

View File

@ -0,0 +1,129 @@
import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/node";
import { PrismaClient } from "@prisma/client";
import { requireAuth } from "~/utils/auth-helper";
import crypto from "crypto";
const prisma = new PrismaClient();
// GET /api/oauth/clients - List OAuth clients for user's workspace
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireAuth(request);
try {
// Get user's workspace
const userRecord = await prisma.user.findUnique({
where: { id: user.id },
include: { Workspace: true },
});
if (!userRecord?.Workspace) {
return json({ error: "No workspace found" }, { status: 404 });
}
const clients = await prisma.oAuthClient.findMany({
where: { workspaceId: userRecord.Workspace.id },
select: {
id: true,
clientId: true,
name: true,
description: true,
redirectUris: true,
allowedScopes: true,
requirePkce: true,
logoUrl: true,
homepageUrl: true,
isActive: true,
createdAt: true,
updatedAt: true,
createdBy: {
select: {
id: true,
email: true,
name: true,
},
},
},
orderBy: { createdAt: "desc" },
});
return json({ clients });
} catch (error) {
console.error("Error fetching OAuth clients:", error);
return json({ error: "Internal server error" }, { status: 500 });
}
};
// POST /api/oauth/clients - Create new OAuth client
export const action = async ({ request }: ActionFunctionArgs) => {
if (request.method !== "POST") {
return json({ error: "Method not allowed" }, { status: 405 });
}
const user = await requireAuth(request);
try {
const body = await request.json();
const { name, description, redirectUris, allowedScopes, requirePkce, logoUrl, homepageUrl } = body;
// Validate required fields
if (!name || !redirectUris) {
return json({ error: "Name and redirectUris are required" }, { status: 400 });
}
// Get user's workspace
const userRecord = await prisma.user.findUnique({
where: { id: user.id },
include: { Workspace: true },
});
if (!userRecord?.Workspace) {
return json({ error: "No workspace found" }, { status: 404 });
}
// Generate client credentials
const clientId = crypto.randomUUID();
const clientSecret = crypto.randomBytes(32).toString('hex');
// Create OAuth client
const client = await prisma.oAuthClient.create({
data: {
clientId,
clientSecret,
name,
description: description || null,
redirectUris: Array.isArray(redirectUris) ? redirectUris.join(',') : redirectUris,
allowedScopes: Array.isArray(allowedScopes) ? allowedScopes.join(',') : allowedScopes || "read",
requirePkce: requirePkce || false,
logoUrl: logoUrl || null,
homepageUrl: homepageUrl || null,
workspaceId: userRecord.Workspace.id,
createdById: user.id,
},
select: {
id: true,
clientId: true,
clientSecret: true,
name: true,
description: true,
redirectUris: true,
allowedScopes: true,
requirePkce: true,
logoUrl: true,
homepageUrl: true,
isActive: true,
createdAt: true,
},
});
return json({
success: true,
client,
message: "OAuth client created successfully. Save the client_secret securely - it won't be shown again."
});
} catch (error) {
console.error("Error creating OAuth client:", error);
return json({ error: "Internal server error" }, { status: 500 });
}
};

View File

@ -0,0 +1,33 @@
import { type LoaderFunctionArgs, json } from "@remix-run/node";
import { requireOAuth2, requireScope } from "~/utils/oauth2-middleware";
export const loader = async ({ request }: LoaderFunctionArgs) => {
try {
// Require OAuth2 authentication
const oauth2Context = await requireOAuth2(request);
// Require 'read' scope
requireScope(oauth2Context, 'read');
// Return user profile information
return json({
user: oauth2Context.user,
client: {
name: oauth2Context.client.name,
id: oauth2Context.client.clientId,
},
scope: oauth2Context.token.scope,
});
} catch (error) {
// Error responses are already formatted by the middleware
throw error;
}
};
// This endpoint only supports GET
export const action = () => {
return json(
{ error: "method_not_allowed", error_description: "Only GET method is allowed" },
{ status: 405 }
);
};

View File

@ -0,0 +1,203 @@
import { type ActionFunctionArgs, type LoaderFunctionArgs, redirect } from "@remix-run/node";
import { Form, useLoaderData, useSearchParams } from "@remix-run/react";
import { getUser } from "~/services/session.server";
import { oauth2Service, OAuth2Errors, type OAuth2AuthorizeRequest } from "~/services/oauth2.server";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
export const loader = async ({ request }: LoaderFunctionArgs) => {
// Check if user is authenticated
const user = await getUser(request);
if (!user) {
// Redirect to login with return URL
const url = new URL(request.url);
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("redirectTo", url.pathname + url.search);
return redirect(loginUrl.toString());
}
const url = new URL(request.url);
const params: OAuth2AuthorizeRequest = {
client_id: url.searchParams.get("client_id") || "",
redirect_uri: url.searchParams.get("redirect_uri") || "",
response_type: url.searchParams.get("response_type") || "",
scope: url.searchParams.get("scope") || undefined,
state: url.searchParams.get("state") || undefined,
code_challenge: url.searchParams.get("code_challenge") || undefined,
code_challenge_method: url.searchParams.get("code_challenge_method") || undefined,
};
// Validate required parameters
if (!params.client_id || !params.redirect_uri || !params.response_type) {
return redirect(`${params.redirect_uri}?error=${OAuth2Errors.INVALID_REQUEST}&error_description=Missing required parameters${params.state ? `&state=${params.state}` : ""}`);
}
// Only support authorization code flow
if (params.response_type !== "code") {
return redirect(`${params.redirect_uri}?error=${OAuth2Errors.UNSUPPORTED_RESPONSE_TYPE}&error_description=Only authorization code flow is supported${params.state ? `&state=${params.state}` : ""}`);
}
try {
// Validate client
const client = await oauth2Service.validateClient(params.client_id);
// Validate redirect URI
if (!oauth2Service.validateRedirectUri(client, params.redirect_uri)) {
return redirect(`${params.redirect_uri}?error=${OAuth2Errors.INVALID_REQUEST}&error_description=Invalid redirect URI${params.state ? `&state=${params.state}` : ""}`);
}
return {
user,
client,
params,
};
} catch (error) {
return redirect(`${params.redirect_uri}?error=${OAuth2Errors.INVALID_CLIENT}&error_description=Invalid client${params.state ? `&state=${params.state}` : ""}`);
}
};
export const action = async ({ request }: ActionFunctionArgs) => {
const user = await getUser(request);
if (!user) {
return redirect("/login");
}
const formData = await request.formData();
const action = formData.get("action");
const params: OAuth2AuthorizeRequest = {
client_id: formData.get("client_id") as string,
redirect_uri: formData.get("redirect_uri") as string,
response_type: formData.get("response_type") as string,
scope: formData.get("scope") as string || undefined,
state: formData.get("state") as string || undefined,
code_challenge: formData.get("code_challenge") as string || undefined,
code_challenge_method: formData.get("code_challenge_method") as string || undefined,
};
if (action === "deny") {
return redirect(`${params.redirect_uri}?error=${OAuth2Errors.ACCESS_DENIED}&error_description=User denied access${params.state ? `&state=${params.state}` : ""}`);
}
if (action === "allow") {
try {
// Validate client again
const client = await oauth2Service.validateClient(params.client_id);
if (!oauth2Service.validateRedirectUri(client, params.redirect_uri)) {
return redirect(`${params.redirect_uri}?error=${OAuth2Errors.INVALID_REQUEST}&error_description=Invalid redirect URI${params.state ? `&state=${params.state}` : ""}`);
}
// Create authorization code
const authCode = await oauth2Service.createAuthorizationCode({
clientId: params.client_id,
userId: user.id,
redirectUri: params.redirect_uri,
scope: params.scope,
state: params.state,
codeChallenge: params.code_challenge,
codeChallengeMethod: params.code_challenge_method,
});
// Redirect back to client with authorization code
const redirectUrl = new URL(params.redirect_uri);
redirectUrl.searchParams.set("code", authCode);
if (params.state) {
redirectUrl.searchParams.set("state", params.state);
}
return redirect(redirectUrl.toString());
} catch (error) {
return redirect(`${params.redirect_uri}?error=${OAuth2Errors.SERVER_ERROR}&error_description=Failed to create authorization code${params.state ? `&state=${params.state}` : ""}`);
}
}
return redirect(`${params.redirect_uri}?error=${OAuth2Errors.INVALID_REQUEST}&error_description=Invalid action${params.state ? `&state=${params.state}` : ""}`);
};
export default function OAuthAuthorize() {
const { user, client, params } = useLoaderData<typeof loader>();
const [searchParams] = useSearchParams();
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Authorize Application</CardTitle>
<CardDescription>
<strong>{client.name}</strong> wants to access your Echo account
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center space-x-3">
{client.logoUrl && (
<img
src={client.logoUrl}
alt={client.name}
className="w-8 h-8 rounded"
/>
)}
<div>
<p className="font-medium">{client.name}</p>
{client.description && (
<p className="text-sm text-gray-600">{client.description}</p>
)}
</div>
</div>
<div className="bg-gray-50 p-3 rounded-lg">
<p className="text-sm font-medium mb-2">This application will be able to:</p>
<ul className="text-sm text-gray-600 space-y-1">
{params.scope ? (
params.scope.split(' ').map((scope, index) => (
<li key={index}> {scope === 'read' ? 'Read your profile information' : scope}</li>
))
) : (
<li> Read your profile information</li>
)}
</ul>
</div>
<div className="bg-blue-50 p-3 rounded-lg">
<p className="text-sm text-blue-800">
<strong>Signed in as:</strong> {user.email}
</p>
</div>
<Form method="post" className="space-y-3">
<input type="hidden" name="client_id" value={params.client_id} />
<input type="hidden" name="redirect_uri" value={params.redirect_uri} />
<input type="hidden" name="response_type" value={params.response_type} />
{params.scope && <input type="hidden" name="scope" value={params.scope} />}
{params.state && <input type="hidden" name="state" value={params.state} />}
{params.code_challenge && <input type="hidden" name="code_challenge" value={params.code_challenge} />}
{params.code_challenge_method && <input type="hidden" name="code_challenge_method" value={params.code_challenge_method} />}
<div className="flex space-x-3">
<Button
type="submit"
name="action"
value="allow"
className="flex-1"
>
Allow Access
</Button>
<Button
type="submit"
name="action"
value="deny"
variant="outline"
className="flex-1"
>
Deny
</Button>
</div>
</Form>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,143 @@
import { type ActionFunctionArgs, json } from "@remix-run/node";
import { oauth2Service, OAuth2Errors, type OAuth2TokenRequest } from "~/services/oauth2.server";
export const action = async ({ request }: ActionFunctionArgs) => {
if (request.method !== "POST") {
return json(
{ error: OAuth2Errors.INVALID_REQUEST, error_description: "Only POST method is allowed" },
{ status: 405 }
);
}
try {
const contentType = request.headers.get("content-type");
let body: any;
let tokenRequest: OAuth2TokenRequest;
// Support both JSON and form-encoded data
if (contentType?.includes("application/json")) {
body = await request.json();
tokenRequest = {
grant_type: body.grant_type,
code: body.code || undefined,
redirect_uri: body.redirect_uri || undefined,
client_id: body.client_id,
client_secret: body.client_secret || undefined,
code_verifier: body.code_verifier || undefined,
};
} else {
// Fall back to form data for compatibility
const formData = await request.formData();
body = Object.fromEntries(formData);
tokenRequest = {
grant_type: formData.get("grant_type") as string,
code: formData.get("code") as string || undefined,
redirect_uri: formData.get("redirect_uri") as string || undefined,
client_id: formData.get("client_id") as string,
client_secret: formData.get("client_secret") as string || undefined,
code_verifier: formData.get("code_verifier") as string || undefined,
};
}
// Validate required parameters
if (!tokenRequest.grant_type || !tokenRequest.client_id) {
return json(
{ error: OAuth2Errors.INVALID_REQUEST, error_description: "Missing required parameters" },
{ status: 400 }
);
}
// Handle authorization code grant
if (tokenRequest.grant_type === "authorization_code") {
if (!tokenRequest.code || !tokenRequest.redirect_uri) {
return json(
{ error: OAuth2Errors.INVALID_REQUEST, error_description: "Missing code or redirect_uri" },
{ status: 400 }
);
}
// Validate client
try {
await oauth2Service.validateClient(tokenRequest.client_id, tokenRequest.client_secret);
} catch (error) {
return json(
{ error: OAuth2Errors.INVALID_CLIENT, error_description: "Invalid client credentials" },
{ status: 401 }
);
}
// Exchange code for tokens
try {
const tokens = await oauth2Service.exchangeCodeForTokens({
code: tokenRequest.code,
clientId: tokenRequest.client_id,
redirectUri: tokenRequest.redirect_uri,
codeVerifier: tokenRequest.code_verifier,
});
return json(tokens);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return json(
{ error: errorMessage, error_description: "Failed to exchange code for tokens" },
{ status: 400 }
);
}
}
// Handle refresh token grant
if (tokenRequest.grant_type === "refresh_token") {
const refreshToken = body.refresh_token;
if (!refreshToken) {
return json(
{ error: OAuth2Errors.INVALID_REQUEST, error_description: "Missing refresh_token" },
{ status: 400 }
);
}
// Validate client
try {
await oauth2Service.validateClient(tokenRequest.client_id, tokenRequest.client_secret);
} catch (error) {
return json(
{ error: OAuth2Errors.INVALID_CLIENT, error_description: "Invalid client credentials" },
{ status: 401 }
);
}
// Refresh access token
try {
const tokens = await oauth2Service.refreshAccessToken(refreshToken, tokenRequest.client_id);
return json(tokens);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return json(
{ error: errorMessage, error_description: "Failed to refresh access token" },
{ status: 400 }
);
}
}
// Unsupported grant type
return json(
{ error: OAuth2Errors.UNSUPPORTED_GRANT_TYPE, error_description: "Unsupported grant type" },
{ status: 400 }
);
} catch (error) {
console.error("OAuth2 token endpoint error:", error);
return json(
{ error: OAuth2Errors.SERVER_ERROR, error_description: "Internal server error" },
{ status: 500 }
);
}
};
// This endpoint only supports POST
export const loader = () => {
return json(
{ error: OAuth2Errors.INVALID_REQUEST, error_description: "Only POST method is allowed" },
{ status: 405 }
);
};

View File

@ -0,0 +1,44 @@
import { type LoaderFunctionArgs, json } from "@remix-run/node";
import { oauth2Service } from "~/services/oauth2.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
try {
// Get authorization header
const authHeader = request.headers.get("authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return json(
{ error: "invalid_token", error_description: "Missing or invalid authorization header" },
{ status: 401 }
);
}
const token = authHeader.substring(7); // Remove "Bearer " prefix
// Validate token and get user info
try {
const userInfo = await oauth2Service.getUserInfo(token);
return json(userInfo);
} catch (error) {
return json(
{ error: "invalid_token", error_description: "Invalid or expired access token" },
{ status: 401 }
);
}
} catch (error) {
console.error("OAuth2 userinfo endpoint error:", error);
return json(
{ error: "server_error", error_description: "Internal server error" },
{ status: 500 }
);
}
};
// This endpoint only supports GET
export const action = () => {
return json(
{ error: "invalid_request", error_description: "Only GET method is allowed" },
{ status: 405 }
);
};

View File

@ -1,4 +1,5 @@
import { findUserByToken } from "~/models/personal-token.server";
import { oauth2Service } from "~/services/oauth2.server";
// See this for more: https://twitter.com/mattpocockuk/status/1653403198885904387?s=20
export type Prettify<T> = {
@ -12,10 +13,14 @@ export type ApiAuthenticationResult =
export type ApiAuthenticationResultSuccess = {
ok: true;
apiKey: string;
type: "PRIVATE";
type: "PRIVATE" | "OAUTH2";
userId: string;
scopes?: string[];
oneTimeUse?: boolean;
oauth2?: {
clientId: string;
scope: string | null;
};
};
export type ApiAuthenticationResultFailure = {
@ -53,6 +58,27 @@ export async function authenticateApiKeyWithFailure(
apiKey: string,
options: { allowPublicKey?: boolean; allowJWT?: boolean } = {},
): Promise<ApiAuthenticationResult> {
// First try OAuth2 access token
try {
const accessToken = await oauth2Service.validateAccessToken(apiKey);
if (accessToken) {
return {
ok: true,
apiKey,
type: "OAUTH2",
userId: accessToken.user.id,
scopes: accessToken.scope ? accessToken.scope.split(' ') : undefined,
oauth2: {
clientId: accessToken.client.clientId,
scope: accessToken.scope,
},
};
}
} catch (error) {
// If OAuth2 token validation fails, continue to PAT validation
}
// Fall back to PAT authentication
const result = getApiKeyResult(apiKey);
if (!result) {

View File

@ -7,7 +7,7 @@ export type AuthorizationResources = {
};
export type AuthorizationEntity = {
type: "PRIVATE";
type: "PRIVATE" | "OAUTH2";
scopes?: string[];
};
@ -26,5 +26,10 @@ export function checkAuthorization(
return { authorized: true };
}
// "OAUTH2" tokens are also authorized (scope-based authorization can be added later)
if (entity.type === "OAUTH2") {
return { authorized: true };
}
return { authorized: false, reason: "No key" };
}

View File

@ -0,0 +1,336 @@
import { PrismaClient } from "@prisma/client";
import crypto from "crypto";
const prisma = new PrismaClient();
export interface OAuth2AuthorizeRequest {
client_id: string;
redirect_uri: string;
response_type: string;
scope?: string;
state?: string;
code_challenge?: string;
code_challenge_method?: string;
}
export interface OAuth2TokenRequest {
grant_type: string;
code?: string;
redirect_uri?: string;
client_id: string;
client_secret?: string;
code_verifier?: string;
}
export interface OAuth2TokenResponse {
access_token: string;
token_type: string;
expires_in: number;
refresh_token?: string;
scope?: string;
}
export interface OAuth2ErrorResponse {
error: string;
error_description?: string;
error_uri?: string;
state?: string;
}
// OAuth2 Error types
export const OAuth2Errors = {
INVALID_REQUEST: "invalid_request",
INVALID_CLIENT: "invalid_client",
INVALID_GRANT: "invalid_grant",
UNAUTHORIZED_CLIENT: "unauthorized_client",
UNSUPPORTED_GRANT_TYPE: "unsupported_grant_type",
INVALID_SCOPE: "invalid_scope",
ACCESS_DENIED: "access_denied",
UNSUPPORTED_RESPONSE_TYPE: "unsupported_response_type",
SERVER_ERROR: "server_error",
TEMPORARILY_UNAVAILABLE: "temporarily_unavailable",
} as const;
export class OAuth2Service {
// Generate secure random string
private generateSecureToken(length: number = 32): string {
return crypto.randomBytes(length).toString("hex");
}
// Validate OAuth2 client
async validateClient(clientId: string, clientSecret?: string): Promise<any> {
const client = await prisma.oAuthClient.findUnique({
where: {
clientId,
isActive: true,
},
include: {
workspace: true,
},
});
if (!client) {
throw new Error(OAuth2Errors.INVALID_CLIENT);
}
// If client secret is provided, validate it
if (clientSecret && client.clientSecret !== clientSecret) {
throw new Error(OAuth2Errors.INVALID_CLIENT);
}
return client;
}
// Validate redirect URI
validateRedirectUri(client: any, redirectUri: string): boolean {
const allowedUris = client.redirectUris
.split(",")
.map((uri: string) => uri.trim());
return allowedUris.includes(redirectUri);
}
// Validate PKCE challenge
validatePkceChallenge(
codeVerifier: string,
codeChallenge: string,
method: string = "S256",
): boolean {
if (method === "S256") {
const hash = crypto.createHash("sha256").update(codeVerifier).digest();
const challenge = hash.toString("base64url");
return challenge === codeChallenge;
} else if (method === "plain") {
return codeVerifier === codeChallenge;
}
return false;
}
// Create authorization code
async createAuthorizationCode(params: {
clientId: string;
userId: string;
redirectUri: string;
scope?: string;
state?: string;
codeChallenge?: string;
codeChallengeMethod?: string;
}): Promise<string> {
const code = this.generateSecureToken(32);
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
// Find the client to get the internal database ID
const client = await prisma.oAuthClient.findUnique({
where: { clientId: params.clientId },
select: { id: true },
});
if (!client) {
throw new Error(OAuth2Errors.INVALID_CLIENT);
}
await prisma.oAuthAuthorizationCode.create({
data: {
code,
clientId: client.id, // Use internal database ID
userId: params.userId,
redirectUri: params.redirectUri,
scope: params.scope,
state: params.state,
codeChallenge: params.codeChallenge,
codeChallengeMethod: params.codeChallengeMethod,
expiresAt,
},
});
return code;
}
// Exchange authorization code for tokens
async exchangeCodeForTokens(params: {
code: string;
clientId: string;
redirectUri: string;
codeVerifier?: string;
}): Promise<OAuth2TokenResponse> {
// Find the client first to get the internal database ID
const client = await prisma.oAuthClient.findUnique({
where: { clientId: params.clientId },
select: { id: true },
});
if (!client) {
throw new Error(OAuth2Errors.INVALID_CLIENT);
}
// Find and validate authorization code
const authCode = await prisma.oAuthAuthorizationCode.findFirst({
where: {
code: params.code,
clientId: client.id, // Use internal database ID
redirectUri: params.redirectUri,
used: false,
expiresAt: { gt: new Date() },
},
include: {
client: true,
user: true,
},
});
if (!authCode) {
throw new Error(OAuth2Errors.INVALID_GRANT);
}
// Validate PKCE if required
if (authCode.codeChallenge) {
if (!params.codeVerifier) {
throw new Error(OAuth2Errors.INVALID_REQUEST);
}
if (
!this.validatePkceChallenge(
params.codeVerifier,
authCode.codeChallenge,
authCode.codeChallengeMethod || "S256",
)
) {
throw new Error(OAuth2Errors.INVALID_GRANT);
}
}
// Mark code as used
await prisma.oAuthAuthorizationCode.update({
where: { id: authCode.id },
data: { used: true },
});
// Generate access token
const accessToken = this.generateSecureToken(64);
const refreshToken = this.generateSecureToken(64);
const expiresIn = 3600; // 1 hour
const accessTokenExpiresAt = new Date(Date.now() + expiresIn * 1000);
const refreshTokenExpiresAt = new Date(
Date.now() + 30 * 24 * 60 * 60 * 1000,
); // 30 days
// Store tokens
await prisma.oAuthAccessToken.create({
data: {
token: accessToken,
clientId: client.id, // Use internal database ID
userId: authCode.userId,
scope: authCode.scope,
expiresAt: accessTokenExpiresAt,
},
});
await prisma.oAuthRefreshToken.create({
data: {
token: refreshToken,
clientId: client.id, // Use internal database ID
userId: authCode.userId,
scope: authCode.scope,
expiresAt: refreshTokenExpiresAt,
},
});
return {
access_token: accessToken,
token_type: "Bearer",
expires_in: expiresIn,
refresh_token: refreshToken,
scope: authCode.scope || undefined,
};
}
// Validate access token
async validateAccessToken(token: string): Promise<any> {
const accessToken = await prisma.oAuthAccessToken.findFirst({
where: {
token,
revoked: false,
expiresAt: { gt: new Date() },
},
include: {
client: true,
user: true,
},
});
if (!accessToken) {
throw new Error("Invalid or expired token");
}
return accessToken;
}
// Get user info from access token
async getUserInfo(token: string): Promise<any> {
const accessToken = await this.validateAccessToken(token);
return {
sub: accessToken.user.id,
email: accessToken.user.email,
name: accessToken.user.name,
display_name: accessToken.user.displayName,
avatar_url: accessToken.user.avatarUrl,
email_verified: true, // Assuming email is verified if user exists
};
}
// Refresh access token
async refreshAccessToken(
refreshToken: string,
clientId: string,
): Promise<OAuth2TokenResponse> {
// Find the client first to get the internal database ID
const client = await prisma.oAuthClient.findUnique({
where: { clientId },
select: { id: true },
});
if (!client) {
throw new Error(OAuth2Errors.INVALID_CLIENT);
}
const storedRefreshToken = await prisma.oAuthRefreshToken.findFirst({
where: {
token: refreshToken,
clientId: client.id, // Use internal database ID
revoked: false,
expiresAt: { gt: new Date() },
},
include: {
client: true,
user: true,
},
});
if (!storedRefreshToken) {
throw new Error(OAuth2Errors.INVALID_GRANT);
}
// Generate new access token
const accessToken = this.generateSecureToken(64);
const expiresIn = 3600; // 1 hour
const accessTokenExpiresAt = new Date(Date.now() + expiresIn * 1000);
await prisma.oAuthAccessToken.create({
data: {
token: accessToken,
clientId: client.id, // Use internal database ID
userId: storedRefreshToken.userId,
scope: storedRefreshToken.scope,
expiresAt: accessTokenExpiresAt,
},
});
return {
access_token: accessToken,
token_type: "Bearer",
expires_in: expiresIn,
scope: storedRefreshToken.scope || undefined,
};
}
}
export const oauth2Service = new OAuth2Service();

View File

@ -0,0 +1,109 @@
import { json, redirect } from "@remix-run/node";
import { getUser } from "~/services/session.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
import { oauth2Service } from "~/services/oauth2.server";
import { getUserById } from "~/models/user.server";
export type AuthenticatedUser = {
id: string;
email: string;
name: string | null;
displayName: string | null;
avatarUrl: string | null;
admin: boolean;
createdAt: Date;
updatedAt: Date;
confirmedBasicDetails: boolean;
authMethod: 'session' | 'pat' | 'oauth2';
oauth2?: {
clientId: string;
scope: string | null;
};
};
/**
* Authenticates a request using session, PAT, or OAuth2 access token
* Returns the authenticated user or throws an error response
*/
export async function requireAuth(request: Request): Promise<AuthenticatedUser> {
const authHeader = request.headers.get("authorization");
// Try OAuth2 access token authentication
if (authHeader && authHeader.startsWith("Bearer ")) {
const token = authHeader.substring(7);
// Check if it's a PAT token first
const patAuth = await authenticateApiRequestWithPersonalAccessToken(request);
if (patAuth) {
const user = await getUserById(patAuth.userId);
if (user) {
return {
id: user.id,
email: user.email,
name: user.name,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
admin: user.admin,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
confirmedBasicDetails: user.confirmedBasicDetails,
authMethod: 'pat',
};
}
}
// Try OAuth2 access token
try {
const accessToken = await oauth2Service.validateAccessToken(token);
return {
id: accessToken.user.id,
email: accessToken.user.email,
name: accessToken.user.name,
displayName: accessToken.user.displayName,
avatarUrl: accessToken.user.avatarUrl,
admin: accessToken.user.admin,
createdAt: accessToken.user.createdAt,
updatedAt: accessToken.user.updatedAt,
confirmedBasicDetails: accessToken.user.confirmedBasicDetails,
authMethod: 'oauth2',
oauth2: {
clientId: accessToken.client.clientId,
scope: accessToken.scope,
},
};
} catch (error) {
// OAuth2 token validation failed, continue to session auth
}
}
// Try session authentication
const sessionUser = await getUser(request);
if (sessionUser) {
return {
id: sessionUser.id,
email: sessionUser.email,
name: sessionUser.name,
displayName: sessionUser.displayName,
avatarUrl: sessionUser.avatarUrl,
admin: sessionUser.admin,
createdAt: sessionUser.createdAt,
updatedAt: sessionUser.updatedAt,
confirmedBasicDetails: sessionUser.confirmedBasicDetails,
authMethod: 'session',
};
}
// If no authentication method worked, return 401
throw json({ error: "Unauthorized" }, { status: 401 });
}
/**
* Optional authentication - returns user if authenticated, null otherwise
*/
export async function getAuthenticatedUser(request: Request): Promise<AuthenticatedUser | null> {
try {
return await requireAuth(request);
} catch (error) {
return null;
}
}

View File

@ -0,0 +1,92 @@
import { json } from "@remix-run/node";
import { oauth2Service } from "~/services/oauth2.server";
export interface OAuth2Context {
user: {
id: string;
email: string;
name: string | null;
displayName: string | null;
avatarUrl: string | null;
};
client: {
id: string;
clientId: string;
name: string;
};
token: {
id: string;
token: string;
scope: string | null;
expiresAt: Date;
};
}
export async function requireOAuth2(request: Request): Promise<OAuth2Context> {
const authHeader = request.headers.get("authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
throw json(
{ error: "invalid_token", error_description: "Missing or invalid authorization header" },
{ status: 401 }
);
}
const token = authHeader.substring(7); // Remove "Bearer " prefix
try {
const accessToken = await oauth2Service.validateAccessToken(token);
return {
user: {
id: accessToken.user.id,
email: accessToken.user.email,
name: accessToken.user.name,
displayName: accessToken.user.displayName,
avatarUrl: accessToken.user.avatarUrl,
},
client: {
id: accessToken.client.id,
clientId: accessToken.client.clientId,
name: accessToken.client.name,
},
token: {
id: accessToken.id,
token: accessToken.token,
scope: accessToken.scope,
expiresAt: accessToken.expiresAt,
},
};
} catch (error) {
throw json(
{ error: "invalid_token", error_description: "Invalid or expired access token" },
{ status: 401 }
);
}
}
export async function getOAuth2Context(request: Request): Promise<OAuth2Context | null> {
try {
return await requireOAuth2(request);
} catch (error) {
return null;
}
}
export function hasScope(context: OAuth2Context, requiredScope: string): boolean {
if (!context.token.scope) {
return false;
}
const scopes = context.token.scope.split(' ');
return scopes.includes(requiredScope);
}
export function requireScope(context: OAuth2Context, requiredScope: string): void {
if (!hasScope(context, requiredScope)) {
throw json(
{ error: "insufficient_scope", error_description: `Required scope: ${requiredScope}` },
{ status: 403 }
);
}
}

View File

@ -0,0 +1,106 @@
-- CreateTable
CREATE TABLE "OAuthAuthorizationCode" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"redirectUri" TEXT NOT NULL,
"scope" TEXT,
"state" TEXT,
"codeChallenge" TEXT,
"codeChallengeMethod" TEXT,
"expiresAt" TIMESTAMP(3) NOT NULL,
"used" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OAuthAuthorizationCode_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OAuthClient" (
"id" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"clientSecret" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"redirectUris" TEXT NOT NULL,
"allowedScopes" TEXT NOT NULL DEFAULT 'read',
"grantTypes" TEXT NOT NULL DEFAULT 'authorization_code',
"requirePkce" BOOLEAN NOT NULL DEFAULT false,
"logoUrl" TEXT,
"homepageUrl" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"workspaceId" TEXT NOT NULL,
"createdById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OAuthClient_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OAuthAccessToken" (
"id" TEXT NOT NULL,
"token" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"scope" TEXT,
"expiresAt" TIMESTAMP(3) NOT NULL,
"revoked" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OAuthAccessToken_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OAuthRefreshToken" (
"id" TEXT NOT NULL,
"token" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"scope" TEXT,
"expiresAt" TIMESTAMP(3) NOT NULL,
"revoked" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OAuthRefreshToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "OAuthAuthorizationCode_code_key" ON "OAuthAuthorizationCode"("code");
-- CreateIndex
CREATE UNIQUE INDEX "OAuthClient_clientId_key" ON "OAuthClient"("clientId");
-- CreateIndex
CREATE UNIQUE INDEX "OAuthAccessToken_token_key" ON "OAuthAccessToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "OAuthRefreshToken_token_key" ON "OAuthRefreshToken"("token");
-- AddForeignKey
ALTER TABLE "OAuthAuthorizationCode" ADD CONSTRAINT "OAuthAuthorizationCode_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "OAuthClient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OAuthAuthorizationCode" ADD CONSTRAINT "OAuthAuthorizationCode_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OAuthClient" ADD CONSTRAINT "OAuthClient_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OAuthClient" ADD CONSTRAINT "OAuthClient_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OAuthAccessToken" ADD CONSTRAINT "OAuthAccessToken_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "OAuthClient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OAuthAccessToken" ADD CONSTRAINT "OAuthAccessToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OAuthRefreshToken" ADD CONSTRAINT "OAuthRefreshToken_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "OAuthClient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OAuthRefreshToken" ADD CONSTRAINT "OAuthRefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -46,6 +46,110 @@ model AuthorizationCode {
updatedAt DateTime @updatedAt
}
model OAuthAuthorizationCode {
id String @id @default(cuid())
code String @unique
// OAuth2 specific fields
clientId String
userId String
redirectUri String
scope String?
state String?
codeChallenge String?
codeChallengeMethod String?
expiresAt DateTime
used Boolean @default(false)
// Relations
client OAuthClient @relation(fields: [clientId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model OAuthClient {
id String @id @default(cuid())
clientId String @unique
clientSecret String
name String
description String?
// Redirect URIs (comma-separated for simplicity)
redirectUris String
// Allowed scopes (comma-separated)
allowedScopes String @default("read")
// Grant types allowed
grantTypes String @default("authorization_code")
// PKCE support
requirePkce Boolean @default(false)
// Client metadata
logoUrl String?
homepageUrl String?
// GitHub-style features
isActive Boolean @default(true)
// Workspace relationship (like GitHub orgs)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
workspaceId String
// Created by user (for audit trail)
createdBy User @relation(fields: [createdById], references: [id])
createdById String
// Relations
oauthAuthorizationCodes OAuthAuthorizationCode[]
accessTokens OAuthAccessToken[]
refreshTokens OAuthRefreshToken[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model OAuthAccessToken {
id String @id @default(cuid())
token String @unique
clientId String
userId String
scope String?
expiresAt DateTime
revoked Boolean @default(false)
// Relations
client OAuthClient @relation(fields: [clientId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model OAuthRefreshToken {
id String @id @default(cuid())
token String @unique
clientId String
userId String
scope String?
expiresAt DateTime
revoked Boolean @default(false)
// Relations
client OAuthClient @relation(fields: [clientId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Conversation {
id String @id @default(uuid())
createdAt DateTime @default(now())
@ -319,6 +423,12 @@ model User {
Conversation Conversation[]
ConversationHistory ConversationHistory[]
IngestionRule IngestionRule[]
// OAuth2 relations
oauthAuthorizationCodes OAuthAuthorizationCode[]
oauthAccessTokens OAuthAccessToken[]
oauthRefreshTokens OAuthRefreshToken[]
oauthClientsCreated OAuthClient[]
}
model WebhookConfiguration {
@ -375,6 +485,7 @@ model Workspace {
WebhookConfiguration WebhookConfiguration[]
Conversation Conversation[]
IngestionRule IngestionRule[]
OAuthClient OAuthClient[]
}
enum AuthenticationMethod {