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 });