Lambdaオーソライザー の認証/認可が失敗した場合に特定のレスポンスヘッダーを返したい

Lambdaオーソライザー の認証/認可が失敗した場合に特定のレスポンスヘッダーを返したい

Clock Icon2025.03.14

はじめに

皆様こんにちは、あかいけです。
今回はLambdaオーソライザーの認証/認可が失敗した場合に、
特定のレスポンスヘッダーを返す方法を調べてみました。

やりたいこと

  • Lambdaオーソライザーによる認証/認可の実装
  • 認証失敗時(401)や認可失敗時(403)に特定のレスポンスヘッダー(本記事ではHSTS)を返す

HSTSヘッダー is 何?

今回設定するHSTSヘッダー(Strict-Transport-Security)は、ブラウザに対してHTTPSのみを使用するよう指示するヘッダーです。

通常のリダイレクト(301)との違いは、2回目以降のアクセスでのHTTP通信の有無です。
HSTSは2回目以降のアクセスではHTTPSのみになりますが、リダイレクト(301)はアクセスの度にHTTPからHTTPSへの転送が発生します。
詳細については以下ドキュメントがわかりやすいので、よければこちらでご確認ください。

https://developer.mozilla.org/ja/docs/Web/HTTP/Reference/Headers/Strict-Transport-Security

実装方法

今回はAPI Gatewayのゲートウェイレスポンスを利用します。
ゲートウェイレスポンスを設定することにより、バックエンドのLambda等でエラーを返す処理を記述しなくてもAPI Gateway側でエラーを返すようになります。

またLambdaオーソライザーで認証/認可が失敗した場合、そもそもバックエンドへの処理が発生しないため、ゲートウェイレスポンスを利用することで簡単にレスポンスヘッダーを設定できます。

https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/api-gateway-gatewayResponse-definition.html

ゲートウェイレスポンスにはいくつか種類があり、
Lambdaオーソライザーに関連するものは以下の通りです。

レスポンスタイプ デフォルトステータスコード 該当するパターン
UNAUTHORIZED 401 Lambdaオーソライザーが発信者の認証に失敗した場合など
ACCESS_DENIED 403 Lambdaオーソライザーでアクセスが拒否された場合など
AUTHORIZER_FAILURE 500 Lambdaオーソライザーが発信者の認証に失敗した場合など(サーバー起因)
AUTHORIZER_CONFIGURATION_ERROR 500 API GatewayからLambdaオーソライザーへ接続できなかった場合など

その中でも認証/認可のエラーに関連するのはUNAUTHORIZEDACCESS_DENIEDです。

※ゲートウェイレスポンスの全量は以下をご参照ください
https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/supported-gateway-response-types.html

Cloud Formation コード

今回は以下のコード用意しました。

gateway-response.yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: >
  カスタムヘッダー(x-custom-auth)をチェックし、
  Lambda オーソライザーでの認証/認可が失敗した場合にゲートウェイレスポンスでHSTSヘッダーを返すREST APIです。
  UNAUTHORIZED パターンと ACCESS_DENIED パターンの両方に対応しています。

Resources:
  # API Gateway
  ApiGatewayRestApi:
    Type: AWS::ApiGateway::RestApi
    Properties:
      Name: Api

  # Lambda用 実行ロール
  LambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action: sts:AssumeRole
      Path: /
      Policies:
        - PolicyName: LambdaPolicy
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource: arn:aws:logs:*:*:*

  # バックエンド用 Lambda関数
  LambdaFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: BackendFunction
      Handler: index.handler
      Role: !GetAtt LambdaExecutionRole.Arn
      Runtime: python3.9
      Code:
        ZipFile: |
          def handler(event, context):
              return {
                  "statusCode": 200,
                  "headers": {
                      "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload"
                  },
                  "body": "Hello, World!"
              }

  # Lambdaオーソライザー用 Lambda関数
  LambdaAuthorizerFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: LambdaAuthorizerFunction
      Handler: index.handler
      Role: !GetAtt LambdaExecutionRole.Arn
      Runtime: python3.9
      Code:
        ZipFile: |
          def handler(event, context):
              # "x-custom-auth" ヘッダーが存在するかチェック
              headers = event.get("headers") or {}

              if "x-custom-auth" not in headers:
                  # ヘッダーがない場合は認証エラー(UNAUTHORIZED)
                  print("UNAUTHORIZED")
                  return {
                      "principalId": "user",
                      "policyDocument": {
                          "Version": "2012-10-17",
                          "Statement": [{
                              "Action": "execute-api:Invoke",
                              "Effect": "Deny",
                              "Resource": event["methodArn"]
                          }]
                      }
                  }

              auth_value = headers["x-custom-auth"]

              if auth_value == "allow":
                  # 正しい値の場合は許可
                  print("Allow")
                  return {
                      "principalId": "user",
                      "policyDocument": {
                          "Version": "2012-10-17",
                          "Statement": [{
                              "Action": "execute-api:Invoke",
                              "Effect": "Allow",
                              "Resource": event["methodArn"]
                          }]
                      }
                  }
              else:
                  # 間違った値の場合は拒否(ACCESS_DENIED)
                  print("ACCESS_DENIED")
                  return {
                      "principalId": "user",
                      "policyDocument": {
                          "Version": "2012-10-17",
                          "Statement": [{
                              "Action": "execute-api:Invoke",
                              "Effect": "Deny",
                              "Resource": event["methodArn"]
                          }]
                      }
                  }

  # API Gateway バックエンドLambda呼び出し用パーミッション
  ApiGatewayLambdaPermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !Ref LambdaFunction
      Action: lambda:InvokeFunction
      Principal: apigateway.amazonaws.com
      SourceArn: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${ApiGatewayRestApi}/*/*/*

  # API Gateway Lambdaオーソライザー呼び出し用パーミッション
  ApiGatewayAuthorizerLambdaPermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !Ref LambdaAuthorizerFunction
      Action: lambda:InvokeFunction
      Principal: apigateway.amazonaws.com
      SourceArn: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${ApiGatewayRestApi}/authorizers/*

  # API Gateway リソース
  TestResource:
    Type: AWS::ApiGateway::Resource
    Properties:
      ParentId: !GetAtt ApiGatewayRestApi.RootResourceId
      PathPart: "test"
      RestApiId: !Ref ApiGatewayRestApi

  # Lambdaオーソライザー
  LambdaAuthorizer:
    Type: AWS::ApiGateway::Authorizer
    Properties:
      Name: LambdaAuthorizer
      RestApiId: !Ref ApiGatewayRestApi
      Type: REQUEST
      AuthorizerUri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${LambdaAuthorizerFunction.Arn}/invocations
      IdentitySource: method.request.header.x-custom-auth
      AuthorizerResultTtlInSeconds: 300

  # GET メソッドの定義(Lambda オーソライザーによる認証)
  GetMethod:
    Type: AWS::ApiGateway::Method
    Properties:
      HttpMethod: GET
      ResourceId: !Ref TestResource
      RestApiId: !Ref ApiGatewayRestApi
      AuthorizationType: CUSTOM
      AuthorizerId: !Ref LambdaAuthorizer
      Integration:
        IntegrationHttpMethod: POST
        Type: AWS_PROXY
        Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${LambdaFunction.Arn}/invocations

  # UNAUTHORIZED (401) レスポンス カスタマイズ
  UnauthorizedGatewayResponse:
    Type: AWS::ApiGateway::GatewayResponse
    Properties:
      ResponseType: UNAUTHORIZED
      RestApiId: !Ref ApiGatewayRestApi
      StatusCode: "401"
      ResponseParameters:
        gatewayresponse.header.Strict-Transport-Security: "'max-age=31536000; includeSubDomains; preload'"
      ResponseTemplates:
        application/json: '{"message":"認証エラー: UNAUTHORIZED"}'

  # ACCESS_DENIED (403) レスポンス カスタマイズ
  AccessDeniedGatewayResponse:
    Type: AWS::ApiGateway::GatewayResponse
    Properties:
      ResponseType: ACCESS_DENIED
      RestApiId: !Ref ApiGatewayRestApi
      StatusCode: "403"
      ResponseParameters:
        gatewayresponse.header.Strict-Transport-Security: "'max-age=31536000; includeSubDomains; preload'"
      ResponseTemplates:
        application/json: '{"message":"認可エラー: ACCESS_DENIED"}'

  # API Gateway ステージ デプロイ
  ApiDeployment:
    Type: AWS::ApiGateway::Deployment
    Properties:
      RestApiId: !Ref ApiGatewayRestApi
      StageName: prod
    DependsOn:
      - GetMethod
      - UnauthorizedGatewayResponse
      - AccessDeniedGatewayResponse

Outputs:
  ApiUrl:
    Description: "API Gateway endpoint URL for the prod stage"
    Value: !Sub "https://${ApiGatewayRestApi}.execute-api.${AWS::Region}.amazonaws.com/prod/"

  ApiTestEndpoint:
    Description: "Test endpoint URL"
    Value: !Sub "https://${ApiGatewayRestApi}.execute-api.${AWS::Region}.amazonaws.com/prod/test"

以下のコマンドでデプロイしてください。

aws cloudformation create-stack --stack-name api-gateway-test --template-body file://gateway-response.yaml --capabilities CAPABILITY_NAMED_IAM

実装内容

本検証でのポイントとなる箇所を説明します。

①.Lambdaオーソライザー

Lambdaオーソライザーの認証方法はAPIキーは利用せず、シンプルに特定のリクエストヘッダーが含まれているかを確認しています。

また処理の都合上、認証エラー/認可エラーで分けていますが、
実際のエラー内容はAPI Gateway側で判断されるので、returnの内容は同じとなっています。

Lambdaオーソライザー 設定
  # Lambdaオーソライザー用 Lambda関数
  LambdaAuthorizerFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: LambdaAuthorizerFunction
      Handler: index.handler
      Role: !GetAtt LambdaExecutionRole.Arn
      Runtime: python3.9
      Code:
        ZipFile: |
          def handler(event, context):
              # "x-custom-auth" ヘッダーが存在するかチェック
              headers = event.get("headers") or {}

              if "x-custom-auth" not in headers:
                  # ヘッダーがない場合は認証エラー(UNAUTHORIZED)
                  print("UNAUTHORIZED")
                  return {
                      "principalId": "user",
                      "policyDocument": {
                          "Version": "2012-10-17",
                          "Statement": [{
                              "Action": "execute-api:Invoke",
                              "Effect": "Deny",
                              "Resource": event["methodArn"]
                          }]
                      }
                  }

              auth_value = headers["x-custom-auth"]

              if auth_value == "allow":
                  # 正しい値の場合は許可
                  print("Allow")
                  return {
                      "principalId": "user",
                      "policyDocument": {
                          "Version": "2012-10-17",
                          "Statement": [{
                              "Action": "execute-api:Invoke",
                              "Effect": "Allow",
                              "Resource": event["methodArn"]
                          }]
                      }
                  }
              else:
                  # 間違った値の場合は拒否(ACCESS_DENIED)
                  print("ACCESS_DENIED")
                  return {
                      "principalId": "user",
                      "policyDocument": {
                          "Version": "2012-10-17",
                          "Statement": [{
                              "Action": "execute-api:Invoke",
                              "Effect": "Deny",
                              "Resource": event["methodArn"]
                          }]
                      }
                  }

②.ゲートウェイレスポンス

ゲートウェイレスポンスに関連した設定は以下の箇所です。
こちらでゲートウェイレスポンスの種類ごとにレスポンスヘッダーを設定できます。

ゲートウェイレスポンス 設定
  # UNAUTHORIZED (401) レスポンス カスタマイズ
  UnauthorizedGatewayResponse:
    Type: AWS::ApiGateway::GatewayResponse
    Properties:
      ResponseType: UNAUTHORIZED
      RestApiId: !Ref ApiGatewayRestApi
      StatusCode: "401"
      ResponseParameters:
        gatewayresponse.header.Strict-Transport-Security: "'max-age=31536000; includeSubDomains; preload'"
      ResponseTemplates:
        application/json: '{"message":"認証エラー: UNAUTHORIZED"}'

  # ACCESS_DENIED (403) レスポンス カスタマイズ
  AccessDeniedGatewayResponse:
    Type: AWS::ApiGateway::GatewayResponse
    Properties:
      ResponseType: ACCESS_DENIED
      RestApiId: !Ref ApiGatewayRestApi
      StatusCode: "403"
      ResponseParameters:
        gatewayresponse.header.Strict-Transport-Security: "'max-age=31536000; includeSubDomains; preload'"
      ResponseTemplates:
        application/json: '{"message":"認可エラー: ACCESS_DENIED"}'

レスポンス確認

デプロイが完了したら以下コマンドでAPI GatewayのURLを確認できます。

API Gateway URL
% aws cloudformation describe-stacks --stack-name api-gateway-test --query "Stacks[0].Outputs" --output text
API Gateway endpoint URL for the prod stage     ApiUrl  https://<api-gateway-id>.execute-api.<region>.amazonaws.com/<stage>/
Test endpoint URL       ApiTestEndpoint https://<api-gateway-id>.execute-api.<region>.amazonaws.com/<stage>/<resource>

最後に以下の方法で特定のレスポンスヘッダーが返されるか確認します。

  • 認証エラー (UNAUTHORIZED - 401)
    Lambdaオーソライザーで判定するリクエストヘッダーが付与されていない場合です。
    strict-transport-security(HSTSヘッダー)がレスポンスに含まれていることが確認できます。
% curl -i https://<api-gateway-id>.execute-api.<region>.amazonaws.com/<stage>/<resource>
HTTP/2 401 
content-type: application/json
content-length: 43
date: Fri, 14 Mar 2025 04:19:58 GMT
x-amz-apigw-id: HZgxQFC2tjMEOlg=
x-amzn-requestid: 88a55639-fe1a-4d22-a504-f8c290e71638
strict-transport-security: max-age=31536000; includeSubDomains; preload
x-amzn-errortype: UnauthorizedException
x-cache: Error from cloudfront
via: 1.1 29f44a2f60272cb6e4a119f49c4a4390.cloudfront.net (CloudFront)
x-amz-cf-pop: NRT20-P1
x-amz-cf-id: VU9-8s8ZZLTXMVAhILN8FcbhUI9rUENEMfct230iHhEMPR_BFr6rQg==

{"message":"認証エラー: UNAUTHORIZED"}
  • 認可エラー (ACCESS_DENIED - 403)
    Lambdaオーソライザーで判定するリクエストヘッダーが付与されているが値が誤っている場合です。
    strict-transport-security(HSTSヘッダー)がレスポンスに含まれていることが確認できます。
% curl -i -H "x-custom-auth: deny" https://<api-gateway-id>.execute-api.<region>.amazonaws.com/<stage>/<resource>
HTTP/2 403 
content-type: application/json
content-length: 44
date: Fri, 14 Mar 2025 04:20:12 GMT
x-amz-apigw-id: HZgzbGGgNjMEqZQ=
x-amzn-requestid: f052867b-b01c-4cf0-845d-b9bb390a30cf
strict-transport-security: max-age=31536000; includeSubDomains; preload
x-amzn-errortype: AccessDeniedException
x-cache: Error from cloudfront
via: 1.1 f61e62675297499135b65035072cd836.cloudfront.net (CloudFront)
x-amz-cf-pop: NRT20-P1
x-amz-cf-id: vPw2bdN9PiKkE4B6WOIbJUaBIo4Ap9DnK00k78yTJ8bfqz-JtP7bQg==

{"message":"認可エラー: ACCESS_DENIED"}
  • 成功した場合
    最後にリクエストが成功した場合です。
    バックエンドのLambdaでHSTSヘッダーを返す設定をしているので、こちらもstrict-transport-security(HSTSヘッダー)がレスポンスに含まれていることが確認できます。
% curl -i -H "x-custom-auth: allow" https://<api-gateway-id>.execute-api.<region>.amazonaws.com/<stage>/<resource>
HTTP/2 200 
content-type: application/json
content-length: 13
date: Fri, 14 Mar 2025 04:20:30 GMT
x-amzn-trace-id: Root=1-67d3ae8e-4d442b35130b653042d73c65;Parent=1712c281cb9163a5;Sampled=0;Lineage=2:ea2c6d28:0
x-amzn-requestid: 57ba25fb-2bdf-47d6-9526-100db79b785f
strict-transport-security: max-age=31536000; includeSubDomains; preload
x-amz-apigw-id: HZg2UGJ3tjMEI4A=
x-cache: Miss from cloudfront
via: 1.1 f76b4c0eb6c4658feb5d2183e218bcee.cloudfront.net (CloudFront)
x-amz-cf-pop: NRT20-P1
x-amz-cf-id: BPo-muH3Xi-N3mzTbNJ44yXucFcYK2pYqr0oWfE17dpwefYAUHc0BA==

Hello, World!

その他確認方法

  • ブラウザの開発者モード
    ブラウザの開発者モードで確認することもできます。
    以下はChromeで確認した例です。

まず初回のアクセスでレスポンスヘッダーにstrict-transport-security(HSTSヘッダー)が含まれていることが確認でき、
スクリーンショット 2025-03-14 14.51.29

二回目以降アクセスでHTTPを指定すると、最初に「307 Internal Redirect」 が発生します。
これはブラウザ内で発生するリダイレクトのため、実際の通信はHTTPSで行われます。
また「Non-Authoritative-Reason: HSTS」がレスポンスヘッダーに含まれていますが、これはHSTSによってリダイレクトが発生したことを表しています。

スクリーンショット 2025-03-14 14.52.09

  • HSTS確認サイト
    あとは以下のような確認サイトでも状態を確認できます。

https://www.site24x7.com/ja/tools/hsts.html

さいごに

以上、Lambdaオーソライザーの認証/認可が失敗した場合に特定のレスポンスヘッダーを返す方法でした。
思いのほか簡単にレスポンスヘッダーをカスタマイズでき、改めてAWSのマネージドサービスのパワフルさを感じることができました。

今後もAWSのサービスを深掘りしながら、様々な情報を発信していきますので、よろしくお願いします。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.