この記事について
Cognito UserPoolで認可認証するWEBアプリをいくつか作成してきたので大まかなフローについてまとめる
全機能を網羅するのはさすがに難しいので以下に絞って書いていく
- UserPoolへのサインアップ
- サーバーサイドで認証するフロー(
ADMIN_USER_PASSWORD_AUTH
)についてまとめる - MFAデバイス登録については触れない
- Lambdaを使って認証フローをカスタマイズする機能についても触れない
- サーバーサイドで認証するフロー(
- トークン取得
- OAuth2.0
AuthorizationCodeGrant
によるアクセストークン等の取得方法についてまとめる - OIDCやUserPoolのサインインAPIによるトークン取得方法については触れない
- また、IDプールによるユーザー毎の権限管理等についても触れない
- OAuth2.0
サインアップ
フローの確認
超ざっくりとしたフローはこんな感じになる↓※クライアント=フロントエンド+バックエンド
そしてこのフローを実際にCognito UserPool APIを使って実装する場合のシーケンス図が↓※クライアントとCognitoの間にCognitoを操作するためのAPIサーバーをかませている
インフラ構成図
シーケンス図の登場人物をインフラ構成図に書き出した
※クライアントは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
スタックの編集
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、ライブラリはexpress、serverless-express、aws-sdk-v3を使う
設定ファイルを用意
{
"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
しておく
{
"compilerOptions": {
"sourceMap": true,
"target": "ES2021",
"module": "ES2020",
"moduleResolution": "node"
}
}
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"],
},
};
実装
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で実装する
フロントエンド
<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
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 build
→node dist/main.js
で実行
ブラウザでhttp://localhost:3000にアクセス
こんな感じの画面が表示されるのでメールアドレスとパスワードを入力してサブミット
するとサインアップの確認コードメールが届く
UserPoolを確認するとユーザーが作成されている
確認コードを入力してサブミットすると確認ステータスが確認済みになる
トークン取得
Cognitoのユーザー作成に成功したのでOAuth2.0のAuthorizationCodeGrantでアクセストークンを取得する
シーケンス図
実装
client/
に移動してライブラリを追加する
npm i uuid cookie-parser
npm i -D @types/uuid @types/cookie-parser
バックエンドを編集
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 build
→node dist main.js
でサーバーを起動してhttp://localhost:300 にアクセス
実行
をクリックするとCognitoの認証画面へリダイレクトする
Email
Password
を入力してSign in
をクリック
するとローカルに建てたサーバーへリダイレクトしIDTokenを取得する処理が走る
サーバーのログに以下のように表示されていれば成功
{
"id_token": "ID_TOKEN",
"expires_in": 3600,
"token_type": "Bearer"
}