CDK(TypeScript)でAPIにLambdaオーソライザーを適用してみた

CX事業本部の阿部です。

今日は、CDKを使ってLambdaオーソライザーで認可するAPIを作ってみたいと思います。 CDKについては和田のブログが大いに参考になりますので、こちらをご覧ください。

AWS CDK が GA! さっそく TypeScript でサーバーレスアプリケーションを構築するぜ【 Cloud Development Kit 】

今日試したのは、

  1. シンプルなトークンベース
  2. 認可してバックエンドに出力を渡す
  3. 1〜2を行うオーソライザーをもつAPIをCDKでデプロイする

ところまでやってみます。

オーソライザー関数を作る

一般的なLambdaのオーソライザー関数です。オーソライザーのレスポンスに contextauthorizedUser キーを設定しています。これを後続のLambdaに渡します。

export async function handler(event: AuthorizerEvent): Promise<PolicyOutput> {
    return new Promise<PolicyOutput>((resolve) => {
        resolve(AuthorizationUsecase.authorize(event))
    });
}

export class AuthorizationUsecase {
    public static authorize(event: AuthorizerEvent): PolicyOutput {
        const policyStatement = {
            Action: '*',
            Effect: event.authorizationToken==='mytoken' ? 'Allow' : 'Deny',
            Resource: event.methodArn,
        }

        const userId = event.authorizationToken==='mytoken' ? 'my-user-id' : 'null'

        return {
            principalId: 1,
            policyDocument: {
                Version: '2012-10-17',
                Statement: [policyStatement],
            },
            context: {
                authorizedUser: userId,
            }
        }
    } 
}

export interface AuthorizerEvent {
    authorizationToken: string;
    methodArn: string;
}

export interface PolicyOutput {
    principalId: number;
    policyDocument: PolicyDocument;
    context: any;
}

export interface PolicyDocument {
    Version: string;
    Statement: PolicyStatement[];
}

export interface PolicyStatement {
    Action: string;
    Effect: string;
    Resource: string;
}

CDKでリソースを構築する

次に、この関数をCDKで構築します。このセクションで出てくるコードは全て、CDKのインフラコード内に書かれています。和田のエントリーで作成したインフラコードの拡張です。 やったことは、

  1. オーソライザー実行ロールの設定
  2. CfnAuthorizerリソースの作成
  3. バックエンドのLambda Functionのリクエストマッピング修正
  4. 認可するLambda Functionへのオーソライザー登録

の三つです。

ロールの設定

認可時にAPI Gatewayがオーソライザーを実行する必要があるので、そのためのロールを作成します。

    // オーソライザー関数の実行権限をサービスロールとして作成
    const role = new iam.Role(this, 'RestApiAuthHandlerRole', {
      assumedBy: new iam.ServicePrincipal('apigateway.amazonaws.com'),
    });
    authorizerLambda.grantInvoke(role);

    // 同じロールにSTSのAssumeRoleを許可するインラインポリシーをアタッチする
    const policyStatement = new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      actions: ["sts:AssumeRole"],
      resources: ["*"]
    });
    const assumePolicy = new iam.Policy(this, 'StsAssumeForApigateway');
    assumePolicy.addStatements(policyStatement);
    role.attachInlinePolicy(assumePolicy);

CfnAuthorizerリソースの作成

API Gatewayにオーソライザーを登録します。 type で、ヘッダーにトークンを渡す指定をしています。リクエストパラメータで渡すこともできます。 identitySource で、トークンを渡すヘッダー名を指定しています。

    const apiAuthorizer = new apigateway.CfnAuthorizer(this, 'apiAuthorizer', {
      restApiId: api.restApiId,
      authorizerCredentials: role.roleArn,
      authorizerUri: `arn:aws:apigateway:${props? props.env!.region: 'ap-northeast-1'}:lambda:path/2015-03-31/functions/${
        authorizerLambda.functionArn
      }/invocations`,
      identitySource: 'method.request.header.Authorization',
      name: 'lv-api-authorizer',
      type: 'TOKEN'
    });

バックエンドのLambda Functionのリクエスト時のマッピングテンプレート修正

拡張元にしたバックエンドのLambda Functionはプロキシ統合なしで設定されているため、オーソライザーで出力する項目を例とするリクエストのコンテキスト情報などはマッピングテンプレートで指定する必要があります。

    const putGreetingItemIntegration = new apigateway.LambdaIntegration(
      putGreetingItemLambda,
      {
        proxy: false,
        integrationResponses: [
          {
            statusCode: '200',
            responseTemplates: {
              'application/json': '$input.json("$")'
            }
          }
        ],
        passthroughBehavior: apigateway.PassthroughBehavior.WHEN_NO_MATCH,
        requestTemplates: {
          'application/json': "{ \"name\": \"$input.path('$').name\", \"authorizedUser\": \"$context.authorizer.authorizedUser\" }",
        },
      }
    );

requestTemplates$context.authorizer.authorizedUser をマッピングしています。これで後続のLambda FunctionでauthorizedUserを取得できます。

認可されるLambda Functionへのオーソライザー登録

トークンによって認可されるLambda Functionへのオーソライザー登録です。APIのリソースにHTTPメソッドを追加する際に authorizer 属性にオーソライザーのIDを指定します。

    greetingResouce.addMethod(
      'POST',
      putGreetingItemIntegration,
      {
        methodResponses: [{statusCode: '200',}],
        authorizationType: apigateway.AuthorizationType.CUSTOM,
        authorizer: {
          authorizerId: apiAuthorizer.ref,
        },
      }
    );

試してる間にやらかしたつまらないミス

問題が色々混ざって大変でした。皆様におかれましてはこのようなミスをされないようご注意ください。

オーソライザから返すLambda実行のarnの間違い

デプロイして実行しているときに、上記インフラがうまく構築されているにも関わらず、オーソライザーは実行完了になっているのにAPIがAccessDeniedになる、という状況がありました。

で、恥ずかしい話ですが、オーソライザーのLambda実行のARNをワイルドカードで指定して、しかもパスの深度ミスってました。 ワイルドカードとかあまりよろしくないし、リクエストから取れるのでそっちを使うようにすると、認可されたAPIが動くようになりました。

Lambda FunctionでCDKのAPI使ってた

これはオーソライザー書いてすぐのときのやらかしです。デプロイしてLambdaを実行したときに、オーソライザーでライブラリの参照エラーになりました。

オーソライザーは普通のLambdaだし、そのままではLambdaの実行環境には当然CDKはデプロイされないので、参照エラーも仕方ないですね。 型情報をプロジェクトにインストールして使うことになるので全てのコードに対してimport可能だし、ローカルでのビルドはなんならそのまま通ってしまうのでちょっと気をつけないとやらかしがちだな、と思いました。

Lambdaプロキシ統合を利用する際のコンテキスト情報の扱いを間違えて理解していた

AWSのドキュメントを読んでいて、プロキシ統合なしの場合はLambdaの引数の context にセットされて渡されるものだとばかり思ってました。 マッピングテンプレートを作成せずになんでオーソライザーの出力が渡されないんだろう、と悩むこと数時間。結局社内で教えてもらって、ようやくドキュメントを間違えて理解していたことに気づく始末。もう少しAWSのドキュメント読解力が欲しいです。

curlでAPIを叩くときに Content-Type をつけ忘れて実行していた

通りで、リクエストマッピングがパススルーされるわけですね。。。恥ずかしい。