Golangはじめて物語(第8話:API Gateway＋LambdaでJWTを実装する)

# はじめに
Golang での JWT の実装はライブラリが充実しているので、比較的簡単に実装できる。
しかし、ググって見つかるのは Gin や Echo と組み合わせたものが多く、AWS で API Gateway を使った実例というのが少なかったので、組み込んでみる。

本記事は、以下の知識があることを前提とする。

- JWT に関する概要が分かっている(と言っても、あまり難しいことは知らなくてよくて[この記事](https://qiita.com/Naoto9282/items/8427918564400968bd2b)くらいの概要が分かっていれば充分)
- Golang で API Gateway ＋ Lambda の WebAPI を実装したことがある(手前味噌ではあるが、[この記事](https://qiita.com/neruneruo/items/1ef52f908e4497103a59)を読んで理解ができていれば充分)

なお、今回は IdP には頼らずに自分で秘密鍵を使ってトークンを払い出して検証するという方式を検証する。

# トークン払い出しの実装
まずは、JWT のトークンを払い出す部分を実装する。
ライブラリは ```form3tech-oss/jwt-go``` を使用する。

```Golang
import (
	"context"
	"encoding/json"
	"time"

	jwt "github.com/form3tech-oss/jwt-go"
	"github.com/google/uuid"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
)

func main() {
	lambda.Start(handler)
}

func handler(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	uuidObj, _ := uuid.NewUUID()

	tokenString, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, &jwt.StandardClaims{
		Id:        uuidObj.String(),
		ExpiresAt: time.Now().Add(time.Minute * 1).Unix(),
	}).SignedString("[秘密鍵の文字列]")

	jsonBytes, _ := json.Marshal(struct {
		Token string `json:"token"`
	}{
		Token: tokenString,
	})

	return events.APIGatewayProxyResponse{
		StatusCode:      200,
		IsBase64Encoded: false,
		Body:            string(jsonBytes),
	}, nil
}
```

これだけ。とてもシンプル。
キモになるのは以下の部分で、それ以外は API Gateway のレスポンスを作っているだけである。

```Golang
	tokenString, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, &jwt.StandardClaims{
		Id:        uuidObj.String(),
		ExpiresAt: time.Now().Add(time.Minute * 1).Unix(),
	}).SignedString("[秘密鍵の文字列]")
```

見ての通り、```jwt.NewWithClaims()``` は、暗号化方式、Claim、秘密鍵 を渡しているだけである。

秘密鍵の文字列は、当然のことながら平文で書いて Git に登録すべきではない。本記事では AWS で API Gateway を使う前提としているため、KMS で管理しているデータキーを使うのが良いだろう。

```ExpiresAt``` は本記事では検証のために短く設定しているが、ここは要件に合わせて設定をしよう。

```Id``` については何でもよい。これはトークンの JTI にあたる部分になる。
リクエスト単位でランダムな文字列になり、改ざん検知ができれば良いので、UUID を生成して渡すことにした。

これを、API Gateway の Lambda 統合で呼び出すように設定をしよう(設定方法は冒頭の、第一話を参照)。

これでAPIを呼び出すと、以下のように JSON が返却される。

```shell-session
$ curl -i  https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/prod/auth
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MjEyNjEzNDIsImp0aSI6IjI3ZTk3NzJiLWI3MWItMTFlYi1hYWQyLTYyM2E0YzVlMGY4MCJ9.Rf2f0_Iwm-uIXXOPYvbEjjkBk9xcc8IYNDWGoobqdE8"}
```

これを、[JWT.IO](https://jwt.io/) の Debugger に食わせてみると、以下のように、自分の作ったキーが復号されることが分かる。
※右下の ```your-256-bit-secret``` と書かれているところに、上記で設定した秘密鍵の文字列を入力すると、正しい文字列になる。

![キャプチャ2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111314/ca7f5f07-a136-554a-4ffc-a8b5f91f3ff3.png)


# トークン検証の実装
さて、続いてはトークンの検証だ。

本記事では、検証を行う必要がある API で、Authorization ヘッダに設定されたトークンを検証する。
※実装を簡易にするために、Bearer の文字列は設定しない。

以下のような関数を作って検証をすることにしよう。

```Golang
import (
	"errors"
	"log"

	jwt "github.com/form3tech-oss/jwt-go"
)

func checkToken(headers map[string]string) error {
	tokenString, headerIsNotNull := headers["Authorization"]
	if !headerIsNotNull {
		return errors.New("[Header]Authorization is not specified")
	}

	token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
		b := []byte("[秘密鍵の文字列]")
		return b, nil
	})
	if err != nil {
		log.Println(err)
		return errors.New("jwt.Parse() returned error")
	}

	return nil
}
```

キモになるのは、

```Golang
	token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
		b := []byte("[秘密鍵の文字列]")
		return b, nil
	})
```

で、これでトークンを構造体に割り付けることができる。
ここも、払い出し同様に、秘密鍵の文字列はシークレット用の処理を施すこと。

トークンから Claim を参照にするには、```token.Claims.(jwt.MapClaims)``` を見ればよい。
あとは、好きに中身を検証しよう。

なお、```ExpiresAt(exp)``` は、```jwt.Parse()``` 内で勝手に検証してくれるため、自分で実装する必要はない。便利！

[ドキュメントには ```VerifyExpiresAt``` という関数もある](https://pkg.go.dev/github.com/dgrijalva/jwt-go#MapClaims.VerifyExpiresAt)が、おそらくこれは、検証なしの Parse 関数を使った場合等に自分で実装をするためのものと思われる。

ともあれ、これを実装した API に対して、以下のようにデータを投げてみよう。

```shell-session
$ curl -i -X PUT -H 'Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MjEyNjEzNDIsImp0aSI6IjI3ZTk3NzJiLWI3MWItMTFlYi1hYWQyLTYyM2E0YzVlMGY4MCJ9.Rf2f0_Iwm-uIXXOPYvbEjjkBk9xcc8IYNDWGoobqdE8' https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/prod/resource
```

払い出した正しいトークンを投げた場合は正常に処理が行われ、そうでない場合は ```jwt.Parse()``` がエラーになった旨のログが出るだろう。ためしに、トークンの文字列を変更してみたり、払い出しから1分経過後にトークンを使ってみると分かりやすい。

# でも、色々な API でトークンの検証するの面倒臭くない？
そんな物臭なあなたに [Lambda Authorizer](https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html)。
というか、AWS で API Gateway を使うのであればこちらを採用する方が、マネージドサービスの恩恵を享受できるだろう。
Authorize 設定をした API について、共通的に検証用の Lambda 関数を呼び出してくれるという便利機能だ。

Terraformでは、Authorize 設定をしたいリソース/メソッドについて、以下の設定を行う。

```hcl
resource "aws_api_gateway_method" "data_put" {
  rest_api_id   = aws_api_gateway_rest_api.jwt_example.id
  resource_id   = aws_api_gateway_resource.resource.id
  http_method   = "PUT"
  authorization = "CUSTOM"
  authorizer_id = aws_api_gateway_authorizer.jwt.id
}

resource "aws_api_gateway_authorizer" "jwt" {
  name           = "jwt_authorizer"
  type           = "REQUEST"
  rest_api_id    = aws_api_gateway_rest_api.jwt_example.id
  authorizer_uri = aws_lambda_function.authorizer.invoke_arn
}
```

```aws_api_gateway_method``` の ```authorization``` は、 ```IAM``` や ```COGNITO_USER_POOLS``` による認証も可能だが、今回の Lambda の方式の場合は ```CUSTOM``` を設定する。

また、認証用の Lambda を別途 Terraform で定義して、```aws_api_gateway_authorizer``` の ```authorizer_uri``` に ARN を設定する。

上記の設定をした Lambda Authorizer では、API Gateway が Lambda に渡してくるイベントが ```APIGatewayProxyRequest``` ではなくて [APIGatewayCustomAuthorizerRequestTypeRequest](https://pkg.go.dev/github.com/aws/aws-lambda-go/events#APIGatewayCustomAuthorizerRequestTypeRequest) になるので注意が必要だ。また、ハンドラの戻り値についても、[APIGatewayCustomAuthorizerResponse](https://pkg.go.dev/github.com/aws/aws-lambda-go/events#APIGatewayCustomAuthorizerResponse) を返す必要がある。

APIGatewayCustomAuthorizerResponse の PolicyDocument では、IAM のポリシードキュメント形式で返す必要があるが、結局は、APIGatewayCustomAuthorizerRequestTypeRequest の MethodArn のプロパティで、アクセスさせたいリソースとメソッドは簡単に抽出できるため、たいして難しくはない。捻ったことをしようとしない限りは、お決まりの形で Allow/Deny を返してあげれば良いだろう。

上記を踏まえて、以下のような Lambda のハンドラを用意する。

```Golang
import (
	"context"
	"errors"
	"log"

	jwt "github.com/form3tech-oss/jwt-go"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
)

func main() {
	lambda.Start(handler)
}

func checkToken(headers map[string]string) error {
	tokenString, headerIsNotNull := headers["Authorization"]
	if !headerIsNotNull {
		return errors.New("[Header]Authorization is not specified")
	}

	token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
		b := secretKey
		return b, nil
	})
	if err != nil {
		log.Println(err)
		return errors.New("jwt.Parse() returned error")
	}

	return nil
}

func handler(ctx context.Context, request events.APIGatewayCustomAuthorizerRequestTypeRequest) (events.APIGatewayCustomAuthorizerResponse, error) {
	if err := checkToken(request.Headers); err != nil {
		log.Println(err)
		log.Println("Invalid Token.")
		return events.APIGatewayCustomAuthorizerResponse{
			PrincipalID: "user",
			PolicyDocument: events.APIGatewayCustomAuthorizerPolicy{
				Version: "2012-10-17",
				Statement: []events.IAMPolicyStatement{
					{
						Action:   []string{"execute-api:Invoke"},
						Effect:   "Deny",
						Resource: []string{request.MethodArn},
					},
				},
			},
		}, nil
	}

	return events.APIGatewayCustomAuthorizerResponse{
		PrincipalID: "user",
		PolicyDocument: events.APIGatewayCustomAuthorizerPolicy{
			Version: "2012-10-17",
			Statement: []events.IAMPolicyStatement{
				{
					Action:   []string{"execute-api:Invoke"},
					Effect:   "Allow",
					Resource: []string{request.MethodArn},
				},
			},
		},
	}, nil
}
```

これで、先ほどと同様に

```shell-session
$ curl -i -X PUT -H 'Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MjExMzUyODYsInN1YiI6IjAwMDAxIn0.QcJWTzH1wBe6QdbWJIECO3QMn9ZiE_bK9-Paft1YBy4' https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/prod/resource
```

を投げ込むと、resource の PUT の API でチェックを実装しなくても、API Gateway がオーソライザーの Lambda を実行してチェックしてくれるぞ！なお、このケースでは、デフォルトの HTTP レスポンスコードは 403 で、

```
{"Message":"User is not authorized to access this resource with an explicit deny"}
```

というワーディングが返される。
変更したい場合は、ゲートウェイのレスポンスから変更を行おう。
また、デフォルトではAPI Gatewayのキャッシュ機能がONであるため、トークン期限切れでも認証が通ってしまうことがある。嫌なのであれば、Terraform の ```aws_api_gateway_authorizer``` のリソースで ```authorizer_result_ttl_in_seconds``` の設定を変更しよう。