Skip to content

Commit 8d48016

Browse files
committed
feat: implement email verification dialog; add validation for Konkuk University emails and integrate with Google OAuth flow
1 parent 3f06ce3 commit 8d48016

File tree

8 files changed

+598
-157
lines changed

8 files changed

+598
-157
lines changed

src/apis/auth.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ export async function googleOAuth(
2626

2727
/**
2828
* Send verification code to email
29-
* Send 6-digit verification code to Konkuk University email
29+
* POST /auth/send-code
30+
* @param data.kuMail - 건국대 이메일 (@konkuk.ac.kr)
31+
* @requires Authorization: Bearer {guest_token}
3032
*/
3133
export async function sendVerificationCode(
3234
data: SendCodeRequest
@@ -36,7 +38,10 @@ export async function sendVerificationCode(
3638

3739
/**
3840
* Verify email code
39-
* Verify 6-digit code sent to email
41+
* POST /auth/verify-code
42+
* @param data.kuMail - 건국대 이메일
43+
* @param data.authCode - 6자리 인증 코드
44+
* @requires Authorization: Bearer {guest_token}
4045
*/
4146
export async function verifyEmailCode(
4247
data: VerifyCodeRequest

src/background/handlers/oauth.ts

Lines changed: 132 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -1,149 +1,109 @@
11
/**
2-
* OAuth Handler for Background Script
3-
* Handles Google OAuth authentication using chrome.identity API
4-
* This MUST run in background context as chrome.identity is not available in popup
2+
* Google OAuth Handler for Chrome Extension
3+
*
4+
* 백엔드 API 스펙 (PR #26):
5+
* 1. GET /api/oauth2/google?redirectUri={uri} - Google OAuth 페이지로 리다이렉트
6+
* 2. GET /api/oauth2/google/login?redirectUri={uri}&code={code} - 토큰 교환
7+
*
8+
* 응답 형식:
9+
* {
10+
* "code": 1000,
11+
* "message": "SUCCESS",
12+
* "result": {
13+
* "accessToken": "token_here",
14+
* "refreshToken": "refresh_or_null"
15+
* }
16+
* }
517
*/
618

7-
import type { GoogleLoginResponse } from '../types';
19+
import type { GoogleLoginResponse } from "../types";
820

921
// Backend URL from environment
1022
const BACKEND_URL = (() => {
1123
const baseUrl = import.meta.env.VITE_API_BASE_URL;
12-
if (!baseUrl) return '';
24+
if (!baseUrl) return "";
1325

1426
try {
1527
const url = new URL(baseUrl);
16-
if (url.pathname.endsWith('/api')) {
28+
if (url.pathname.endsWith("/api")) {
1729
url.pathname = url.pathname.slice(0, -4);
1830
}
19-
return url.origin + url.pathname;
31+
const result = url.origin + url.pathname;
32+
return result.endsWith("/") ? result.slice(0, -1) : result;
2033
} catch {
21-
// Fallback to string replacement if URL parsing fails
22-
return baseUrl.replace('/api', '');
34+
const result = baseUrl.replace("/api", "");
35+
return result.endsWith("/") ? result.slice(0, -1) : result;
2336
}
2437
})();
25-
const OAUTH_STATE_KEY = 'oauth_state';
2638

2739
/**
28-
* User profile type
40+
* Save tokens and auth state to chrome.storage.local
2941
*/
30-
interface UserProfile {
31-
email: string;
32-
name: string;
33-
picture: string;
34-
}
35-
36-
/**
37-
* Generate random state string for CSRF protection
38-
*/
39-
function generateState(): string {
40-
const array = new Uint8Array(16);
41-
crypto.getRandomValues(array);
42-
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
43-
}
44-
45-
/**
46-
* Save state to chrome.storage.session
47-
*/
48-
async function saveState(state: string): Promise<void> {
49-
await chrome.storage.session.set({ [OAUTH_STATE_KEY]: state });
50-
}
51-
52-
/**
53-
* Save tokens to chrome.storage.local
54-
*/
55-
async function saveTokens(accessToken: string, refreshToken?: string): Promise<void> {
56-
const data: Record<string, string> = { accessToken };
42+
async function saveTokens(
43+
accessToken: string,
44+
refreshToken?: string | null
45+
): Promise<void> {
46+
const isGuest = !refreshToken;
47+
const data: Record<string, string | boolean> = {
48+
accessToken,
49+
isGuest,
50+
};
5751
if (refreshToken) {
5852
data.refreshToken = refreshToken;
5953
}
6054
await chrome.storage.local.set(data);
6155
}
6256

63-
/**
64-
* Save user profile to chrome.storage.local
65-
*/
66-
async function saveUserProfile(profile: UserProfile): Promise<void> {
67-
await chrome.storage.local.set({ userProfile: profile });
68-
}
69-
7057
/**
7158
* Handle Google OAuth Login
72-
* This function runs in background context where chrome.identity is available
59+
* Uses chrome.identity.launchWebAuthFlow for OAuth flow
7360
*/
7461
export async function handleGoogleLogin(): Promise<GoogleLoginResponse> {
7562
try {
76-
console.log('[Background] Starting Google OAuth flow');
77-
78-
// 1. Generate and save state
79-
const state = generateState();
80-
await saveState(state);
81-
82-
// 2. Construct Redirect URI
83-
// Chrome Extension에서 OAuth를 사용할 때는 특별한 형식의 redirect URI를 사용합니다.
84-
// 형식: https://<extension-id>.chromiumapp.org/
85-
//
86-
// 이 redirect URI는:
87-
// - OAuth 인증 완료 후 Google이 사용자를 리다이렉트할 엔드포인트
88-
// - Authorization code가 쿼리 파라미터로 추가됨 (예: ?code=4/0AbC...)
89-
// - Chrome이 이 패턴(*.chromiumapp.org)을 감지하면 자동으로 OAuth 창을 닫음
90-
//
91-
// 요구사항 (RFC 6749 & Google OAuth):
92-
// ✅ 절대 URI여야 함 (상대 경로 불가)
93-
// ✅ 프로토콜 필수 (https://)
94-
// ✅ Fragment 포함 불가 (#이후 부분)
95-
// ✅ 공개 IP 주소 사용 불가
96-
// ✅ 와일드카드 사용 불가
97-
//
98-
// 중요: 이 URI를 Google Cloud Console의 "Authorized redirect URIs"에 등록해야 함!
63+
console.log("[Background] Starting Google OAuth flow");
64+
65+
// 1. Get extension ID and construct redirect URI
9966
const extensionId = chrome.runtime.id;
10067
const redirectUri = `https://${extensionId}.chromiumapp.org/`;
10168

102-
console.log('[Background] Extension ID:', extensionId);
103-
console.log('[Background] Redirect URI:', redirectUri);
69+
console.log("[Background] Extension ID:", extensionId);
70+
console.log("[Background] Redirect URI:", redirectUri);
71+
console.log("[Background] Backend URL:", BACKEND_URL);
10472

105-
// 3. Build OAuth Authorization URL
10673
if (!BACKEND_URL) {
10774
return {
10875
success: false,
109-
error: 'Backend URL이 설정되지 않았습니다. 환경 변수를 확인해주세요.',
76+
error: "Backend URL이 설정되지 않았습니다. 환경 변수를 확인해주세요.",
11077
};
11178
}
11279

113-
const authUrl = new URL(`${BACKEND_URL}/api/oauth2/authorization/google`);
114-
115-
// redirect_uri 파라미터 명시적 지정
116-
// Google Cloud Console에 여러 redirect URI를 등록했더라도,
117-
// authorization request에 사용할 URI를 명시적으로 지정해야 합니다.
118-
// Google은 자동으로 선택하지 않습니다!
119-
//
120-
// Google이 검증하는 방법:
121-
// 1. 요청의 redirect_uri 파라미터 값을 확인
122-
// 2. Google Cloud Console에 등록된 URI 목록과 정확히 일치하는지 검증
123-
// 3. 일치하면 인증 진행, 불일치하면 redirect_uri_mismatch 오류 발생
124-
authUrl.searchParams.set('redirect_uri', redirectUri);
80+
// 2. Construct OAuth URL (새 API 스펙)
81+
const authUrl = new URL(`${BACKEND_URL}/api/oauth2/google`);
82+
authUrl.searchParams.set("redirectUri", redirectUri);
12583

126-
console.log('[Background] Auth URL:', authUrl.toString());
84+
console.log("[Background] Auth URL:", authUrl.toString());
12785

128-
// 4. Launch OAuth flow using chrome.identity API
86+
// 3. Launch OAuth flow using chrome.identity API
12987
const responseUrl = await chrome.identity.launchWebAuthFlow({
13088
url: authUrl.toString(),
13189
interactive: true,
13290
});
13391

134-
console.log('[Background] OAuth response URL received');
92+
console.log("[Background] Response URL:", responseUrl);
13593

136-
// 5. Parse response URL
13794
if (!responseUrl) {
138-
return { success: false, error: '인증이 취소되었습니다.' };
95+
return { success: false, error: "인증이 취소되었습니다." };
13996
}
14097

98+
// 4. Parse response URL to extract code
14199
const url = new URL(responseUrl);
142-
const code = url.searchParams.get('code');
143-
const error = url.searchParams.get('error');
100+
const code = url.searchParams.get("code");
101+
const error = url.searchParams.get("error");
102+
103+
console.log("[Background] Extracted code:", code ? "있음" : "없음");
144104

145-
// Check for OAuth errors
146105
if (error) {
106+
console.error("[Background] OAuth error:", error);
147107
return {
148108
success: false,
149109
error: `OAuth 오류: ${error}`,
@@ -153,77 +113,120 @@ export async function handleGoogleLogin(): Promise<GoogleLoginResponse> {
153113
if (!code) {
154114
return {
155115
success: false,
156-
error: '인증 코드를 받지 못했습니다.',
116+
error: "인증 코드를 받지 못했습니다.",
157117
};
158118
}
159119

160-
console.log('[Background] Authorization code received');
120+
// 5. Exchange code for token via backend (새 API 스펙)
121+
console.log("[Background] Exchanging code for token...");
122+
123+
const tokenUrl = new URL(`${BACKEND_URL}/api/oauth2/google/login`);
124+
tokenUrl.searchParams.set("redirectUri", redirectUri);
125+
tokenUrl.searchParams.set("code", code);
161126

162-
// 6. Exchange code for token via backend
163-
const tokenResponse = await fetch(`${BACKEND_URL}/api/oauth2/google/token`, {
164-
method: 'POST',
127+
console.log("[Background] Token URL:", tokenUrl.toString());
128+
129+
const tokenResponse = await fetch(tokenUrl.toString(), {
130+
method: "GET",
165131
headers: {
166-
'Content-Type': 'application/json',
132+
Accept: "application/json",
167133
},
168-
body: JSON.stringify({
169-
authorizationCode: code,
170-
redirectUri,
171-
}),
172134
});
173135

136+
console.log("[Background] Token Response Status:", tokenResponse.status);
137+
174138
if (!tokenResponse.ok) {
175139
const errorText = await tokenResponse.text();
176-
console.error('[Background] Token exchange failed:', errorText);
140+
console.error("[Background] Token exchange failed:", errorText);
177141
return {
178142
success: false,
179143
error: `토큰 교환 실패: ${tokenResponse.status} ${tokenResponse.statusText}`,
180144
};
181145
}
182146

183147
const tokenData = await tokenResponse.json();
148+
console.log("[Background] Token Data:", JSON.stringify(tokenData, null, 2));
184149

185-
if (!tokenData.success || !tokenData.data) {
150+
// 6. Parse backend response
151+
// 응답 형식: { code: 1000, message: "SUCCESS", result: { accessToken, refreshToken } }
152+
if (tokenData.code !== 1000) {
186153
return {
187154
success: false,
188-
error: tokenData.error?.message || '토큰 교환에 실패했습니다.',
155+
error: tokenData.message || "토큰 교환에 실패했습니다.",
189156
};
190157
}
191158

192-
console.log('[Background] Token exchange successful');
159+
const { accessToken, refreshToken } = tokenData.result || {};
193160

194-
// 7. Save tokens and user profile
195-
// The backend returns guestToken - save it as accessToken
196-
await saveTokens(tokenData.data.guestToken);
161+
if (!accessToken) {
162+
console.error("[Background] No accessToken in response:", tokenData);
163+
return {
164+
success: false,
165+
error: "백엔드 응답에서 토큰을 찾을 수 없습니다.",
166+
};
167+
}
197168

198-
// Save user profile for later use
199-
await saveUserProfile({
200-
email: tokenData.data.profile.email,
201-
name: tokenData.data.profile.name,
202-
picture: tokenData.data.profile.picture,
203-
});
169+
// 7. Save tokens
170+
await saveTokens(accessToken, refreshToken);
171+
console.log("[Background] Tokens saved successfully");
204172

205-
console.log('[Background] OAuth flow completed successfully');
173+
// 8. Return success response
174+
// refreshToken이 없으면 게스트(신규 회원)
175+
const isGuest = !refreshToken;
206176

207177
return {
208178
success: true,
209-
response: tokenData.data,
179+
response: {
180+
guestToken: accessToken,
181+
requiresSignup: isGuest,
182+
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
183+
profile: {
184+
email: "",
185+
name: "",
186+
picture: "",
187+
},
188+
},
210189
};
211190
} catch (error) {
212-
console.error('[Background] OAuth error:', error);
213-
214-
// User closed the popup
215-
if (error instanceof Error && error.message.includes('closed')) {
216-
return { success: false, error: '로그인 창이 닫혔습니다.' };
217-
}
218-
219-
// Interrupted
220-
if (error instanceof Error && error.message.includes('interrupted')) {
221-
return { success: false, error: '로그인이 중단되었습니다.' };
191+
console.error("[Background] OAuth error:", error);
192+
193+
// User closed the popup or cancelled
194+
if (error instanceof Error) {
195+
if (
196+
error.message.includes("The user did not approve") ||
197+
error.message.includes("closed") ||
198+
error.message.includes("cancelled")
199+
) {
200+
return {
201+
success: false,
202+
error: "사용자가 인증을 취소했습니다.",
203+
};
204+
}
205+
206+
// Authorization page could not be loaded
207+
if (error.message.includes("Authorization page could not be loaded")) {
208+
return {
209+
success: false,
210+
error:
211+
"인증 페이지를 로드할 수 없습니다. 백엔드 서버 상태를 확인해주세요.",
212+
};
213+
}
214+
215+
// Interrupted
216+
if (error.message.includes("interrupted")) {
217+
return {
218+
success: false,
219+
error: "로그인이 중단되었습니다.",
220+
};
221+
}
222222
}
223223

224224
return {
225225
success: false,
226-
error: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.',
226+
error:
227+
error instanceof Error
228+
? error.message
229+
: "알 수 없는 오류가 발생했습니다.",
227230
};
228231
}
229232
}

src/background/index.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,11 @@ chrome.runtime.onMessage.addListener(
6060
}
6161

6262
// Unknown message type
63-
// TypeScript narrows to never here, but runtime may have unknown types
64-
const unknownMessage = typedMessage as { type: string };
65-
console.warn('[Background] Unknown message type:', unknownMessage.type);
63+
const unknownType = (message as { type: string }).type;
64+
console.warn('[Background] Unknown message type:', unknownType);
6665
sendResponse({
6766
success: false,
68-
error: `Unknown message type: ${unknownMessage.type}`,
67+
error: `Unknown message type: ${unknownType}`,
6968
});
7069

7170
return false;

0 commit comments

Comments
 (0)