/ server / api / auth / github / callback.get.ts
callback.get.ts
  1  import { prisma } from "~~/prisma/prisma";
  2  import { decrypt, encrypt } from "paseto-ts/v4";
  3  import { handleApiError} from "~~/server/utils/logging";
  4  
  5  interface GithubTokenResponse {
  6    access_token: string;
  7    refresh_token?: string;
  8    scope: string;
  9    token_type: string;
 10  }
 11  
 12  interface GithubUser {
 13    id: number;
 14    login: string;
 15    name: string;
 16    email: string;
 17  }
 18  
 19  interface GithubEmail {
 20    email: string;
 21    primary: boolean;
 22    verified: boolean;
 23    visibility: string;
 24  }
 25  
 26  export default defineEventHandler(async (event) => {
 27    const config = useRuntimeConfig();
 28    const query = getQuery(event);
 29    const code = query.code as string;
 30    const state = query.state as string;
 31  
 32    if (!code) {
 33      throw handleApiError(400, "GitHub callback error: No authorization code provided.");
 34    }
 35  
 36    const storedState = getCookie(event, "github_oauth_state");
 37  
 38    if (!state || state !== storedState) {
 39      console.error("GitHub Callback error: Invalid state");
 40      return sendRedirect(event, "/login?error=invalid_state");
 41    }
 42  
 43    const isLinking = getCookie(event, "github_link_account") === "true";
 44    const linkSession = getCookie(event, "github_link_session");
 45  
 46    deleteCookie(event, "github_oauth_state");
 47    deleteCookie(event, "github_link_account");
 48    deleteCookie(event, "github_link_session");
 49  
 50    try {
 51      const tokenResponse = await $fetch<GithubTokenResponse>(
 52        "https://github.com/login/oauth/access_token",
 53        {
 54          method: "POST",
 55          headers: {
 56            Accept: "application/json",
 57            "Content-Type": "application/json",
 58          },
 59          body: JSON.stringify({
 60            client_id: config.githubClientId,
 61            client_secret: config.githubClientSecret,
 62            code,
 63            redirect_uri: config.githubRedirectUri,
 64          }),
 65        },
 66      );
 67  
 68      const accessToken = tokenResponse.access_token;
 69  
 70      const githubUser = await $fetch<GithubUser>("https://api.github.com/user", {
 71        headers: {
 72          Authorization: `Bearer ${accessToken}`,
 73          Accept: "application/vnd.github.v3+json",
 74        },
 75      });
 76  
 77      const emails = await $fetch<GithubEmail[]>(
 78        "https://api.github.com/user/emails",
 79        {
 80          headers: {
 81            Authorization: `Bearer ${accessToken}`,
 82            Accept: "application/vnd.github.v3+json",
 83          },
 84        },
 85      );
 86  
 87      const primaryEmail =
 88        emails.find((email) => email.primary)?.email || emails[0]?.email;
 89  
 90      if (!primaryEmail) {
 91        throw handleApiError(500, `GitHub callback error: No primary email found for GitHub user ID ${githubUser.id}. Emails received: ${JSON.stringify(emails)}`, "Could not retrieve email from GitHub");
 92      }
 93  
 94      if (isLinking && linkSession) {
 95        try {
 96          const { payload } = decrypt(config.pasetoKey, linkSession);
 97          
 98          if (typeof payload === "object" && payload !== null && "userId" in payload) {
 99            const userId = payload.userId;
100            let redirectUrl = "/settings?success=github_linked";
101  
102            await prisma.$transaction(async (tx) => {
103              const [existingGithubUser, currentUser] = await Promise.all([
104                tx.user.findFirst({
105                  where: { githubId: githubUser.id.toString() },
106                }),
107                tx.user.findUnique({
108                  where: { id: userId },
109                }),
110              ]);
111  
112              if (existingGithubUser && existingGithubUser.id !== userId) {
113                await Promise.all([
114                  tx.heartbeats.updateMany({
115                    where: { userId: existingGithubUser.id },
116                    data: { userId: userId },
117                  }),
118                  tx.summaries.updateMany({
119                    where: { userId: existingGithubUser.id },
120                    data: { userId: userId },
121                  }),
122                  tx.user.delete({
123                    where: { id: existingGithubUser.id },
124                  }),
125                  tx.user.update({
126                    where: { id: userId },
127                    data: {
128                      githubId: githubUser.id.toString(),
129                      githubUsername: githubUser.login,
130                      githubAccessToken: accessToken,
131                    },
132                  }),
133                ]);
134  
135                redirectUrl = "/settings?success=accounts_merged";
136              } else if (currentUser?.githubId === githubUser.id.toString()) {
137                await tx.user.update({
138                  where: { id: userId },
139                  data: {
140                    githubAccessToken: accessToken,
141                  },
142                });
143                redirectUrl = "/settings?success=github_updated";
144              } else {
145                await tx.user.update({
146                  where: { id: userId },
147                  data: {
148                    githubId: githubUser.id.toString(),
149                    githubUsername: githubUser.login,
150                    githubAccessToken: accessToken,
151                  },
152                });
153              }
154            });
155  
156            return sendRedirect(event, redirectUrl);
157          }
158        } catch {
159          return sendRedirect(event, "/settings?error=link_failed");
160        }
161      }
162  
163      const result = await prisma.$transaction(async (tx) => {
164        let user = await tx.user.findFirst({
165          where: { githubId: githubUser.id.toString() },
166        });
167  
168        if (!user) {
169          user = await tx.user.findUnique({
170            where: { email: primaryEmail },
171          });
172  
173          if (user) {
174            user = await tx.user.update({
175              where: { id: user.id },
176              data: {
177                githubId: githubUser.id.toString(),
178                githubUsername: githubUser.login,
179                githubAccessToken: accessToken,
180              },
181            });
182          } else {
183            user = await tx.user.create({
184              data: {
185                email: primaryEmail,
186                passwordHash: null,
187                githubId: githubUser.id.toString(),
188                githubUsername: githubUser.login,
189                githubAccessToken: accessToken,
190              },
191            });
192          }
193        } else {
194          user = await tx.user.update({
195            where: { id: user.id },
196            data: {
197              githubAccessToken: accessToken,
198            },
199          });
200        }
201  
202        const token = encrypt(
203          config.pasetoKey,
204          { 
205            userId: user.id,
206            exp: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString()
207          }
208        );
209  
210        setCookie(event, "ziit_session", token, {
211          httpOnly: true,
212          secure: process.env.NODE_ENV === "production",
213          maxAge: 60 * 60 * 24 * 7,
214          path: "/",
215          sameSite: "lax",
216        });
217  
218        return "/";
219      });
220  
221      return sendRedirect(event, result);
222    } catch (error) {
223      const detailedMessage = error instanceof Error ? error.message : "An unknown error occurred during GitHub authentication.";
224      throw handleApiError(500, `GitHub authentication failed: ${detailedMessage}`, "GitHub authentication failed. Please try again.");
225    }
226  });