🍻

golangでOAuthとOpenID Connectの違いを整理して理解する

2022/01/03に公開1

はじめに

前回の記事に引き続きAuth屋さんのOIDC本を読みました。
今回もチュートリアルのcurlとブラウザで行っている部分をgolangに置き換えてみたいと思います。

方針は前回の実装と同じです。

  • httpサーバを起動させる
  • アクセスするとgoogleにリダイレクトさせる
  • callbackを受けたら認可コードでトークンリクエストをする
  • 取得したトークンでプロフィールにアクセスする

OAuthではGoogleのPhoto APIにアクセスしましたが、プロフィール情報にアクセスするのが違いとなります。
IDトークンの検証も行いますが勉強のためなるべくライブラリなどは使用せず標準pkgで愚直に書いてみます。
golangに最近入門したのでお見苦しいコードを書いているかもですがご笑覧ください🙇‍♂️🙇‍♂️🙇‍♂️

最終的なコードは以下にあります。
https://github.com/sat0ken/goidc-client

準備

OAuthでGoogleに設定したものをそのまま使い回しますので省略します。

実装

今回もnet/httpでサーバを立てて処理を書いていきます。
/login/callbackの2つのエンドポイントを用意しておきます。

func main() {
	setUp()
	http.HandleFunc("/login", login)
	http.HandleFunc("/callback", callback)
	log.Println("start server localhost:8080...")
	err := http.ListenAndServe("localhost:8080", nil)
	if err != nil {
		log.Fatal(err)
	}
}

リダイレクト

OAuthの時とリダイレクトの処理は同じですが、パラメータが一部異なります。
異なるのはscopenonceです。
scopeにはopenid profileをセットします。nonceはIDトークンの検証に使用する値です。

func login(w http.ResponseWriter, req *http.Request) {

	v := url.Values{}
	v.Add("response_type", response_type)
	v.Add("client_id", oidc.clientId)
	v.Add("state", oidc.state)
	v.Add("scope", scope)
	v.Add("redirect_uri", redirect_uri)
	v.Add("nonce", oidc.nonce)

	log.Printf("http redirect to: %s", fmt.Sprintf("%s?%s", oidc.authEndpoint, v.Encode()))
	// Googleの認可エンドポイントにリダイレクトさせる
	http.Redirect(w, req, fmt.Sprintf("%s?%s", oidc.authEndpoint, v.Encode()), 302)
}

コールバック

GoogleにログインするとOAuthの時と同じくlocalhostに戻ってくるのでその処理を書きます。
コールバックされたらそのまま認可コードが入っているURLクエリごとトークンをリクエストする関数に渡します。

func callback(w http.ResponseWriter, req *http.Request) {

	query := req.URL.Query()
	token, err := tokenRequest(query)
	if err != nil {
		log.Println(err)
	}
	以下略

トークンリクエスト

コールバックされた時に受け取った認可コードをセットしてトークンリクエストを送信します。
成功したらレスポンスをjson.Unmarshalして戻します。

func tokenRequest(query url.Values) (map[string]interface{}, error) {

	v := url.Values{}
	v.Add("client_id", oidc.clientId)
	v.Add("client_secret", oidc.clientSecret)
	v.Add("grant_type", grant_type)
	v.Add("code", query.Get("code"))
	v.Add("redirect_uri", redirect_uri)

	req, err := http.NewRequest("POST", oidc.tokenEndpoint, strings.NewReader(v.Encode()))
	if err != nil {
		return nil, err
	}
	以下略

JWTのデコード

トークンリクエストのレスポンスは以下のようなjsonです。
トークンリクエストした関数で map[string]interface{}にjsonデコードしているので、map["id_token"]で値を取り出せます。
取り出したid_tokenの値をJWTをデコードする関数に渡します。

{
  "access_token": "ya29.~略~7WjQ",
  "expires_in": 3599,
  "scope": "https://www.googleapis.com/auth/userinfo.profile openid",
  "token_type": "Bearer",
  "id_token": "eyJhbGciK9h~略~6BUAT3eSxJGlA"
}

JWTのデータは"."で区切られており、先頭からヘッダー、ペイロード、署名の順番になっています。
"."で3つに分割してから、Base64URLエンコード→JSONデコードして後から使うためデータ保持用の構造体に入れておきます。
署名検証で使うのでそのままの文字列も構造体に入れて戻します。

func decodeJWT(idToken string) (jwtdata JwtData) {
	tmp := strings.Split(idToken, ".")
	jwtdata.header_payload = fmt.Sprintf("%s.%s", tmp[0], tmp[1])
	jwtdata.header_raw = tmp[0]
	jwtdata.payLoad_raw = tmp[1]

	header := fillB64Length(tmp[0])
	payload := fillB64Length(tmp[1])

	decHeader, _ := base64.StdEncoding.DecodeString(header)
	decPayload, _ := base64.StdEncoding.DecodeString(payload)
	decSignature, _ := base64.RawURLEncoding.DecodeString(tmp[2])
	jwtdata.signature = string(decSignature)

	json.NewDecoder(bytes.NewReader(decHeader)).Decode(&jwtdata.header)
	json.NewDecoder(bytes.NewReader(decPayload)).Decode(&jwtdata.payLoad)

	return jwtdata

Googleの公開鍵取得

Googleの公開鍵を利用して署名が正しいか確認します。

Googleの公開鍵はhttps://www.googleapis.com/oauth2/v3/certsにJWKの形式で公開されているのでまずそれを取得します。
公開鍵は2つ公開されているので署名に使われた方を選択する必要があります。
署名に使われた鍵のIDはJWTのヘッダー部に含まれているので、それと一致する方のn(module)をrsa.PublicKeyにセットします。
e(exponent)は65537を固定で入れています。

func verifyJWTSignature(jwtdata JwtData, id_token string) error {

	pubkey := rsa.PublicKey{}
	var keyList map[string]interface{}

	keyURL := "https://www.googleapis.com/oauth2/v3/certs"
	req, err := http.NewRequest("GET", keyURL, nil)
	if err != nil {
		return fmt.Errorf("http request err : %s\n", err)
	}
	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		return fmt.Errorf("http client err : %s\n", err)
	}
	defer resp.Body.Close()
	json.NewDecoder(resp.Body).Decode(&keyList)

	for _, val := range keyList["keys"].([]interface{}) {
		key := val.(map[string]interface{})
		if key["kid"] == jwtdata.header["kid"].(string) {
			number, _ := base64.RawURLEncoding.DecodeString(key["n"].(string))
			pubkey.N = new(big.Int).SetBytes(number)
			pubkey.E = 65537
		}
	}

署名検証

  1. 標準pkgでやる方法と2. golang-jwtを使うのと2通り試してみました。

1.はrsa.VerifyPKCS1v15関数にGoogleの公開鍵(rsa.PublicKey), JWTのヘッダーとペイロード部のハッシュ値を計算した値、署名を渡して検証をします。

2.はpem形式で公開鍵を渡さないといけなかったので取得したGoogleの公開鍵を変換する処理を間に挟んでから検証する関数を呼んでいます。
JWTの文字列はそのまま渡せるのですがなんかまどろっこしくなってしまいました。。。

	hasher := sha256.New()
	hasher.Write([]byte(jwtdata.header_payload))

	// 1. 標準pkgの機能で署名検証
	err = rsa.VerifyPKCS1v15(&pubkey, crypto.SHA256, hasher.Sum(nil), []byte(jwtdata.signature))
	if err != nil {
		return fmt.Errorf("Verify err : %s\n", err)
	} else {
		log.Println("Verify success by VerifyPKCS1v15!!")
	}

	// 2. golang-jwtライブラリで署名検証
	derRsaPubKey, err := x509.MarshalPKIXPublicKey(&pubkey)
	if err != nil {
		return err
	}
	var buf bytes.Buffer
	err = pem.Encode(&buf, &pem.Block{Type: "RSA PUBLIC KEY", Bytes: derRsaPubKey})
	if err != nil {
		return err
	}

	// https://github.com/golang-jwt/jwt/blob/main/cmd/jwt/main.go
	token, err := jwt.Parse(id_token, func(token *jwt.Token) (interface{}, error) {
		return jwt.ParseRSAPublicKeyFromPEM(buf.Bytes())
	})
	if err != nil {
		log.Printf("couldn't parse token: %s \n", err)
	}
	if !token.Valid {
		log.Println("token is invalid")
	} else {
		log.Println("token is valid!!")
	}
	return nil
}

ちょっと脱線しますがお仕事でやるならこのライブラリがよさそうでした。
https://github.com/lestrrat-go/jwx

公開鍵のURLとJWTを渡せばよしなにやってくれるので↑のコードよりはるかにすっきりです😊

func main() {
	var id_token = []byte(`eyJhbGciOiJSUzI1NiIs~省略~qN6KJqMWObu7eEpP616HCK9h6BUAT3eSxJGlA`)

	set, err := jwk.Fetch(context.Background(), "https://www.googleapis.com/oauth2/v3/certs")
	if err != nil {
		log.Printf("failed to parse JWK: %s", err)
		return
	}

	token, err := jwt.Parse(id_token, jwt.WithKeySet(set))
	if err != nil {
		log.Printf("failed to parse payload: %s", err)
	}
	fmt.Println(token)
}

IDトークン検証

署名検証に成功したらデータは改ざんされていないことがわかります。
しかし発行されたIDトークンは正しい相手に発行されたものなのか?など中身をチェックする必要があります。

トークンを検証している関数では以下を確認しています。

  • iss 発行元がGoogleのURLであるか
  • aud 発行されたのが自分のクライアントIDであるか
  • nonce 認可エンドポイントにリダイレクトするときにセットしたものと同じか
  • at_hash アクセストークンのハッシュと一致するか
  • exp 有効期限内か
func verifyToken(data JwtData, access_token string) error {

	// トークン発行元の確認
	if "https://accounts.google.com" != data.payLoad["iss"].(string) {
		return fmt.Errorf("iss not match")
	}
	// クライアントIDの確認
	if oidc.clientId != data.payLoad["aud"].(string) {
		return fmt.Errorf("acoount_id not match")
	}
	// nonceの確認
	if oidc.nonce != data.payLoad["nonce"].(string) {
		return fmt.Errorf("nonce is not match")
	}
	// IDトークンの有効期限を期限を確認
	now := time.Now().Unix()
	if data.payLoad["exp"].(float64) < float64(now) {
		return fmt.Errorf("token time limit expired")
	}
	// at_hashのチェック
	token_athash := base64URLEncode(access_token)
	if token_athash[0:21] != data.payLoad["at_hash"].(string)[0:21] {
		return fmt.Errorf("at_hash not match")
	}

	return nil
}

プロフィール取得

署名検証とトークン検証が正しく完了したら、アクセストークンを使ってGoogleのUserInfoエンドポイントにアクセスします。
自分のプロフィール情報がログとブラウザに出力されれば完了です。

	userInfoURL := oidc.userInfoEndpoint
	req, err = http.NewRequest("GET", userInfoURL, nil)
	if nil != err {
		log.Println(err)
	}
	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token["access_token"].(string)))
	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		log.Println(err)
	}
	defer resp.Body.Close()

	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		log.Println(err)
	}

	log.Println(string(body))
	w.Write(body)

さいごにAuth屋さんの本をおすすめしておきます!

https://authya.booth.pm/items/1550861

Discussion

ryohma0510ryohma0510

JWKs endpointから鍵を生成して検証する部分が参考になりました。ありがとうございます!