diff --git a/.github/workflows/regional_templates.yml b/.github/workflows/regional_templates.yml new file mode 100644 index 0000000..2f0a425 --- /dev/null +++ b/.github/workflows/regional_templates.yml @@ -0,0 +1,170 @@ +name: Publish Regional Templates +on: + push: + tags: + - v1.* +jobs: + build-and-publish: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + strategy: + matrix: + include: + - region: us-east-1 + region-name: US East (N. Virginia) + aws-account-id: "080825576347" + aws-partition: aws + - region: us-east-2 + region-name: US East (Ohio) + aws-account-id: "080825576347" + aws-partition: aws + - region: us-west-1 + region-name: US West (N. California) + aws-account-id: "080825576347" + aws-partition: aws + - region: us-west-2 + region-name: US West (Oregon) + aws-account-id: "080825576347" + aws-partition: aws + - region: af-south-1 + region-name: Africa (Cape Town) + aws-account-id: "080825576347" + aws-partition: aws + - region: ap-east-1 + region-name: Asia Pacific (Hong Kong) + aws-account-id: "080825576347" + aws-partition: aws + - region: ap-south-2 + region-name: Asia Pacific (Hyderabad) + aws-account-id: "080825576347" + aws-partition: aws + - region: ap-southeast-3 + region-name: Asia Pacific (Jakarta) + aws-account-id: "080825576347" + aws-partition: aws + - region: ap-southeast-4 + region-name: Asia Pacific (Melbourne) + aws-account-id: "080825576347" + aws-partition: aws + - region: ap-south-1 + region-name: Asia Pacific (Mumbai) + aws-account-id: "080825576347" + aws-partition: aws + - region: ap-northeast-3 + region-name: Asia Pacific (Osaka) + aws-account-id: "080825576347" + aws-partition: aws + - region: ap-northeast-2 + region-name: Asia Pacific (Seoul) + aws-account-id: "080825576347" + aws-partition: aws + - region: ap-southeast-1 + region-name: Asia Pacific (Singapore) + aws-account-id: "080825576347" + aws-partition: aws + - region: ap-southeast-2 + region-name: Asia Pacific (Sydney) + aws-account-id: "080825576347" + aws-partition: aws + - region: ap-northeast-1 + region-name: Asia Pacific (Tokyo) + aws-account-id: "080825576347" + aws-partition: aws + - region: ca-central-1 + region-name: Canada (Central) + aws-account-id: "080825576347" + aws-partition: aws + - region: ca-west-1 + region-name: Canada (Calgary) + aws-account-id: "080825576347" + aws-partition: aws + - region: eu-central-1 + region-name: Europe (Frankfurt) + aws-account-id: "080825576347" + aws-partition: aws + - region: eu-west-1 + region-name: Europe (Ireland) + aws-account-id: "080825576347" + aws-partition: aws + - region: eu-west-2 + region-name: Europe (London) + aws-account-id: "080825576347" + aws-partition: aws + - region: eu-south-1 + region-name: Europe (Milan) + aws-account-id: "080825576347" + aws-partition: aws + - region: eu-west-3 + region-name: Europe (Paris) + aws-account-id: "080825576347" + aws-partition: aws + - region: eu-south-2 + region-name: Europe (Spain) + aws-account-id: "080825576347" + aws-partition: aws + - region: eu-north-1 + region-name: Europe (Stockholm) + aws-account-id: "080825576347" + aws-partition: aws + - region: eu-central-2 + region-name: Europe (Zurich) + aws-account-id: "080825576347" + aws-partition: aws + - region: il-central-1 + region-name: Israel (Tel Aviv) + aws-account-id: "080825576347" + aws-partition: aws + - region: me-south-1 + region-name: Middle East (Bahrain) + aws-account-id: "080825576347" + aws-partition: aws + - region: me-central-1 + region-name: Middle East (UAE) + aws-account-id: "080825576347" + aws-partition: aws + - region: sa-east-1 + region-name: South America (São Paulo) + aws-account-id: "080825576347" + aws-partition: aws + - region: us-gov-east-1 + region-name: US East + aws-account-id: "282774566237" + aws-partition: aws-us-gov + - region: us-gov-west-1 + region-name: US West + aws-account-id: "282774566237" + aws-partition: aws-us-gov + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + check-latest: true + - run: make build --always-make + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:${{ matrix.aws-partition }}:iam::${{ matrix.aws-account-id }}:role/GitHubActionsJWTIssuerStackTemplatesRole + aws-region: ${{ matrix.region }} + - uses: aws-actions/setup-sam@v2 + with: + use-installer: true + - run: | + sam package \ + --template template.yml \ + --s3-bucket jwt-issuer-stack-templates-${{ matrix.region }} \ + --s3-prefix jwt-issuer-${{ github.ref_name }} \ + --output-template-file jwt-issuer-packaged.yml \ + --region ${{ matrix.region }} + env: + SAM_CLI_TELEMETRY: "0" + - run: | + aws s3 cp \ + jwt-issuer-packaged.yml \ + s3://jwt-issuer-stack-templates-${{ matrix.region }}/jwt-issuer-v1.x.yml \ + --region ${{ matrix.region }} + aws s3 cp \ + jwt-issuer-packaged.yml \ + s3://jwt-issuer-stack-templates-${{ matrix.region }}/jwt-issuer-${{ github.ref_name }}.yml \ + --region ${{ matrix.region }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..893267e --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,20 @@ +name: Test + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + name: Run Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + check-latest: true + - run: go version + - run: make test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..99d9078 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +bin +.aws-sam +samconfig.toml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5f88da4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) JK Tech, Inc. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c7a63a7 --- /dev/null +++ b/Makefile @@ -0,0 +1,26 @@ +MAKE_REL_PATH:=$(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) + +GOLANG ?= go +GOARCH ?= arm64 +GO_BUILD_FLAGS ?= -ldflags="-s -w" +GO_LAMBDA_TAGS ?= -tags "lambda.norpc" +GO_BUILD ?= CGO_ENABLED=0 GOOS=linux GOARCH=${GOARCH} ${GOLANG} build ${GO_BUILD_FLAGS} +GO_BUILD_FILES := $(shell find . -type f -not -path "./.git/*" -not -path "./bin/*" -not -name "*_test.go") + +bin/%/bootstrap: ${GO_BUILD_FILES} + cd cmd/$(patsubst bin/%/bootstrap,%,$@) && ${GO_BUILD} ${GO_LAMBDA_TAGS} -o ${MAKE_REL_PATH}/$@ + +.PHONY: test +test: + ${GOLANG} test -count=1 ./... + +.PHONY: mocks +mocks: + mockery --name=KMSAPI --srcpkg=./internal/issuer --output=internal/mocks + mockery --name=SSMAPI --srcpkg=./internal/issuer --output=internal/mocks + +.PHONY: build +build: bin/key_generator/bootstrap +build: bin/key_info_loader/bootstrap +build: bin/jwt_issuer_kms/bootstrap +build: bin/jwt_issuer_parameter_store/bootstrap diff --git a/README.md b/README.md new file mode 100644 index 0000000..71b28bb --- /dev/null +++ b/README.md @@ -0,0 +1,269 @@ +# JSON Web Token (JWT) Issuer + +This is a [Serverless Application Model (SAM)](https://aws.amazon.com/serverless/sam/) application that provides a Lambda function for signing and issuing JSON Web Tokens (JWTs) with an asymmetric key stored in **either** [AWS Systems Manager Parameter Store](https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-parameter-store.html) or [AWS Key Management Service (KMS)](https://docs.aws.amazon.com/kms/latest/developerguide/symmetric-asymmetric.html#asymmetric-cmks) using the ECDSA_SHA_256 (ES256) signing algorithm. + +It's designed for easy use with [Hotsock](https://github.com/hotsock/hotsock), but can securely issue JWTs for anything. + +This service does not provide functionality for token verification. Instead, the public key is provided in the stack output, which can be used to verify tokens by any external service. + +There are two configuration modes for key custody: Parameter Store and KMS. The API is identical for both modes, so there are no application-level design considerations for mode selection. + +### Parameter Store (default) + +With this mode, your private key material is generated during installation and stored in [Parameter Store](https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-parameter-store.html) as a `SecureString`. Its value is encrypted with KMS using the default AWS managed key. + +When the JWT Issuer Lambda function (cold) starts, it loads the private key value into memory from Parameter Store and uses it to sign keys for the lifetime of that Lambda execution environment. + +You'll grant your internal applications access to invoke this Lambda function and receive signed JWTs _without_ granting them access to the stored private key. + +This mode is fast, cost-effective, and secure enough for most cases. Why "secure enough"? It's possible that the private key value could be leaked, modified, or deleted. Whether a bug in code, a bad actor in your AWS account, or a permissions mis-configuration, there are no service-level guarantees on the privacy and integrity of the stored key. + +#### Performance + +Each JWT signing operation requires a call to invoke Lambda. Each Lambda function invocation to sign a JWT takes less than 2ms. Cold-start invocations take about 175ms. + +#### Cost + +There are no baseline costs when standing up a stack in Parameter Store mode. Everying is usage-based. + +Monthly Cost assuming 1,000,000 signed tokens (us-west-2 pricing example): + +- $0.20: Lambda requests ($0.20 per 1M requests) +- $0.0034: Lambda duration ($0.0000000017 per 1ms) +- KMS (decrypt) is called once for each Lambda cold start ($0.03 per 10,000 KMS requests). Actual cold start count is very workload dependent so your mileage may vary, but for a real-world instance of this function serving 400 million invocations per month, the KMS decrypt bill is less than $10 per month. + +### KMS + +KMS mode provides additional security. The private key material never leaves the KMS service in your AWS account, ensuring only AWS principals explicitly authorized with `kms:Sign` permissions for this key can ever generate digital signatures with this key. Even with this permission granted, no one can ever access the underlying private key. A KMS customer managed key (CMK) is created during stack installation and is used for all signing requests. + +Since each JWT must be signed and the private key is not directly accessible, each Lambda invocation must call KMS. This adds some runtime latency for each signing operation and KMS calls incur additional costs. + +KMS key material can never be modified and if a key is deleted, there is a deletion recovery period to ensure accidental deletion is not permanent. If your company or organization has key compliance requirements, this is probably the best option for you. + +#### Performance + +Each JWT signing operation requires a call to Lambda, which calls KMS to generate a token signature. Each function invocation takes ~15ms in Lambda. Cold-start invocations take about 200ms. KMS has a default quota of 300 requests per second for ECC signing operations, so be sure to request an increase if you need more than that. + +#### Cost + +Standing up a stack in your AWS account creates a KMS key, which incurs a charge for its ongoing management. Other than the key management, everything is usage-based. + +Monthly Cost assuming 1,000,000 signed tokens (us-west-2 pricing example): + +- $1.00: KMS key management +- $15.00: 1,000,000 KMS asymmetric signing requests ($0.15 per 10,000 requests) +- $0.20: Lambda requests ($0.20 per 1M requests) +- $0.13: Lambda duration ($0.0000000067 per 1ms) + +As you can see, most of the cost is in KMS. If you're signing a billion tokens each month, this might become cost prohibitive. + +## Installation + +Launch a stack in your AWS account in less than 5 minutes. Installs using CloudFormation to any of the following regions. + +The only option you need to consider is the `KeyCustodianParameter`. Choose `ParameterStore` or `KMS` based on your assessment above, compliance requirements, etc. Other than that, CloudFormation defaults should be fine as you step through the stack creation process. + +| Region | Alias | Launch URL | +| ------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| US East (N. Virginia) | us-east-1 | [Launch Stack](https://console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/new?stackName=JWTIssuer&templateURL=https://jwt-issuer-stack-templates-us-east-1.s3.us-east-1.amazonaws.com/jwt-issuer-v1.x.yml) | +| US East (Ohio) | us-east-2 | [Launch Stack](https://console.aws.amazon.com/cloudformation/home?region=us-east-2#/stacks/new?stackName=JWTIssuer&templateURL=https://jwt-issuer-stack-templates-us-east-2.s3.us-east-2.amazonaws.com/jwt-issuer-v1.x.yml) | +| US West (N. California) | us-west-1 | [Launch Stack](https://console.aws.amazon.com/cloudformation/home?region=us-west-1#/stacks/new?stackName=JWTIssuer&templateURL=https://jwt-issuer-stack-templates-us-west-1.s3.us-west-1.amazonaws.com/jwt-issuer-v1.x.yml) | +| US West (Oregon) | us-west-2 | [Launch Stack](https://console.aws.amazon.com/cloudformation/home?region=us-west-2#/stacks/new?stackName=JWTIssuer&templateURL=https://jwt-issuer-stack-templates-us-west-2.s3.us-west-2.amazonaws.com/jwt-issuer-v1.x.yml) | +| Africa (Cape Town) | af-south-1 | [Launch Stack](https://console.aws.amazon.com/cloudformation/home?region=af-south-1#/stacks/new?stackName=JWTIssuer&templateURL=https://jwt-issuer-stack-templates-af-south-1.s3.af-south-1.amazonaws.com/jwt-issuer-v1.x.yml) | +| Asia Pacific (Hong Kong) | ap-east-1 | [Launch Stack](https://console.aws.amazon.com/cloudformation/home?region=ap-east-1#/stacks/new?stackName=JWTIssuer&templateURL=https://jwt-issuer-stack-templates-ap-east-1.s3.ap-east-1.amazonaws.com/jwt-issuer-v1.x.yml) | +| Asia Pacific (Hyderabad) | ap-south-2 | [Launch Stack](https://console.aws.amazon.com/cloudformation/home?region=ap-south-2#/stacks/new?stackName=JWTIssuer&templateURL=https://jwt-issuer-stack-templates-ap-south-2.s3.ap-south-2.amazonaws.com/jwt-issuer-v1.x.yml) | +| Asia Pacific (Jakarta) | ap-southeast-3 | [Launch Stack](https://console.aws.amazon.com/cloudformation/home?region=ap-southeast-3#/stacks/new?stackName=JWTIssuer&templateURL=https://jwt-issuer-stack-templates-ap-southeast-3.s3.ap-southeast-3.amazonaws.com/jwt-issuer-v1.x.yml) | +| Asia Pacific (Melbourne) | ap-southeast-4 | [Launch Stack](https://console.aws.amazon.com/cloudformation/home?region=ap-southeast-4#/stacks/new?stackName=JWTIssuer&templateURL=https://jwt-issuer-stack-templates-ap-southeast-4.s3.ap-southeast-4.amazonaws.com/jwt-issuer-v1.x.yml) | +| Asia Pacific (Mumbai) | ap-south-1 | [Launch Stack](https://console.aws.amazon.com/cloudformation/home?region=ap-south-1#/stacks/new?stackName=JWTIssuer&templateURL=https://jwt-issuer-stack-templates-ap-south-1.s3.ap-south-1.amazonaws.com/jwt-issuer-v1.x.yml) | +| Asia Pacific (Osaka) | ap-northeast-3 | [Launch Stack](https://console.aws.amazon.com/cloudformation/home?region=ap-northeast-3#/stacks/new?stackName=JWTIssuer&templateURL=https://jwt-issuer-stack-templates-ap-northeast-3.s3.ap-northeast-3.amazonaws.com/jwt-issuer-v1.x.yml) | +| Asia Pacific (Seoul) | ap-northeast-2 | [Launch Stack](https://console.aws.amazon.com/cloudformation/home?region=ap-northeast-2#/stacks/new?stackName=JWTIssuer&templateURL=https://jwt-issuer-stack-templates-ap-northeast-2.s3.ap-northeast-2.amazonaws.com/jwt-issuer-v1.x.yml) | +| Asia Pacific (Singapore) | ap-southeast-1 | [Launch Stack](https://console.aws.amazon.com/cloudformation/home?region=ap-southeast-1#/stacks/new?stackName=JWTIssuer&templateURL=https://jwt-issuer-stack-templates-ap-southeast-1.s3.ap-southeast-1.amazonaws.com/jwt-issuer-v1.x.yml) | +| Asia Pacific (Sydney) | ap-southeast-2 | [Launch Stack](https://console.aws.amazon.com/cloudformation/home?region=ap-southeast-2#/stacks/new?stackName=JWTIssuer&templateURL=https://jwt-issuer-stack-templates-ap-southeast-2.s3.ap-southeast-2.amazonaws.com/jwt-issuer-v1.x.yml) | +| Asia Pacific (Tokyo) | ap-northeast-1 | [Launch Stack](https://console.aws.amazon.com/cloudformation/home?region=ap-northeast-1#/stacks/new?stackName=JWTIssuer&templateURL=https://jwt-issuer-stack-templates-ap-northeast-1.s3.ap-northeast-1.amazonaws.com/jwt-issuer-v1.x.yml) | +| Canada (Central) | ca-central-1 | [Launch Stack](https://console.aws.amazon.com/cloudformation/home?region=ca-central-1#/stacks/new?stackName=JWTIssuer&templateURL=https://jwt-issuer-stack-templates-ca-central-1.s3.ca-central-1.amazonaws.com/jwt-issuer-v1.x.yml) | +| Europe (Frankfurt) | eu-central-1 | [Launch Stack](https://console.aws.amazon.com/cloudformation/home?region=eu-central-1#/stacks/new?stackName=JWTIssuer&templateURL=https://jwt-issuer-stack-templates-eu-central-1.s3.eu-central-1.amazonaws.com/jwt-issuer-v1.x.yml) | +| Europe (Ireland) | eu-west-1 | [Launch Stack](https://console.aws.amazon.com/cloudformation/home?region=eu-west-1#/stacks/new?stackName=JWTIssuer&templateURL=https://jwt-issuer-stack-templates-eu-west-1.s3.eu-west-1.amazonaws.com/jwt-issuer-v1.x.yml) | +| Europe (London) | eu-west-2 | [Launch Stack](https://console.aws.amazon.com/cloudformation/home?region=eu-west-2#/stacks/new?stackName=JWTIssuer&templateURL=https://jwt-issuer-stack-templates-eu-west-2.s3.eu-west-2.amazonaws.com/jwt-issuer-v1.x.yml) | +| Europe (Milan) | eu-south-1 | [Launch Stack](https://console.aws.amazon.com/cloudformation/home?region=eu-south-1#/stacks/new?stackName=JWTIssuer&templateURL=https://jwt-issuer-stack-templates-eu-south-1.s3.eu-south-1.amazonaws.com/jwt-issuer-v1.x.yml) | +| Europe (Paris) | eu-west-3 | [Launch Stack](https://console.aws.amazon.com/cloudformation/home?region=eu-west-3#/stacks/new?stackName=JWTIssuer&templateURL=https://jwt-issuer-stack-templates-eu-west-3.s3.eu-west-3.amazonaws.com/jwt-issuer-v1.x.yml) | +| Europe (Spain) | eu-south-2 | [Launch Stack](https://console.aws.amazon.com/cloudformation/home?region=eu-south-2#/stacks/new?stackName=JWTIssuer&templateURL=https://jwt-issuer-stack-templates-eu-south-2.s3.eu-south-2.amazonaws.com/jwt-issuer-v1.x.yml) | +| Europe (Stockholm) | eu-north-1 | [Launch Stack](https://console.aws.amazon.com/cloudformation/home?region=eu-north-1#/stacks/new?stackName=JWTIssuer&templateURL=https://jwt-issuer-stack-templates-eu-north-1.s3.eu-north-1.amazonaws.com/jwt-issuer-v1.x.yml) | +| Europe (Zurich) | eu-central-2 | [Launch Stack](https://console.aws.amazon.com/cloudformation/home?region=eu-central-2#/stacks/new?stackName=JWTIssuer&templateURL=https://jwt-issuer-stack-templates-eu-central-2.s3.eu-central-2.amazonaws.com/jwt-issuer-v1.x.yml) | +| Israel (Tel Aviv) | il-central-1 | [Launch Stack](https://console.aws.amazon.com/cloudformation/home?region=il-central-1#/stacks/new?stackName=JWTIssuer&templateURL=https://jwt-issuer-stack-templates-il-central-1.s3.il-central-1.amazonaws.com/jwt-issuer-v1.x.yml) | +| Middle East (Bahrain) | me-south-1 | [Launch Stack](https://console.aws.amazon.com/cloudformation/home?region=me-south-1#/stacks/new?stackName=JWTIssuer&templateURL=https://jwt-issuer-stack-templates-me-south-1.s3.me-south-1.amazonaws.com/jwt-issuer-v1.x.yml) | +| Middle East (UAE) | me-central-1 | [Launch Stack](https://console.aws.amazon.com/cloudformation/home?region=me-central-1#/stacks/new?stackName=JWTIssuer&templateURL=https://jwt-issuer-stack-templates-me-central-1.s3.me-central-1.amazonaws.com/jwt-issuer-v1.x.yml) | +| South America (São Paulo) | sa-east-1 | [Launch Stack](https://console.aws.amazon.com/cloudformation/home?region=sa-east-1#/stacks/new?stackName=JWTIssuer&templateURL=https://jwt-issuer-stack-templates-sa-east-1.s3.sa-east-1.amazonaws.com/jwt-issuer-v1.x.yml) | + +AWS GovCloud regions are not currently supported because the regions are missing `provided.al2023` runtime support in Lambda. + +The CloudFormation stack will have the status `CREATE_COMPLETE` when the installation is finished. At this point, you can go to the "Outputs" tab in the stack and you'll see the following variables. + +### `JWTIssuerFunctionArn` + +This is the Amazon Resource Name (Arn) of the Lambda function you'll invoke to sign JWTs. Examples of how to use it in the usage section below. This can be used as the value for `function-name` (CLI) or `function_name` (Ruby SDK) below. + +Example: `arn:aws:lambda:us-east-1:111111111111:function:JWTProd-JWTIssuerPSFunction-mUI2JR398C8c` + +### `KeyArn` + +This is the Amazon Resource Name (Arn) of the KMS key that is used when signing keys. This is left blank if using Parameter Store. + +### `KeyID` + +When signing tokens, this is the value that the `kid` header claim will be set to in all JWTs. If using Parameter store, it's the UUID in the CloudFormation stack's ARN. If using KMS, it's the UUID in the KMS key ARN. + +Example: `ef814598-df45-4aa4-9f32-1b616ae6afda` + +### `PublicKeyPEMBase64` + +This is the public key in PEM format encoded to Base 64. If you're using [Hotsock](https://github.com/hotsock/hotsock), you can paste this value directly into the `SigningKey1EncodedParameter` or `SigningKey2EncodedParameter` to allow Hotsock to authorize signed keys from this stack. + +It's completely harmless for this public key to be passed around. It's named appropriately! + +Example: `LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUZrd0V3WUhLb1pJemowQ0FRWUlLb1pJemowREFRY0RRZ0FFL2RmYXdYbkZxb0FWTG81NU04UW5yelBpazZOcgpYQnUybllLQkY5YTM2bGZtK0FPcG8xYzhxUzJKQkhYVVV1WE1YajAzdzh0Q1F0bGZidXFaaUljWGVnPT0KLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg==` + +If you decode this from Base 64 to a string, you'll see it's a PEM-formatted public key. + +``` +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE/dfawXnFqoAVLo55M8QnrzPik6Nr +XBu2nYKBF9a36lfm+AOpo1c8qS2JBHXUUuXMXj03w8tCQtlfbuqZiIcXeg== +-----END PUBLIC KEY----- +``` + +### `SigningMethod` + +This is the JWT signing algorithm. Always set to `ES256`. + +### `Version` + +The release version of your installation. + +Example: `v1.0` + +## Usage + +First you need to grant your application the ability to invoke the JWT issuer Lambda function using IAM. At a minimum, an IAM policy tied to your application's AWS role or user must have `Allow` set for the `lambda:InvokeFunction` action on the Arn referenced in the `JWTIssuerFunctionArn` output from your installation. If, for example, your application runs on [AWS Fargate](https://aws.amazon.com/fargate/), you'd want to add this permissions policy to the task execution role for your ECS service. If your application runs on [EC2](https://aws.amazon.com/ec2/), you probably need to add this permissions policy to the [IAM role associated with your EC2 instance(s)](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html). You can also use IAM users with hard-coded credentials, but that's not recommended. + +Here's a sample policy. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Action": ["lambda:InvokeFunction"], + "Effect": "Allow", + "Resource": [ + "arn:aws:lambda:us-east-1:111111111111:function:JWTProd-JWTIssuerPSFunction-mUI2JR398C8c" + ] + } + ] +} +``` + +Using the AWS SDK in the language of your choice, call the Lambda invoke API to sign a token. Here's an example using the AWS CLI. + +This generates a token with the `aud` and `channels` claims set explicitly and configures the `exp` claim to expire the token 30 seconds after it is issued. + +``` +aws lambda invoke \ + --function-name JWTIssuer-JWTIssuerPSFunction-MFlF1fyVpWkZ \ + --payload '{"claims":{"aud":"hotsock","channels":{"chat":{"subscribe":true}}},"ttl":30}' \ + --cli-binary-format raw-in-base64-out \ + /dev/stdout +``` + +The response is JSON and contains the signed token in the `token` field. + +```json +{ + "token": "eyJhbGciOiJFUzI1NiIsImtpZCI6ImVmODE0NTk4LWRmNDUtNGFhNC05ZjMyLTFiNjE2YWU2YWZkYSIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJob3Rzb2NrIiwiY2hhbm5lbHMiOnsiY2hhdCI6eyJzdWJzY3JpYmUiOnRydWV9fSwiZXhwIjoxNzEzODM2OTUwfQ.Gz5iLG6O7YBQf8jAJafbaeCUxC08JnVEfnzbPOnn3S90hdiptlztp4Io3UmnhKjTqphf1G1ZYKQ29jbU7C6Xow" +} +``` + +Here's the same invocation, but using the [Ruby SDK](https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/Lambda/Client.html). + +```ruby +Aws::Lambda::Client.new.invoke( + function_name: "JWTIssuer-JWTIssuerPSFunction-MFlF1fyVpWkZ", + payload: JSON.dump({"claims":{"aud":"hotsock","channels":{"chat":{"subscribe":true}}},"ttl":30}) +).payload.read +# => "{\"token\":\"eyJhbGciOiJFUzI1NiIsImtpZCI6ImVmODE0NTk4LWRmNDUtNGFhNC05ZjMyLTFiNjE2YWU2YWZkYSIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJob3Rzb2NrIiwiY2hhbm5lbHMiOnsiY2hhdCI6eyJzdWJzY3JpYmUiOnRydWV9fSwiZXhwIjoxNzEzODM2OTUwfQ.Gz5iLG6O7YBQf8jAJafbaeCUxC08JnVEfnzbPOnn3S90hdiptlztp4Io3UmnhKjTqphf1G1ZYKQ29jbU7C6Xow\"}" +``` + +### `claims` + +`Object` (required) - Provide all claims here as a JSON object. + +### `setIat` + +`Boolean` (optional) - If true, sets the `iat` claim to the time that the token was issued. Overrides explicit `iat` set in `claims`. Defaults to `false`. + +### `setJti` + +`Boolean` (optional) - If true, sets the `jti` claim to a randomly generated UUID (v4). Overrides explicit `jti` set in `claims`. Defaults to `false`. + +### `ttl` + +`Integer` (optional) - If supplied, sets the token expiration claim (`exp`) to a timestamp this many seconds from when the token is issued. Overrides explicit `exp` set in `claims`. If not supplied, make sure you specify your own `exp` claim in `claims` to ensure the token expires. + +## Updates & maintenance + +You can assume that v1.x is stable. Updating an existing stack to the latest 1.x may add new functionality, but will not break existing APIs documented in this README, replace AWS resources, or change behavior. The underlying Go code may change at any time, as the code is not intended for use as a library imported into your code. + +To update an existing stack, open CloudFormation in the AWS Console. + +1. Find your installation's stack (it's called JWTIssuer if you used the default name) and click the "Update" button. +1. On the "Prepare template" screen, choose "Replace current template". +1. For "Template source", use "Amazon S3 URL" and copy the URL for your region from the table below. Click "Next" through the screens that follow keeping all other defaults. Acknowledge any capabilities requirements on the final screen and click "Submit". Stack updates typically take no longer than 2 minutes. + +| Region | Alias | Amazon S3 URL URL | +| ------------------------- | -------------- | ----------------------------------------------------------------------------------------------------- | +| US East (N. Virginia) | us-east-1 | https://jwt-issuer-stack-templates-us-east-1.s3.us-east-1.amazonaws.com/jwt-issuer-v1.x.yml | +| US East (Ohio) | us-east-2 | https://jwt-issuer-stack-templates-us-east-2.s3.us-east-2.amazonaws.com/jwt-issuer-v1.x.yml | +| US West (N. California) | us-west-1 | https://jwt-issuer-stack-templates-us-west-1.s3.us-west-1.amazonaws.com/jwt-issuer-v1.x.yml | +| US West (Oregon) | us-west-2 | https://jwt-issuer-stack-templates-us-west-2.s3.us-west-2.amazonaws.com/jwt-issuer-v1.x.yml | +| Africa (Cape Town) | af-south-1 | https://jwt-issuer-stack-templates-af-south-1.s3.af-south-1.amazonaws.com/jwt-issuer-v1.x.yml | +| Asia Pacific (Hong Kong) | ap-east-1 | https://jwt-issuer-stack-templates-ap-east-1.s3.ap-east-1.amazonaws.com/jwt-issuer-v1.x.yml | +| Asia Pacific (Hyderabad) | ap-south-2 | https://jwt-issuer-stack-templates-ap-south-2.s3.ap-south-2.amazonaws.com/jwt-issuer-v1.x.yml | +| Asia Pacific (Jakarta) | ap-southeast-3 | https://jwt-issuer-stack-templates-ap-southeast-3.s3.ap-southeast-3.amazonaws.com/jwt-issuer-v1.x.yml | +| Asia Pacific (Melbourne) | ap-southeast-4 | https://jwt-issuer-stack-templates-ap-southeast-4.s3.ap-southeast-4.amazonaws.com/jwt-issuer-v1.x.yml | +| Asia Pacific (Mumbai) | ap-south-1 | https://jwt-issuer-stack-templates-ap-south-1.s3.ap-south-1.amazonaws.com/jwt-issuer-v1.x.yml | +| Asia Pacific (Osaka) | ap-northeast-3 | https://jwt-issuer-stack-templates-ap-northeast-3.s3.ap-northeast-3.amazonaws.com/jwt-issuer-v1.x.yml | +| Asia Pacific (Seoul) | ap-northeast-2 | https://jwt-issuer-stack-templates-ap-northeast-2.s3.ap-northeast-2.amazonaws.com/jwt-issuer-v1.x.yml | +| Asia Pacific (Singapore) | ap-southeast-1 | https://jwt-issuer-stack-templates-ap-southeast-1.s3.ap-southeast-1.amazonaws.com/jwt-issuer-v1.x.yml | +| Asia Pacific (Sydney) | ap-southeast-2 | https://jwt-issuer-stack-templates-ap-southeast-2.s3.ap-southeast-2.amazonaws.com/jwt-issuer-v1.x.yml | +| Asia Pacific (Tokyo) | ap-northeast-1 | https://jwt-issuer-stack-templates-ap-northeast-1.s3.ap-northeast-1.amazonaws.com/jwt-issuer-v1.x.yml | +| Canada (Central) | ca-central-1 | https://jwt-issuer-stack-templates-ca-central-1.s3.ca-central-1.amazonaws.com/jwt-issuer-v1.x.yml | +| Europe (Frankfurt) | eu-central-1 | https://jwt-issuer-stack-templates-eu-central-1.s3.eu-central-1.amazonaws.com/jwt-issuer-v1.x.yml | +| Europe (Ireland) | eu-west-1 | https://jwt-issuer-stack-templates-eu-west-1.s3.eu-west-1.amazonaws.com/jwt-issuer-v1.x.yml | +| Europe (London) | eu-west-2 | https://jwt-issuer-stack-templates-eu-west-2.s3.eu-west-2.amazonaws.com/jwt-issuer-v1.x.yml | +| Europe (Milan) | eu-south-1 | https://jwt-issuer-stack-templates-eu-south-1.s3.eu-south-1.amazonaws.com/jwt-issuer-v1.x.yml | +| Europe (Paris) | eu-west-3 | https://jwt-issuer-stack-templates-eu-west-3.s3.eu-west-3.amazonaws.com/jwt-issuer-v1.x.yml | +| Europe (Spain) | eu-south-2 | https://jwt-issuer-stack-templates-eu-south-2.s3.eu-south-2.amazonaws.com/jwt-issuer-v1.x.yml | +| Europe (Stockholm) | eu-north-1 | https://jwt-issuer-stack-templates-eu-north-1.s3.eu-north-1.amazonaws.com/jwt-issuer-v1.x.yml | +| Europe (Zurich) | eu-central-2 | https://jwt-issuer-stack-templates-eu-central-2.s3.eu-central-2.amazonaws.com/jwt-issuer-v1.x.yml | +| Israel (Tel Aviv) | il-central-1 | https://jwt-issuer-stack-templates-il-central-1.s3.il-central-1.amazonaws.com/jwt-issuer-v1.x.yml | +| Middle East (Bahrain) | me-south-1 | https://jwt-issuer-stack-templates-me-south-1.s3.me-south-1.amazonaws.com/jwt-issuer-v1.x.yml | +| Middle East (UAE) | me-central-1 | https://jwt-issuer-stack-templates-me-central-1.s3.me-central-1.amazonaws.com/jwt-issuer-v1.x.yml | +| South America (São Paulo) | sa-east-1 | https://jwt-issuer-stack-templates-sa-east-1.s3.sa-east-1.amazonaws.com/jwt-issuer-v1.x.yml | + +Note: The above URLs will appear to not work if clicked on from a browser. They are only meant for use within CloudFormation. These templates are generated and written to S3 in all regions from GitHub Actions ([.github/workflows/regional_templates.yml](.github/workflows/regional_templates.yml)) when new releases are tagged. + +### Switch from Parameter Store to KMS or vice versa + +**Switching key custodians is not recommended.** Technically, switching the `KeyCustodianParameter` and updating the stack will do the right thing and change your preference. If you switch this way, your private/public keys will be deleted from KMS/Parameter Store during the update, the Lambda function used to sign keys will be replaced (and will have a different Arn in `JWTIssuerFunctionArn`), and anything still attempting to sign with the previous keys will stop working immediately. + +Instead, the recommendation is to launch a new stack that uses the desired service for key custody (Parameter Store or KMS). You can begin signing keys with the new installation immediately and delete the old stack once you've verified it is no longer needed. + +## Local development & manual builds + +To develop and test locally or to deploy a manual build, clone this repository and install the following. + +- Install [AWS SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html) +- Install [Go](https://go.dev/doc/install) (the latest release should work) + +Run tests with `make test`. Build all binaries for deployment on Lambda with `make build`. + +Use `sam deploy --guided` to package local CloudFormation and deploy to a new stack using your AWS CLI credentials. diff --git a/cmd/jwt_issuer_kms/ec256-private.pem b/cmd/jwt_issuer_kms/ec256-private.pem new file mode 100644 index 0000000..a6882b3 --- /dev/null +++ b/cmd/jwt_issuer_kms/ec256-private.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIAh5qA3rmqQQuu0vbKV/+zouz/y/Iy2pLpIcWUSyImSwoAoGCCqGSM49 +AwEHoUQDQgAEYD54V/vp+54P9DXarYqx4MPcm+HKRIQzNasYSoRQHQ/6S6Ps8tpM +cT+KvIIC8W/e9k0W7Cm72M1P9jU7SLf/vg== +-----END EC PRIVATE KEY----- diff --git a/cmd/jwt_issuer_kms/ec256-public.pem b/cmd/jwt_issuer_kms/ec256-public.pem new file mode 100644 index 0000000..7191361 --- /dev/null +++ b/cmd/jwt_issuer_kms/ec256-public.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEYD54V/vp+54P9DXarYqx4MPcm+HK +RIQzNasYSoRQHQ/6S6Ps8tpMcT+KvIIC8W/e9k0W7Cm72M1P9jU7SLf/vg== +-----END PUBLIC KEY----- diff --git a/cmd/jwt_issuer_kms/main.go b/cmd/jwt_issuer_kms/main.go new file mode 100644 index 0000000..6367ca1 --- /dev/null +++ b/cmd/jwt_issuer_kms/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "context" + "log/slog" + "os" + "strings" + + "github.com/aws/aws-lambda-go/lambda" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/kms" + "github.com/hotsock/jwt-issuer/internal/issuer" +) + +var KMS issuer.KMSAPI +var signingKeyArn string +var keyID string + +func main() { + baseConfig, _ := config.LoadDefaultConfig(context.TODO(), config.WithRegion(os.Getenv("AWS_REGION"))) + KMS = kms.NewFromConfig(baseConfig) + + signingKeyArn = os.Getenv("SIGNING_KEY_ARN") + + arnParts := strings.Split(signingKeyArn, "/") + if len(arnParts) == 2 { + keyID = arnParts[1] + } + + lambda.StartHandlerFunc(issuer.HandlerWithLambdaLogging(handler)) +} + +func handler(ctx context.Context, input issuer.JWTIssuerFunctionInput) (issuer.JWTIssuerFunctionOutput, error) { + defer issuer.LogWithTiming(ctx, slog.LevelDebug, "jwt_issuer_kms.handler", "input", input)() + + token := issuer.PrepareToken(input, keyID) + + signedToken, err := issuer.SignJWTWithKMS(ctx, KMS, token, signingKeyArn) + if err != nil { + return issuer.JWTIssuerFunctionOutput{}, err + } + + return issuer.JWTIssuerFunctionOutput{ + Token: signedToken, + }, nil +} diff --git a/cmd/jwt_issuer_kms/main_test.go b/cmd/jwt_issuer_kms/main_test.go new file mode 100644 index 0000000..2265f92 --- /dev/null +++ b/cmd/jwt_issuer_kms/main_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/rand" + _ "embed" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/service/kms" + "github.com/golang-jwt/jwt/v5" + "github.com/hotsock/jwt-issuer/internal/issuer" + "github.com/hotsock/jwt-issuer/internal/mocks" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +//go:embed ec256-private.pem +var privateKeyPEM []byte + +//go:embed ec256-public.pem +var publicKeyPEM []byte + +func Test_handler(t *testing.T) { + signingKeyArn = "arn:aws:kms:us-east-1:111111111111:key/4a2c1b37-e4c8-466a-b873-11aaf144b01b" + keyID = "4a2c1b37-e4c8-466a-b873-11aaf144b01b" + + claims := jwt.MapClaims{ + "exp": jwt.NewNumericDate(time.Now().Add(time.Minute)), + } + token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) + token.Header["kid"] = keyID + + privateKeyObj, err := jwt.ParseECPrivateKeyFromPEM(privateKeyPEM) + require.NoError(t, err) + + publicKeyObj, err := jwt.ParseECPublicKeyFromPEM(publicKeyPEM) + require.NoError(t, err) + + h := crypto.SHA256.New() + signingString, _ := token.SigningString() + h.Write([]byte(signingString)) + signature, _ := ecdsa.SignASN1(rand.Reader, privateKeyObj, h.Sum(nil)) + + mockKMS := mocks.KMSAPI{} + mockKMS.On("Sign", mock.Anything, mock.Anything).Return(&kms.SignOutput{Signature: signature}, nil) + KMS = &mockKMS + + output, err := handler(context.Background(), issuer.JWTIssuerFunctionInput{Claims: claims}) + require.NoError(t, err) + + _, err = jwt.Parse(output.Token, func(t *jwt.Token) (any, error) { + return publicKeyObj, nil + }, jwt.WithValidMethods([]string{"ES256"})) + + require.NoError(t, err) +} diff --git a/cmd/jwt_issuer_parameter_store/ec256-private.pem b/cmd/jwt_issuer_parameter_store/ec256-private.pem new file mode 100644 index 0000000..a6882b3 --- /dev/null +++ b/cmd/jwt_issuer_parameter_store/ec256-private.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIAh5qA3rmqQQuu0vbKV/+zouz/y/Iy2pLpIcWUSyImSwoAoGCCqGSM49 +AwEHoUQDQgAEYD54V/vp+54P9DXarYqx4MPcm+HKRIQzNasYSoRQHQ/6S6Ps8tpM +cT+KvIIC8W/e9k0W7Cm72M1P9jU7SLf/vg== +-----END EC PRIVATE KEY----- diff --git a/cmd/jwt_issuer_parameter_store/ec256-public.pem b/cmd/jwt_issuer_parameter_store/ec256-public.pem new file mode 100644 index 0000000..7191361 --- /dev/null +++ b/cmd/jwt_issuer_parameter_store/ec256-public.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEYD54V/vp+54P9DXarYqx4MPcm+HK +RIQzNasYSoRQHQ/6S6Ps8tpMcT+KvIIC8W/e9k0W7Cm72M1P9jU7SLf/vg== +-----END PUBLIC KEY----- diff --git a/cmd/jwt_issuer_parameter_store/main.go b/cmd/jwt_issuer_parameter_store/main.go new file mode 100644 index 0000000..0cd9943 --- /dev/null +++ b/cmd/jwt_issuer_parameter_store/main.go @@ -0,0 +1,57 @@ +package main + +import ( + "context" + "crypto/ecdsa" + "log/slog" + "os" + + "github.com/aws/aws-lambda-go/lambda" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/ssm" + "github.com/golang-jwt/jwt/v5" + "github.com/hotsock/jwt-issuer/internal/issuer" + "github.com/samber/lo" +) + +var SSM issuer.SSMAPI +var privateKey *ecdsa.PrivateKey +var keyID string + +func main() { + baseConfig, _ := config.LoadDefaultConfig(context.TODO(), config.WithRegion(os.Getenv("AWS_REGION"))) + SSM = ssm.NewFromConfig(baseConfig) + + getParamResponse, err := SSM.GetParameter(context.TODO(), &ssm.GetParameterInput{ + Name: lo.ToPtr(issuer.PrivateKeyParameterName()), + WithDecryption: lo.ToPtr(true), + }) + if err != nil { + panic(err) + } + + key, err := jwt.ParseECPrivateKeyFromPEM([]byte(lo.FromPtr(getParamResponse.Parameter.Value))) + if err != nil { + panic(err) + } + + privateKey = key + keyID = issuer.ParameterStoreKeyID() + + lambda.StartHandlerFunc(issuer.HandlerWithLambdaLogging(handler)) +} + +func handler(ctx context.Context, input issuer.JWTIssuerFunctionInput) (issuer.JWTIssuerFunctionOutput, error) { + defer issuer.LogWithTiming(ctx, slog.LevelDebug, "jwt_issuer_parameter_store.handler", "input", input)() + + token := issuer.PrepareToken(input, keyID) + + signedToken, err := token.SignedString(privateKey) + if err != nil { + return issuer.JWTIssuerFunctionOutput{}, err + } + + return issuer.JWTIssuerFunctionOutput{ + Token: signedToken, + }, nil +} diff --git a/cmd/jwt_issuer_parameter_store/main_test.go b/cmd/jwt_issuer_parameter_store/main_test.go new file mode 100644 index 0000000..eaf4c4b --- /dev/null +++ b/cmd/jwt_issuer_parameter_store/main_test.go @@ -0,0 +1,51 @@ +package main + +import ( + "context" + _ "embed" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/hotsock/jwt-issuer/internal/issuer" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +//go:embed ec256-private.pem +var privateKeyPEM []byte + +//go:embed ec256-public.pem +var publicKeyPEM []byte + +func Test_handler(t *testing.T) { + keyID = "4a2c1b37" + + claims := jwt.MapClaims{ + "foo": "bar", + } + + privateKeyObj, err := jwt.ParseECPrivateKeyFromPEM(privateKeyPEM) + require.NoError(t, err) + privateKey = privateKeyObj + + publicKeyObj, err := jwt.ParseECPublicKeyFromPEM(publicKeyPEM) + require.NoError(t, err) + + output, err := handler(context.Background(), issuer.JWTIssuerFunctionInput{Claims: claims, TTL: lo.ToPtr(time.Duration(60)), SetIat: lo.ToPtr(true), SetJti: lo.ToPtr(true)}) + require.NoError(t, err) + + generatedToken, err := jwt.Parse(output.Token, func(t *jwt.Token) (any, error) { + return publicKeyObj, nil + }, jwt.WithValidMethods([]string{"ES256"})) + + require.NoError(t, err) + + generatedClaims := generatedToken.Claims.(jwt.MapClaims) + assert.Equal(t, keyID, generatedToken.Header["kid"]) + assert.Equal(t, "bar", generatedClaims["foo"]) + assert.Greater(t, generatedClaims["exp"], float64(time.Now().Unix())) + assert.LessOrEqual(t, generatedClaims["iat"], float64(time.Now().Unix())) + assert.Len(t, generatedClaims["jti"], 36) +} diff --git a/cmd/key_generator/cloudformation-input.json b/cmd/key_generator/cloudformation-input.json new file mode 100644 index 0000000..4545b45 --- /dev/null +++ b/cmd/key_generator/cloudformation-input.json @@ -0,0 +1,12 @@ +{ + "RequestType": "Create", + "RequestId": "3fa6d24b-8ce7-4e2e-87b8-8414f28246d7", + "ResponseURL": "https://cloudformation-custom-resource-response-useast1.s3.amazonaws.com/arn%3Aaws%3Acloudformation%3Aus-east-1%3A111111111111%3Astack/JWTIssuer/d9385410-50ee-11ee-b05b-0a236ebfa8d3%7CKeyGeneratorCustomResource%7C3fa6d24b-8ce7-4e2e-87b8-8414f28246d7", + "ResourceType": "Custom::KeyGeneratorCustomResource", + "LogicalResourceId": "KeyGeneratorCustomResource", + "StackId": "arn:aws:cloudformation:us-east-1:111111111111:stack/JWTIssuer/d9385410-50ee-11ee-b05b-0a236ebfa8d3", + "ResourceProperties": { + "ServiceToken": "arn:aws:lambda:us-east-1:111111111111:function:JWTIssuer-KeyGeneratorFunction-nUthvnFU95BL", + "Version": "1" + } +} diff --git a/cmd/key_generator/main.go b/cmd/key_generator/main.go new file mode 100644 index 0000000..dbf802e --- /dev/null +++ b/cmd/key_generator/main.go @@ -0,0 +1,130 @@ +package main + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "fmt" + "log/slog" + "os" + + "github.com/aws/aws-lambda-go/cfn" + "github.com/aws/aws-lambda-go/lambda" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/ssm" + ssmtypes "github.com/aws/aws-sdk-go-v2/service/ssm/types" + "github.com/hotsock/jwt-issuer/internal/issuer" + "github.com/samber/lo" +) + +var SSM issuer.SSMAPI + +func main() { + baseConfig, _ := config.LoadDefaultConfig(context.TODO(), config.WithRegion(os.Getenv("AWS_REGION"))) + SSM = ssm.NewFromConfig(baseConfig) + + lambda.Start(cfn.LambdaWrap(issuer.CloudFormationHandlerWithLambdaLogging(handler))) +} + +func handler(ctx context.Context, event cfn.Event) (physicalResourceID string, data map[string]any, err error) { + defer issuer.LogWithTiming(ctx, slog.LevelInfo, "key_generator.handler", "event", event)() + + physicalResourceID = "KeyGenerator" + + switch event.RequestType { + case cfn.RequestCreate: + privateKeyPEM, publicKeyPEM := generateKeyPair() + err = createParameters(ctx, privateKeyPEM, publicKeyPEM) + data = map[string]any{ + "KeyArn": "", + "KeyID": issuer.ParameterStoreKeyID(), + "PublicKeyPEMBase64": base64.StdEncoding.EncodeToString(publicKeyPEM), + "SigningMethod": "ES256", + } + return + case cfn.RequestUpdate: + // no-op + return + case cfn.RequestDelete: + deleteParameters(ctx) + return + } + return +} + +func generateKeyPair() (privateKeyPEM []byte, publicKeyPEM []byte) { + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + panic(err) + } + x509Private, _ := x509.MarshalPKCS8PrivateKey(privateKey) + privateKeyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: x509Private}) + x509Public, _ := x509.MarshalPKIXPublicKey(&privateKey.PublicKey) + publicKeyPEM = pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: x509Public}) + + return privateKeyPEM, publicKeyPEM +} + +func createParameters(ctx context.Context, privateKeyPEM []byte, publicKeyPEM []byte) error { + permittedError := false + + _, err := SSM.PutParameter(ctx, &ssm.PutParameterInput{ + DataType: lo.ToPtr("text"), + Description: lo.ToPtr("JWT Issuer Private Key"), + Name: lo.ToPtr(issuer.PrivateKeyParameterName()), + Overwrite: lo.ToPtr(false), + Type: ssmtypes.ParameterTypeSecureString, + Value: lo.ToPtr(string(privateKeyPEM)), + }) + + if err != nil { + var alreadyExists *ssmtypes.AlreadyExistsException + if !errors.As(err, &alreadyExists) { + permittedError = true + } + + if !permittedError { + return err + } + } + + _, err = SSM.PutParameter(ctx, &ssm.PutParameterInput{ + DataType: lo.ToPtr("text"), + Description: lo.ToPtr("JWT Issuer Public Key"), + Name: lo.ToPtr(issuer.PublicKeyParameterName()), + Overwrite: lo.ToPtr(false), + Type: ssmtypes.ParameterTypeSecureString, + Value: lo.ToPtr(string(publicKeyPEM)), + }) + + if err != nil { + var alreadyExists *ssmtypes.AlreadyExistsException + if !errors.As(err, &alreadyExists) { + permittedError = true + } + } + + if !permittedError { + return err + } + + return nil +} + +func deleteParameters(ctx context.Context) error { + _, err := SSM.DeleteParameters(ctx, &ssm.DeleteParametersInput{ + Names: []string{ + issuer.PrivateKeyParameterName(), + issuer.PublicKeyParameterName(), + }, + }) + if err != nil { + fmt.Println(err) + } + return err +} diff --git a/cmd/key_generator/main_test.go b/cmd/key_generator/main_test.go new file mode 100644 index 0000000..dd7d126 --- /dev/null +++ b/cmd/key_generator/main_test.go @@ -0,0 +1,81 @@ +package main + +import ( + "context" + _ "embed" + "encoding/json" + "os" + "testing" + + "github.com/aws/aws-lambda-go/cfn" + "github.com/aws/aws-sdk-go-v2/service/ssm" + ssmtypes "github.com/aws/aws-sdk-go-v2/service/ssm/types" + "github.com/hotsock/jwt-issuer/internal/issuer" + "github.com/hotsock/jwt-issuer/internal/mocks" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +//go:embed cloudformation-input.json +var cloudformationInput []byte + +func Test_handler(t *testing.T) { + os.Setenv(issuer.StackArnEnvVar, "arn:aws:cloudformation:us-east-1:111111111111:stack/JWTIssuer/d9385410-50ee-11ee-b05b-0a236ebfa8d3") + + var event cfn.Event + json.Unmarshal(cloudformationInput, &event) + + t.Run("update requests no-op", func(t *testing.T) { + mockSSM := mockedSSM() + SSM = mockSSM + + event.RequestType = cfn.RequestUpdate + handler(context.Background(), event) + mockSSM.AssertNotCalled(t, "PutParameter", mock.Anything, mock.Anything) + }) + + t.Run("delete requests delete parameters", func(t *testing.T) { + mockSSM := mockedSSM() + SSM = mockSSM + + event.RequestType = cfn.RequestDelete + handler(context.Background(), event) + mockSSM.AssertCalled(t, "DeleteParameters", mock.Anything, mock.Anything) + }) + + t.Run("create requests write key parameters", func(t *testing.T) { + mockSSM := mockedSSM() + SSM = mockSSM + + event.RequestType = cfn.RequestCreate + handler(context.Background(), event) + mockSSM.AssertNumberOfCalls(t, "PutParameter", 2) + + call1 := mockSSM.Calls[0].Arguments[1].(*ssm.PutParameterInput) + call2 := mockSSM.Calls[1].Arguments[1].(*ssm.PutParameterInput) + + assert.Equal(t, issuer.PrivateKeyParameterName(), lo.FromPtr(call1.Name)) + assert.Len(t, lo.FromPtr(call1.Value), 247) + assert.Equal(t, issuer.PublicKeyParameterName(), lo.FromPtr(call2.Name)) + assert.Len(t, lo.FromPtr(call2.Value), 178) + assert.NotEqual(t, lo.FromPtr(call1.Value), lo.FromPtr(call2.Value)) + }) + + t.Run("create requests no-op parameter store write if parameters already exist", func(t *testing.T) { + mockSSM := mocks.SSMAPI{} + mockSSM.On("PutParameter", mock.Anything, mock.Anything).Return(nil, &ssmtypes.ParameterAlreadyExists{Message: lo.ToPtr("parameter already exists")}) + SSM = &mockSSM + + event.RequestType = cfn.RequestCreate + handler(context.Background(), event) + mockSSM.AssertNumberOfCalls(t, "PutParameter", 2) + }) +} + +func mockedSSM() *mocks.SSMAPI { + mockSSM := mocks.SSMAPI{} + mockSSM.On("PutParameter", mock.Anything, mock.Anything).Return(nil, nil) + mockSSM.On("DeleteParameters", mock.Anything, mock.Anything).Return(nil, nil) + return &mockSSM +} diff --git a/cmd/key_info_loader/cloudformation-input.json b/cmd/key_info_loader/cloudformation-input.json new file mode 100644 index 0000000..7dddf0a --- /dev/null +++ b/cmd/key_info_loader/cloudformation-input.json @@ -0,0 +1,13 @@ +{ + "RequestType": "Create", + "RequestId": "cd08e91e-0452-4ca5-9766-19c649f3ae14", + "ResponseURL": "https://cloudformation-custom-resource-response-useast1.s3.amazonaws.com/arn%3Aaws%3Acloudformation%3Aus-east-1%3A111111111111%3Astack/JWTIssuer/0680fd80-50ba-11ee-8ec4-12b8a5fcf885%7CKeyInfoLoaderCustomResource%7Ccd08e91e-0452-4ca5-9766-19c649f3ae14", + "ResourceType": "Custom::KeyInfoLoaderCustomResource", + "LogicalResourceId": "KeyInfoLoaderCustomResource", + "StackId": "arn:aws:cloudformation:us-east-1:111111111111:stack/JWTIssuer/0680fd80-50ba-11ee-8ec4-12b8a5fcf885", + "ResourceProperties": { + "KeyArn": "arn:aws:kms:us-east-1:111111111111:key/c662cc14-a835-4e28-b6c1-0c77126d98b9", + "ServiceToken": "arn:aws:lambda:us-east-1:111111111111:function:JWTIssuer-KeyInfoLoaderFunction-YnmuwN7LuK5a", + "Version": "1" + } +} diff --git a/cmd/key_info_loader/main.go b/cmd/key_info_loader/main.go new file mode 100644 index 0000000..642bfc4 --- /dev/null +++ b/cmd/key_info_loader/main.go @@ -0,0 +1,58 @@ +package main + +import ( + "context" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "log/slog" + "os" + "strings" + + "github.com/aws/aws-lambda-go/cfn" + "github.com/aws/aws-lambda-go/lambda" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/kms" + "github.com/hotsock/jwt-issuer/internal/issuer" + "github.com/samber/lo" +) + +var KMS issuer.KMSAPI + +func main() { + baseConfig, _ := config.LoadDefaultConfig(context.TODO(), config.WithRegion(os.Getenv("AWS_REGION"))) + KMS = kms.NewFromConfig(baseConfig) + + lambda.Start(cfn.LambdaWrap(issuer.CloudFormationHandlerWithLambdaLogging(handler))) +} + +func handler(ctx context.Context, event cfn.Event) (physicalResourceID string, data map[string]any, err error) { + defer issuer.LogWithTiming(ctx, slog.LevelInfo, "key_info_loader.handler", "event", event)() + + physicalResourceID = "KeyInfoLoader" + + publicKeyOutput, err := KMS.GetPublicKey(ctx, &kms.GetPublicKeyInput{ + KeyId: lo.ToPtr(os.Getenv("SIGNING_KEY_ARN")), + }) + + if err != nil { + return + } + + keyArn := lo.FromPtr(publicKeyOutput.KeyId) + arnParts := strings.Split(keyArn, "/") + keyID := arnParts[1] + publicKey, _ := x509.ParsePKIXPublicKey(publicKeyOutput.PublicKey) + x509Public, _ := x509.MarshalPKIXPublicKey(publicKey) + publicKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: x509Public}) + publicKeyPEMBase64 := base64.StdEncoding.EncodeToString(publicKeyPEM) + + data = map[string]any{ + "KeyArn": keyArn, + "KeyID": keyID, + "PublicKeyPEMBase64": publicKeyPEMBase64, + "SigningMethod": "ES256", + } + + return +} diff --git a/cmd/key_info_loader/main_test.go b/cmd/key_info_loader/main_test.go new file mode 100644 index 0000000..6763802 --- /dev/null +++ b/cmd/key_info_loader/main_test.go @@ -0,0 +1,45 @@ +package main + +import ( + "context" + _ "embed" + "encoding/base64" + "encoding/json" + "testing" + + "github.com/aws/aws-lambda-go/cfn" + "github.com/aws/aws-sdk-go-v2/service/kms" + "github.com/hotsock/jwt-issuer/internal/mocks" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +//go:embed cloudformation-input.json +var cloudformationInput []byte + +const kmsPublicKeyResponse = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE/qZBAS8rW1+QG5BRpMdF/hf+ZsB/QZ0/EOwD5UM2l2Kxxv76RbOTtx3H1ZRP6ppxt/oC5Pvy0p+g+a0WoF4GXQ==" + +func Test_handler(t *testing.T) { + var event cfn.Event + require.NoError(t, json.Unmarshal(cloudformationInput, &event)) + + mockKMS := mocks.KMSAPI{} + kmsPublicKey, _ := base64.StdEncoding.DecodeString(kmsPublicKeyResponse) + kmsOutput := &kms.GetPublicKeyOutput{ + KeyId: lo.ToPtr(event.ResourceProperties["KeyArn"].(string)), + PublicKey: []byte(kmsPublicKey), + } + mockKMS.On("GetPublicKey", mock.Anything, mock.Anything).Return(kmsOutput, nil) + KMS = &mockKMS + + physicalResourceID, data, err := handler(context.Background(), event) + require.NoError(t, err) + + assert.Equal(t, "KeyInfoLoader", physicalResourceID) + assert.Equal(t, "arn:aws:kms:us-east-1:111111111111:key/c662cc14-a835-4e28-b6c1-0c77126d98b9", data["KeyArn"]) + assert.Equal(t, "c662cc14-a835-4e28-b6c1-0c77126d98b9", data["KeyID"]) + assert.Equal(t, "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUZrd0V3WUhLb1pJemowQ0FRWUlLb1pJemowREFRY0RRZ0FFL3FaQkFTOHJXMStRRzVCUnBNZEYvaGYrWnNCLwpRWjAvRU93RDVVTTJsMkt4eHY3NlJiT1R0eDNIMVpSUDZwcHh0L29DNVB2eTBwK2crYTBXb0Y0R1hRPT0KLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg==", data["PublicKeyPEMBase64"]) + assert.Equal(t, "ES256", data["SigningMethod"]) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1d80081 --- /dev/null +++ b/go.mod @@ -0,0 +1,35 @@ +module github.com/hotsock/jwt-issuer + +go 1.23 + +require ( + github.com/aws/aws-lambda-go v1.47.0 + github.com/aws/aws-sdk-go-v2 v1.30.4 + github.com/aws/aws-sdk-go-v2/config v1.27.30 + github.com/aws/aws-sdk-go-v2/service/kms v1.35.5 + github.com/aws/aws-sdk-go-v2/service/ssm v1.52.6 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/google/uuid v1.6.0 + github.com/samber/lo v1.47.0 + github.com/stretchr/testify v1.9.0 +) + +require ( + github.com/aws/aws-sdk-go-v2/credentials v1.17.29 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.12 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.22.5 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.30.5 // indirect + github.com/aws/smithy-go v1.20.4 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + golang.org/x/text v0.17.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5884f7e --- /dev/null +++ b/go.sum @@ -0,0 +1,60 @@ +github.com/aws/aws-lambda-go v1.47.0 h1:0H8s0vumYx/YKs4sE7YM0ktwL2eWse+kfopsRI1sXVI= +github.com/aws/aws-lambda-go v1.47.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A= +github.com/aws/aws-sdk-go-v2 v1.30.4 h1:frhcagrVNrzmT95RJImMHgabt99vkXGslubDaDagTk8= +github.com/aws/aws-sdk-go-v2 v1.30.4/go.mod h1:CT+ZPWXbYrci8chcARI3OmI/qgd+f6WtuLOoaIA8PR0= +github.com/aws/aws-sdk-go-v2/config v1.27.30 h1:AQF3/+rOgeJBQP3iI4vojlPib5X6eeOYoa/af7OxAYg= +github.com/aws/aws-sdk-go-v2/config v1.27.30/go.mod h1:yxqvuubha9Vw8stEgNiStO+yZpP68Wm9hLmcm+R/Qk4= +github.com/aws/aws-sdk-go-v2/credentials v1.17.29 h1:CwGsupsXIlAFYuDVHv1nnK0wnxO0wZ/g1L8DSK/xiIw= +github.com/aws/aws-sdk-go-v2/credentials v1.17.29/go.mod h1:BPJ/yXV92ZVq6G8uYvbU0gSl8q94UB63nMT5ctNO38g= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.12 h1:yjwoSyDZF8Jth+mUk5lSPJCkMC0lMy6FaCD51jm6ayE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.12/go.mod h1:fuR57fAgMk7ot3WcNQfb6rSEn+SUffl7ri+aa8uKysI= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16 h1:TNyt/+X43KJ9IJJMjKfa3bNTiZbUP7DeCxfbTROESwY= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16/go.mod h1:2DwJF39FlNAUiX5pAc0UNeiz16lK2t7IaFcm0LFHEgc= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16 h1:jYfy8UPmd+6kJW5YhY0L1/KftReOGxI/4NtVSTh9O/I= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16/go.mod h1:7ZfEPZxkW42Afq4uQB8H2E2e6ebh6mXTueEpYzjCzcs= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.4 h1:KypMCbLPPHEmf9DgMGw51jMj77VfGPAN2Kv4cfhlfgI= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.4/go.mod h1:Vz1JQXliGcQktFTN/LN6uGppAIRoLBR2bMvIMP0gOjc= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18 h1:tJ5RnkHCiSH0jyd6gROjlJtNwov0eGYNz8s8nFcR0jQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18/go.mod h1:++NHzT+nAF7ZPrHPsA+ENvsXkOO8wEu+C6RXltAG4/c= +github.com/aws/aws-sdk-go-v2/service/kms v1.35.5 h1:XUomV7SiclZl1QuXORdGcfFqHxEHET7rmNGtxTfNB+M= +github.com/aws/aws-sdk-go-v2/service/kms v1.35.5/go.mod h1:A5CS0VRmxxj2YKYLCY08l/Zzbd01m6JZn0WzxgT1OCA= +github.com/aws/aws-sdk-go-v2/service/ssm v1.52.6 h1:uvd3OF/3jt2csfs2xZ64NIOukDY/YJYZiHqT9vP3Mhg= +github.com/aws/aws-sdk-go-v2/service/ssm v1.52.6/go.mod h1:Bw2YSeqq/I4VyVs9JSfdT9ArqyAbQkJEwj13AVm0heg= +github.com/aws/aws-sdk-go-v2/service/sso v1.22.5 h1:zCsFCKvbj25i7p1u94imVoO447I/sFv8qq+lGJhRN0c= +github.com/aws/aws-sdk-go-v2/service/sso v1.22.5/go.mod h1:ZeDX1SnKsVlejeuz41GiajjZpRSWR7/42q/EyA/QEiM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.5 h1:SKvPgvdvmiTWoi0GAJ7AsJfOz3ngVkD/ERbs5pUnHNI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.5/go.mod h1:20sz31hv/WsPa3HhU3hfrIet2kxM4Pe0r20eBZ20Tac= +github.com/aws/aws-sdk-go-v2/service/sts v1.30.5 h1:OMsEmCyz2i89XwRwPouAJvhj81wINh+4UK+k/0Yo/q8= +github.com/aws/aws-sdk-go-v2/service/sts v1.30.5/go.mod h1:vmSqFK+BVIwVpDAGZB3CoCXHzurt4qBE8lf+I/kRTh0= +github.com/aws/smithy-go v1.20.4 h1:2HK1zBdPgRbjFOHlfeQZfpC4r72MOb9bZkiFwggKO+4= +github.com/aws/smithy-go v1.20.4/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc= +github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/issuer/jwt.go b/internal/issuer/jwt.go new file mode 100644 index 0000000..41af065 --- /dev/null +++ b/internal/issuer/jwt.go @@ -0,0 +1,60 @@ +package issuer + +import ( + "log/slog" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/samber/lo" +) + +type JWTIssuerFunctionInput struct { + // Whether or not to apply an issued at "iat" claim with the current time. + // Overrides "iat" in Claims, if true. + SetIat *bool `json:"setIat,omitempty"` + + // Whether or not to generate an apply a `jti` claim with a generated UUID. + // Overrides "jti" in Claims, if true. + SetJti *bool `json:"setJti,omitempty"` + + // Optional number of seconds until the token will expire. Overrides "exp" in + // Claims, if provided. + TTL *time.Duration `json:"ttl,omitempty"` + + // All the claims for the token. + Claims jwt.MapClaims `json:"claims,omitempty"` +} + +type JWTIssuerFunctionOutput struct { + // The signed JWT. + Token string `json:"token"` +} + +func PrepareToken(input JWTIssuerFunctionInput, keyID string) *jwt.Token { + if input.Claims == nil { + input.Claims = jwt.MapClaims{} + } + + now := time.Now() + if lo.FromPtr(input.SetIat) { + input.Claims["iat"] = jwt.NewNumericDate(now) + } + + if input.TTL != nil { + input.Claims["exp"] = jwt.NewNumericDate(now.Add(time.Second * lo.FromPtr(input.TTL))) + } + + if lo.FromPtr(input.SetJti) { + input.Claims["jti"] = uuid.New().String() + } + + slog.Debug("issuer.PrepareToken/claims", "claims", input.Claims) + + token := jwt.NewWithClaims(jwt.SigningMethodES256, input.Claims) + if keyID != "" { + token.Header["kid"] = keyID + } + + return token +} diff --git a/internal/issuer/kms.go b/internal/issuer/kms.go new file mode 100644 index 0000000..2192a2f --- /dev/null +++ b/internal/issuer/kms.go @@ -0,0 +1,62 @@ +package issuer + +import ( + "context" + "encoding/asn1" + "encoding/base64" + "log/slog" + "math/big" + "strings" + + "github.com/aws/aws-sdk-go-v2/service/kms" + kmstypes "github.com/aws/aws-sdk-go-v2/service/kms/types" + "github.com/golang-jwt/jwt/v5" + "github.com/samber/lo" +) + +type KMSAPI interface { + GetPublicKey(context.Context, *kms.GetPublicKeyInput, ...func(*kms.Options)) (*kms.GetPublicKeyOutput, error) + Sign(context.Context, *kms.SignInput, ...func(*kms.Options)) (*kms.SignOutput, error) +} + +// SignJWTWithKMS signs a JWT using a private key that is known only to KMS +func SignJWTWithKMS(ctx context.Context, kmsClient KMSAPI, token *jwt.Token, kmsKeyArn string) (string, error) { + defer LogWithTiming(ctx, slog.LevelDebug, "issuer.SignJWTWithKMS", "token", token, "kmsKeyArn", kmsKeyArn)() + + sstr, err := token.SigningString() + if err != nil { + return "", err + } + + signInput := &kms.SignInput{ + KeyId: lo.ToPtr(kmsKeyArn), + Message: []byte(sstr), + MessageType: kmstypes.MessageTypeRaw, + SigningAlgorithm: kmstypes.SigningAlgorithmSpecEcdsaSha256, + } + + signOutput, err := kmsClient.Sign(ctx, signInput) + if err != nil { + return "", err + } + + // KMS returns a DER-encoded object as defined by ANS X9.62–2005 + // and RFC 3279 Section 2.2.3 (https://tools.ietf.org/html/rfc3279#section-2.2.3). + // + // We need to convert it to the JWT r || s format before applying + // it as the signature. + // https://stackoverflow.com/questions/66170120/aws-kms-signature-returns-invalid-signature-for-my-jwt + // https://stackoverflow.com/questions/48423188/verifying-a-ecdsa-signature-with-a-provided-public-key + var esig struct { + R, S *big.Int + } + asn1.Unmarshal(signOutput.Signature, &esig) + + fullSignature := []byte{} + fullSignature = append(fullSignature, esig.R.Bytes()...) + fullSignature = append(fullSignature, esig.S.Bytes()...) + + sig := strings.TrimRight(base64.URLEncoding.EncodeToString(fullSignature), "=") + + return strings.Join([]string{sstr, sig}, "."), nil +} diff --git a/internal/issuer/logging.go b/internal/issuer/logging.go new file mode 100644 index 0000000..26c0af1 --- /dev/null +++ b/internal/issuer/logging.go @@ -0,0 +1,75 @@ +package issuer + +import ( + "context" + "log/slog" + "os" + "time" + + "github.com/aws/aws-lambda-go/cfn" + "github.com/aws/aws-lambda-go/lambdacontext" +) + +func HandlerWithLambdaLogging[E, R any](handler func(context.Context, E) (R, error)) func(context.Context, E) (R, error) { + var level slog.Level + switch os.Getenv("AWS_LAMBDA_LOG_LEVEL") { + case "DEBUG": + level = slog.LevelDebug + case "INFO": + level = slog.LevelInfo + case "WARN": + level = slog.LevelWarn + case "ERROR": + level = slog.LevelError + default: + level = slog.LevelInfo + } + + return func(ctx context.Context, event E) (R, error) { + lc, _ := lambdacontext.FromContext(ctx) + logHandler := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level})).With("requestId", lc.AwsRequestID) + slog.SetDefault(logHandler) + + return handler(ctx, event) + } +} + +func CloudFormationHandlerWithLambdaLogging(handler func(context.Context, cfn.Event) (string, map[string]any, error)) func(context.Context, cfn.Event) (string, map[string]any, error) { + var level slog.Level + switch os.Getenv("AWS_LAMBDA_LOG_LEVEL") { + case "DEBUG": + level = slog.LevelDebug + case "INFO": + level = slog.LevelInfo + case "WARN": + level = slog.LevelWarn + case "ERROR": + level = slog.LevelError + default: + level = slog.LevelInfo + } + + return func(ctx context.Context, event cfn.Event) (string, map[string]any, error) { + lc, _ := lambdacontext.FromContext(ctx) + + alwaysLogAttrs := []any{ + "requestId", lc.AwsRequestID, + "functionName", os.Getenv("AWS_LAMBDA_FUNCTION_NAME"), + } + logHandler := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level})).With(alwaysLogAttrs...) + slog.SetDefault(logHandler) + + return handler(ctx, event) + } +} + +func LogWithTiming(ctx context.Context, level slog.Level, message string, args ...any) func() { + slog.Log(ctx, level, message, args...) + start := time.Now() + + return func() { + duration := time.Since(start) + durationMs := float64(duration) / float64(time.Millisecond) + slog.Log(ctx, level, "timing:"+message, "duration", duration.String(), "durationMs", durationMs) + } +} diff --git a/internal/issuer/parameter_store.go b/internal/issuer/parameter_store.go new file mode 100644 index 0000000..23e64e7 --- /dev/null +++ b/internal/issuer/parameter_store.go @@ -0,0 +1,37 @@ +package issuer + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws/arn" + "github.com/aws/aws-sdk-go-v2/service/ssm" +) + +const StackArnEnvVar = "STACK_ARN" + +type SSMAPI interface { + DeleteParameters(context.Context, *ssm.DeleteParametersInput, ...func(*ssm.Options)) (*ssm.DeleteParametersOutput, error) + GetParameter(context.Context, *ssm.GetParameterInput, ...func(*ssm.Options)) (*ssm.GetParameterOutput, error) + PutParameter(context.Context, *ssm.PutParameterInput, ...func(*ssm.Options)) (*ssm.PutParameterOutput, error) +} + +func ParameterStoreKeyID() string { + stackArn, _ := arn.Parse(os.Getenv(StackArnEnvVar)) + return strings.Split(stackArn.Resource, "/")[2] +} + +func PrivateKeyParameterName() string { + return fmt.Sprintf("%s/private-key", parameterNamePrefix()) +} + +func PublicKeyParameterName() string { + return fmt.Sprintf("%s/public-key", parameterNamePrefix()) +} + +func parameterNamePrefix() string { + stackArn, _ := arn.Parse(os.Getenv(StackArnEnvVar)) + return fmt.Sprintf("/jwt-issuer/%s", stackArn.Resource) +} diff --git a/internal/issuer/parameter_store_test.go b/internal/issuer/parameter_store_test.go new file mode 100644 index 0000000..ed2fee7 --- /dev/null +++ b/internal/issuer/parameter_store_test.go @@ -0,0 +1,25 @@ +package issuer + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_ParameterStoreKeyID(t *testing.T) { + os.Setenv(StackArnEnvVar, "arn:aws:cloudformation:us-east-1:111111111111:stack/JWTIssuer/d0a511e0-531d-11ee-8080-0a1f08df5697") + assert.Equal(t, "d0a511e0-531d-11ee-8080-0a1f08df5697", ParameterStoreKeyID()) +} + +func Test_PrivateKeyParameterName(t *testing.T) { + os.Setenv(StackArnEnvVar, "arn:aws:cloudformation:us-east-1:111111111111:stack/JWTIssuer/d0a511e0-531d-11ee-8080-0a1f08df5697") + name := PrivateKeyParameterName() + assert.Equal(t, "/jwt-issuer/stack/JWTIssuer/d0a511e0-531d-11ee-8080-0a1f08df5697/private-key", name) +} + +func Test_PublicKeyParameterName(t *testing.T) { + os.Setenv(StackArnEnvVar, "arn:aws:cloudformation:us-east-1:111111111111:stack/JWTIssuer/d0a511e0-531d-11ee-8080-0a1f08df5697") + name := PublicKeyParameterName() + assert.Equal(t, "/jwt-issuer/stack/JWTIssuer/d0a511e0-531d-11ee-8080-0a1f08df5697/public-key", name) +} diff --git a/internal/mocks/KMSAPI.go b/internal/mocks/KMSAPI.go new file mode 100644 index 0000000..3b8e4e1 --- /dev/null +++ b/internal/mocks/KMSAPI.go @@ -0,0 +1,104 @@ +// Code generated by mockery v2.44.1. DO NOT EDIT. + +package mocks + +import ( + context "context" + + kms "github.com/aws/aws-sdk-go-v2/service/kms" + + mock "github.com/stretchr/testify/mock" +) + +// KMSAPI is an autogenerated mock type for the KMSAPI type +type KMSAPI struct { + mock.Mock +} + +// GetPublicKey provides a mock function with given fields: _a0, _a1, _a2 +func (_m *KMSAPI) GetPublicKey(_a0 context.Context, _a1 *kms.GetPublicKeyInput, _a2 ...func(*kms.Options)) (*kms.GetPublicKeyOutput, error) { + _va := make([]interface{}, len(_a2)) + for _i := range _a2 { + _va[_i] = _a2[_i] + } + var _ca []interface{} + _ca = append(_ca, _a0, _a1) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for GetPublicKey") + } + + var r0 *kms.GetPublicKeyOutput + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *kms.GetPublicKeyInput, ...func(*kms.Options)) (*kms.GetPublicKeyOutput, error)); ok { + return rf(_a0, _a1, _a2...) + } + if rf, ok := ret.Get(0).(func(context.Context, *kms.GetPublicKeyInput, ...func(*kms.Options)) *kms.GetPublicKeyOutput); ok { + r0 = rf(_a0, _a1, _a2...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*kms.GetPublicKeyOutput) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *kms.GetPublicKeyInput, ...func(*kms.Options)) error); ok { + r1 = rf(_a0, _a1, _a2...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Sign provides a mock function with given fields: _a0, _a1, _a2 +func (_m *KMSAPI) Sign(_a0 context.Context, _a1 *kms.SignInput, _a2 ...func(*kms.Options)) (*kms.SignOutput, error) { + _va := make([]interface{}, len(_a2)) + for _i := range _a2 { + _va[_i] = _a2[_i] + } + var _ca []interface{} + _ca = append(_ca, _a0, _a1) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for Sign") + } + + var r0 *kms.SignOutput + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *kms.SignInput, ...func(*kms.Options)) (*kms.SignOutput, error)); ok { + return rf(_a0, _a1, _a2...) + } + if rf, ok := ret.Get(0).(func(context.Context, *kms.SignInput, ...func(*kms.Options)) *kms.SignOutput); ok { + r0 = rf(_a0, _a1, _a2...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*kms.SignOutput) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *kms.SignInput, ...func(*kms.Options)) error); ok { + r1 = rf(_a0, _a1, _a2...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NewKMSAPI creates a new instance of KMSAPI. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewKMSAPI(t interface { + mock.TestingT + Cleanup(func()) +}) *KMSAPI { + mock := &KMSAPI{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/mocks/SSMAPI.go b/internal/mocks/SSMAPI.go new file mode 100644 index 0000000..1c871d8 --- /dev/null +++ b/internal/mocks/SSMAPI.go @@ -0,0 +1,141 @@ +// Code generated by mockery v2.44.1. DO NOT EDIT. + +package mocks + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" + + ssm "github.com/aws/aws-sdk-go-v2/service/ssm" +) + +// SSMAPI is an autogenerated mock type for the SSMAPI type +type SSMAPI struct { + mock.Mock +} + +// DeleteParameters provides a mock function with given fields: _a0, _a1, _a2 +func (_m *SSMAPI) DeleteParameters(_a0 context.Context, _a1 *ssm.DeleteParametersInput, _a2 ...func(*ssm.Options)) (*ssm.DeleteParametersOutput, error) { + _va := make([]interface{}, len(_a2)) + for _i := range _a2 { + _va[_i] = _a2[_i] + } + var _ca []interface{} + _ca = append(_ca, _a0, _a1) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for DeleteParameters") + } + + var r0 *ssm.DeleteParametersOutput + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *ssm.DeleteParametersInput, ...func(*ssm.Options)) (*ssm.DeleteParametersOutput, error)); ok { + return rf(_a0, _a1, _a2...) + } + if rf, ok := ret.Get(0).(func(context.Context, *ssm.DeleteParametersInput, ...func(*ssm.Options)) *ssm.DeleteParametersOutput); ok { + r0 = rf(_a0, _a1, _a2...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*ssm.DeleteParametersOutput) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *ssm.DeleteParametersInput, ...func(*ssm.Options)) error); ok { + r1 = rf(_a0, _a1, _a2...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetParameter provides a mock function with given fields: _a0, _a1, _a2 +func (_m *SSMAPI) GetParameter(_a0 context.Context, _a1 *ssm.GetParameterInput, _a2 ...func(*ssm.Options)) (*ssm.GetParameterOutput, error) { + _va := make([]interface{}, len(_a2)) + for _i := range _a2 { + _va[_i] = _a2[_i] + } + var _ca []interface{} + _ca = append(_ca, _a0, _a1) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for GetParameter") + } + + var r0 *ssm.GetParameterOutput + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *ssm.GetParameterInput, ...func(*ssm.Options)) (*ssm.GetParameterOutput, error)); ok { + return rf(_a0, _a1, _a2...) + } + if rf, ok := ret.Get(0).(func(context.Context, *ssm.GetParameterInput, ...func(*ssm.Options)) *ssm.GetParameterOutput); ok { + r0 = rf(_a0, _a1, _a2...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*ssm.GetParameterOutput) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *ssm.GetParameterInput, ...func(*ssm.Options)) error); ok { + r1 = rf(_a0, _a1, _a2...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// PutParameter provides a mock function with given fields: _a0, _a1, _a2 +func (_m *SSMAPI) PutParameter(_a0 context.Context, _a1 *ssm.PutParameterInput, _a2 ...func(*ssm.Options)) (*ssm.PutParameterOutput, error) { + _va := make([]interface{}, len(_a2)) + for _i := range _a2 { + _va[_i] = _a2[_i] + } + var _ca []interface{} + _ca = append(_ca, _a0, _a1) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for PutParameter") + } + + var r0 *ssm.PutParameterOutput + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *ssm.PutParameterInput, ...func(*ssm.Options)) (*ssm.PutParameterOutput, error)); ok { + return rf(_a0, _a1, _a2...) + } + if rf, ok := ret.Get(0).(func(context.Context, *ssm.PutParameterInput, ...func(*ssm.Options)) *ssm.PutParameterOutput); ok { + r0 = rf(_a0, _a1, _a2...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*ssm.PutParameterOutput) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *ssm.PutParameterInput, ...func(*ssm.Options)) error); ok { + r1 = rf(_a0, _a1, _a2...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NewSSMAPI creates a new instance of SSMAPI. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewSSMAPI(t interface { + mock.TestingT + Cleanup(func()) +}) *SSMAPI { + mock := &SSMAPI{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/template.yml b/template.yml new file mode 100644 index 0000000..ba1f0ff --- /dev/null +++ b/template.yml @@ -0,0 +1,179 @@ +AWSTemplateFormatVersion: "2010-09-09" +Transform: AWS::Serverless-2016-10-31 +Description: JWT Issuer +Parameters: + KeyCustodianParameter: + Type: String + Description: | + The private key used for signing requests can either be stored in + Parameter Store or can be managed by KMS. The Parameter Store option + encrypts and stores a private key generated by this stack, where + the generated private key is decrypted and loaded into Lambda at runtime + when signing keys. The KMS option creates a key managed by KMS, where + Lambda makes calls to KMS for each JWT signing request but the private + key can never leave the KMS service. The Parameter Store option is faster + (because the key is in-memory) and costs less (because KMS is only called + during Lambda cold starts to decrypt the key). The KMS option is more + secure (because it's impossible for the key to leak) but it's + significantly more expensive due to KMS API calls for every signing + operation. + Default: ParameterStore + AllowedValues: + - KMS + - ParameterStore + LogLevelApplicationParameter: + Type: String + Description: | + Choose the log level for application logs that are sent to CloudWatch + Logs. + Default: ERROR + AllowedValues: + - DEBUG + - ERROR + LogLevelSystemParameter: + Type: String + Description: | + Choose the log level for Lambda system-generated logs that are sent to + CloudWatch Logs. + Default: WARN + AllowedValues: + - DEBUG + - INFO + - WARN +Conditions: + IsKeyCustodianKms: !Equals [!Ref KeyCustodianParameter, KMS] + IsKeyCustodianParameterStore: + !Equals [!Ref KeyCustodianParameter, ParameterStore] +Globals: + Function: + Runtime: provided.al2023 + Handler: bootstrap + Timeout: 2 + MemorySize: 128 + Architectures: [arm64] + LoggingConfig: + ApplicationLogLevel: !Ref LogLevelApplicationParameter + LogFormat: JSON + SystemLogLevel: !Ref LogLevelSystemParameter + Environment: + Variables: + SIGNING_KEY_ARN: !If [IsKeyCustodianKms, !GetAtt Key.Arn, ""] + STACK_ARN: !Ref AWS::StackId +Resources: + Key: + Type: AWS::KMS::Key + Condition: IsKeyCustodianKms + Properties: + Description: JWT Issuer Signing Key + EnableKeyRotation: false + KeyPolicy: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + AWS: !Sub arn:${AWS::Partition}:iam::${AWS::AccountId}:root + Action: kms:* + Resource: "*" + KeySpec: ECC_NIST_P256 + KeyUsage: SIGN_VERIFY + KeyGeneratorParameterStoreCustomResource: + Type: Custom::KeyGeneratorParameterStoreCustomResource + Condition: IsKeyCustodianParameterStore + Properties: + ServiceToken: !GetAtt KeyGeneratorParameterStore.Arn + Version: "1" + KeyGeneratorParameterStore: + Type: AWS::Serverless::Function + Condition: IsKeyCustodianParameterStore + Properties: + CodeUri: ./bin/key_generator + Policies: + - Statement: + - Effect: Allow + Action: + - ssm:PutParameter + - ssm:DeleteParameters + Resource: + - !Sub + - arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter/jwt-issuer/${StackPath}/private-key + - StackPath: !Select [5, !Split [":", !Ref AWS::StackId]] + - !Sub + - arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter/jwt-issuer/${StackPath}/public-key + - StackPath: !Select [5, !Split [":", !Ref AWS::StackId]] + KeyInfoLoaderKmsCustomResource: + Type: Custom::KeyInfoLoaderKmsCustomResource + Condition: IsKeyCustodianKms + Properties: + KeyArn: !GetAtt Key.Arn + ServiceToken: !GetAtt KeyInfoLoaderKms.Arn + Version: "1" + KeyInfoLoaderKms: + Type: AWS::Serverless::Function + Condition: IsKeyCustodianKms + Properties: + CodeUri: ./bin/key_info_loader + Policies: + - Statement: + - Effect: Allow + Action: + - kms:GetPublicKey + Resource: + - !GetAtt Key.Arn + JwtIssuerKms: + Type: AWS::Serverless::Function + Condition: IsKeyCustodianKms + Properties: + CodeUri: ./bin/jwt_issuer_kms + MemorySize: 384 + Policies: + - Statement: + - Effect: Allow + Action: + - kms:Sign + Resource: + - !GetAtt Key.Arn + JwtIssuerParameterStore: + Type: AWS::Serverless::Function + Condition: IsKeyCustodianParameterStore + Properties: + CodeUri: ./bin/jwt_issuer_parameter_store + Policies: + - Statement: + - Effect: Allow + Action: + - ssm:GetParameter + Resource: + - !Sub + - arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter/jwt-issuer/${StackPath}/private-key + - StackPath: !Select [5, !Split [":", !Ref AWS::StackId]] + - !Sub + - arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter/jwt-issuer/${StackPath}/public-key + - StackPath: !Select [5, !Split [":", !Ref AWS::StackId]] +Outputs: + JwtIssuerFunctionArn: + Value: !If + - IsKeyCustodianKms + - !GetAtt JwtIssuerKms.Arn + - !GetAtt JwtIssuerParameterStore.Arn + KeyArn: + Value: !If + - IsKeyCustodianKms + - !GetAtt KeyInfoLoaderKmsCustomResource.KeyArn + - !GetAtt KeyGeneratorParameterStoreCustomResource.KeyArn + KeyID: + Value: !If + - IsKeyCustodianKms + - !GetAtt KeyInfoLoaderKmsCustomResource.KeyID + - !GetAtt KeyGeneratorParameterStoreCustomResource.KeyID + PublicKeyPEMBase64: + Value: !If + - IsKeyCustodianKms + - !GetAtt KeyInfoLoaderKmsCustomResource.PublicKeyPEMBase64 + - !GetAtt KeyGeneratorParameterStoreCustomResource.PublicKeyPEMBase64 + SigningMethod: + Value: !If + - IsKeyCustodianKms + - !GetAtt KeyInfoLoaderKmsCustomResource.SigningMethod + - !GetAtt KeyGeneratorParameterStoreCustomResource.SigningMethod + Version: + Value: v1.0