🐒

Cognitoが発行したJWTトークンをHttpOnly属性を有効にしてCookieに保存したい...!

2023/03/12に公開
2

久しぶりの投稿です。しばらくは初めてWebアプリケーション開発をした際の話を備忘として残していこうと思います。まずは、Cognito を用いたログイン機能開発時にJWTトークンの保存先をどうするかについてつまずいた時の話です。もし、読んで気づいたこと・誤ったこと等書いている場合はご指摘いただけると非常に嬉しいです。

きっかけ

ホスト UI なしで、ログイン機能を開発していたときのことです。aws-amplify を用いて実装しようとしていたときに、色々調べていると、Amplify は Cognito が発行する JWT トークンをデフォルトで localStorage に保存するという仕様だと分かりました。[1]

localStorage は Javascript からアクセスすることができるので、localStorage にトークンがある状態では、XSS 攻撃のターゲットになった際に、トークンが抜き取られてしまう可能性があります。なので、トークンを Javascript で取得できるところに保管しないようにする必要がありました。

調べていると、HttpOnly 属性が ON になっている Cookie に保存するとよいということなので、Amplify の設定を変更して、トークンの保存先を Cookie にしました。

const Auth = {
  region: process.env.REACT_APP_AUTH_REGION,
  userPoolId: process.env.REACT_APP_AUTH_USER_POOL_ID,
  userPoolWebClientId: process.env.REACT_APP_AUTH_USER_POOL_WEB_CLIENT_ID,
  cookieStorage: {
    domain: process.env.REACT_APP_AUTH_COOKIE_STORAGE_DOMAIN,
    path: "/",
    expires: 365,
    sameSite: "lax",
    secure: true,
  },
  authenticationFlowType: "USER_SRP_AUTH",
};

export default Auth;

ただ、1つここで問題が。Cookie に付与する属性に関する値は cookieStorage にまとめましたが、ここに httpOnly: true としても、ブラウザ側に保存されている Cookie には適用されていない。ということがありました。これについて AWS サポートについて問い合わせしたところ、

Amplify はフロントエンドで動作する Javascript を使用したライブラリなので、HttpOnly 属性を付与して Cookie を保存するといったことはできない。[2]

といった回答をいただきました。

HttpOnly 属性は、サーバー側で付与して Cookie を送信することで、Javascript から Cookie にアクセスを行えなくするためのものなので、そういった理由で Amplify 側では付与できない、ということですね。

Apmlify を諦めよう

で、あれば、Amplify を諦めて、サーバーサイドで取得したトークンを Set-Cookie に設定してレスポンスとして送信する際に、HttpOnly 属性を含めることにしました。これを AdminInitiateAuth API[3]を用いて実現することにします。

API Gateway + Lambdaで実現

Lambda で singIn 関数として、以下のようなものを実装しました。この関数は API Gateway の POST メソッドをトリガーとして呼び出されることとし、呼び出される際のリクエストボディにメールアドレスとパスワードの情報が含まれているとします。今回、認証には素のメールアドレスとパスワードを用いていますが、ここはシステムの要件によっても違うと思います。AuthFlowのところを適宜求められる要件のものにするといった感じでしょう。

import { CognitoIdentityProvider } from "@aws-sdk/client-cognito-identity-provider";

export const handler = async (event) => {
  try {
    console.log(event);

    const cognitoIdentityServiceProvider = new CognitoIdentityProvider({
      region: process.env.AUTH_REGION,
    });

    const body = JSON.parse(event.body);
    const params = {
      AuthFlow: "ADMIN_USER_PASSWORD_AUTH",
      AuthParameters: {
        USERNAME: body.email,
        PASSWORD: body.password,
      },
      ClientId: process.env.AUTH_CLIENT_ID,
      UserPoolId: process.env.AUTH_USER_POOL_ID,
    };
    const result = await cognitoIdentityServiceProvider.adminInitiateAuth(
      params
    );
    console.log(result);

    // 初回ログイン
    if (result.ChallengeName == "NEW_PASSWORD_REQUIRED") {
      const session = result.Session;
      const ssCookie =
        "session=" +
        session +
        "; Path=/; Max-Age=3600; SameSite=strict; Secure; HttpOnly";
      const response = {
        isBase64Encoded: false,
        statusCode: 200,
        headers: {
          "Access-Control-Allow-Credentials": true,
          "Access-Control-Allow-Headers": "application/json",
          "Access-Control-Allow-Origin": "http://localhost:3000",
          "Access-Control-Allow-Methods": "OPTIONS, POST, GET, DELETE",
          "Set-Cookie": ssCookie,
        },
        body: JSON.stringify(result),
      };
      return response;
      // 本パス設定後ログイン
    } else {
      const accessToken = result.AuthenticationResult.AccessToken;
      const idToken = result.AuthenticationResult.IdToken;
      const refreshToken = result.AuthenticationResult.RefreshToken;

      const atCookie =
        "accessToken=" +
        accessToken +
        "; Path=/; Max-Age=3600; SameSite=strict; Secure; HttpOnly";
      const idCookie =
        "idToken=" +
        idToken +
        "; Path=/; Max-Age=3600; SameSite=strict; Secure; HttpOnly";
      const rfCookie =
        "refreshToken=" +
        refreshToken +
        "; Path=/; Max-Age=3600; SameSite=strict; Secure; HttpOnly";
      const response = {
        isBase64Encoded: false,
        statusCode: 200,
        headers: {
          "Access-Control-Allow-Credentials": true,
          "Access-Control-Allow-Headers": "application/json",
          "Access-Control-Allow-Origin": "http://localhost:3000",
          "Access-Control-Allow-Methods": "OPTIONS, POST, GET, DELETE",
          "Set-Cookie": atCookie,
          "Set-CookiE": idCookie,
          "Set-CookIE": rfCookie,
        },
        body: JSON.stringify(result),
      };
      return response;
    }
  } catch (e) {
    console.error(e);
    const response = {
      isBase64Encoded: false,
      statusCode: e["$metadata"]["httpStatusCode"],
      headers: {
        "Access-Control-Allow-Credentials": true,
        "Access-Control-Allow-Headers": "application/json",
        "Access-Control-Allow-Origin": "http://localhost:3000",
        "Access-Control-Allow-Methods": "OPTIONS, POST, GET, DELETE",
      },
      body: JSON.stringify(e),
    };
    return response;
  }
};

Cookie 確認

フロント側の実装は省略します。下記のように必要な情報を入力する欄を用意して、ログインボタンを押下した際に用意したAPIを叩かせるような実装にします。

下記のようにトークンがそれぞれHttpOnly属性が有効になった状態で保存されていることが確認できました。いったんこれでやりたかったことは実現できました。

おわりに

とはいえ、あくまで、Cookie の HttpOnly 属性は「Cookie を盗まれる」ことが防げるだけで、サイトやアプリケーションに対するリクエスト自体には Cookie を付与されることから、XSS攻撃CSRF 攻撃自体を防ぐわけではないので、注意が必要ですね。

追記:一部記載に誤りがありました。申し訳ございません。SENKEN様、ご指摘ありがとうございます!

脚注
  1. https://docs.amplify.aws/lib/auth/manageusers/q/platform/js/#managing-security-tokens ↩︎

  2. https://developer.mozilla.org/ja/docs/Web/HTTP/Cookies ↩︎

  3. https://docs.aws.amazon.com/ja_jp/cognito-user-identity-pools/latest/APIReference/API_AdminInitiateAuth.html ↩︎

Discussion

SENKENSENKEN

同じ悩みを抱えていたので大変参考になります!

一点記事へのコメントです。
最後で「XSS 攻撃自体を防ぐわけではない」とおっしゃっていますが、Cookieに関する攻撃はCSRF攻撃だと思います!

YusukeYusuke

コメントありがとうございます。

大変失礼しました...ご指摘の通りでしたね...
とても助かります!ありがとうございます!