Skip to content

Commit f2ec0f9

Browse files
committed
fix(auth,db): address OAuth security issues and complete Drizzle relations
1 parent 54f6d8c commit f2ec0f9

9 files changed

Lines changed: 400 additions & 305 deletions

File tree

Lines changed: 138 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -1,150 +1,162 @@
11
import { NextRequest, NextResponse } from "next/server";
2-
import { eq } from "drizzle-orm";
2+
import { and, eq } from "drizzle-orm";
33

44
import { db } from "@/db";
55
import { users } from "@/db/schema/users";
66
import { signAuthToken, setAuthCookie } from "@/lib/auth";
77
import { authEnv } from "@/lib/env/auth";
8+
import { consumeOAuthState } from "@/lib/auth/oauth-state";
89

910
type GithubTokenResponse = {
10-
access_token: string;
11-
token_type: string;
12-
scope: string;
11+
access_token: string;
12+
token_type: string;
13+
scope: string;
1314
};
1415

1516
type GithubUser = {
16-
id: number;
17-
login: string;
18-
name: string | null;
19-
avatar_url: string;
17+
id: number;
18+
login: string;
19+
name: string | null;
20+
avatar_url: string;
2021
};
2122

2223
type GithubEmail = {
23-
email: string;
24-
primary: boolean;
25-
verified: boolean;
24+
email: string;
25+
primary: boolean;
26+
verified: boolean;
27+
};
28+
29+
const GITHUB_HEADERS = {
30+
"User-Agent": "devlovers-app",
2631
};
2732

2833
export async function GET(req: NextRequest) {
29-
const code = req.nextUrl.searchParams.get("code");
30-
31-
if (!code) {
32-
return NextResponse.redirect(new URL("/login", req.url));
33-
}
34-
35-
const tokenRes = await fetch(
36-
"https://github.com/login/oauth/access_token",
37-
{
38-
method: "POST",
39-
headers: {
40-
Accept: "application/json",
41-
},
42-
body: new URLSearchParams({
43-
client_id: authEnv.github.clientId,
44-
client_secret: authEnv.github.clientSecret,
45-
code,
46-
redirect_uri: authEnv.github.redirectUri,
47-
}),
34+
const code = req.nextUrl.searchParams.get("code");
35+
const state = req.nextUrl.searchParams.get("state");
36+
37+
if (!(await consumeOAuthState(state))) {
38+
return NextResponse.redirect(new URL("/login", req.url));
39+
}
40+
41+
if (!code) {
42+
return NextResponse.redirect(new URL("/login", req.url));
43+
}
44+
45+
const tokenRes = await fetch(
46+
"https://github.com/login/oauth/access_token",
47+
{
48+
method: "POST",
49+
headers: {
50+
Accept: "application/json",
51+
},
52+
body: new URLSearchParams({
53+
client_id: authEnv.github.clientId,
54+
client_secret: authEnv.github.clientSecret,
55+
code,
56+
redirect_uri: authEnv.github.redirectUri,
57+
}),
58+
}
59+
);
60+
61+
if (!tokenRes.ok) {
62+
console.error(
63+
"GitHub token exchange failed",
64+
await tokenRes.text()
65+
);
66+
return NextResponse.redirect(new URL("/login", req.url));
67+
}
68+
69+
const tokenData = (await tokenRes.json()) as GithubTokenResponse;
70+
71+
const userRes = await fetch("https://api.github.com/user", {
72+
headers: {
73+
...GITHUB_HEADERS,
74+
Authorization: `Bearer ${tokenData.access_token}`,
75+
},
76+
});
77+
78+
if (!userRes.ok) {
79+
return NextResponse.redirect(new URL("/login", req.url));
4880
}
49-
);
50-
51-
if (!tokenRes.ok) {
52-
console.error(
53-
"GitHub token exchange failed",
54-
await tokenRes.text()
55-
);
56-
return NextResponse.redirect(new URL("/login", req.url));
57-
}
58-
59-
const tokenData = (await tokenRes.json()) as GithubTokenResponse;
60-
61-
const userRes = await fetch("https://api.github.com/user", {
62-
headers: {
63-
Authorization: `Bearer ${tokenData.access_token}`,
64-
},
65-
});
66-
67-
if (!userRes.ok) {
68-
return NextResponse.redirect(new URL("/login", req.url));
69-
}
70-
71-
const ghUser = (await userRes.json()) as GithubUser;
72-
73-
const emailsRes = await fetch("https://api.github.com/user/emails", {
74-
headers: {
75-
Authorization: `Bearer ${tokenData.access_token}`,
76-
},
77-
});
78-
79-
if (!emailsRes.ok) {
80-
return NextResponse.redirect(new URL("/login", req.url));
81-
}
82-
83-
const emails = (await emailsRes.json()) as GithubEmail[];
84-
85-
const primaryEmail = emails.find(
86-
(e) => e.primary && e.verified
87-
)?.email;
88-
89-
if (!primaryEmail) {
90-
return NextResponse.redirect(new URL("/login", req.url));
91-
}
92-
93-
const githubId = String(ghUser.id);
94-
let user = null;
95-
96-
const [githubUser] = await db
97-
.select()
98-
.from(users)
99-
.where(eq(users.providerId, githubId))
100-
.limit(1);
101-
102-
if (githubUser) {
103-
user = githubUser;
104-
} else {
105-
const [emailUser] = await db
106-
.select()
107-
.from(users)
108-
.where(eq(users.email, primaryEmail))
109-
.limit(1);
110-
111-
if (emailUser) {
112-
await db
113-
.update(users)
114-
.set({
115-
provider: "github",
116-
providerId: githubId,
117-
emailVerified: emailUser.emailVerified ?? new Date(),
118-
image: emailUser.image ?? ghUser.avatar_url,
119-
name: emailUser.name ?? ghUser.name ?? ghUser.login,
120-
})
121-
.where(eq(users.id, emailUser.id));
122-
123-
user = emailUser;
81+
82+
const ghUser = (await userRes.json()) as GithubUser;
83+
84+
const emailsRes = await fetch("https://api.github.com/user/emails", {
85+
headers: {
86+
...GITHUB_HEADERS,
87+
Authorization: `Bearer ${tokenData.access_token}`,
88+
},
89+
});
90+
91+
if (!emailsRes.ok) {
92+
return NextResponse.redirect(new URL("/login", req.url));
93+
}
94+
95+
const emails = (await emailsRes.json()) as GithubEmail[];
96+
97+
const primaryEmail = emails.find(
98+
(e) => e.primary && e.verified
99+
)?.email;
100+
101+
if (!primaryEmail) {
102+
return NextResponse.redirect(new URL("/login", req.url));
103+
}
104+
105+
const githubId = String(ghUser.id);
106+
let user = null;
107+
108+
const [githubUser] = await db
109+
.select()
110+
.from(users)
111+
.where(and(eq(users.providerId, githubId), eq(users.provider, "github")))
112+
.limit(1);
113+
114+
if (githubUser) {
115+
user = githubUser;
124116
} else {
125-
const [created] = await db
126-
.insert(users)
127-
.values({
128-
email: primaryEmail,
129-
name: ghUser.name ?? ghUser.login,
130-
image: ghUser.avatar_url,
131-
provider: "github",
132-
providerId: githubId,
133-
emailVerified: new Date(),
134-
})
135-
.returning();
136-
137-
user = created;
117+
const [emailUser] = await db
118+
.select()
119+
.from(users)
120+
.where(eq(users.email, primaryEmail))
121+
.limit(1);
122+
123+
if (emailUser) {
124+
await db
125+
.update(users)
126+
.set({
127+
provider: "github",
128+
providerId: githubId,
129+
emailVerified: emailUser.emailVerified ?? new Date(),
130+
image: emailUser.image ?? ghUser.avatar_url,
131+
name: emailUser.name ?? ghUser.name ?? ghUser.login,
132+
})
133+
.where(eq(users.id, emailUser.id));
134+
135+
user = emailUser;
136+
} else {
137+
const [created] = await db
138+
.insert(users)
139+
.values({
140+
email: primaryEmail,
141+
name: ghUser.name ?? ghUser.login,
142+
image: ghUser.avatar_url,
143+
provider: "github",
144+
providerId: githubId,
145+
emailVerified: new Date(),
146+
})
147+
.returning();
148+
149+
user = created;
150+
}
138151
}
139-
}
140152

141-
const token = signAuthToken({
142-
userId: user.id,
143-
email: user.email,
144-
role: user.role === "admin" ? "admin" : "user",
145-
});
153+
const token = signAuthToken({
154+
userId: user.id,
155+
email: user.email,
156+
role: user.role === "admin" ? "admin" : "user",
157+
});
146158

147-
await setAuthCookie(token);
159+
await setAuthCookie(token);
148160

149-
return NextResponse.redirect(new URL("/", req.url));
161+
return NextResponse.redirect(new URL("/", req.url));
150162
}
Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import { authEnv } from "@/lib/env/auth";
22
import { NextResponse } from "next/server";
3+
import { generateOAuthState, setOAuthStateCookie } from "@/lib/auth/oauth-state";
34

45
export async function GET() {
5-
const params = new URLSearchParams({
6-
client_id: authEnv.github.clientId,
7-
redirect_uri: authEnv.github.redirectUri,
8-
scope: "user:email",
9-
});
6+
const state = generateOAuthState();
7+
await setOAuthStateCookie(state);
8+
const params = new URLSearchParams({
9+
client_id: authEnv.github.clientId,
10+
redirect_uri: authEnv.github.redirectUri,
11+
scope: "user:email",
12+
});
1013

11-
return NextResponse.redirect(
12-
`https://github.com/login/oauth/authorize?${params.toString()}`
13-
);
14+
return NextResponse.redirect(
15+
`https://github.com/login/oauth/authorize?${params.toString()}`
16+
);
1417
}

frontend/app/api/auth/google/callback/route.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { NextRequest, NextResponse } from "next/server";
2-
import { eq } from "drizzle-orm";
2+
import { and, eq } from "drizzle-orm";
33

44
import { db } from "@/db";
55
import { users } from "@/db/schema/users";
66
import { signAuthToken, setAuthCookie } from "@/lib/auth";
77
import { authEnv } from "@/lib/env/auth";
8+
import { consumeOAuthState } from "@/lib/auth/oauth-state";
89

910
type GoogleTokenResponse = {
1011
access_token: string;
@@ -28,6 +29,12 @@ export async function GET(req: NextRequest) {
2829
return NextResponse.redirect(new URL("/login", req.url));
2930
}
3031

32+
const state = req.nextUrl.searchParams.get("state");
33+
34+
if (!(await consumeOAuthState(state))) {
35+
return NextResponse.redirect(new URL("/login", req.url));
36+
}
37+
3138
const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
3239
method: "POST",
3340
headers: {
@@ -43,6 +50,8 @@ export async function GET(req: NextRequest) {
4350
});
4451

4552
if (!tokenRes.ok) {
53+
console.error(
54+
"Google token exchange failed",)
4655
return NextResponse.redirect(new URL("/login", req.url));
4756
}
4857

@@ -63,6 +72,11 @@ export async function GET(req: NextRequest) {
6372

6473
const profile = (await profileRes.json()) as GoogleProfile;
6574

75+
if (!profile.verified_email) {
76+
console.warn("Google email not verified", profile);
77+
return NextResponse.redirect(new URL("/login?error=unverified_email", req.url));
78+
}
79+
6680
const email = profile.email;
6781
const googleId = profile.id;
6882

@@ -71,7 +85,7 @@ export async function GET(req: NextRequest) {
7185
const [googleUser] = await db
7286
.select()
7387
.from(users)
74-
.where(eq(users.providerId, googleId))
88+
.where(and(eq(users.providerId, googleId), eq(users.provider, "google")))
7589
.limit(1);
7690

7791
if (googleUser) {

frontend/app/api/auth/google/route.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,24 @@
11
import { authEnv } from "@/lib/env/auth";
22
import { NextResponse } from "next/server";
3+
import {
4+
generateOAuthState,
5+
setOAuthStateCookie,
6+
} from "@/lib/auth/oauth-state";
7+
38

49
export async function GET() {
10+
const { clientId, redirectUri } = authEnv.github
11+
12+
if (!clientId || !redirectUri) {
13+
throw new Error("Google OAuth is not properly configured");
14+
}
15+
16+
const state = generateOAuthState();
17+
await setOAuthStateCookie(state);
18+
519
const params = new URLSearchParams({
6-
client_id: authEnv.google.clientId,
7-
redirect_uri: authEnv.google.redirectUri,
20+
client_id: clientId,
21+
redirect_uri: redirectUri,
822
response_type: "code",
923
scope: "openid email profile",
1024
prompt: "select_account",

0 commit comments

Comments
 (0)