Cognitoのトークンを少しだけ理解する

## はじめに
アプリ開発でS3にReactを配置したSPA構成で作成し、ログイン認証にCognitoを利用しました。バックエンドにはGo。フレームワークはechoを使用しています。
まずトークンが3種類あることを知りませんでした。その中でもどのトークンを使っていいのかイメージが湧きませんでした。

## 実装イメージ

![スクリーンショット 2025-03-02 19.19.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3813857/50b33d7f-8976-4618-a374-5ff0e4242767.png)


色々考えた結果このような流れで実装しようと考えました。
まず、フロントエンドでログインしようとするとCognitoのページに飛んで認証を行います。
認証されたらトークンを受け取ることができます。
そのトークンには3種類ありました。3種類一気に受け取ります。

- IDトークン
- アクセストークン
- リフレッシュトークン

### IDトークン
目的: ユーザー認証（ユーザーが誰であるかを確認）

情報: ユーザーに関する情報（クレームと呼ばれる属性）が含まれます。例えば、ユーザーID (sub)、名前、メールアドレスなど。

対象: 主にクライアント（フロントエンド）がユーザーインターフェースの表示や簡単な認可判断のために使用します。

### アクセストークン
目的: リソースへのアクセス認可（ユーザーが特定のリソースにアクセスできるかを確認）

情報: アクセスを許可されたリソース（API、サービスなど）と、その範囲（スコープ）が記述されています。

対象: 主にバックエンドAPIが、クライアントからのリクエストを認可するために使用します。

### リフレッシュトークン
目的: アクセストークンが期限切れになった際、ユーザーに再ログインを求めることなく、新しいアクセストークンを取得すること

## 結局何を使うのか？
フロントエンドからバックエンドにトークンを使ってアクセスする時、何を使うのか？と悩みましたが、
結論、アクセストークンを使うべきとなりました。

### 理由
- IDトークンにはSub（ユーザーID）、name、emailなどが含まれている。
- すでに認証自体はCognitoで行なっているので、バックエンドが行うのはそのトークンの検証ができればいい。
- トークンの検証はトークンに署名が付与されているので、それを検証する。これはアクセストークンでも可能。
- Sub以外のユーザー情報が必要な時はSDKを使用してCognitoから直接取得する。その際、アクセストークンが必要だった.（userInfoエンドポイント）

[公式ドキュメント](https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-with-identity-providers.html)

実際に使用したCognitoのGerUserエンドポイントの記述
https://docs.aws.amazon.com/ja_jp/cognito-user-identity-pools/latest/APIReference/API_GetUser.html

![スクリーンショット 2025-03-02 20.07.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3813857/d12271d1-00db-48c2-8b99-15a431887883.png)

上記の理由からフロントエンドからのリクエストヘッダーにアクセストークンを含めてバックエンドでそれを検証するという実装に落ち着きました。

トークン検証する際はこちらを参考にしました。
https://pkg.go.dev/github.com/jhosan7/cognito-jwt-verify

## 実際に実装した内容

```go
// Cognito認証関連
package auth

import (
	"context"
	"fmt"
	"log"
	"os"
	"skill-typing-back/repository"

	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider"
	"github.com/golang-jwt/jwt/v5"
	"github.com/jhosan7/cognito-jwt-verify/utils"
	"github.com/labstack/echo/v4"
	"gorm.io/gorm"
)

// Cognito API クライアントを保持する構造体
type CognitoUserService struct {
	client *cognitoidentityprovider.Client
}

// ユーザー情報取得メソッド
func (s *CognitoUserService) GetUserInfo(ctx context.Context, accessToken string) (*cognitoidentityprovider.GetUserOutput, error) {
	input := &cognitoidentityprovider.GetUserInput{
		AccessToken: &accessToken,
	}
	return s.client.GetUser(ctx, input)
}

// Cognitoの設定やクレームを扱う構造体
type CognitoAuth struct {
	verifier CognitoJwtVerifier
	repo     *repository.DbRepository
}

// JWTクレームの構造体
type CognitoClaims struct {
	jwt.RegisteredClaims
	Sub       string `json:"sub"`
	Iss       string `json:"iss"`
	Version   int    `json:"version"`
	ClientID  string `json:"client_id"`
	OriginJti string `json:"origin_jti"`
	EventID   string `json:"event_id"`
	TokenUse  string `json:"token_use"` // トークンタイプ（"access" of "id"）
	Scope     string `json:"scope"`
	AuthTime  int64  `json:"auth_time"`
	Username  string `json:"username"`
}

// CognitoJwtVerifierの構造体
type CognitoJwtVerifier struct {
	issuer   string
	jwksUri  string
	tokenUse string
	cache    *utils.Cache
}

type Config struct {
	UserPoolId string
	TokenUse   string
	ClientId   string
}

func NewCognitoUserService() (*CognitoUserService, error) {
	// SDkの設定を初期化
	cfg, err := config.LoadDefaultConfig(context.TODO(),
		config.WithRegion(os.Getenv("AWS_REGION")),
	)
	if err != nil {
		return nil, fmt.Errorf("unable to load SDK config: %v", err)
	}
	// Cognitoクライアントを作成
	client := cognitoidentityprovider.NewFromConfig(cfg)

	return &CognitoUserService{
		client: client,
	}, nil
}

// 新しいCognitoAuth インスタンス作成
func NewCognitoAuth(repo *repository.DbRepository) (*CognitoAuth, error) {
	config := Config{
		UserPoolId: os.Getenv("COGNITO_USER_POOL_ID"),
		TokenUse:   "access",
		ClientId:   os.Getenv("COGNITO_CLIENT_ID"),
	}

	verifier, err := Create(config)
	if err != nil {
		return nil, fmt.Errorf("failed to create cognito verifier: %w", err)
	}

	return &CognitoAuth{
		verifier: verifier,
		repo:     repo,
	}, nil
}

// JWTVerifierの作成
func Create(config Config) (CognitoJwtVerifier, error) {
	issuer, jwksUri, err := utils.ParseUserPoolId(config.UserPoolId)
	if err != nil {
		return CognitoJwtVerifier{}, err
	}
	return CognitoJwtVerifier{
		issuer:   issuer,
		jwksUri:  jwksUri,
		tokenUse: config.TokenUse,
		cache:    utils.NewCache(),
	}, nil
}

// トークン検証
func (c CognitoJwtVerifier) Verify(token string) (jwt.Claims, error) {
	decomposeUnverifiedJwt, err := utils.DecomposeUnverifiedJwt(token)
	if err != nil {
		return nil, err
	}

	jwk, err := utils.GetJwk(decomposeUnverifiedJwt, c.jwksUri, c.cache)
	if err != nil {
		return nil, err
	}

	err = utils.VerifyDecomposedJwt(decomposeUnverifiedJwt, c.issuer, c.tokenUse, jwk.Alg)
	if err != nil {
		return nil, err
	}

	validToken, err := utils.ValidateJwt(token, jwk)
	if err != nil {
		return nil, err
	}

	return validToken.Claims, nil
}

// 認証ミドルウェア
func (a *CognitoAuth) AuthMiddleware(cognitoService *CognitoUserService) echo.MiddlewareFunc {
	return func(next echo.HandlerFunc) echo.HandlerFunc {
		return func(c echo.Context) error {
			// Authorizationヘッダーからトークンを取得
			auth := c.Request().Header.Get("Authorization")
			if auth == "" {
				return echo.ErrUnauthorized
			}

			// "Bearer"を除去
			token := auth[7:]

			// トークンを検証
			claims, err := a.verifier.Verify(token)
			if err != nil {
				return echo.ErrUnauthorized
			}

			mapClaims, ok := claims.(jwt.MapClaims)
			if !ok {
				return echo.ErrInternalServerError
			}

			// クレームをCognitoClaimsにマッピング
			cognitoClaims := &CognitoClaims{
				Sub:      mapClaims["sub"].(string),
				TokenUse: mapClaims["token_use"].(string),
			}

			sub := cognitoClaims.Sub

			// 以下DBでユーザーを検索する処理など
            
			
```

この認証ミドルウェアをrouter.goで認証をしたいエンドポイントに適用させました。

```go

	/*
	* ルーティング(cognito認証なし)
	 */
	e.GET("/", func(c echo.Context) error {
		return c.String(http.StatusOK, "Hello, World!")
	})

	/*
	* ルーティング(cognito認証あり)
	 */

	api := e.Group("/api")

	// api配下にのみauthMiddlwareを適用
	api.Use(authMiddleware)

	// ユーザー情報取得エンドポイント
	api.GET("/users/me", apiHandler.GetMe)
	// 質問の作成エンドポイント
	api.POST("/questions", apiHandler.SaveQuizHandler)
	// スコアの作成エンドポイント
	api.POST("/scores", apiHandler.CreateScore)
	// 最新スコア取得エンドポイント
	api.GET("/scores/latest", apiHandler.GetLatestScore)
	//  AI クイズ生成エンドポイント
	api.GET("/generate-quiz", apiHandler.GenerateQuizHandler)
	//ゲーム画面の取得エンドポイント
	api.GET("/game/questions", apiHandler.GetGameQuestions)
	//ゲーム画面の追加の取得エンドポイント
	api.GET("/game/questions/additional", apiHandler.GetAdditionalGameQuestions)

	return e
}
```

これで、バックエンドでトークン検証を行なったユーザーのみがアプリを利用できるようになりました。

## さいごに
この実装内容がベストプラクティスでは無いかもしれませんが、誰かの参考になれば幸いです。