Skip to content

Commit 30e5783

Browse files
committed
feat: implement GitHub OAuth flow with enhanced error handling and user info retrieval
1 parent 3760d49 commit 30e5783

File tree

8 files changed

+166
-94
lines changed

8 files changed

+166
-94
lines changed
Lines changed: 45 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,63 @@
11
import { GithubOAuthService } from "@/backend/services/oauth/GithubOAuthService";
2-
import { ActionException } from "@/backend/services/RepositoryException";
32
import * as sessionActions from "@/backend/services/session.actions";
43
import * as userActions from "@/backend/services/user.action";
54
import { NextResponse } from "next/server";
65

76
const githubOAuthService = new GithubOAuthService();
87

98
export async function GET(request: Request) {
10-
try {
11-
const url = new URL(request.url);
12-
const code = url.searchParams.get("code");
13-
const state = url.searchParams.get("state");
14-
const afterAuthRedirect = await sessionActions.getAfterAuthRedirect();
15-
16-
if (code === null || state === null) {
17-
return NextResponse.json({ error: "Please restart the process." });
18-
}
19-
20-
const githubUser = await githubOAuthService.getUserInfo(code!, state!);
21-
22-
const bootedSocialUser = await userActions.bootSocialUser({
23-
service: "github",
24-
service_uid: githubUser.id.toString(),
25-
name: githubUser.name,
26-
username: githubUser.login,
27-
email: githubUser.email,
28-
profile_photo: githubUser.avatar_url,
29-
bio: githubUser.bio,
30-
});
9+
const url = new URL(request.url);
10+
const code = url.searchParams.get("code");
11+
const state = url.searchParams.get("state");
12+
const afterAuthRedirect = await sessionActions.getAfterAuthRedirect();
3113

32-
await sessionActions.createLoginSession({
33-
user_id: bootedSocialUser?.user.id!,
34-
request,
35-
});
14+
if (code === null || state === null) {
15+
return NextResponse.json({ error: "Please restart the process." });
16+
}
17+
18+
const githubUser = await githubOAuthService.getUserInfo(code!, state!);
19+
if (!githubUser.success) {
20+
return NextResponse.json(
21+
{ error: githubUser.error, type: `Can't fetch github user` },
22+
{ status: 500 }
23+
);
24+
}
25+
26+
const bootedSocialUser = await userActions.bootSocialUser({
27+
service: "github",
28+
service_uid: githubUser?.data.id?.toString(),
29+
name: githubUser?.data?.login,
30+
username: githubUser?.data?.login,
31+
email: githubUser?.data.email,
32+
profile_photo: githubUser?.data?.avatar_url,
33+
bio: githubUser?.data?.bio ?? "",
34+
});
35+
36+
if (!bootedSocialUser.success) {
37+
return NextResponse.json(
38+
{ error: bootedSocialUser.error },
39+
{ status: 500 }
40+
);
41+
}
3642

37-
if (afterAuthRedirect) {
38-
return new Response(null, {
39-
status: 302,
40-
headers: {
41-
Location: afterAuthRedirect ?? "/",
42-
},
43-
});
44-
}
43+
await sessionActions.createLoginSession({
44+
user_id: bootedSocialUser.data?.user.id!,
45+
request,
46+
});
4547

48+
if (afterAuthRedirect) {
4649
return new Response(null, {
4750
status: 302,
4851
headers: {
49-
Location: "/",
52+
Location: afterAuthRedirect ?? "/",
5053
},
5154
});
52-
} catch (error) {
53-
if (error instanceof ActionException) {
54-
return NextResponse.json({ error: error.toString() }, { status: 400 });
55-
}
56-
if (error instanceof Error) {
57-
return NextResponse.json(
58-
{ error: "Something went wrong" },
59-
{ status: 500 }
60-
);
61-
}
6255
}
56+
57+
return new Response(null, {
58+
status: 302,
59+
headers: {
60+
Location: "/",
61+
},
62+
});
6363
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export type ActionResponse<T = any> =
2+
| {
3+
success: true | boolean;
4+
data: T;
5+
}
6+
| {
7+
success: false;
8+
error: string;
9+
};

src/backend/persistence/persistence-repositories.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { pgClient } from "./clients";
1616
import { DatabaseTableName } from "./persistence-contracts";
1717

1818
const repositoryConfig = {
19-
logging: true,
19+
// logging: true,
2020
};
2121

2222
const userRepository = new Repository<User>(
Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,41 @@
1-
import { zodErrorToString } from "@/lib/utils";
21
import { z } from "zod";
32

3+
export const handleActionException = (
4+
error: unknown
5+
): {
6+
success: false;
7+
error: string;
8+
} => {
9+
console.log(JSON.stringify(error));
10+
if (error instanceof z.ZodError) {
11+
return {
12+
success: false as const,
13+
error: zodErrorToString(error),
14+
};
15+
}
16+
17+
if (error instanceof ActionException) {
18+
console.log("Action exception:", error.message);
19+
return {
20+
success: false as const,
21+
error: error.message,
22+
};
23+
}
24+
25+
if (error instanceof Error) {
26+
console.log("Standard error:", error.message);
27+
return {
28+
success: false as const,
29+
error: error.message,
30+
};
31+
}
32+
33+
return {
34+
error: "An unknown error occurred",
35+
success: false as const,
36+
};
37+
};
38+
439
export class ActionException extends Error {
540
constructor(message?: string, options?: ErrorOptions) {
641
super(message, options);
@@ -12,16 +47,8 @@ export class ActionException extends Error {
1247
}
1348
}
1449

15-
export const handleActionException = (error: any) => {
16-
if (error instanceof ActionException) {
17-
return error.toString();
18-
}
19-
20-
if (error instanceof Error) {
21-
return error.message;
22-
}
23-
24-
if (error instanceof z.ZodError) {
25-
throw new ActionException(zodErrorToString(error));
26-
}
50+
export const zodErrorToString = (err: z.ZodError) => {
51+
return err.errors.reduce((acc, curr) => {
52+
return acc + curr.message + "\n";
53+
}, "");
2754
};

src/backend/services/oauth/GithubOAuthService.ts

Lines changed: 49 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
1-
import {generateRandomString} from "@/lib/utils";
2-
import {IGithubUser, IOAuthService} from "./oauth-contract";
3-
import {cookies} from "next/headers";
4-
import {env} from "@/env";
1+
import { env } from "@/env";
2+
import { generateRandomString } from "@/lib/utils";
3+
import { cookies } from "next/headers";
4+
import { ActionException, handleActionException } from "../RepositoryException";
5+
import {
6+
GithubUserEmailAPIResponse,
7+
IGithubUser,
8+
IOAuthService,
9+
} from "./oauth-contract";
510

611
export class GithubOAuthService implements IOAuthService<IGithubUser> {
712
async getAuthorizationUrl(): Promise<string> {
813
const state = generateRandomString(50);
914
const params = new URLSearchParams({
1015
client_id: env.GITHUB_CLIENT_ID,
1116
redirect_uri: env.GITHUB_CALLBACK_URL,
12-
scope: "user:email",
17+
scope: "read:user,user:email",
1318
state,
1419
});
1520
const _cookies = await cookies();
@@ -23,25 +28,32 @@ export class GithubOAuthService implements IOAuthService<IGithubUser> {
2328
return `https://github.com/login/oauth/authorize?${params.toString()}`;
2429
}
2530

26-
async getUserInfo(code: string, state: string): Promise<IGithubUser> {
27-
const _cookies = await cookies();
28-
const storedState = _cookies.get("github_oauth_state")?.value ?? null;
31+
async getUserInfo(code: string, state: string) {
32+
try {
33+
const _cookies = await cookies();
34+
const storedState = _cookies.get("github_oauth_state")?.value ?? null;
2935

30-
if (code === null || state === null || storedState === null) {
31-
throw new Error("Please restart the process.");
32-
}
33-
if (state !== storedState) {
34-
throw new Error("Please restart the process.");
35-
}
36+
if (code === null || state === null || storedState === null) {
37+
throw new ActionException("Please restart the process.");
38+
}
39+
if (state !== storedState) {
40+
throw new ActionException("Please restart the process.");
41+
}
3642

37-
const githubAccessToken = await validateGitHubCode(
38-
code,
39-
env.GITHUB_CLIENT_ID,
40-
env.GITHUB_CLIENT_SECRET,
41-
env.GITHUB_CALLBACK_URL
42-
);
43+
const githubAccessToken = await validateGitHubCode(
44+
code,
45+
env.GITHUB_CLIENT_ID,
46+
env.GITHUB_CLIENT_SECRET,
47+
env.GITHUB_CALLBACK_URL
48+
);
4349

44-
return await getGithubUser(githubAccessToken.access_token);
50+
return {
51+
success: true as const,
52+
data: await getGithubUser(githubAccessToken.access_token),
53+
};
54+
} catch (error) {
55+
return handleActionException(error);
56+
}
4557
}
4658
}
4759

@@ -82,14 +94,23 @@ export const validateGitHubCode = async (
8294
};
8395

8496
const getGithubUser = async (accessToken: string): Promise<IGithubUser> => {
85-
const githubAPI = await fetch("https://api.github.com/user", {
86-
headers: {
87-
Authorization: `Bearer ${accessToken}`,
88-
},
97+
const userInfoAPI = await fetch("https://api.github.com/user", {
98+
headers: { Authorization: `Bearer ${accessToken}` },
8999
});
90-
if (!githubAPI.ok) {
91-
throw new Error("Failed to get GitHub user");
100+
101+
const userEmailAPI = await fetch("https://api.github.com/user/emails", {
102+
headers: { Authorization: `Bearer ${accessToken}` },
103+
});
104+
105+
if (!userInfoAPI.ok || !userEmailAPI.ok) {
106+
throw new ActionException("Failed to get GitHub user");
92107
}
93108

94-
return (await githubAPI.json()) as IGithubUser;
109+
const user = (await userInfoAPI.json()) as IGithubUser;
110+
const emails = (await userEmailAPI.json()) as GithubUserEmailAPIResponse[];
111+
112+
const primaryEmail = emails.find((e) => e.primary);
113+
user.email = primaryEmail?.email!;
114+
115+
return user;
95116
};

src/backend/services/oauth/oauth-contract.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import { ActionResponse } from "@/backend/models/action-contracts";
2+
13
export interface IOAuthService<T> {
24
getAuthorizationUrl(state: string, clientId: string): Promise<string>;
3-
getUserInfo(code: string, state: string): Promise<T>;
5+
getUserInfo(code: string, state: string): Promise<ActionResponse<T>>;
46
}
57

68
export interface IGithubUser {
@@ -39,3 +41,10 @@ export interface IGithubUser {
3941
created_at: Date;
4042
updated_at: Date;
4143
}
44+
45+
export interface GithubUserEmailAPIResponse {
46+
email: string;
47+
verified: boolean;
48+
primary: boolean;
49+
visibility: string;
50+
}

src/backend/services/session.actions.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,14 @@ import { UserSessionInput } from "./inputs/session.input";
2121
*/
2222
export async function createLoginSession(
2323
_input: z.infer<typeof UserSessionInput.createLoginSessionInput>
24-
): Promise<void> {
24+
) {
2525
const _cookies = await cookies();
2626
const token = generateRandomString(120);
2727
try {
2828
const input =
2929
await UserSessionInput.createLoginSessionInput.parseAsync(_input);
3030
const agent = userAgent(input.request);
31-
await persistenceRepository.userSession.insert([
31+
const insertData = await persistenceRepository.userSession.insert([
3232
{
3333
token,
3434
user_id: input.user_id,
@@ -51,8 +51,12 @@ export async function createLoginSession(
5151
maxAge: 60 * 60 * 24 * 30,
5252
sameSite: "lax",
5353
});
54+
return {
55+
success: true as const,
56+
data: insertData.rows,
57+
};
5458
} catch (error) {
55-
handleActionException(error);
59+
return handleActionException(error);
5660
}
5761
}
5862

src/backend/services/user.action.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { drizzleClient } from "@/backend/persistence/clients";
1010
import { usersTable } from "@/backend/persistence/schemas";
1111
import { authID } from "./session.actions";
1212
import { filterUndefined } from "@/lib/utils";
13+
import { ActionResult } from "next/dist/server/app-render/types";
14+
import { ActionResponse } from "../models/action-contracts";
1315

1416
/**
1517
* Creates or syncs a user account from a social login provider.
@@ -66,11 +68,11 @@ export async function bootSocialUser(
6668
}
6769

6870
return {
69-
user,
70-
userSocial,
71+
success: true as const,
72+
data: { user, userSocial },
7173
};
7274
} catch (error) {
73-
handleActionException(error);
75+
return handleActionException(error);
7476
}
7577
}
7678

0 commit comments

Comments
 (0)