mTLSのクライアント証明書で証明書別アクセスコントロールをしてみる。

## はじめに

IDトークンを使えばABACできるけど、mTLSのみで認証をする場合、どうやるんだろう？と思い検証してみました。

Claudeが以下の記事を見つけてくれたので、今回はこれを参考に検証してみます！

https://aws.amazon.com/jp/blogs/compute/propagating-valid-mtls-client-certificate-identity-to-downstream-services-using-amazon-api-gateway/

この記事では以下のようにBackendがEC2の構成ですが、今回はLambdaにしています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3894042/f37df233-5bf6-4311-b96f-dd4006405d02.png)

## やってみた。

資材はここに格納していますので、ポイント部分のみ説明します。

https://github.com/SatoshiMoriyama/mTLS-ABAC-sample/tree/main

### 証明書の作成

最初にmTLSで必要な証明書を作っていきます。

#### CA証明書


まずは、クライアント証明書を署名する認証局用の証明書です。

```
## CA（認証局）証明書作成
### CA用の秘密鍵の生成
openssl genrsa -out ca.key 4096

### CA証明書の作成
openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 -out ca.crt -subj "/
C=JP/ST=Hokkaido/L=Sapporo/O=MyCompany/OU=IT/CN=MyPrivateCA"
```

CA証明書はバージョニングを有効にしたS3バケット（`template_s3.yaml`）へアップロードしておきます。

#### クライアント証明書

次にクライアント証明書です。

クライアント証明書のサブジェクトに`/C=JP/ST=Hokkaido/L=Sapporo/O=MyCompany/OU=Finance/CN=tenant-001`といった組織情報を付与します。

この組織ベースにアクセスコントロールがしたいのです。

```
## クライアント証明書の作成
### tenant-001用の秘密鍵の生成
openssl genrsa -out tenant-001.key 2048

### 証明書署名要求(CSR)の作成
openssl req -new -key tenant-001.key -out tenant-001.csr -subj "/C=JP/ST=Hokkaido/L=Sapporo/O=MyCompany/OU=Finance/CN=tenant-001"

### CAによるクライアント証明書への署名
openssl x509 -req -in tenant-001.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out tenant-001.crt -days 365 -sha256
```

### カスタムドメインとmTLSの設定

`AWS::Serverless::Api`のプロパティ`Domain`を設定し、それぞれカスタムドメインとmTLSを設定します。

```diff_yaml:templete.yaml
  MyApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: !Ref StageName
+      Domain:
+        DomainName: !Ref DomainName
+        CertificateArn: !Ref CertificateArn
+        EndpointConfiguration: REGIONAL
+        Route53:
+          HostedZoneId: !Ref HostedZoneId
+        BasePath:
+          - !Ref BasePath
+        MutualTlsAuthentication:
+          TruststoreUri: !Ref TrustStoreBucketURI
+          TruststoreVersion: !Ref TrustStoreObjectKey
```

### Lambdaオーサライザの設定

mTLSが有効な状態でバックエンド（プロキシ統合をしたLambda）からevent情報を確認すると、`requestContext.identity.clientCert`にクライアント証明書の情報が付与されているのが分かります。

```json
{
    "resource": "/hello",
    "path": "/v1/hello",
    "httpMethod": "GET",
    "headers": {
        "省略"
    },
    "multiValueHeaders": {
        "省略"
    },
    "queryStringParameters": null,
    "multiValueQueryStringParameters": null,
    "pathParameters": null,
    "stageVariables": null,
    "requestContext": {
        "resourceId": "u28775",
        "resourcePath": "/hello",
        "httpMethod": "GET",
        "extendedRequestId": "KUgdTGoCNjMFTcA=",
        "requestTime": "09/May/2025:21:59:23 +0000",
        "path": "/v1/hello",
        "accountId": "111122223333",
        "protocol": "HTTP/1.1",
        "stage": "Prod",
        "domainPrefix": "mtls",
        "requestTimeEpoch": 1746827963135,
        "requestId": "28509ceb-1631-4adc-ad9d-8fae03926aa8",
        "identity": {
            "cognitoIdentityPoolId": null,
            "clientCert": {
                "clientCertPem": "-----BEGIN CERTIFICATE-----\nMIIEpjCCAo6gAwIBAgIUd7zhSbkJfqFr9n9sEVDRZgpDq64wDQYJKoZIhvcNAQEL\nBQAwaTELMAkGA1UEBhMCSlAxETAPBgNVBAgMCEhva2thaWRvMRAwDgYDVQQHDAdT\nYXBwb3JvMRIwEAYDVQQKDAlNeUNvbXBhbnkxCzAJBgNVBAsMAklUMRQwEgYDVQQD\nDAtNeVByaXZhdGVDQTAeFw0yNTA1MDcyMDAyNTlaFw0yNjA1MDcyMDAyNTlaMG0x\nCzAJBgNVBAYTAkpQMREwDwYDVQQIDAhIb2trYWlkbzEQMA4GA1UEBwwHU2FwcG9y\nbzESMBAGA1UECgwJTXlDb21wYW55MRAwDgYDVQQLDAdGaW5hbmNlMRMwEQYDVQQD\nDAp0ZW5hbnQtMDAxMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwK8t\npy1PPCqwylMUlo6ashii3OkPEYnDlGArk/c4amb1qniQWkqvI5SEds+GLlyi57Dh\nGB6NXcJSBaxX/N6Xl9TVbsHMcDxdYSBJktKczV/QHXZ4bL9zK8oCtKCdeB4p6AAY\nFciwra0FyngIRMnEmoB2ZwQWu91u4xtmCzmzQrr4ThcgFdHi2yyjolf7p3rsSUUy\nScHfd0GrILLLIsiAwyeZbD4ZhCBl0IXvVUgrmmU67nf10yUbYTjCqcv2Hq5o7pjH\nnc2gK/eZFYUaoOLATqQV4Pmn51ykn43wYvuh/resh0UrA4M3MpYQyariflumQNkM\n1coIF0SN086D7VUZnQIDAQABo0IwQDAdBgNVHQ4EFgQUylCeTMX5c3IKpuGrXhn8\nSqYmIhAwHwYDVR0jBBgwFoAUHRD+/MD344HIc/m7n85+R8PWLYUwDQYJKoZIhvcN\nAQELBQADggIBAEp7AaNSmNptwi5uzqpKDqiNdh60z7VzrF6mRLYXTrLsMmILz9pw\nrlZn8GmrYjJFoT9rGAx5ChZDOO+O9pWZdstq9nnj9Ma8Tjj231gKyOzgLVs8ha2B\nz0LZPGjA7t9hj/RtqG7ABW0DhR/skDxIvhFubkglrJaIjbS7eQa8tVk8gJIncLC4\ncHxjq+IpMiJXJmVJyatN0/lJnrTOS0a1H8H6kcNAyHsWe9SrswXICX0iGZbcbE1n\noJXdw3ImOUn8GGjHAueK74SGfaXPQkt1ViD8YpA5iAG2CyEXCqN8HHLghasWe9o7\nIzo0CCPuGwPMZMsQ3t6jbdx+yrNJeez04f4SK8NeI40ori04Q4a6pl7RvHaYEGZY\n+xphRQSGwlQCGJIFMjNLNgGz3adpobpdGKD8fi0iSM7SvdaVGfZgQ3MEzN4ogTla\nG6rhgTYtmr19aRCtvoKiEqn2X0SYaPSkrhDF5EtGQLKmx5MsRr/2xOXfnVV193m5\n96E1ouKqNF613ILCpggNvtRIKRKcsk9IXBeIV7tgBdoDIhxHCieO0eiajP9rnU53\nWjYkFn50lwU/vf8zubLAx+a3yQaHEeeecFAmPVw87X5+tG2AGPXfIK4pmA6L0UP6\nwtqPujLxQfhoR+AL4az0FD4OG+TXIa66eDssdSrDc6xzH5V5cw9OKjfc\n-----END CERTIFICATE-----\n",
                "serialNumber": "683582067176264570083049528076393372651509492654",
                "issuerDN": "C=JP,ST=Hokkaido,L=Sapporo,O=MyCompany,OU=IT,CN=MyPrivateCA",
                "validity": {
                    "notAfter": "May  7 20:02:59 2026 GMT",
                    "notBefore": "May  7 20:02:59 2025 GMT"
                },
                "subjectDN": "C=JP,ST=Hokkaido,L=Sapporo,O=MyCompany,OU=Finance,CN=tenant-001"
            },
            "cognitoIdentityId": null,
            "principalOrgId": null,
            "cognitoAuthenticationType": null,
            "userArn": null,
            "userAgent": "PostmanRuntime/7.43.0",
            "accountId": null,
            "caller": null,
            "sourceIp": "60.69.219.227",
            "accessKey": null,
            "cognitoAuthenticationProvider": null,
            "user": null
        },
        "domainName": "aaa.vvv.com",
        "deploymentId": "tkl1xs",
        "apiId": "56gdg6d"
    },
    "body": null,
    "isBase64Encoded": false
}
```

`requestContext.identity.clientCert`の情報をそのままAPI Gatewayの統合リクエストでバックエンドに流せばいいのですが、プロキシ統合を使わないとなぜか情報が取得できません。。
(理由誰か教えて！)

なので、Lambdaオーソライザで明示的にクライアント証明書の情報を`principalId`として付与します。

```js:authorizer/app.mjs
export const authorizerHandler = async (event) => {
  console.log("> handler", JSON.stringify(event, null, 4));

  const clientCertSub = event.requestContext.identity.clientCert.subjectDN;

  const response = {
    principalId: clientCertSub,
    policyDocument: {
      Version: "2012-10-17",
      Statement: [
        {
          Action: "execute-api:Invoke",
          Effect: "allow",
          Resource: event.methodArn,
        },
      ],
    },
  };

  console.log("Authorizer Response", JSON.stringify(response, null, 4));
  return response;
};

```

### 統合リクエストの設定

Lambdaオーソライザで指定した情報をリクエストヘッダーに付与するため、統合リクエストの設定をします。

プロキシ統合だと付与できないみたいなので、記述が複雑になりますが、`DefinitionBody`属性を使って指定してきます。（少し記載は省略しています。）

`requestTemplates`内の
` "X-Client-Cert-Subject":"$context.authorizer.principalId"`が実際にヘッダを付与している部分です。

```diff_yaml:templete.yaml
  MyApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: !Ref StageName
      Domain:
        DomainName: !Ref DomainName
        CertificateArn: !Ref CertificateArn
        EndpointConfiguration: REGIONAL
        Route53:
          HostedZoneId: !Ref HostedZoneId
        BasePath:
          - !Ref BasePath
        MutualTlsAuthentication:
          TruststoreUri: !Ref TrustStoreBucketURI
          TruststoreVersion: !Ref TrustStoreObjectKey
+      DefinitionBody:
+        swagger: "2.0"
+        info:
+          title: "MyMtlsApi"
+          version: "1.0"
+        securityDefinitions:
+          mTLSAuthorizer:
+            type: "apiKey"
+            name: "Authorization"
+            in: "header"
+            x-amazon-apigateway-authtype: "custom"
+            x-amazon-apigateway-authorizer:
+              type: "request"
+              authorizerUri: !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${AuthorizerFunction.Arn}/invocations"
+              authorizerResultTtlInSeconds: 300
+              identitySource: "method.request.header.Host"
+        paths:
+          /hello:
+            get:
+              security:
+                - mTLSAuthorizer: []
+              produces:
+                - "application/json"
+              responses:
+                "200":
+                  description: "成功レスポンス"
+                  schema:
+                    type: "object"
+              x-amazon-apigateway-integration:
+                type: "aws"
+                uri: !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${HelloWorldFunction.Arn}/invocations"
+                httpMethod: "POST"
+                passthroughBehavior: "when_no_templates"
+                contentHandling: "CONVERT_TO_TEXT"
+                timeoutInMillis: 29000
+                requestTemplates:
+                  application/json: |
+                    {
+                      "body": $input.json('$'),
+                      "headers": {
+                        #foreach($header in $input.params().header.keySet())
+                       "$header": "$util.escapeJavaScript($input.params().header.get($header))"#if($foreach.hasNext),#end
+                       #end,
+                        "X-Client-Cert-Subject":"$context.authorizer.principalId"
+                     }
+                    }
+                responses:
+                  default:
+                    statusCode: "200"
+                    responseTemplates:
+                      application/json: |
+                        $input.json('$')
```

### Backendの作成

バックエンドは、リクエストヘッダ`X-Client-Cert-Subject`の値を、`clientCertSubject`として応答するだけです。

`clientCertSubject`をキーにデータ取得をするなどでアクセスコントロールをすることを考えています。

```js:hello-world/app.mjs
export const lambdaHandler = async (event, context) => {
  console.log("Event:", JSON.stringify(event, null, 2));

  const subject = event.headers && event.headers["X-Client-Cert-Subject"];
  const response = {
    clientCertSubject: subject || "Not available",
  };

  return response;
};

```

### 動作確認

最後にPostmanで動作確認してみます。

クライアント証明書を利用する場合、'Settings'->'Certificates'からドメイン単位でクライアント証明書及び、ペアの秘密鍵を設定しておきます。

![スクリーンショット 2025-05-11 10.12.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3894042/173d590a-e0f0-4f8a-b6df-3c0c389d5952.png)

これで、クライアント証明書の情報が取得できました。

![スクリーンショット 2025-05-11 10.20.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3894042/10a09981-1828-40d5-8e63-0eb03832afb8.png)

## 最後に

いつもプロキシ統合を使ってたので、統合リクエスト・レスポンスの設定で時間を溶かしてしまいました。

API Gatewayのことも勉強しないとなぁ。

後この方法って一般的なのかな？