1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

コードで理解する Cognito × JWT:認証フローと検証処理

1
Posted at

Cognito の認証や JWT は、概念だけを見るとどうしても分かりづらく、実装でつまずきやすいポイントです。

本記事では、ログイン〜APIを実行する一連の流れ全体 を「コードベース」で丁寧に解説し、実際の実装イメージがつかみやすいように構成しました。

この記事を読むと、次のことが理解できます:

  • ユーザーがログインした瞬間に どのタイミングで JWT が発行されるか
  • 発行された JWT が どこに保存され、どうやってフロントで使われるのか
  • API リクエスト時に JWT が どのように送信されるか(Bearer トークン)
  • バックエンドが JWT をどう検証してユーザーを認証しているか
  • Cognito × Next.js × Express の構成での 具体的な実装パターン

ログインするまで

1. cognitoへログインリクエスト(フロントエンド)

  • email, passを使用するログインフォーム送信コードがある
  • signIn関数を使ってログインリクエストをcognitoに対して送信する
    • signInは、cognitoと直接通信する関数
    • signInは、以下の環境変数を読み込んで、通信先のcognitoを特定する
      NEXT_PUBLIC_COGNITO_USER_POOL_ID=ap-northeast-1_xxxxxxx # ユーザープールID
      NEXT_PUBLIC_COGNITO_CLIENT_ID=xxxxxxxxxxxxxxxxxxxxxx # クライアントID
      NEXT_PUBLIC_COGNITO_REGION=ap-northeast-1 # リージョン
      
  • ソースコード(フロントエンド)
    import { signIn } from 'aws-amplify/auth';
    
    async function login(email: string, password: string) {
      const res = await signIn({
        username: email,
        password: password,
      });
    
      console.log(res);
    }
    

2. cognitoがemail, passを検証し、JWTトークンを発行(cognitoの内部処理)

  • 認証がOKならば、JWTトークンがブラウザのローカルストレージに送られて保存される
    • キー
      CognitoIdentityServiceProvider.<CLIENT_ID>.<USERNAME>
      
    • {
        "UserAttributes": [
          { "Name": "sub", "Value": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" },
          { "Name": "email", "Value": "john.doe@example.com" },
          { "Name": "email_verified", "Value": "true" }
        ],
        "SignInUserSession": {
          "IdToken": {
            "jwtToken": "eyJraWQiOiJLT2p...長いJWTトークン...",
            "payload": {
              "sub": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
              "email": "john.doe@example.com",
              "token_use": "id",
              "exp": 1712345678,
              "iat": 1712342078
            }
          },
          "AccessToken": {
            "jwtToken": "eyJraWQiOiJLT2p...別のJWT...",
            "payload": { ... }
          },
          "RefreshToken": {
            "token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
          },
          "ClockDrift": 0
        },
        "Username": "john.doe@example.com"
      }
      

3. ログイン後に見られる箇所が見られるようになる(フロントエンド)

  • フロント側での制御部は以下のようになる
  • 認証済みかどうか、フラグを設ける:isAuth
  • 認証情報を取る関数Auth.currentAuthenticatedUserを使用する
  • この関数もsignInと同じ環境変数を読む("aws-amplify"ライブラリがこれらの環境変数を読むものである)
    'use client';
    
    import { useEffect, useState } from "react";
    import { Auth } from "aws-amplify";
    import { useRouter } from "next/navigation";
    
    export function ProtectedComponent({ children }: { children: React.ReactNode }) {
      // null: 認証中, true: 認証成功, false: 認証失敗
      const [isAuth, setIsAuth] = useState<boolean | null>(null);
    
      useEffect(() => {
        async function checkAuth() {
          try {
            const user = await Auth.currentAuthenticatedUser();
            setIsAuth(true);
          } catch {
            setIsAuth(false);
          }
        }
    
        checkAuth();
      }, []);
    
      if (isAuth === null) return <p>認証中...</p>;
      if (!isAuth) return <p>パスワード、ユーザーが違います</p>
    
      return <>{children}</>; // 認証成功なら中身を表示
    }
    
    

ログイン後のAPI

1. JWTトークンを取得し、リクエストに付与して送信(フロントエンド)

  • Auth.currentSessionで、ログイン済みユーザーのJWTセッション情報を取得
    • ブラウザのローカルストレージに保存されているJWT関連の情報を読み取って取得
  • session.getAccessToken().getJwtToken()で取得してセッション情報からAPIリクエスト用のBearerトークンを取得
    • Bearerトークンとは、Bearer <JWTトークン>という文字列
    • Bare認証方式であることを伝えるためのもの
  • リクエストヘッダに、Bearerトークンをつけてリクエスト送信
    import { Auth } from "aws-amplify";
    
    async function fetchNotes() {
      try {
        // JWT を取得
        const session = await Auth.currentSession();
        const token = session.getAccessToken().getJwtToken();
    
        // API にリクエスト
        const res = await fetch("/api/notes", {
          method: "GET",
          headers: {
            "Authorization": `Bearer ${token}`,
            "Content-Type": "application/json",
          },
        });
    
        if (!res.ok) throw new Error("Failed to fetch notes");
    
        const data = await res.json();
        return data; // メモ一覧
      } catch (err) {
        console.error("メモ取得失敗:", err);
        throw err;
      }
    }
    

2. サーバー側でトークンが検証され、正しければフロントにpayloadを返す(バックエンド)

  • JWTは、以下の構成で成り立っている
    • JWT = ヘッダー + 本文 + 署名
    • 公開鍵を使って署名されている
  • これが、偽造されていないか確かめたい
    • 検証する関数jsonwebtokenjwt.veryfyを使用する
    • jwt.verify(token, publicKey, { algorithms: ['RS256'] });
    • ここで偽造が発覚すると、エラーをthrowする
    • 認証OKなら、「本文」の部分が帰ってくる(以下のような)
      {
        "sub": "1234-5678-90",
        "email": "abc@example.com",
        "email_verified": true,
        "cognito:groups": [],
        "iat": 1234567890,
        "exp": 1234569999,
        "aud": "xxxxClientIdxxxx",
        "iss": "https://cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_aaaaaaa"
      }
      
  • 実際のバックエンドでは、まずトークン検証のための関数verifyJwtTokenを作成する
    // verifyJwtToken.ts
    
    import jwt, { JwtPayload } from "jsonwebtoken";
    import jwkToPem from "jwk-to-pem";
    
    // ⓪ 環境変数読み込み
    const REGION = process.env.COGNITO_REGION!; # リージョン
    const USER_POOL_ID = process.env.COGNITO_USER_POOL_ID!; # ユーザープールID
    const JWK_URL = `https://cognito-idp.${REGION}.amazonaws.com/${USER_POOL_ID}/.well-known/jwks.json`; // cognitoの公開鍵URL
    
    export async function verifyJwtToken(token: string): Promise<JwtPayload> {
      // 引数のtokenは、リクエストヘッダーに含まれていたJWTトークン
      
      // ① 公開鍵(JWK)を取得(キャッシュなし)
      const res = await fetch(JWK_URL);
      const { keys } = await res.json();
    
      // ② JWTヘッダを読んで kid を取得
      const decodedHeader = jwt.decode(token, { complete: true }) as any;
      if (!decodedHeader?.header?.kid) {
        throw new Error("Invalid token header");
      }
    
      const kid = decodedHeader.header.kid;
    
      // ③ kid に対応する公開鍵を探す
      // ユーザープールには、複数の公開鍵あるので、どのキーかを特定する情報(kid=キーID)がある。
      const jwk = keys.find((k: any) => k.kid === kid);
      if (!jwk) {
        throw new Error("Unknown kid");
      }
    
      // ④ JWK → PEM(公開鍵)に変換
      // ユーザープールにはJSON形式でキーが入っているが、jwt.verifyではpem形式を要求しているため変換
      const publicKey = jwkToPem(jwk);
    
      // ⑤ JWT 検証(署名検証 + 有効期限チェック)
      return jwt.verify(token, publicKey) as JwtPayload;
    }
    
  • ミドルウェアで、このverifyJwtTokenを使用してトークン認証必須にする
  • authMiddlewareを作成
    // authMiddleware.ts
    
    import { Request, Response, NextFunction } from "express";
    import { verifyJwtToken } from "./verifyJwtToken";
    
    export async function authMiddleware(req: Request, res: Response, next: NextFunction) {
      const auth = req.headers.authorization;
    
      // リクエストヘッダーにトークンがなければエラーを吐いて終了
      if (!auth || !auth.startsWith("Bearer ")) {
        return res.status(401).json({ message: "Token missing" });
      }
    
      // JWTトークンを取り出す
      const token = auth.replace("Bearer ", "").trim();
    
      try {
        // トークンを検証する
        const payload = await verifyJwtToken(token);
        req.user = payload; // reqにuser=JWTの中身という情報を付与して、本処理に渡す
        next(); // 本処理に進む
      } catch (err) {
        console.error("JWT verification error:", err);
        return res.status(401).json({ message: "Invalid token" });
      }
    }
    
  • エンドポイントにミドルウェアを設定したサーバーは以下
    // server.ts
    import express from "express";
    import { authMiddleware } from "./authMiddleware";
    
    const app = express();
    
    app.use(express.json());
    
    // 認証必須メモ API
    // ミドルウェアで、reqに付与されているJWTトークンが検証される
    // ミドルウェアを通過した場合のみ、メモがフロントに返却される
    app.get("/api/memos", authMiddleware, (req, res) => {
      console.log("User:", req.user); // ログ例
    
      return res.json({
        user: req.user,
        memos: [
          { id: 1, text: "牛乳を買う" },
          { id: 2, text: "Next.js + Cognito 完了!" }
        ]
      });
    });
    
    app.listen(3001, () => {
      console.log("Backend running at http://localhost:3001");
    });
    

3. フロントエンドにpayloadが返ってくる(フロントエンド)

  • フロントエンドで、バックエンドから送られてきたpayloadを受け取る
  • UI上に表示したし、処理で使用できる
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?