🏸

CDK v2 でAzureADとOIDC連携するAPI Gateway - Lambdaを作成する

2022/03/16に公開

背景

AzureADに登録されている特定のユーザーだけにwebAPI叩かせたい時ってありますよね。
色々調べると、API Gateway v2 (HTTP API)ではかなり楽にIdPと連携できるようになっていたので、存分にその恩恵を享受しました。

さて、いい感じに出来たし、CDKで構築しますか

CDK v2の記事全然ねえ....
ということで備忘録を兼ねてまとめておきます。

詳しく書かないこと

  • OIDC認証について
  • ReactアプリとAzureAD連携の部分

構成

構成図

AzureAD

連携するアプリをクライアントとして登録します。
書きたいことは山程ありますが、、割愛!AzureAD GUIのクセがすごいんじゃ。

フロントエンド

create-react-appで作成。npm buildした成果物をS3に配置し、cloudfrontで提供。
まずログイン画面を表示し、AzureADでの認証に成功するとIDトークンの取得を行うようにした。

ログイン画面

reactアプリとAzureADの連携は、MicroSoft Authenticate Library(MSAL)にお世話になりました。機会があれば紹介します。

取得したIDトークンはただの文字列なので、axiosでheaderに載せてAPIアクセスを行います。

const response = await axios
    .get(`${API_URL}`, {
        headers: {
	    Authorization: `Bearer ${idToken}`,
	},
    })
    .catch((error) => {...})

ここでのBearerはBearerトークンであることの意思表示です。
Bearerトークンは持参人トークンというピンとこない日本語で呼ばれており、「トークンさえ持っていれば、その持ち主の検証はしない。」ということを意味しています。(切符みたいなものですね)

バックエンド

API Gateway v2 & lambda & RDSの基本的な構成を採用。JWTオーソライザーとしてIdPを簡単に登録できるのでREST APIではなくHTTP APIにしました。

API Gatewayにおいて、ヘッダーで受け取ったIDトークンの検証をAzureADと連携して行い、
AzureAD上に登録されたユーザーからのリクエストであれば後続のlambdaを呼び出します。
不正なIDトークンの場合は401 Unauthorizedを返します。
これにより、lambda上でIDトークンの検証を行う必要がなくなります。

lambdaからはAurora serverlessクラスターにクエリを投げます。
AuroraはVPC&private subnetが必要なので、そのあたりも適宜用意します。

環境

$ cdk --version
2.16.0 (build 4c77925)

バックエンドのCDKコード

例として https://API_ROOT_URL/name に対するGETリクエストのAPIを作成します。
とりあえずコード全体はこちら。

import {
  Stack,
  StackProps,
  Duration,
  aws_lambda as lambda,
  aws_ec2 as ec2,
  aws_rds as rds,
} from "aws-cdk-lib";
import * as apigw from "@aws-cdk/aws-apigatewayv2-alpha";
import { HttpLambdaIntegration } from "@aws-cdk/aws-apigatewayv2-integrations-alpha";
import { Construct } from "constructs";
import * as path from "path";

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

    // Aurora用VPCの定義
    const vpc = new ec2.Vpc(this, "Vpc", {
      cidr: VPC_CIDR,
      // privatesubnetのみ作成。isolatedなので、NAT Gatewayも作られない。
      subnetConfiguration: [
        {
          cidrMask: 26,
          name: "rds",
          subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
        },
      ],
      maxAzs: 2,
      vpcName: "vpcName",
    });

    // Aurora serverlessの定義
    const cluster = new rds.ServerlessCluster(this, "Cluster", {
      engine: rds.DatabaseClusterEngine.AURORA_MYSQL,
      vpc,
      parameterGroup: rds.ParameterGroup.fromParameterGroupName(
        this,
        "ParameterGroup",
        "default.aurora-mysql5.7"
      ),
      scaling: {
        autoPause: Duration.minutes(10),
        minCapacity: rds.AuroraCapacityUnit.ACU_1,
        maxCapacity: rds.AuroraCapacityUnit.ACU_2,
      },
      enableDataApi: true,
      vpcSubnets: {
        subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
      },
    });

    // API Gatewayの定義
    const httpApi = new apigw.HttpApi(this, "httpApi", {
      apiName: "apiName",
      corsPreflight: {
        allowHeaders: ["Authorization", "Content-Type"],
        allowMethods: [
          apigw.CorsHttpMethod.GET,
          apigw.CorsHttpMethod.POST,
          apigw.CorsHttpMethod.OPTIONS,
        ],
        allowOrigins: ["*"],
        maxAge: Duration.seconds(300),
      },
    });
    // API Gateway Authorizerの定義
    const httpApiAuthorizer = new apigw.HttpAuthorizer(
      this,
      "httpApiAuthorizer",
      {
        httpApi: httpApi,
        identitySource: ["$request.header.Authorization"],
        type: apigw.HttpAuthorizerType.JWT,
        authorizerName: "AzureAD",
	// アプリケーションIDを指定
        jwtAudience: [JWT_AUDIENCE],
	// JWTのIssuerを指定
        jwtIssuer: JWT_ISSUER,
      }
    );
    // 何故かhttpApiAuthorizerはaddRoutesのauthorizerに渡せないので、渡せる形式に変換
    const iHttpRouteAuthorizer =
      apigw.HttpAuthorizer.fromHttpAuthorizerAttributes(
        this,
        "iHttpRouteAuthorizer",
        {
          authorizerId: httpApiAuthorizer.authorizerId,
          authorizerType: "JWT",
        }
      );

    // APIのlambda
    const getNameLambda = new lambda.Function(
      this,
      "get-name-lambda",
      {
        runtime: lambda.Runtime.PYTHON_3_9,
        handler: "getName.lambda_handler",
        code: lambda.Code.fromAsset(path.join(__dirname, "../lambda")),
        environment: {
          CLUSTER_ARN: cluster.clusterArn,
          SECRET_ARN: cluster.secret!.secretArn,
        },
        functionName: "get-name",
        timeout: Duration.minutes(2),
      }
    );
    cluster.grantDataApiAccess(getNameLambda);
    // API path指定
    httpApi.addRoutes({
      path: "/name",
      methods: [apigw.HttpMethod.GET],
      integration: new HttpLambdaIntegration(
        "get-name-integration",
        getNameLambda
      ),
      // JWT認証のオーソライザーを定義
      authorizer: iHttpRouteAuthorizer,
    });

API Gateway向けライブラリのimport

まだstableではないので-alphaライブラリからimport

import * as apigw from "@aws-cdk/aws-apigatewayv2-alpha";
import { HttpLambdaIntegration } from "@aws-cdk/aws-apigatewayv2-integrations-alpha";

API Gateway

素直に設定していく。CORS設定はcorsPreflightに詰めていく。allowMethodsは適宜修正する。

const httpApi = new apigw.HttpApi(this, "httpApi", {
  apiName: "apiName",
    corsPreflight: {
      allowHeaders: ["Authorization", "Content-Type"],
      allowMethods: [
        apigw.CorsHttpMethod.GET,
        apigw.CorsHttpMethod.POST,
        apigw.CorsHttpMethod.OPTIONS,
      ],
      allowOrigins: ["*"],
      maxAge: Duration.seconds(300),
    },
  }
);

API Gateway Authorizer

JWTオーソライザーの設定。
jwtAudienceはAzureAD上で登録したアプリケーションのアプリケーション(クライアント)ID、
jwtIssuerは https://login.microsoftonline.com/{AzureADのテナントID}/v2.0
これらはIDトークンをデコードして、audとissから取得することも出来る。

// API Gateway Authorizerの定義
const httpApiAuthorizer = new apigw.HttpAuthorizer(
  this,
  "httpApiAuthorizer",
  {
    httpApi: httpApi,
    identitySource: ["$request.header.Authorization"],
    type: apigw.HttpAuthorizerType.JWT,
    authorizerName: "AzureAD",
    // アプリケーションIDを指定
    jwtAudience: [JWT_AUDIENCE],
    // JWTのIssuerを指定
    jwtIssuer: JWT_ISSUER,
  }
);

更に、定義したauthorizerをimportし、httpApiで受け取れる変数(iHttpRoute型)に変換する

const iHttpRouteAuthorizer =
  apigw.HttpAuthorizer.fromHttpAuthorizerAttributes(
    this,
    "iHttpRouteAuthorizer",
    {
      authorizerId: httpApiAuthorizer.authorizerId,
      authorizerType: "JWT",
    }
  );

lambdaの定義

RDSにアクセスするための権限を付与するのを忘れない(忘れた)

    // APIのlambda
    const getNameLambda = new lambda.Function(
      this,
      "get-name-lambda",
      {
        runtime: lambda.Runtime.PYTHON_3_9,
        handler: "getName.lambda_handler",
        code: lambda.Code.fromAsset(path.join(__dirname, "../lambda")),
	// DataApiでアクセスするので、自動生成されるclusterArnとsecretArnを渡してあげればOK
        environment: {
          CLUSTER_ARN: cluster.clusterArn,
          SECRET_ARN: cluster.secret!.secretArn,
        },
        functionName: "get-name",
        timeout: Duration.minutes(2),
      }
    );
    // DataApiへのアクセス権限をlambdaに付与!!!
    cluster.grantDataApiAccess(getNameLambda);

APIの定義

addRoutesメソッドでAPIを追加していく。
authorizerにはiHttpRoute型の変数を渡してあげる。

// API path指定
httpApi.addRoutes({
  path: "/name",
  methods: [apigw.HttpMethod.GET],
  integration: new HttpLambdaIntegration(
    "get-name-integration",
    getNameLambda
  ),
  // JWT認証のオーソライザーを定義
  authorizer: iHttpRouteAuthorizer,
});

最後に

v2の情報は本当に少ないですね、、、
どなたかの役に立てば嬉しいです。

Discussion