mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-18 11:08:27 +00:00
415 lines
11 KiB
TypeScript
415 lines
11 KiB
TypeScript
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;
|
|
}
|
|
|
|
// Validate scopes against client's allowed scopes
|
|
validateScopes(client: any, requestedScopes: string): boolean {
|
|
const allowedScopes = client.allowedScopes
|
|
.split(",")
|
|
.map((s: string) => s.trim());
|
|
const requestedScopeArray = requestedScopes
|
|
.split(",")
|
|
.map((s: string) => s.trim());
|
|
|
|
return requestedScopeArray.every((scope) => allowedScopes.includes(scope));
|
|
}
|
|
|
|
// Determine scope type for routing (simplified)
|
|
getScopeType(scope: string): "auth" | "integration" | "mixed" {
|
|
const scopes = scope.split(",").map((s) => s.trim());
|
|
|
|
// Google-style auth scopes
|
|
const authScopes = ["profile", "email", "openid"];
|
|
// Single integration scope
|
|
const integrationScopes = ["integration"];
|
|
|
|
const hasAuthScopes = scopes.some((s) => authScopes.includes(s));
|
|
const hasIntegrationScopes = scopes.some((s) =>
|
|
integrationScopes.includes(s),
|
|
);
|
|
|
|
if (hasAuthScopes && hasIntegrationScopes) {
|
|
return "mixed";
|
|
} else if (hasAuthScopes) {
|
|
return "auth";
|
|
} else if (hasIntegrationScopes) {
|
|
return "integration";
|
|
}
|
|
|
|
// Default to auth for unknown scopes
|
|
return "auth";
|
|
}
|
|
|
|
// Get scope descriptions for UI
|
|
getScopeDescriptions(
|
|
scopes: string[],
|
|
): Array<{ scope: string; description: string; icon: string }> {
|
|
const scopeMap: Record<string, { description: string; icon: string }> = {
|
|
profile: {
|
|
description: "Access your profile information",
|
|
icon: "user",
|
|
},
|
|
email: { description: "Access your email address", icon: "mail" },
|
|
openid: { description: "Verify your identity", icon: "shield" },
|
|
integration: {
|
|
description: "Access your workspace integrations",
|
|
icon: "database",
|
|
},
|
|
};
|
|
|
|
return scopes.map((scope) => ({
|
|
scope,
|
|
description: scopeMap[scope]?.description || `Access to ${scope}`,
|
|
icon: scopeMap[scope]?.icon || "align-left",
|
|
}));
|
|
}
|
|
|
|
// Create authorization code
|
|
async createAuthorizationCode(params: {
|
|
clientId: string;
|
|
userId: string;
|
|
redirectUri: string;
|
|
workspaceId: string;
|
|
scope?: string;
|
|
state?: string;
|
|
codeChallenge?: string;
|
|
codeChallengeMethod?: string;
|
|
}): Promise<string> {
|
|
const code = this.generateSecureToken(32);
|
|
const 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,
|
|
workspaceId: params.workspaceId,
|
|
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,
|
|
workspaceId: authCode.workspaceId,
|
|
},
|
|
});
|
|
|
|
await prisma.oAuthRefreshToken.create({
|
|
data: {
|
|
token: refreshToken,
|
|
clientId: client.id, // Use internal database ID
|
|
userId: authCode.userId,
|
|
scope: authCode.scope,
|
|
expiresAt: refreshTokenExpiresAt,
|
|
workspaceId: authCode.workspaceId,
|
|
},
|
|
});
|
|
|
|
await prisma.oAuthClientInstallation.create({
|
|
data: {
|
|
oauthClientId: client.id,
|
|
workspaceId: authCode.workspaceId,
|
|
installedById: authCode.userId,
|
|
isActive: true,
|
|
grantedScopes: authCode.scope,
|
|
},
|
|
});
|
|
|
|
return {
|
|
access_token: accessToken,
|
|
token_type: "Bearer",
|
|
expires_in: expiresIn,
|
|
refresh_token: refreshToken,
|
|
scope: authCode.scope || undefined,
|
|
};
|
|
}
|
|
|
|
// Validate access token
|
|
async validateAccessToken(token: string, scopes?: string[]): Promise<any> {
|
|
const accessToken = await prisma.oAuthAccessToken.findFirst({
|
|
where: {
|
|
token,
|
|
revoked: false,
|
|
expiresAt: { gt: new Date() },
|
|
...(scopes ? { scope: { contains: scopes.join(",") } } : {}),
|
|
},
|
|
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 = 86400; // 1 day
|
|
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,
|
|
workspaceId: storedRefreshToken.workspaceId,
|
|
},
|
|
});
|
|
|
|
return {
|
|
access_token: accessToken,
|
|
token_type: "Bearer",
|
|
expires_in: expiresIn,
|
|
scope: storedRefreshToken.scope || undefined,
|
|
};
|
|
}
|
|
}
|
|
|
|
export const oauth2Service = new OAuth2Service();
|