| import { |
| authorizeOAuthApplicationSchema, |
| createOAuthApplicationSchema, |
| deleteOAuthApplicationSchema, |
| getApplicationInfoSchema, |
| getOAuthApplicationSchema, |
| regenerateClientSecretSchema, |
| updateApprovalStatusSchema, |
| updateOAuthApplicationSchema, |
| } from "@api/schemas/oauth-applications"; |
| import { revokeUserApplicationAccessSchema } from "@api/schemas/oauth-flow"; |
| import { resend } from "@api/services/resend"; |
| import { createTRPCRouter, protectedProcedure } from "@api/trpc/init"; |
| import { |
| createAuthorizationCode, |
| createOAuthApplication, |
| deleteOAuthApplication, |
| getOAuthApplicationByClientId, |
| getOAuthApplicationById, |
| getOAuthApplicationsByTeam, |
| getTeamsByUserId, |
| getUserAuthorizedApplications, |
| hasUserEverAuthorizedApp, |
| regenerateClientSecret, |
| revokeUserApplicationTokens, |
| updateOAuthApplication, |
| updateOAuthApplicationstatus, |
| } from "@midday/db/queries"; |
| import { AppInstalledEmail } from "@midday/email/emails/app-installed"; |
| import { AppReviewRequestEmail } from "@midday/email/emails/app-review-request"; |
| import { render } from "@midday/email/render"; |
| import { createLoggerWithContext } from "@midday/logger"; |
|
|
| const logger = createLoggerWithContext("trpc:oauth-applications"); |
|
|
| export const oauthApplicationsRouter = createTRPCRouter({ |
| list: protectedProcedure.query(async ({ ctx }) => { |
| const { db, teamId } = ctx; |
|
|
| const applications = await getOAuthApplicationsByTeam(db, teamId!); |
|
|
| return { |
| data: applications, |
| }; |
| }), |
|
|
| getApplicationInfo: protectedProcedure |
| .input(getApplicationInfoSchema) |
| .query(async ({ ctx, input }) => { |
| const { db } = ctx; |
| const { clientId, redirectUri, scope, state } = input; |
|
|
| |
| const application = await getOAuthApplicationByClientId(db, clientId); |
| if (!application || !application.active) { |
| throw new Error("Invalid client_id"); |
| } |
|
|
| |
| if (!application.redirectUris.includes(redirectUri)) { |
| throw new Error("Invalid redirect_uri"); |
| } |
|
|
| |
| const requestedScopes = scope.split(" ").filter(Boolean); |
| const invalidScopes = requestedScopes.filter( |
| (s) => !application.scopes.includes(s), |
| ); |
|
|
| if (invalidScopes.length > 0) { |
| throw new Error(`Invalid scopes: ${invalidScopes.join(", ")}`); |
| } |
|
|
| |
| return { |
| id: application.id, |
| name: application.name, |
| description: application.description, |
| overview: application.overview, |
| developerName: application.developerName, |
| logoUrl: application.logoUrl, |
| website: application.website, |
| installUrl: application.installUrl, |
| screenshots: application.screenshots, |
| clientId: application.clientId, |
| scopes: requestedScopes, |
| redirectUri: redirectUri, |
| state, |
| status: application.status, |
| }; |
| }), |
|
|
| authorize: protectedProcedure |
| .input(authorizeOAuthApplicationSchema) |
| .mutation(async ({ ctx, input }) => { |
| const { db, session } = ctx; |
| const { |
| clientId, |
| decision, |
| scopes, |
| redirectUri, |
| state, |
| codeChallenge, |
| teamId, |
| } = input; |
|
|
| |
| const application = await getOAuthApplicationByClientId(db, clientId); |
| if (!application || !application.active) { |
| throw new Error("Invalid client_id"); |
| } |
|
|
| |
| const invalidScopes = scopes.filter( |
| (scope) => !application.scopes.includes(scope), |
| ); |
|
|
| if (invalidScopes.length > 0) { |
| throw new Error(`Invalid scopes: ${invalidScopes.join(", ")}`); |
| } |
|
|
| const redirectUrl = new URL(redirectUri); |
|
|
| |
| if (decision === "deny") { |
| redirectUrl.searchParams.set("error", "access_denied"); |
| redirectUrl.searchParams.set("error_description", "User denied access"); |
| if (state) { |
| redirectUrl.searchParams.set("state", state); |
| } |
| return { redirect_url: redirectUrl.toString() }; |
| } |
|
|
| |
| const userTeams = await getTeamsByUserId(db, session.user.id); |
|
|
| if (!userTeams) { |
| throw new Error("User not found"); |
| } |
|
|
| const hasTeamAccess = userTeams.some((team) => team.id === teamId); |
|
|
| if (!hasTeamAccess) { |
| throw new Error("User is not a member of the specified team"); |
| } |
|
|
| |
| if (application.isPublic && !codeChallenge) { |
| throw new Error("PKCE is required for public clients"); |
| } |
|
|
| |
| const authCode = await createAuthorizationCode(db, { |
| applicationId: application.id, |
| userId: session.user.id, |
| teamId, |
| scopes, |
| redirectUri, |
| codeChallenge, |
| }); |
|
|
| if (!authCode) { |
| throw new Error("Failed to create authorization code"); |
| } |
|
|
| |
| try { |
| |
| const hasAuthorizedBefore = await hasUserEverAuthorizedApp( |
| db, |
| session.user.id, |
| teamId, |
| application.id, |
| ); |
|
|
| if (!hasAuthorizedBefore) { |
| |
| const userTeam = userTeams.find((team) => team.id === teamId); |
|
|
| if (userTeam && session.user.email) { |
| const html = await render( |
| AppInstalledEmail({ |
| email: session.user.email, |
| teamName: userTeam.name!, |
| appName: application.name, |
| }), |
| ); |
|
|
| await resend.emails.send({ |
| from: "Midday <middaybot@midday.ai>", |
| to: session.user.email, |
| subject: "An app has been added to your team", |
| html, |
| }); |
| } |
| } |
| } catch (error) { |
| |
| logger.error("Failed to send app installation email", { |
| error: error instanceof Error ? error.message : String(error), |
| }); |
| } |
|
|
| |
| redirectUrl.searchParams.set("code", authCode.code); |
| if (state) { |
| redirectUrl.searchParams.set("state", state); |
| } |
|
|
| return { redirect_url: redirectUrl.toString() }; |
| }), |
|
|
| create: protectedProcedure |
| .input(createOAuthApplicationSchema) |
| .mutation(async ({ ctx, input }) => { |
| const { db, teamId, session } = ctx; |
|
|
| const application = await createOAuthApplication(db, { |
| ...input, |
| teamId: teamId!, |
| createdBy: session.user.id, |
| }); |
|
|
| return application; |
| }), |
|
|
| get: protectedProcedure |
| .input(getOAuthApplicationSchema) |
| .query(async ({ ctx, input }) => { |
| const { db, teamId } = ctx; |
|
|
| const application = await getOAuthApplicationById(db, input.id, teamId!); |
|
|
| if (!application) { |
| throw new Error("OAuth application not found"); |
| } |
|
|
| return application; |
| }), |
|
|
| update: protectedProcedure |
| .input(updateOAuthApplicationSchema) |
| .mutation(async ({ ctx, input }) => { |
| const { db, teamId } = ctx; |
| const { id, ...updateData } = input; |
|
|
| const application = await updateOAuthApplication(db, { |
| ...updateData, |
| id, |
| teamId: teamId!, |
| }); |
|
|
| if (!application) { |
| throw new Error("OAuth application not found"); |
| } |
|
|
| return application; |
| }), |
|
|
| delete: protectedProcedure |
| .input(deleteOAuthApplicationSchema) |
| .mutation(async ({ ctx, input }) => { |
| const { db, teamId } = ctx; |
|
|
| const result = await deleteOAuthApplication(db, { |
| id: input.id, |
| teamId: teamId!, |
| }); |
|
|
| if (!result) { |
| throw new Error("OAuth application not found"); |
| } |
|
|
| return { success: true }; |
| }), |
|
|
| regenerateSecret: protectedProcedure |
| .input(regenerateClientSecretSchema) |
| .mutation(async ({ ctx, input }) => { |
| const { db, teamId } = ctx; |
|
|
| const result = await regenerateClientSecret(db, input.id, teamId!); |
|
|
| if (!result) { |
| throw new Error("OAuth application not found"); |
| } |
|
|
| return result; |
| }), |
|
|
| authorized: protectedProcedure.query(async ({ ctx }) => { |
| const { db, teamId, session } = ctx; |
|
|
| const applications = await getUserAuthorizedApplications( |
| db, |
| session.user.id, |
| teamId!, |
| ); |
|
|
| return { |
| data: applications, |
| }; |
| }), |
|
|
| revokeAccess: protectedProcedure |
| .input(revokeUserApplicationAccessSchema) |
| .mutation(async ({ ctx, input }) => { |
| const { db, session } = ctx; |
|
|
| await revokeUserApplicationTokens( |
| db, |
| session.user.id, |
| input.applicationId, |
| ); |
|
|
| return { success: true }; |
| }), |
|
|
| updateApprovalStatus: protectedProcedure |
| .input(updateApprovalStatusSchema) |
| .mutation(async ({ ctx, input }) => { |
| const { db, teamId, session } = ctx; |
|
|
| |
| const application = await getOAuthApplicationById(db, input.id, teamId!); |
|
|
| if (!application) { |
| throw new Error("OAuth application not found"); |
| } |
|
|
| const result = await updateOAuthApplicationstatus(db, { |
| id: input.id, |
| teamId: teamId!, |
| status: input.status, |
| }); |
|
|
| if (!result) { |
| throw new Error("OAuth application not found"); |
| } |
|
|
| |
| if (input.status === "pending") { |
| try { |
| |
| const userTeams = await getTeamsByUserId(db, session.user.id); |
| const currentTeam = userTeams?.find((team) => team.id === teamId); |
|
|
| if (currentTeam && session.user.email) { |
| const html = await render( |
| AppReviewRequestEmail({ |
| applicationName: application.name, |
| developerName: application.developerName || undefined, |
| teamName: currentTeam.name!, |
| userEmail: session.user.email, |
| }), |
| ); |
|
|
| await resend.emails.send({ |
| from: "Midday <middaybot@midday.ai>", |
| to: "pontus@midday.ai", |
| subject: `Application Review Request - ${application.name}`, |
| html, |
| }); |
| } |
| } catch (error) { |
| |
| logger.error("Failed to send application review request", { |
| error: error instanceof Error ? error.message : String(error), |
| }); |
| } |
| } |
|
|
| return result; |
| }), |
| }); |
|
|