golangでOAuthとOpenID Connectの違いを整理して理解する
はじめに
前回の記事に引き続きAuth屋さんのOIDC本を読みました。
今回もチュートリアルのcurlとブラウザで行っている部分をgolangに置き換えてみたいと思います。
方針は前回の実装と同じです。
- httpサーバを起動させる
- アクセスするとgoogleにリダイレクトさせる
- callbackを受けたら認可コードでトークンリクエストをする
- 取得したトークンでプロフィールにアクセスする
OAuthではGoogleのPhoto APIにアクセスしましたが、プロフィール情報にアクセスするのが違いとなります。
IDトークンの検証も行いますが勉強のためなるべくライブラリなどは使用せず標準pkgで愚直に書いてみます。
golangに最近入門したのでお見苦しいコードを書いているかもですがご笑覧ください🙇♂️🙇♂️🙇♂️
最終的なコードは以下にあります。
準備
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の時とリダイレクトの処理は同じですが、パラメータが一部異なります。
異なるのはscope
とnonce
です。
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
}
}
署名検証
- 標準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
}
ちょっと脱線しますがお仕事でやるならこのライブラリがよさそうでした。
公開鍵の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屋さんの本をおすすめしておきます!
Discussion
JWKs endpointから鍵を生成して検証する部分が参考になりました。ありがとうございます!