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
1022const 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 */
7461export 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}
0 commit comments