GolangでGoogleのOpenIDConnectを使った実装をしてみた

2022/04/17に公開

概要

Golangを勉強したのでGoogleのOpenIDConnectを使ったサンプルアプリを実装してみました

実装物はこちらに置いてあります

https://github.com/physphys/go-sns-login

実装物の解説

Google連携するとGoogleアカウントで新規会員登録されるという単純なサンプルアプリです。

認可リクエスト

handler/google.go
func AuthGoogleSignUpHandler(w http.ResponseWriter, r *http.Request) {
   client := oidc.NewGoogleOidcClient()

   // CSRFを防ぐためにstateを保存し、後の処理でstateが一致するか確認する
   state, err := oidc.RandomState()
   if err != nil {
   	fmt.Println(err)
   	return
   }
   cookie := http.Cookie{Name: "state", Value: state}
   http.SetCookie(w, &cookie)

   // ユーザーをGoogleのログイン画面にリダイレクト
   redirectUrl := client.AuthUrl(
   	"code",
   	[]string{"openid", "email", "profile"},
   	fmt.Sprintf(
   		"%s://%s:%s/auth/google/sign_up/callback",
   		os.Getenv("SERVER_PROTO"),
   		os.Getenv("SERVER_HOST"),
   		os.Getenv("SERVER_PORT"),
   	),
   	state,
   )
   http.Redirect(w, r, redirectUrl, 301)
}

ランダムな文字列をstateとしてセットして、Googleのログイン画面から返ってきた時に一致するか検証しています。

トークンリクエスト

Googleのログイン画面で認証情報を入力したユーザーはhandler/google.goで指定したredirectUrlに帰ってきます。

  • stateの検証
  • トークンエンドポイントにリクエスト
  • 署名の検証
  • ペイロードの検証

を行なっていきます

stateの検証

認可リクエストを投げる時に設定したstateが一致しているのか確認することで不正な操作を防ぎます。

handler/google.go
	cookieState, err := r.Cookie("state")
	if err != nil {
		fmt.Printf("Cookie get error %s", err)
		return
	}
	queryState := r.URL.Query().Get("state")
	if queryState != cookieState.Value {
		fmt.Printf("state does not match %s : %s", queryState, cookieState.Value)
		return
	}

トークンエンドポイントにリクエスト

handler/google.go
	// 認可コードを取り出しトークンエンドポイントに投げることでid_tokenを取得できる
	client := oidc.NewGoogleOidcClient()
	tokenResp, err := client.PostTokenEndpoint(
		r.URL.Query().Get("code"),
		fmt.Sprintf(
			"%s://%s:%s/auth/google/sign_up/callback",
			os.Getenv("SERVER_PROTO"),
			os.Getenv("SERVER_HOST"),
			os.Getenv("SERVER_PORT"),
		),
		"authorization_code",
	)
	if err != nil {
		fmt.Println(err)
		return
	}

Googleのログイン画面から返ってくるとクエリパラメーターに認可コードが付与されています。この認可コードを取り出し、トークンエンドポイントに投げることでid_tokenやaccess_tokenを取得することができます。

oidc/client.go
// tokenResponse はトークンエンドポイントのレスポンスをunmarshalするため構造体
type tokenResponse struct {
	AccessToken string `json:"access_token"`
	ExpiresIn   int    `json:"expires_in"`
	Scope       string `json:"scope"`
	TokenType   string `json:"token_type"`
	IdToken     string `json:"id_token"`
}

トークンエンドポイントのレスポンスはこのような構造になっています。今回はIdTokenのみを扱っていきます。

署名の検証

handler/google.go
	// JWKsエンドポイントから公開鍵を取得しid_token(JWT)の署名を検証。改竄されていないことを確認する
	idToken, err := oidc.NewIdToken(tokenResp.IdToken)
	if err != nil {
		fmt.Println(err)
		return
	}
	if err := idToken.ValidateSignature(client.JwksEndpoint); err != nil {
		fmt.Println(err)
		return
	}

id_tokenはJWT形式になっており、文字列のままだと扱いにくいので、oidc.NewIdToken(tokenResp.IdToken)で構造体に焼き直しています。

oidc/id_token.go
type idToken struct {
	rawToken     string
	rawHeader    string
	RawPayload   string
	rawSignature string
	header       header
}

type header struct {
	Alg string `json:"alg"`
	Kid string `json:"kid"`
	Typ string `json:"typ"`
}

次にid_tokenが改竄されていないかを確かめるために署名を検証します。

  • JWKsエンドポイントに公開鍵が公開されているので、鍵を取得
  • JWTのヘッダーからkidを取り出し、一致する公開鍵を使う
  • 公開鍵を使ってJWTの署名検証を行う
oidc/id_token.go
func (token idToken) ValidateSignature(jwksUrl string) error {
	resp, err := http.Get(jwksUrl)
	if err != nil {
		return err
	}
	
	// ...

	var key jwk
	var isFound bool
	for _, v := range keys.Keys {
		if token.header.Kid == v.Kid {
			key = v
			isFound = true
		}
	}
	if !isFound {
		return errors.New("JWK not found")
	}

        // ...

	headerAndPayload := fmt.Sprintf("%s.%s", token.rawHeader, token.RawPayload)
	hasher := sha256.New()
	hasher.Write([]byte(headerAndPayload))

	decSignature, err := base64.RawURLEncoding.DecodeString(token.rawSignature)
	if err != nil {
		return err
	}

	if err := rsa.VerifyPKCS1v15(pubKey, crypto.SHA256, hasher.Sum(nil), decSignature); err != nil {
		return err
	}

	return nil
}

真面目に実装するなら、JWKsエンドポイントから取得した公開鍵はキャッシュしたほうがいいと思います。

Googleが公開鍵を変更することはめったにないため、HTTP応答のキャッシュディレクティブを使用して公開鍵をキャッシュでき

https://developers.google.com/identity/protocols/oauth2/openid-connect#validatinganidtoken

Rubyでの実装になりますが、このgemの実装が参考になります

https://github.com/google/google-id-token/blob/master/lib/google-id-token.rb#L82-L101

ペイロードの検証

handler/google.go
	// id_tokenのpayload部分をチェックし、期限切れなどしていないか確認する
	bytePayload, err := jwt.DecodeSegment(idToken.RawPayload)
	if err != nil {
		fmt.Println(err)
		return
	}
	payload := &oidc.GoogleIdTokenPayload{}
	if err := json.Unmarshal(bytePayload, payload); err != nil {
		fmt.Println(err)
		return
	}
	if err = payload.IsValid(client.ClientId); err != nil {
		fmt.Println(err)
		return
	}

JWT自体が改竄されていないことを確認できたら、ペイロードの中身を検証していきます。この辺りは単純な文字列比較などになります。

https://developers.google.com/identity/protocols/oauth2/openid-connect#validatinganidtoken

新規登録

今回のサンプルアプリではOpenIDConnectの部分が実装できれば良かったので、最後は適当にDBにデータを登録しています。

handler/google.go
	user := &model.User{Email: payload.Email, Sub: payload.Sub, IdProvider: payload.Iss}
	db.Create(user)
	fmt.Printf("success to create user :%v", user)

Discussion