自作したOAuthサーバを拡張してOIDC機能を追加してみる
はじめに
前回、OAuthサーバを自作してみました。
せっかくなのでOIDCの機能を追加してOIDCサーバとしても振る舞えるようにしたいと思います。
コードは以下になります。
準備
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にして返します。
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を読んでみたりしつつ、今回を含めて以下の一連の記事となりました。
- 雰囲気でOAuth2.0を使っているエンジニアがgolangで学んでみる
- golangでOAuthとOpenID Connectの違いを整理して理解する
- 雰囲気でOAuthを使っていたエンジニアがOAuthサーバ(RFC6749)を自作してみる
まだまだ足りてない機能があると思うのでちょっとずつ学びながら機能追加していきたいと思います。
Discussion