mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-12 09:18:27 +00:00
Feat: OAuth support for external apps
This commit is contained in:
parent
b7b24aecdd
commit
e50c5a7c64
186
apps/webapp/app/routes/api.oauth.clients.$clientId.tsx
Normal file
186
apps/webapp/app/routes/api.oauth.clients.$clientId.tsx
Normal file
@ -0,0 +1,186 @@
|
||||
import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/node";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { requireAuth } from "~/utils/auth-helper";
|
||||
import crypto from "crypto";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// GET /api/oauth/clients/:clientId - Get specific OAuth client
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireAuth(request);
|
||||
|
||||
const { clientId } = params;
|
||||
|
||||
if (!clientId) {
|
||||
return json({ error: "Client ID is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Get user's workspace
|
||||
const userRecord = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
include: { Workspace: true },
|
||||
});
|
||||
|
||||
if (!userRecord?.Workspace) {
|
||||
return json({ error: "No workspace found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const client = await prisma.oAuthClient.findFirst({
|
||||
where: {
|
||||
id: clientId,
|
||||
workspaceId: userRecord.Workspace.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
clientId: true,
|
||||
name: true,
|
||||
description: true,
|
||||
redirectUris: true,
|
||||
allowedScopes: true,
|
||||
requirePkce: true,
|
||||
logoUrl: true,
|
||||
homepageUrl: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
createdBy: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
return json({ error: "OAuth client not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return json({ client });
|
||||
} catch (error) {
|
||||
console.error("Error fetching OAuth client:", error);
|
||||
return json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const user = await requireAuth(request);
|
||||
|
||||
const { clientId } = params;
|
||||
const method = request.method;
|
||||
|
||||
if (!clientId) {
|
||||
return json({ error: "Client ID is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Get user's workspace
|
||||
const userRecord = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
include: { Workspace: true },
|
||||
});
|
||||
|
||||
if (!userRecord?.Workspace) {
|
||||
return json({ error: "No workspace found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Verify client exists and belongs to user's workspace
|
||||
const existingClient = await prisma.oAuthClient.findFirst({
|
||||
where: {
|
||||
id: clientId,
|
||||
workspaceId: userRecord.Workspace.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingClient) {
|
||||
return json({ error: "OAuth client not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// PATCH - Update OAuth client
|
||||
if (method === "PATCH") {
|
||||
const body = await request.json();
|
||||
const { name, description, redirectUris, allowedScopes, requirePkce, logoUrl, homepageUrl, isActive } = body;
|
||||
|
||||
const updateData: any = {};
|
||||
if (name !== undefined) updateData.name = name;
|
||||
if (description !== undefined) updateData.description = description;
|
||||
if (redirectUris !== undefined) {
|
||||
updateData.redirectUris = Array.isArray(redirectUris) ? redirectUris.join(',') : redirectUris;
|
||||
}
|
||||
if (allowedScopes !== undefined) {
|
||||
updateData.allowedScopes = Array.isArray(allowedScopes) ? allowedScopes.join(',') : allowedScopes;
|
||||
}
|
||||
if (requirePkce !== undefined) updateData.requirePkce = requirePkce;
|
||||
if (logoUrl !== undefined) updateData.logoUrl = logoUrl;
|
||||
if (homepageUrl !== undefined) updateData.homepageUrl = homepageUrl;
|
||||
if (isActive !== undefined) updateData.isActive = isActive;
|
||||
|
||||
const updatedClient = await prisma.oAuthClient.update({
|
||||
where: { id: clientId },
|
||||
data: updateData,
|
||||
select: {
|
||||
id: true,
|
||||
clientId: true,
|
||||
name: true,
|
||||
description: true,
|
||||
redirectUris: true,
|
||||
allowedScopes: true,
|
||||
requirePkce: true,
|
||||
logoUrl: true,
|
||||
homepageUrl: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return json({ success: true, client: updatedClient });
|
||||
}
|
||||
|
||||
// POST - Regenerate client secret
|
||||
if (method === "POST") {
|
||||
const body = await request.json();
|
||||
const { action } = body;
|
||||
|
||||
if (action === "regenerate_secret") {
|
||||
const newClientSecret = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
const updatedClient = await prisma.oAuthClient.update({
|
||||
where: { id: clientId },
|
||||
data: { clientSecret: newClientSecret },
|
||||
select: {
|
||||
id: true,
|
||||
clientId: true,
|
||||
clientSecret: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
client: updatedClient,
|
||||
message: "Client secret regenerated successfully. Save it securely - it won't be shown again."
|
||||
});
|
||||
}
|
||||
|
||||
return json({ error: "Invalid action" }, { status: 400 });
|
||||
}
|
||||
|
||||
// DELETE - Delete OAuth client
|
||||
if (method === "DELETE") {
|
||||
await prisma.oAuthClient.delete({
|
||||
where: { id: clientId },
|
||||
});
|
||||
|
||||
return json({ success: true, message: "OAuth client deleted successfully" });
|
||||
}
|
||||
|
||||
return json({ error: "Method not allowed" }, { status: 405 });
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error managing OAuth client:", error);
|
||||
return json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
};
|
||||
129
apps/webapp/app/routes/api.oauth.clients.tsx
Normal file
129
apps/webapp/app/routes/api.oauth.clients.tsx
Normal 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 });
|
||||
}
|
||||
};
|
||||
33
apps/webapp/app/routes/api.profile.tsx
Normal file
33
apps/webapp/app/routes/api.profile.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { type LoaderFunctionArgs, json } from "@remix-run/node";
|
||||
import { requireOAuth2, requireScope } from "~/utils/oauth2-middleware";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
try {
|
||||
// Require OAuth2 authentication
|
||||
const oauth2Context = await requireOAuth2(request);
|
||||
|
||||
// Require 'read' scope
|
||||
requireScope(oauth2Context, 'read');
|
||||
|
||||
// Return user profile information
|
||||
return json({
|
||||
user: oauth2Context.user,
|
||||
client: {
|
||||
name: oauth2Context.client.name,
|
||||
id: oauth2Context.client.clientId,
|
||||
},
|
||||
scope: oauth2Context.token.scope,
|
||||
});
|
||||
} catch (error) {
|
||||
// Error responses are already formatted by the middleware
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// This endpoint only supports GET
|
||||
export const action = () => {
|
||||
return json(
|
||||
{ error: "method_not_allowed", error_description: "Only GET method is allowed" },
|
||||
{ status: 405 }
|
||||
);
|
||||
};
|
||||
203
apps/webapp/app/routes/oauth.authorize.tsx
Normal file
203
apps/webapp/app/routes/oauth.authorize.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
143
apps/webapp/app/routes/oauth.token.tsx
Normal file
143
apps/webapp/app/routes/oauth.token.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
import { type ActionFunctionArgs, json } from "@remix-run/node";
|
||||
import { oauth2Service, OAuth2Errors, type OAuth2TokenRequest } from "~/services/oauth2.server";
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
if (request.method !== "POST") {
|
||||
return json(
|
||||
{ error: OAuth2Errors.INVALID_REQUEST, error_description: "Only POST method is allowed" },
|
||||
{ status: 405 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const contentType = request.headers.get("content-type");
|
||||
let body: any;
|
||||
let tokenRequest: OAuth2TokenRequest;
|
||||
|
||||
// Support both JSON and form-encoded data
|
||||
if (contentType?.includes("application/json")) {
|
||||
body = await request.json();
|
||||
tokenRequest = {
|
||||
grant_type: body.grant_type,
|
||||
code: body.code || undefined,
|
||||
redirect_uri: body.redirect_uri || undefined,
|
||||
client_id: body.client_id,
|
||||
client_secret: body.client_secret || undefined,
|
||||
code_verifier: body.code_verifier || undefined,
|
||||
};
|
||||
} else {
|
||||
// Fall back to form data for compatibility
|
||||
const formData = await request.formData();
|
||||
body = Object.fromEntries(formData);
|
||||
tokenRequest = {
|
||||
grant_type: formData.get("grant_type") as string,
|
||||
code: formData.get("code") as string || undefined,
|
||||
redirect_uri: formData.get("redirect_uri") as string || undefined,
|
||||
client_id: formData.get("client_id") as string,
|
||||
client_secret: formData.get("client_secret") as string || undefined,
|
||||
code_verifier: formData.get("code_verifier") as string || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Validate required parameters
|
||||
if (!tokenRequest.grant_type || !tokenRequest.client_id) {
|
||||
return json(
|
||||
{ error: OAuth2Errors.INVALID_REQUEST, error_description: "Missing required parameters" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Handle authorization code grant
|
||||
if (tokenRequest.grant_type === "authorization_code") {
|
||||
if (!tokenRequest.code || !tokenRequest.redirect_uri) {
|
||||
return json(
|
||||
{ error: OAuth2Errors.INVALID_REQUEST, error_description: "Missing code or redirect_uri" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate client
|
||||
try {
|
||||
await oauth2Service.validateClient(tokenRequest.client_id, tokenRequest.client_secret);
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ error: OAuth2Errors.INVALID_CLIENT, error_description: "Invalid client credentials" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
try {
|
||||
const tokens = await oauth2Service.exchangeCodeForTokens({
|
||||
code: tokenRequest.code,
|
||||
clientId: tokenRequest.client_id,
|
||||
redirectUri: tokenRequest.redirect_uri,
|
||||
codeVerifier: tokenRequest.code_verifier,
|
||||
});
|
||||
|
||||
return json(tokens);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
return json(
|
||||
{ error: errorMessage, error_description: "Failed to exchange code for tokens" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle refresh token grant
|
||||
if (tokenRequest.grant_type === "refresh_token") {
|
||||
const refreshToken = body.refresh_token;
|
||||
|
||||
if (!refreshToken) {
|
||||
return json(
|
||||
{ error: OAuth2Errors.INVALID_REQUEST, error_description: "Missing refresh_token" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate client
|
||||
try {
|
||||
await oauth2Service.validateClient(tokenRequest.client_id, tokenRequest.client_secret);
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ error: OAuth2Errors.INVALID_CLIENT, error_description: "Invalid client credentials" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Refresh access token
|
||||
try {
|
||||
const tokens = await oauth2Service.refreshAccessToken(refreshToken, tokenRequest.client_id);
|
||||
return json(tokens);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
return json(
|
||||
{ error: errorMessage, error_description: "Failed to refresh access token" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Unsupported grant type
|
||||
return json(
|
||||
{ error: OAuth2Errors.UNSUPPORTED_GRANT_TYPE, error_description: "Unsupported grant type" },
|
||||
{ status: 400 }
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
console.error("OAuth2 token endpoint error:", error);
|
||||
return json(
|
||||
{ error: OAuth2Errors.SERVER_ERROR, error_description: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// This endpoint only supports POST
|
||||
export const loader = () => {
|
||||
return json(
|
||||
{ error: OAuth2Errors.INVALID_REQUEST, error_description: "Only POST method is allowed" },
|
||||
{ status: 405 }
|
||||
);
|
||||
};
|
||||
44
apps/webapp/app/routes/oauth.userinfo.tsx
Normal file
44
apps/webapp/app/routes/oauth.userinfo.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { type LoaderFunctionArgs, json } from "@remix-run/node";
|
||||
import { oauth2Service } from "~/services/oauth2.server";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
try {
|
||||
// Get authorization header
|
||||
const authHeader = request.headers.get("authorization");
|
||||
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
return json(
|
||||
{ error: "invalid_token", error_description: "Missing or invalid authorization header" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7); // Remove "Bearer " prefix
|
||||
|
||||
// Validate token and get user info
|
||||
try {
|
||||
const userInfo = await oauth2Service.getUserInfo(token);
|
||||
return json(userInfo);
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ error: "invalid_token", error_description: "Invalid or expired access token" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("OAuth2 userinfo endpoint error:", error);
|
||||
return json(
|
||||
{ error: "server_error", error_description: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// This endpoint only supports GET
|
||||
export const action = () => {
|
||||
return json(
|
||||
{ error: "invalid_request", error_description: "Only GET method is allowed" },
|
||||
{ status: 405 }
|
||||
);
|
||||
};
|
||||
@ -1,4 +1,5 @@
|
||||
import { findUserByToken } from "~/models/personal-token.server";
|
||||
import { 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) {
|
||||
|
||||
@ -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" };
|
||||
}
|
||||
|
||||
336
apps/webapp/app/services/oauth2.server.ts
Normal file
336
apps/webapp/app/services/oauth2.server.ts
Normal file
@ -0,0 +1,336 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import crypto from "crypto";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export interface OAuth2AuthorizeRequest {
|
||||
client_id: string;
|
||||
redirect_uri: string;
|
||||
response_type: string;
|
||||
scope?: string;
|
||||
state?: string;
|
||||
code_challenge?: string;
|
||||
code_challenge_method?: string;
|
||||
}
|
||||
|
||||
export interface OAuth2TokenRequest {
|
||||
grant_type: string;
|
||||
code?: string;
|
||||
redirect_uri?: string;
|
||||
client_id: string;
|
||||
client_secret?: string;
|
||||
code_verifier?: string;
|
||||
}
|
||||
|
||||
export interface OAuth2TokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
refresh_token?: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export interface OAuth2ErrorResponse {
|
||||
error: string;
|
||||
error_description?: string;
|
||||
error_uri?: string;
|
||||
state?: string;
|
||||
}
|
||||
|
||||
// OAuth2 Error types
|
||||
export const OAuth2Errors = {
|
||||
INVALID_REQUEST: "invalid_request",
|
||||
INVALID_CLIENT: "invalid_client",
|
||||
INVALID_GRANT: "invalid_grant",
|
||||
UNAUTHORIZED_CLIENT: "unauthorized_client",
|
||||
UNSUPPORTED_GRANT_TYPE: "unsupported_grant_type",
|
||||
INVALID_SCOPE: "invalid_scope",
|
||||
ACCESS_DENIED: "access_denied",
|
||||
UNSUPPORTED_RESPONSE_TYPE: "unsupported_response_type",
|
||||
SERVER_ERROR: "server_error",
|
||||
TEMPORARILY_UNAVAILABLE: "temporarily_unavailable",
|
||||
} as const;
|
||||
|
||||
export class OAuth2Service {
|
||||
// Generate secure random string
|
||||
private generateSecureToken(length: number = 32): string {
|
||||
return crypto.randomBytes(length).toString("hex");
|
||||
}
|
||||
|
||||
// Validate OAuth2 client
|
||||
async validateClient(clientId: string, clientSecret?: string): Promise<any> {
|
||||
const client = await prisma.oAuthClient.findUnique({
|
||||
where: {
|
||||
clientId,
|
||||
isActive: true,
|
||||
},
|
||||
include: {
|
||||
workspace: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
throw new Error(OAuth2Errors.INVALID_CLIENT);
|
||||
}
|
||||
|
||||
// If client secret is provided, validate it
|
||||
if (clientSecret && client.clientSecret !== clientSecret) {
|
||||
throw new Error(OAuth2Errors.INVALID_CLIENT);
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
// Validate redirect URI
|
||||
validateRedirectUri(client: any, redirectUri: string): boolean {
|
||||
const allowedUris = client.redirectUris
|
||||
.split(",")
|
||||
.map((uri: string) => uri.trim());
|
||||
return allowedUris.includes(redirectUri);
|
||||
}
|
||||
|
||||
// Validate PKCE challenge
|
||||
validatePkceChallenge(
|
||||
codeVerifier: string,
|
||||
codeChallenge: string,
|
||||
method: string = "S256",
|
||||
): boolean {
|
||||
if (method === "S256") {
|
||||
const hash = crypto.createHash("sha256").update(codeVerifier).digest();
|
||||
const challenge = hash.toString("base64url");
|
||||
return challenge === codeChallenge;
|
||||
} else if (method === "plain") {
|
||||
return codeVerifier === codeChallenge;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create authorization code
|
||||
async createAuthorizationCode(params: {
|
||||
clientId: string;
|
||||
userId: string;
|
||||
redirectUri: string;
|
||||
scope?: string;
|
||||
state?: string;
|
||||
codeChallenge?: string;
|
||||
codeChallengeMethod?: string;
|
||||
}): Promise<string> {
|
||||
const code = this.generateSecureToken(32);
|
||||
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
|
||||
|
||||
// Find the client to get the internal database ID
|
||||
const client = await prisma.oAuthClient.findUnique({
|
||||
where: { clientId: params.clientId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
throw new Error(OAuth2Errors.INVALID_CLIENT);
|
||||
}
|
||||
|
||||
await prisma.oAuthAuthorizationCode.create({
|
||||
data: {
|
||||
code,
|
||||
clientId: client.id, // Use internal database ID
|
||||
userId: params.userId,
|
||||
redirectUri: params.redirectUri,
|
||||
scope: params.scope,
|
||||
state: params.state,
|
||||
codeChallenge: params.codeChallenge,
|
||||
codeChallengeMethod: params.codeChallengeMethod,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
// Exchange authorization code for tokens
|
||||
async exchangeCodeForTokens(params: {
|
||||
code: string;
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
codeVerifier?: string;
|
||||
}): Promise<OAuth2TokenResponse> {
|
||||
// Find the client first to get the internal database ID
|
||||
const client = await prisma.oAuthClient.findUnique({
|
||||
where: { clientId: params.clientId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
throw new Error(OAuth2Errors.INVALID_CLIENT);
|
||||
}
|
||||
|
||||
// Find and validate authorization code
|
||||
const authCode = await prisma.oAuthAuthorizationCode.findFirst({
|
||||
where: {
|
||||
code: params.code,
|
||||
clientId: client.id, // Use internal database ID
|
||||
redirectUri: params.redirectUri,
|
||||
used: false,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
include: {
|
||||
client: true,
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!authCode) {
|
||||
throw new Error(OAuth2Errors.INVALID_GRANT);
|
||||
}
|
||||
|
||||
// Validate PKCE if required
|
||||
if (authCode.codeChallenge) {
|
||||
if (!params.codeVerifier) {
|
||||
throw new Error(OAuth2Errors.INVALID_REQUEST);
|
||||
}
|
||||
if (
|
||||
!this.validatePkceChallenge(
|
||||
params.codeVerifier,
|
||||
authCode.codeChallenge,
|
||||
authCode.codeChallengeMethod || "S256",
|
||||
)
|
||||
) {
|
||||
throw new Error(OAuth2Errors.INVALID_GRANT);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark code as used
|
||||
await prisma.oAuthAuthorizationCode.update({
|
||||
where: { id: authCode.id },
|
||||
data: { used: true },
|
||||
});
|
||||
|
||||
// Generate access token
|
||||
const accessToken = this.generateSecureToken(64);
|
||||
const refreshToken = this.generateSecureToken(64);
|
||||
const expiresIn = 3600; // 1 hour
|
||||
const accessTokenExpiresAt = new Date(Date.now() + expiresIn * 1000);
|
||||
const refreshTokenExpiresAt = new Date(
|
||||
Date.now() + 30 * 24 * 60 * 60 * 1000,
|
||||
); // 30 days
|
||||
|
||||
// Store tokens
|
||||
await prisma.oAuthAccessToken.create({
|
||||
data: {
|
||||
token: accessToken,
|
||||
clientId: client.id, // Use internal database ID
|
||||
userId: authCode.userId,
|
||||
scope: authCode.scope,
|
||||
expiresAt: accessTokenExpiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.oAuthRefreshToken.create({
|
||||
data: {
|
||||
token: refreshToken,
|
||||
clientId: client.id, // Use internal database ID
|
||||
userId: authCode.userId,
|
||||
scope: authCode.scope,
|
||||
expiresAt: refreshTokenExpiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
access_token: accessToken,
|
||||
token_type: "Bearer",
|
||||
expires_in: expiresIn,
|
||||
refresh_token: refreshToken,
|
||||
scope: authCode.scope || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Validate access token
|
||||
async validateAccessToken(token: string): Promise<any> {
|
||||
const accessToken = await prisma.oAuthAccessToken.findFirst({
|
||||
where: {
|
||||
token,
|
||||
revoked: false,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
include: {
|
||||
client: true,
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!accessToken) {
|
||||
throw new Error("Invalid or expired token");
|
||||
}
|
||||
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
// Get user info from access token
|
||||
async getUserInfo(token: string): Promise<any> {
|
||||
const accessToken = await this.validateAccessToken(token);
|
||||
|
||||
return {
|
||||
sub: accessToken.user.id,
|
||||
email: accessToken.user.email,
|
||||
name: accessToken.user.name,
|
||||
display_name: accessToken.user.displayName,
|
||||
avatar_url: accessToken.user.avatarUrl,
|
||||
email_verified: true, // Assuming email is verified if user exists
|
||||
};
|
||||
}
|
||||
|
||||
// Refresh access token
|
||||
async refreshAccessToken(
|
||||
refreshToken: string,
|
||||
clientId: string,
|
||||
): Promise<OAuth2TokenResponse> {
|
||||
// Find the client first to get the internal database ID
|
||||
const client = await prisma.oAuthClient.findUnique({
|
||||
where: { clientId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
throw new Error(OAuth2Errors.INVALID_CLIENT);
|
||||
}
|
||||
|
||||
const storedRefreshToken = await prisma.oAuthRefreshToken.findFirst({
|
||||
where: {
|
||||
token: refreshToken,
|
||||
clientId: client.id, // Use internal database ID
|
||||
revoked: false,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
include: {
|
||||
client: true,
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!storedRefreshToken) {
|
||||
throw new Error(OAuth2Errors.INVALID_GRANT);
|
||||
}
|
||||
|
||||
// Generate new access token
|
||||
const accessToken = this.generateSecureToken(64);
|
||||
const expiresIn = 3600; // 1 hour
|
||||
const accessTokenExpiresAt = new Date(Date.now() + expiresIn * 1000);
|
||||
|
||||
await prisma.oAuthAccessToken.create({
|
||||
data: {
|
||||
token: accessToken,
|
||||
clientId: client.id, // Use internal database ID
|
||||
userId: storedRefreshToken.userId,
|
||||
scope: storedRefreshToken.scope,
|
||||
expiresAt: accessTokenExpiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
access_token: accessToken,
|
||||
token_type: "Bearer",
|
||||
expires_in: expiresIn,
|
||||
scope: storedRefreshToken.scope || undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const oauth2Service = new OAuth2Service();
|
||||
109
apps/webapp/app/utils/auth-helper.ts
Normal file
109
apps/webapp/app/utils/auth-helper.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import { json, redirect } from "@remix-run/node";
|
||||
import { getUser } from "~/services/session.server";
|
||||
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
|
||||
import { oauth2Service } from "~/services/oauth2.server";
|
||||
import { getUserById } from "~/models/user.server";
|
||||
|
||||
export type AuthenticatedUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
admin: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
confirmedBasicDetails: boolean;
|
||||
authMethod: 'session' | 'pat' | 'oauth2';
|
||||
oauth2?: {
|
||||
clientId: string;
|
||||
scope: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Authenticates a request using session, PAT, or OAuth2 access token
|
||||
* Returns the authenticated user or throws an error response
|
||||
*/
|
||||
export async function requireAuth(request: Request): Promise<AuthenticatedUser> {
|
||||
const authHeader = request.headers.get("authorization");
|
||||
|
||||
// Try OAuth2 access token authentication
|
||||
if (authHeader && authHeader.startsWith("Bearer ")) {
|
||||
const token = authHeader.substring(7);
|
||||
|
||||
// Check if it's a PAT token first
|
||||
const patAuth = await authenticateApiRequestWithPersonalAccessToken(request);
|
||||
if (patAuth) {
|
||||
const user = await getUserById(patAuth.userId);
|
||||
if (user) {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
admin: user.admin,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
confirmedBasicDetails: user.confirmedBasicDetails,
|
||||
authMethod: 'pat',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Try OAuth2 access token
|
||||
try {
|
||||
const accessToken = await oauth2Service.validateAccessToken(token);
|
||||
return {
|
||||
id: accessToken.user.id,
|
||||
email: accessToken.user.email,
|
||||
name: accessToken.user.name,
|
||||
displayName: accessToken.user.displayName,
|
||||
avatarUrl: accessToken.user.avatarUrl,
|
||||
admin: accessToken.user.admin,
|
||||
createdAt: accessToken.user.createdAt,
|
||||
updatedAt: accessToken.user.updatedAt,
|
||||
confirmedBasicDetails: accessToken.user.confirmedBasicDetails,
|
||||
authMethod: 'oauth2',
|
||||
oauth2: {
|
||||
clientId: accessToken.client.clientId,
|
||||
scope: accessToken.scope,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
// OAuth2 token validation failed, continue to session auth
|
||||
}
|
||||
}
|
||||
|
||||
// Try session authentication
|
||||
const sessionUser = await getUser(request);
|
||||
if (sessionUser) {
|
||||
return {
|
||||
id: sessionUser.id,
|
||||
email: sessionUser.email,
|
||||
name: sessionUser.name,
|
||||
displayName: sessionUser.displayName,
|
||||
avatarUrl: sessionUser.avatarUrl,
|
||||
admin: sessionUser.admin,
|
||||
createdAt: sessionUser.createdAt,
|
||||
updatedAt: sessionUser.updatedAt,
|
||||
confirmedBasicDetails: sessionUser.confirmedBasicDetails,
|
||||
authMethod: 'session',
|
||||
};
|
||||
}
|
||||
|
||||
// If no authentication method worked, return 401
|
||||
throw json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional authentication - returns user if authenticated, null otherwise
|
||||
*/
|
||||
export async function getAuthenticatedUser(request: Request): Promise<AuthenticatedUser | null> {
|
||||
try {
|
||||
return await requireAuth(request);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
92
apps/webapp/app/utils/oauth2-middleware.ts
Normal file
92
apps/webapp/app/utils/oauth2-middleware.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { json } from "@remix-run/node";
|
||||
import { oauth2Service } from "~/services/oauth2.server";
|
||||
|
||||
export interface OAuth2Context {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
client: {
|
||||
id: string;
|
||||
clientId: string;
|
||||
name: string;
|
||||
};
|
||||
token: {
|
||||
id: string;
|
||||
token: string;
|
||||
scope: string | null;
|
||||
expiresAt: Date;
|
||||
};
|
||||
}
|
||||
|
||||
export async function requireOAuth2(request: Request): Promise<OAuth2Context> {
|
||||
const authHeader = request.headers.get("authorization");
|
||||
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
throw json(
|
||||
{ error: "invalid_token", error_description: "Missing or invalid authorization header" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7); // Remove "Bearer " prefix
|
||||
|
||||
try {
|
||||
const accessToken = await oauth2Service.validateAccessToken(token);
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: accessToken.user.id,
|
||||
email: accessToken.user.email,
|
||||
name: accessToken.user.name,
|
||||
displayName: accessToken.user.displayName,
|
||||
avatarUrl: accessToken.user.avatarUrl,
|
||||
},
|
||||
client: {
|
||||
id: accessToken.client.id,
|
||||
clientId: accessToken.client.clientId,
|
||||
name: accessToken.client.name,
|
||||
},
|
||||
token: {
|
||||
id: accessToken.id,
|
||||
token: accessToken.token,
|
||||
scope: accessToken.scope,
|
||||
expiresAt: accessToken.expiresAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
throw json(
|
||||
{ error: "invalid_token", error_description: "Invalid or expired access token" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOAuth2Context(request: Request): Promise<OAuth2Context | null> {
|
||||
try {
|
||||
return await requireOAuth2(request);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasScope(context: OAuth2Context, requiredScope: string): boolean {
|
||||
if (!context.token.scope) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const scopes = context.token.scope.split(' ');
|
||||
return scopes.includes(requiredScope);
|
||||
}
|
||||
|
||||
export function requireScope(context: OAuth2Context, requiredScope: string): void {
|
||||
if (!hasScope(context, requiredScope)) {
|
||||
throw json(
|
||||
{ error: "insufficient_scope", error_description: `Required scope: ${requiredScope}` },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,106 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "OAuthAuthorizationCode" (
|
||||
"id" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"clientId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"redirectUri" TEXT NOT NULL,
|
||||
"scope" TEXT,
|
||||
"state" TEXT,
|
||||
"codeChallenge" TEXT,
|
||||
"codeChallengeMethod" TEXT,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"used" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "OAuthAuthorizationCode_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "OAuthClient" (
|
||||
"id" TEXT NOT NULL,
|
||||
"clientId" TEXT NOT NULL,
|
||||
"clientSecret" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"redirectUris" TEXT NOT NULL,
|
||||
"allowedScopes" TEXT NOT NULL DEFAULT 'read',
|
||||
"grantTypes" TEXT NOT NULL DEFAULT 'authorization_code',
|
||||
"requirePkce" BOOLEAN NOT NULL DEFAULT false,
|
||||
"logoUrl" TEXT,
|
||||
"homepageUrl" TEXT,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"workspaceId" TEXT NOT NULL,
|
||||
"createdById" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "OAuthClient_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "OAuthAccessToken" (
|
||||
"id" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"clientId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"scope" TEXT,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"revoked" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "OAuthAccessToken_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "OAuthRefreshToken" (
|
||||
"id" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"clientId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"scope" TEXT,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"revoked" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "OAuthRefreshToken_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "OAuthAuthorizationCode_code_key" ON "OAuthAuthorizationCode"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "OAuthClient_clientId_key" ON "OAuthClient"("clientId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "OAuthAccessToken_token_key" ON "OAuthAccessToken"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "OAuthRefreshToken_token_key" ON "OAuthRefreshToken"("token");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OAuthAuthorizationCode" ADD CONSTRAINT "OAuthAuthorizationCode_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "OAuthClient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OAuthAuthorizationCode" ADD CONSTRAINT "OAuthAuthorizationCode_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OAuthClient" ADD CONSTRAINT "OAuthClient_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OAuthClient" ADD CONSTRAINT "OAuthClient_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OAuthAccessToken" ADD CONSTRAINT "OAuthAccessToken_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "OAuthClient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OAuthAccessToken" ADD CONSTRAINT "OAuthAccessToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OAuthRefreshToken" ADD CONSTRAINT "OAuthRefreshToken_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "OAuthClient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OAuthRefreshToken" ADD CONSTRAINT "OAuthRefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -46,6 +46,110 @@ model AuthorizationCode {
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user