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 { 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 = { 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 { 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 { // 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 { 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 { 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 { // 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();