2
0

More than 1 year has passed since last update.

Cognito UserPool Oauth2.0 AuthorizationCodeGrantで認可認証する

Posted at

この記事について

Cognito UserPoolで認可認証するWEBアプリをいくつか作成してきたので大まかなフローについてまとめる
全機能を網羅するのはさすがに難しいので以下に絞って書いていく

  • UserPoolへのサインアップ
    • サーバーサイドで認証するフロー(ADMIN_USER_PASSWORD_AUTH)についてまとめる
    • MFAデバイス登録については触れない
    • Lambdaを使って認証フローをカスタマイズする機能についても触れない
  • トークン取得
    • OAuth2.0 AuthorizationCodeGrantによるアクセストークン等の取得方法についてまとめる
    • OIDCやUserPoolのサインインAPIによるトークン取得方法については触れない
    • また、IDプールによるユーザー毎の権限管理等についても触れない

サインアップ

フローの確認

超ざっくりとしたフローはこんな感じになる↓※クライアント=フロントエンド+バックエンド
Untitled Diagram.png

そしてこのフローを実際にCognito UserPool APIを使って実装する場合のシーケンス図が↓※クライアントとCognitoの間にCognitoを操作するためのAPIサーバーをかませている

インフラ構成図

シーケンス図の登場人物をインフラ構成図に書き出した
Untitled Diagram (1).png
※クライアントはDockerでコンテナ化してECSにデプロイした方がいいけど今回は横着してローカルに建てる

インフラの実装

ディレクトリ構成

hoge/
  ├─ client/
  └─ infra/

インフラ

CDKv2でインフラをコード化する
※スタック名とか適当なので適宜変更してもOK

CDKのインストール

npm i -g aws-cdk
cdk --version

プロジェクト初期化

cd infra
cdk init sample-app --language typescript
cdk bootstrap

スタックの編集

/infra/lib/infra-stack.ts
import { RemovalPolicy, Stack, StackProps } from "aws-cdk-lib";
import * as cognito from "aws-cdk-lib/aws-cognito";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as logs from "aws-cdk-lib/aws-logs";
import * as apigateway from "aws-cdk-lib/aws-apigateway";
import { Construct } from "constructs";

const CALLBACK_URL = "http://localhost:3000/auth/authorize/callback";
const DOMAIN_PREFIX = "hoge"; // ※1

export class InfraStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    // UserPool
    const userPool = new cognito.UserPool(this, "UserPool", {
      email: cognito.UserPoolEmail.withCognito(), // ※2
      passwordPolicy: {
        minLength: 8,
        requireDigits: false,
        requireLowercase: false,
        requireUppercase: false,
        requireSymbols: false,
      },
      selfSignUpEnabled: true,
      signInAliases: {
        email: true,
      },
      userPoolName: "cognito-test-user-pool",
      removalPolicy: RemovalPolicy.DESTROY,
    });

    // UserPoolのアプリクライアント
    const userPoolClient = new cognito.UserPoolClient(this, "UserPoolClient", {
      userPool: userPool,
      disableOAuth: false,
      generateSecret: true, // ※3
      oAuth: {
        flows: { authorizationCodeGrant: true },
        callbackUrls: [CALLBACK_URL],
      },
      supportedIdentityProviders: [
        cognito.UserPoolClientIdentityProvider.COGNITO,
      ],
      authFlows: {
        adminUserPassword: true,
      },
      userPoolClientName: "cognito-test-user-pool-client",
    });

    // UserPoolのドメイン
    const userPoolDomain = new cognito.UserPoolDomain(this, "UserPoolDomain", {
      userPool: userPool,
      cognitoDomain: {
        domainPrefix: DOMAIN_PREFIX,
      },
    });

    // 認可認証サーバーのLambda関数
    const authFunction = new lambda.Function(this, "AuthFunction", {
      code: lambda.Code.fromAsset("./lib/lambda/auth/dist"),
      runtime: lambda.Runtime.NODEJS_14_X,
      handler: "main.handler",
    });

    // 認可認証サーバーのLambda関数のロググループ
    const authFunctionLog = new logs.LogGroup(this, "AuthFunctionLogGroup", {
      logGroupName: `/aws/lambda/${authFunction.functionName}`,
      removalPolicy: RemovalPolicy.DESTROY,
    });

    // 認可認証サーバーのAPI Gateway
    const restApi = new apigateway.RestApi(this, "RestAPI", {
      endpointTypes: [apigateway.EndpointType.REGIONAL],
      defaultCorsPreflightOptions: {
        allowOrigins: ["http://localhost:3000"],
        allowMethods: apigateway.Cors.ALL_METHODS,
        allowHeaders: apigateway.Cors.DEFAULT_HEADERS,
        statusCode: 200,
      },
      restApiName: "cognito-test-rest-api",
    });

    const authFunctionIntegration = new apigateway.LambdaIntegration(
      authFunction
    );

    // "/auth"
    const authResource = restApi.root.addResource("auth");

    // "/users"
    const usersResource = authResource.addResource("users");
    usersResource.addMethod("POST", authFunctionIntegration);

    // "/users/confirm"
    const usersConfirmResource = usersResource.addResource("confirm");
    usersConfirmResource.addMethod("POST", authFunctionIntegration);
  }
}

※1 任意の文字列(ただし他のAWSアカウントがすでに使用している値は不可)
※2 サインアップ確認メールをCognitoで送信する(無料だけど1日あたりの送信数に制限があるので本番ではSESを使おう)
※3 クライアントシークレットを作成する(SPAなどバックエンドがないクライアントを使う場合はfalseにする)

認可認証サーバー

Cognito UserPool API(SignUp、ConfirmSignUp)を叩く認可認証サーバーのコードを書く
言語はTypeScript、ライブラリはexpressserverless-expressaws-sdk-v3を使う

設定ファイルを用意

infra/lib/lambda/auth/package.json
{
  "name": "auth",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "build": "webpack"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@types/express": "^4.17.13",
    "ts-loader": "^9.2.6",
    "typescript": "^4.5.5",
    "webpack": "^5.69.1",
    "webpack-cli": "^4.9.2"
  },
  "dependencies": {
    "@aws-sdk/client-cognito-identity-provider": "^3.53.0",
    "@vendia/serverless-express": "^4.5.4",
    "crypto": "^1.0.1",
    "express": "^4.17.3"
  }
}

書き終わったらnpm installしておく

infra/lib/lambda/auth/tsconfig.json
{
  "compilerOptions": {
    "sourceMap": true,
    "target": "ES2021",
    "module": "ES2020",
    "moduleResolution": "node"
  }
}
infra/lib/lambda/auth/webpack.config.js
const path = require("path");

module.exports = {
  mode: "development",
  entry: {
    handler: path.resolve(__dirname, "src/main.ts"),
  },
  module: {
    rules: [
      {
        test: /\.ts/,
        use: "ts-loader",
      },
    ],
  },
  target: "node",
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "main.js",
    libraryTarget: "commonjs",
  },
  resolve: {
    extensions: [".ts", ".js"],
  },
};

実装

infra/lib/lambda/auth/src/main.ts
import serverless from "@vendia/serverless-express";
import * as express from "express";
import {
  CognitoIdentityProvider,
  ConfirmSignUpCommand,
  SignUpCommand,
} from "@aws-sdk/client-cognito-identity-provider";
import { createHmac } from "crypto";

const cognito = new CognitoIdentityProvider({});

function secretHash(
  userName: string,
  clientId: string,
  clientSecret: string
): string {
  return createHmac("sha256", clientSecret)
    .update(userName + clientId)
    .digest("base64");
}

const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

const router = express.Router();

router.post("/auth/users", async (req, res) => {
  const signUpCommand = new SignUpCommand({
    ClientId: req.body.client_id,
    SecretHash: secretHash(
      req.body.email,
      req.body.client_id,
      req.body.client_secret
    ),
    Username: req.body.email,
    Password: req.body.password,
  });
  await cognito.send(signUpCommand).catch((error) => {
    console.error(error);
    res.status(400).send({
      message: "SignUp failed.",
    });
  });
  res.status(200).send({
    message: "SignUp succeed",
  });
});
router.post("/auth/users/confirm", async (req, res) => {
  const confirmSignUpCommand = new ConfirmSignUpCommand({
    ClientId: req.body.client_id,
    SecretHash: secretHash(
      req.body.email,
      req.body.client_id,
      req.body.client_secret
    ),
    Username: req.body.email,
    ConfirmationCode: req.body.confirmation_code,
  });
  await cognito.send(confirmSignUpCommand).catch((error) => {
    console.error(error);
    res.status(400).send({
      message: "ConfirmSignUp failed",
    });
  });
  res.status(200).send({
    message: "ConfirmSignUp succeed",
  });
});

app.use("/", router);

exports.handler = serverless({
  app: app,
});

main.tsがかけたらnpm run buildでビルド
dist/main.jsが作成されていればOK

デプロイ

/infraに戻ってcdk deploy InfraStackでデプロイする
デプロイできたらCognito UserPoolのアプリクライアントからClientSecretを取得し、infra-stack.tsでハードコードしていた部分を書き換えて再デプロイする(これでlambdaの環境変数が書き換えられる)

クライアントの実装

フロントエンドはHTML、バックエンドはexpressで実装する

フロントエンド

client/public/index.html
<html lang="ja">
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <title>Cognitoテスト</title>
  </head>
  <body>
    <h1>Cognitoテスト</h1>

    <h2>サインアップ</h2>
    <form class="signup-form" action="/auth/users" method="post">
      <div class="form-row">
        <label for="email">メールアドレス</label>
        <input type="email" name="email" />
      </div>
      <div class="form-row">
        <label for="password">パスワード</label>
        <input type="password" name="password" />
      </div>
      <div class="form-row">
        <input type="submit" value="実行" />
      </div>
    </form>

    <h2>サインアップ確認</h2>
    <form
      class="confirm-signup-form"
      action="/auth/users/confirm"
      method="post"
    >
      <div class="form-row">
        <label for="email">メールアドレス</label>
        <input type="email" name="email" />
      </div>
      <div class="form-row">
        <label for="confirmation_code">確認コード</label>
        <input name="confirmation_code" />
      </div>
      <div class="form-row">
        <input type="submit" value="実行" />
      </div>
    </form>
  </body>
</html>

バックエンド

設定ファイルはLambdaのディレクトリのものと似たような感じで
使うライブラリは↓

npm i express node-fetch path
npm i -D @types/express typescript webpack webpack-cli
client/src/main.ts
import * as express from "express";
import * as path from "path";
import fetch from "node-fetch";

const STATIC_DIR = path.join(__dirname, "../public");
const HTML_PATH = path.join(STATIC_DIR, "index.html");
const API_GATEWAY_EDNPOINT = ""; // ※
const CLIENT_ID = ""; // ※
const CLIENT_SECRET = ""; // ※

const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.static(STATIC_DIR));

const router = express.Router();

router.get("/", (_, res) => res.sendFile(HTML_PATH));
router.post("/auth/users", async (req, res) => {
  const headers = {
    "Content-Type": "application/json",
  };
  const body = JSON.stringify({
    email: req.body.email,
    password: req.body.password,
    client_id: CLIENT_ID,
    client_secret: CLIENT_SECRET,
  });
  await fetch(API_GATEWAY_EDNPOINT + "/auth/users", {
    method: "POST",
    headers,
    body,
  });
  res.sendFile(HTML_PATH);
});
router.post("/auth/users/confirm", async (req, res) => {
  const headers = {
    "Content-Type": "application/json",
  };
  const body = JSON.stringify({
    email: req.body.email,
    confirmation_code: req.body.confirmation_code,
    client_id: CLIENT_ID,
    client_secret: CLIENT_SECRET,
  });
  await fetch(API_GATEWAY_EDNPOINT + "/auth/users/confirm", {
    method: "POST",
    headers,
    body,
  });
  res.sendFile(HTML_PATH);
});

app.use("/", router);

app.listen(3000, () => console.log("server listen on port 3000"));

※各パラメータをハードコードする

動作確認

できたらnpm run buildnode dist/main.jsで実行
ブラウザでhttp://localhost:3000にアクセス

スクリーンショット 2022-02-27 164051.jpg
こんな感じの画面が表示されるのでメールアドレスとパスワードを入力してサブミット
するとサインアップの確認コードメールが届く
スクリーンショット 2022-02-27 164305.jpg
UserPoolを確認するとユーザーが作成されている
スクリーンショット 2022-02-27 164800.jpg

確認コードを入力してサブミットすると確認ステータスが確認済みになる
スクリーンショット 2022-02-27 164906.jpg

トークン取得

Cognitoのユーザー作成に成功したのでOAuth2.0のAuthorizationCodeGrantでアクセストークンを取得する

シーケンス図

実装

client/に移動してライブラリを追加する

npm i uuid cookie-parser
npm i -D @types/uuid @types/cookie-parser

バックエンドを編集

client/src/main.ts
import * as express from "express";
import * as path from "path";
import fetch from "node-fetch";
// 追加
import { v4 } from "uuid";
import * as cookieParser from "cookie-parser";
// ここまで

const STATIC_DIR = path.join(__dirname, "../public");
const HTML_PATH = path.join(STATIC_DIR, "index.html");
const API_GATEWAY_EDNPOINT = "";
const CLIENT_ID = "";
const CLIENT_SECRET = "";
// 追加
const CUSTOM_DOMAIN = "";
const REGION = "";
const REDIRECT_URI = "http://localhost:3000/auth/authorize/callback";
const COGNITO_ENDPOINT = `https://${CUSTOM_DOMAIN}.auth.${REGION}.amazoncognito.com`;
// ここまで

const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.static(STATIC_DIR));
// 追加
app.use(cookieParser());
// ここまで

const router = express.Router();

router.get("/", (_, res) => res.sendFile(HTML_PATH));
router.post("/auth/users", async (req, res) => {
  const headers = {
    "Content-Type": "application/json",
  };
  const body = JSON.stringify({
    email: req.body.email,
    password: req.body.password,
    client_id: CLIENT_ID,
    client_secret: CLIENT_SECRET,
  });
  await fetch(API_GATEWAY_EDNPOINT + "/auth/users", {
    method: "POST",
    headers,
    body,
  });
  res.sendFile(HTML_PATH);
});
router.post("/auth/users/confirm", async (req, res) => {
  const headers = {
    "Content-Type": "application/json",
  };
  const body = JSON.stringify({
    email: req.body.email,
    confirmation_code: req.body.confirmation_code,
    client_id: CLIENT_ID,
    client_secret: CLIENT_SECRET,
  });
  await fetch(API_GATEWAY_EDNPOINT + "/auth/users/confirm", {
    method: "POST",
    headers,
    body,
  });
  res.sendFile(HTML_PATH);
});
// 追加
router.get("/auth/token", (_, res) => {
  // stateをCookieに保存
  const state = v4();
  res.cookie("state", state, {
    httpOnly: true,
  });
  const url = COGNITO_ENDPOINT + "/oauth2/authorize";
  const query = `?response_type=code&client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}&state=${state}`;
  res.redirect(url + query);
});
router.get("/auth/authorize/callback", async (req, res) => {
  // Cookieのstateとレスポンスのstateが一致するか確認
  if (req.query.state !== req.cookies.state) {
    res.status(400).send({
      message: "Authorization failed.",
    });
  }

  const headers = {
    "Content-Type": "application/x-www-form-urlencoded",
    Authorization:
      "Basic " +
      Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64"),
  };
  const params = new URLSearchParams();
  params.append("grant_type", "authorization_code");
  params.append("code", req.query.code as string);
  params.append("redirect_uri", REDIRECT_URI);
  const tokens = await (
    await fetch(COGNITO_ENDPOINT + "/oauth2/token", {
      method: "POST",
      headers: headers,
      body: params,
    })
  ).json();
  console.log(JSON.stringify(tokens, null, 2));
  res.status(200).send({ message: "succeed" });
});
// ここまで

app.use("/", router);

app.listen(3000, () => console.log("server listen on port 3000"));

動作確認

npm run buildnode dist main.jsでサーバーを起動してhttp://localhost:300 にアクセス

スクリーンショット 2022-03-02 220315.jpg

実行をクリックするとCognitoの認証画面へリダイレクトする
スクリーンショット 2022-03-02 220422.jpg

Email Passwordを入力してSign inをクリック
するとローカルに建てたサーバーへリダイレクトしIDTokenを取得する処理が走る
サーバーのログに以下のように表示されていれば成功

{
  "id_token": "ID_TOKEN",
  "expires_in": 3600,
  "token_type": "Bearer"
}
2
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
2
0