[Golang] OIDCのトークンを検証する

OIDCのトークンを検証するプログラムをGo言語で書いてみます。

本記事はこんな人におすすめ!
  • IDトークンやアクセストークンをGo言語で検証するプログラムが書きたい
  • Go言語でJWTを扱ったプログラムを試してみたい

さっそくプログラムを作成していきましょう!

今回使うライブラリはこの2つです。

参考 jwx github 参考 jwt-go github

どちらもJWTやOIDCの認証においてよく使われるライブラリです。実際にトークンの作成や検証を自作すると大変なのでライブラリを使って簡単に実装します。

トークンについて

トークンはJWTというフォーマットのものを利用します。

<ヘッダー>.<ペイロード>.<署名>

JWTについては様々なページで解説されていますので、そちらを参照ください。

今回のメインは、OIDCでいうリソースサーバにあたる処理で、アプリケーションからアクセストークンを下記のように受け取り、検証を行うプログラムを作成します。また、検証をした上でデータを返します。検証に失敗した場合にはエラーを返すようにします。

モジュールのインストール

今回使用するライブラリをインストールします。作業用ディレクトリ内で下記コマンドを使いましょう。

go get github.com/golang-jwt/jwt
go get github.com/lestrrat-go/jwx

これにより、go.modにjwtとjwxが追加されます。

JWKのエンドポイントから署名用公開鍵の取得

アクセストークンの検証方法の一つに、署名用公開鍵を用いて行う方法があり、今回はその方法を用います。

import "github.com/lestrrat-go/jwx/jwk"

const JWKS_URL = "https://www.googleapis.com/oauth2/v3/certs"

func main() {
    keySet, err := jwk.Fetch(context.Background(), JWKS_URL)
    if err != nil {
        return 
    }
}

このようにjwk.Fetchを使用し公開鍵を取得します。

JWTにkidがあるので、keySetの中からkeyを取得します。

key, ok := keySet.LookupKeyID(kid)
if !ok {
    return nil, fmt.Errorf("cannot find key")
}

var publickey interface{}
err = key.Raw(&publickey)
if err != nil {
    return nil, fmt.Errorf("could not parse public key")
}
return publickey, nil

トークン検証用の公開鍵取得は上記のコードで完了です

トークンの検証

続いてjwtモジュールを使ってトークンの検証を行います。

token, err := jwt.Parse(accessToken, func(token *jwt.Token) (interface{}, error) {})
if err != nil {
   ctx.JSON(http.StatusUnauthorized, errorResponse(err))
   return
}

if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
    ctx.JSON(http.StatusOK, gin.H{"message": "token is valid", "sub": claims["sub"]})
} else {
    ctx.JSON(http.StatusUnauthorized, errorResponse(fmt.Errorf("token is invalid")))
}

後ほど実装しますが、トークンの検証jwt.Parseの2つ目の引数には、先程取得したpublickeyを返すよう関数を実装します。

tokenを取得後はtoken.Validで正当か確認できるので、Validの値が正のときにデータを返すようにしましょう。実際には、スコープなどを確認しデータを返すのか決定しますが、今回は割愛します。

先程の公開鍵の取得部分と組み合わせると全体はこのようになります。

token, err := jwt.Parse(accessToken, func(token *jwt.Token) (interface{}, error) {

    if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
        return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
    }

    keySet, err := jwk.Fetch(context.Background(), JWKS_URL)
    if err != nil {
        return nil, fmt.Errorf("cannot get keys")
    }

    kid, ok := token.Header["kid"].(string)
    if !ok {
        return nil, fmt.Errorf("kid header not found")
    }

    key, ok := keySet.LookupKeyID(kid)
    if !ok {
        return nil, fmt.Errorf("cannot find key")
    }

    var publickey interface{}
    err = key.Raw(&publickey)
    if err != nil {
        return nil, fmt.Errorf("could not parse public key")
    }
    return publickey, nil
})
if err != nil {
    ctx.JSON(http.StatusUnauthorized, errorResponse(err))
    return
}

if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
    ctx.JSON(http.StatusOK, gin.H{"message": "token is valid", "sub": claims["sub"]})
} else {
    ctx.JSON(http.StatusUnauthorized, errorResponse(fmt.Errorf("token is invalid")))
}

追加となる部分は、2つのコードを繋ぐために、tokenのヘッダーからkidtoken.Header["kid"].(string)で取得しています。

バックエンドサーバに実装

最後にバックエンドサーバのコードとして実装するとmain.goはこのようになります。WebフレームワークにはGinを使用しています。

main.go
package main

import (
	"context"
	"fmt"
	"net/http"
	"strings"

	"github.com/gin-gonic/gin"
	"github.com/golang-jwt/jwt"
	"github.com/lestrrat-go/jwx/jwk"
)

const JWKS_URL = "JWKS_URL"

func main() {

	router := gin.Default()

	router.GET("/ping", func(ctx *gin.Context) {
		authorizationString := ctx.GetHeader("Authorization")
		if authorizationString == "" {
			ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication is required"})
			return
		}
		accessToken := strings.TrimPrefix(authorizationString, "Bearer ")

		token, err := jwt.Parse(accessToken, func(token *jwt.Token) (interface{}, error) {

			keySet, err := jwk.Fetch(context.Background(), JWKS_URL)
			if err != nil {
				return nil, fmt.Errorf("cannot get keys")
			}

			// Don't forget to validate the alg is what you expect:
			if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
				return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
			}

			kid, ok := token.Header["kid"].(string)
			if !ok {
				return nil, fmt.Errorf("kid header not found")
			}

			key, ok := keySet.LookupKeyID(kid)
			if !ok {
				return nil, fmt.Errorf("cannot find key")
			}

			var publickey interface{}
			err = key.Raw(&publickey)
			if err != nil {
				return nil, fmt.Errorf("could not parse public key")
			}
			return publickey, nil
		})
		if err != nil {
			ctx.JSON(http.StatusUnauthorized, errorResponse(err))
			return
		}

		if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
			ctx.JSON(http.StatusOK, gin.H{"message": "token is valid", "sub": claims["sub"]})
		} else {
			ctx.JSON(http.StatusUnauthorized, errorResponse(fmt.Errorf("token is invalid")))
		}
		return
	})
	router.Run()
}

func errorResponse(err error) gin.H {
	return gin.H{"error": err.Error()}
}

上記のコードを実行して、curl -H "Authorization: Bearer <access_token>" http://localhost:8080/pingとするとテストができます。

$ curl -vvv -H "Authorization: Bearer $ACCESS_TOKEN" http://localhost:80
80/ping
*   Trying 127.0.0.1:8080...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /ping HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.68.0
> Accept: */*
> Authorization: Bearer <access_token>
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Content-Type: application/json; charset=utf-8
< Date: Mon, 27 Dec 2021 05:05:29 GMT
< Content-Length: 56
<
* Connection #0 to host localhost left intact
{"message":"token is valid","sub":"testuser@test.com"}

正しいトークンを送ると、200 OKとともにsubの値がデータとして返されています。

最後に

認証の外出しが進んできて、バックエンドサーバはトークンを検証しデータを返すような処理が求められるようになってきて、今回のように実装すればいいのですが。結局バックエンドサーバごとに建てなきゃいけないので、この検証をAPI Gatewayなどに外だしすることができます。次回は、API Gatewayを使ってJWTを検証します。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です

CAPTCHA