🍻

自作したOAuthサーバを拡張してOIDC機能を追加してみる

2022/01/10に公開

はじめに

前回、OAuthサーバを自作してみました。
せっかくなのでOIDCの機能を追加してOIDCサーバとしても振る舞えるようにしたいと思います。

コードは以下になります。

https://github.com/sat0ken/goauth-server

準備

jwtを作るときの署名用にopensslコマンドでキーペアを作成しておきます。
pemファイルはmain.goと同じフォルダに置いておきます。

$ openssl genrsa > private-key.pem
$ openssl rsa -in private-key.pem -pubout -out public-key.pem

実装

追加・変更した個所を説明していきます。
まずOAuthサーバに2つエンドポイントを追加しました。

  • /certs 公開鍵をJWKで返すエンドポイント
  • /userinfo Userプロファイルを返すエンドポイント

/certsエンドポイントでは作成したJWKをJSONで返します。

func certs(w http.ResponseWriter, req *http.Request) {
	w.WriteHeader(http.StatusOK)
	w.Write(makeJWK())
}

JWKの作成は以下のライブラリで作成しています。
kidをセットしてJSONにして返します。

https://github.com/lestrrat-go/jwx/

func makeJWK() []byte {

	data, _ := ioutil.ReadFile("public-key.pem")
	keyset, _ := jwk.ParseKey(data, jwk.WithPEM(true))

	keyset.Set(jwk.KeyIDKey, "12345678")
	keyset.Set(jwk.AlgorithmKey, "RS256")
	keyset.Set(jwk.KeyUsageKey, "sig")

	jwk := map[string]interface{}{
		"keys": []interface{}{keyset},
	}
	buf, _ := json.MarshalIndent(jwk, "", "  ")
	return buf
}

curlでアクセスすると以下のようにJWKが返されます。
クライアントはJWTの検証にこのエンドポイントにアクセスします。

> curl localhost:8081/certs
{
  "keys": [
    {
      "alg": "RS256",
      "e": "AQAB",
      "kid": "12345678",
      "kty": "RSA",
      "n": "rgITBuyE57WbgodStcs_n8YUBfnwJsu2VlBPwlLGxbPshP3bLn7yjCrF8okAh08uimh-rouBT_xjf5AIesnX0_PwrfjvW0QPCNjANas5I3U4FnQAx_7Mcji_s1Ugu9ybyzBc98JB3suG8r3zs71NfW4yVOPvcZP3tQL_zqul86BYneWMm7By5NwZdEekl-Dkw8d4Zz7ArEUFBwISOfe1PRPf2qh8oRka792tSAJqO1XgiWDpPEM18Lr38652ihGHjdKfXUWcPFPDaq7r5WwPGGzgpFcMpAdrsunZCYYyJYPClPSyc0dlEIB13V7d1621PlCMsHJpgUd0BAWyToakwQ",
      "use": "sig"
    }
  ]
}

/userinfoエンドポイントでは送られてきたトークンが正しく発行されているものか確認してOKならユーザ情報返します。

func userinfo(w http.ResponseWriter, req *http.Request) {
	h := req.Header.Get("Authorization")
	tmp := strings.Split(h, " ")

	// トークンがあるか確認
	v, ok := TokenCodeList[tmp[1]]
	if !ok {
		w.WriteHeader(http.StatusBadRequest)
		w.Write([]byte(fmt.Sprintf("token is wrong.\n")))
		return
	}

	// トークンの有効期限が切れてないか
	if v.expires_at < time.Now().Unix() {
		w.WriteHeader(http.StatusBadRequest)
		w.Write([]byte(fmt.Sprintf("token is expire.\n")))
		return
	}

	// スコープが正しいか、openid profileで固定
	if v.scopes != "openid profile" {
		w.WriteHeader(http.StatusBadRequest)
		w.Write([]byte(fmt.Sprintf("scope is not permit.\n")))
		return
	}

	// ユーザ情報を返す
	var m = map[string]interface{}{
		"sub":         user.sub,
		"name":        user.name_ja,
		"given_name":  user.given_name,
		"family_name": user.family_name,
		"locale":      user.locale,
	}
	buf, _ := json.MarshalIndent(m, "", "  ")
	w.WriteHeader(http.StatusOK)
	w.Write(buf)
}

認可エンドポイントの変更点

OAuthの場合はクライアントが要求する権限がscopeパラメータに入ってきますが、OIDCの場合はopenidが入ってきます。
今回はopenid profileで送られてくるということにしてそれ以外がOAuthと判断します。
OIDCのリクエストであることを示すためにセッション情報にtrueをセットしておきます。

	// scopeの確認、OAuthかOIDCか
	// 組み合わせへの対応は面倒なので "openid profile" で固定
	if "openid profile" == query.Get("scope") {
		session.oidc = true
	} else {
		session.code_challenge = query.Get("code_challenge")
		session.code_challenge_method = query.Get("code_challenge_method")
	}

トークンエンドポイントの変更点

まずトークンレスポンス用のstructにid_token用のパラメータを追加しておきます。

type TokenResponse struct {
	AccessToken string `json:"access_token"`
	TokenType   string `json:"token_type"`
	ExpiresIn   int64  `json:"expires_in"`
	IdToken     string `json:"id_token,omitempty"`
}

セッション情報でOIDCがtrueであればJWTを作成してセットします。

if session.oidc {
		tokenResp.IdToken, _ = makeJWT()
	}

JWTの作成

JWTの作成処理は、"ヘッダー.ペイロード"の作成と署名の処理に分けています。

"ヘッダー.ペイロード"の作成はbase64のURLエンコードした文字列を"."でくっつけて返します。
subやユーザ名はハードコードしている値をセットしていますが、本当はDBなどに情報を保存しておいてそれをセットするのが正しい動きかと思います。

func makeHeaderPayload() string {
	// ヘッダー
	var header = []byte(`{"alg":"RS256","kid": "12345678","typ":"JWT"}`)

	// ペイロード
	var payload = Payload{
		Iss:        "https://oreore.oidc.com",
		Azp:        clientInfo.id,
		Aud:        clientInfo.id,
		Sub:        user.sub,
		AtHash:     "PRzSZsEPQVqzY8xyB2ls5A",
		Nonce:      "abc",
		Name:       user.name_ja,
		GivenName:  user.given_name,
		FamilyName: user.family_name,
		Locale:     user.locale,
		Iat:        time.Now().Unix(),
		Exp:        time.Now().Unix() + ACCESS_TOKEN_DURATION,
	}
	payload_json, _ := json.Marshal(payload)

	//base64エンコード
	b64header := base64.RawURLEncoding.EncodeToString(header)
	b64payload := base64.RawURLEncoding.EncodeToString(payload_json)

	return fmt.Sprintf("%s.%s", b64header, b64payload)
}

"ヘッダー.ペイロード"の文字列を作成したら署名をする必要があります。
作成した文字列のハッシュ値を計算して作成済みの秘密鍵で署名をします。
署名した文字列を"ヘッダー.ペイロード"の最後にくっつけて返します。

作成したJWTはトークンレスポンスとしてJSONで返されます。

// JWTを作成
func makeJWT() (string, error) {
	jwtString := makeHeaderPayload()

	privateKey, err := readPrivateKey()
	if err != nil {
		return "", err
	}
	err = privateKey.Validate()
	if err != nil {
		return "", fmt.Errorf("private key validate err : %s", err)
	}
	hasher := sha256.New()
	hasher.Write([]byte(jwtString))
	tokenHash := hasher.Sum(nil)

	// 署名を作成
	signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, tokenHash)
	if err != nil {
		return "", fmt.Errorf("sign by private key is err : %s", err)
	}
	enc := base64.RawURLEncoding.EncodeToString(signature)

	// "ヘッダー.ペイロード.署名"を作成して返す
	return fmt.Sprintf("%s.%s", jwtString, enc), nil
}

検証

以前書いたOIDCのクライアントコードで試してみます。
アドレスをGoogleから自作したサーバのエンドポイントにして実行します。

ログインをした後にクライアント側のログでid_tokenが返されていてJWTの検証が正しく成功していればOKです。

2022/01/10 16:23:02 token response :{"access_token":"91db3cd0-f676-4c9c-9cfc-7cfdea85b5bd","token_type":"Bearer","expires_in":1641802982,"id_token":"eyJhbGciOiJSUzI1NiIsImtpZCI6ICIxMjM0NTY3OCIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL29yZW9yZS5vaWRjLmNvbSIsImF6cCI6IjEyMzQiLCJhdWQiOiIxMjM0Iiwic3ViIjoiMTExMTExMTEiLCJhdF9oYXNoIjoiUFJ6U1pzRVBRVnF6WTh4eUIybHM1QSIsIm5vbmNlIjoiYWJjIiwibmFtZSI6IuW-s-W3neaFtuWWnCIsImdpdmVuX25hbWUiOiLmhbbllpwiLCJmYW1pbHlfbmFtZSI6IuW-s-W3nSIsImxvY2FsZSI6ImphIiwiaWF0IjoxNjQxNzk5MzgyLCJleHAiOjE2NDE4MDI5ODJ9.UI3bQI72DxW_oJ7zhKEhtGyqnNuCc-il_8ZwVjY8F0jM2wWRyD2_rjKB2Snn6W4OSikJLnnNvVoxf6lSJEOiB3ZKwsLFrrIY-wdgGI8CJQvhqOrFTcfTuRg3pfbiKmX44IctXHoT2LWpgz67NbcJCoXaumqv9S2YkM9wVal7Hy2MwNTkgalGTTNEghw9fXri8HHrOWzAiI3yqjG25gpiajqwZ0KcltkbaXJuNAG8B2DmPmpuTWdePjXi5NQSudVjCNN75sRadLjkZCzch3E4OkOCy-RWOisfElV9whMVig5_z6Lr0xBGdFZD69XZjlxv5Tnzhx_m_5408oagkqEFpg"}
2022/01/10 16:23:02 verifyJWT : token is 正しい!!!

jwt.ioでも検証してみます。
ログのjwtを貼り付けてみます。

公開鍵の値を貼り付けてSignature Verifiedとなれば正しいJWTであることがわかります。

おわりに

Auth屋さんの本を読むところから始めて他の方のBlogを読んだりRFCを読んでみたりしつつ、今回を含めて以下の一連の記事となりました。

まだまだ足りてない機能があると思うのでちょっとずつ学びながら機能追加していきたいと思います。

Discussion