はじめに
IDトークンを使えばABACできるけど、mTLSのみで認証をする場合、どうやるんだろう?と思い検証してみました。
Claudeが以下の記事を見つけてくれたので、今回はこれを参考に検証してみます!
この記事では以下のようにBackendがEC2の構成ですが、今回はLambdaにしています。
やってみた。
資材はここに格納していますので、ポイント部分のみ説明します。
証明書の作成
最初に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を設定します。
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
にクライアント証明書の情報が付与されているのが分かります。
{
"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
として付与します。
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"
が実際にヘッダを付与している部分です。
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
をキーにデータ取得をするなどでアクセスコントロールをすることを考えています。
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'からドメイン単位でクライアント証明書及び、ペアの秘密鍵を設定しておきます。
これで、クライアント証明書の情報が取得できました。
最後に
いつもプロキシ統合を使ってたので、統合リクエスト・レスポンスの設定で時間を溶かしてしまいました。
API Gatewayのことも勉強しないとなぁ。
後この方法って一般的なのかな?