11import { redirect } from 'react-router'
22
3+ const STORAGE_KEY = 'google_oauth_validation'
4+
35// 検証用の値をローカルストレージに保存する
4- const storeValidationValue = async (
5- key : string ,
6- data : { state : string ; nonce : string } ,
7- ) => {
8- await localStorage . setItem ( key , JSON . stringify ( data ) )
6+ const storeValidationValue = ( data : { state : string ; nonce : string } ) => {
7+ localStorage . setItem ( STORAGE_KEY , JSON . stringify ( data ) )
98}
109
1110// 検証用の値をローカルストレージから取得し、削除する
12- const restoreValidationValue = async ( key : string ) => {
13- const data = await localStorage . getItem ( key )
11+ const restoreValidationValue = ( ) => {
12+ const data = localStorage . getItem ( STORAGE_KEY )
1413 if ( ! data ) {
15- throw new Error ( 'state がありません' )
14+ throw new Error (
15+ '認証セッションが見つかりません。再度ログインしてください。' ,
16+ )
1617 }
17- await localStorage . removeItem ( key )
18+ localStorage . removeItem ( STORAGE_KEY )
1819 return JSON . parse ( data ) as { state : string ; nonce : string }
1920}
2021
22+ // JWT ペイロードをデコードする (署名検証は行わない)
23+ const decodeJwtPayload = ( token : string ) : Record < string , unknown > => {
24+ const base64Url = token . split ( '.' ) [ 1 ]
25+ if ( ! base64Url ) {
26+ throw new Error ( '無効なトークン形式です' )
27+ }
28+ const base64 = base64Url . replace ( / - / g, '+' ) . replace ( / _ / g, '/' )
29+ const jsonPayload = atob ( base64 )
30+ return JSON . parse ( jsonPayload )
31+ }
32+
2133/**
2234 Google OpenID Connect Authenticator
2335
@@ -71,18 +83,19 @@ export const createGoogleAuthenticator = <User>({
7183 return url . toString ( )
7284 }
7385
74- const authenticate = async ( request : Request ) => {
86+ const authenticate = ( request : Request ) => {
7587 const url = new URL ( request . url )
7688 const callbackURL = buildCallbackURL ( request )
7789
7890 // コールバックURL以外: 認可URLにリダイレクトし、コールバックさせる
7991 if ( url . pathname !== callbackURL . pathname ) {
8092 // コールバック時に state と nonce をチェックするために保存しておく
93+ // 暗号学的に安全な乱数を使用
8194 const validation = {
82- state : String ( Math . random ( ) ) ,
83- nonce : String ( Math . random ( ) ) ,
95+ state : crypto . randomUUID ( ) ,
96+ nonce : crypto . randomUUID ( ) ,
8497 }
85- await storeValidationValue ( 'v' , validation )
98+ storeValidationValue ( validation )
8699
87100 // 認可 URL にリダイレクトさせる。成功するとコールバックURLにリダイレクトされる
88101 throw redirect (
@@ -96,27 +109,37 @@ export const createGoogleAuthenticator = <User>({
96109 const url = new URL ( request . url )
97110 const params = new URLSearchParams ( url . hash . slice ( 1 ) )
98111
99- // state のチェック
100- const validation = await restoreValidationValue ( 'v' )
112+ // Google からのエラーレスポンスをチェック
113+ const error = params . get ( 'error' )
114+ if ( error ) {
115+ const errorDescription = params . get ( 'error_description' ) || error
116+ throw new Error ( `認証エラー: ${ errorDescription } ` )
117+ }
118+
119+ // 保存した検証値を取得
120+ const validation = restoreValidationValue ( )
121+
122+ // state のチェック (CSRF 対策)
101123 if ( validation . state !== params . get ( 'state' ) ) {
102- throw new Error ( 'state が一致しません ' )
124+ throw new Error ( '不正なリクエストです。再度ログインしてください。 ' )
103125 }
104126
105127 // id トークンを取得
106128 const idToken = params . get ( 'id_token' )
107129 if ( ! idToken ) {
108- throw new Error ( 'IDトークンがありません ' )
130+ throw new Error ( '認証情報が取得できませんでした ' )
109131 }
110132
111- // id トークンのペイロードに含まれる nonce のチェック
112- const jsonPayload = atob (
113- idToken . split ( '.' ) [ 1 ] . replace ( / - / g, '+' ) . replace ( / _ / g, '/' ) ,
114- )
115- if ( JSON . parse ( jsonPayload ) . nonce !== validation . nonce ) {
116- throw new Error ( 'nonce が一致しません' )
133+ // 1. まず Firebase で署名検証を含む認証を実行
134+ const user = await verifyUser ( request , idToken )
135+
136+ // 2. 署名検証済みトークンから nonce を取得してチェック (リプレイ攻撃対策)
137+ const payload = decodeJwtPayload ( idToken )
138+ if ( payload . nonce !== validation . nonce ) {
139+ throw new Error ( '不正なリクエストです。再度ログインしてください。' )
117140 }
118141
119- return verifyUser ( request , idToken )
142+ return user
120143 }
121144
122145 return {
0 commit comments