cdk-jwks-secret 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +253 -0
- package/docs/how-rotation-works.md +129 -0
- package/docs/using-the-keys.md +93 -0
- package/lib/assets/lambda/handlers/rotation/index.js +28273 -0
- package/lib/constructs/index.d.ts +1 -0
- package/lib/constructs/index.js +18 -0
- package/lib/constructs/jwks-secret/index.d.ts +3 -0
- package/lib/constructs/jwks-secret/index.js +19 -0
- package/lib/constructs/jwks-secret/jwks-secret-props.d.ts +44 -0
- package/lib/constructs/jwks-secret/jwks-secret-props.js +3 -0
- package/lib/constructs/jwks-secret/jwks-secret.d.ts +25 -0
- package/lib/constructs/jwks-secret/jwks-secret.js +144 -0
- package/lib/shared/jwks/generate.d.ts +4 -0
- package/lib/shared/jwks/generate.js +35 -0
- package/lib/shared/jwks/index.d.ts +6 -0
- package/lib/shared/jwks/index.js +23 -0
- package/lib/shared/jwks/jwk.d.ts +32 -0
- package/lib/shared/jwks/jwk.js +42 -0
- package/lib/shared/jwks/options.d.ts +78 -0
- package/lib/shared/jwks/options.js +121 -0
- package/lib/shared/jwks/public.d.ts +28 -0
- package/lib/shared/jwks/public.js +53 -0
- package/lib/shared/jwks/rotate.d.ts +28 -0
- package/lib/shared/jwks/rotate.js +73 -0
- package/lib/shared/jwks/validate.d.ts +32 -0
- package/lib/shared/jwks/validate.js +165 -0
- package/package.json +88 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Justin Tay
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# cdk-jwks-secret
|
|
2
|
+
|
|
3
|
+
[](https://github.com/justin-tay/cdk-jwks-secret/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/cdk-jwks-secret)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
An AWS CDK construct for a JWK Set stored in a Secrets Manager secret and
|
|
8
|
+
rotated by a Lambda. The secret starts empty and is initialised with 2 keys by
|
|
9
|
+
the rotation triggered when the stack is deployed. Every 28 days a new key is
|
|
10
|
+
added, keeping at most 3, so that a key is published before it is used, e.g.
|
|
11
|
+
for `private_key_jwt` client authentication with an OpenID Connect server.
|
|
12
|
+
|
|
13
|
+
| **Reference documentation** | |
|
|
14
|
+
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
|
15
|
+
| Secrets Manager rotation | [Rotate AWS Secrets Manager secrets](https://docs.aws.amazon.com/secretsmanager/latest/userguide/rotating-secrets.html) |
|
|
16
|
+
| `private_key_jwt` | [OpenID Connect Core 1.0, section 9](https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication) |
|
|
17
|
+
| JSON Web Key (JWK) | [RFC 7517](https://www.rfc-editor.org/rfc/rfc7517) |
|
|
18
|
+
| JSON Web Algorithms (JWA) | [RFC 7518](https://www.rfc-editor.org/rfc/rfc7518) |
|
|
19
|
+
| JWK thumbprint (`kid`) | [RFC 7638](https://www.rfc-editor.org/rfc/rfc7638) |
|
|
20
|
+
| Guides | [How rotation works](docs/how-rotation-works.md), [Using the keys](docs/using-the-keys.md) |
|
|
21
|
+
|
|
22
|
+
## Overview
|
|
23
|
+
|
|
24
|
+
Install the package; `aws-cdk-lib` and `constructs` are peer dependencies:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
npm install cdk-jwks-secret
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Then add a `JwksSecret` to a stack:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { JwksSecret } from 'cdk-jwks-secret';
|
|
34
|
+
|
|
35
|
+
const jwksSecret = new JwksSecret(this, 'ClientJwks', {
|
|
36
|
+
use: 'sig', // default; ES256 keys. Use 'enc' for ECDH-ES+A128KW keys.
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
jwksSecret.grantRead(serverTaskRole);
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Your server reads the secret, serves its public keys from the client's
|
|
43
|
+
`jwks_uri` and signs (or decrypts) with the private keys; see
|
|
44
|
+
[Using the keys](docs/using-the-keys.md).
|
|
45
|
+
|
|
46
|
+
> **Cost:** the secret is retained by default when the stack is destroyed and
|
|
47
|
+
> costs $0.40 per month until it is deleted. See [Removal and cost](#removal-and-cost).
|
|
48
|
+
|
|
49
|
+
## Construct Props
|
|
50
|
+
|
|
51
|
+
| **Name** | **Type** | **Default** | **Description** |
|
|
52
|
+
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
53
|
+
| use? | `JwkPublicKeyUse` | The use of `algorithm`, or `'sig'` | Whether the keys sign (`'sig'`, e.g. for `private_key_jwt`) or encrypt (`'enc'`, e.g. for encrypted ID tokens). Selects the default algorithm. If `algorithm` is also given, it must have this use. |
|
|
54
|
+
| algorithm? | `JwkAlgorithm` | `'ES256'` for `sig`, `'ECDH-ES+A128KW'` for `enc` | The JWA algorithm of every key. For `sig` keys: `RS256` `RS384` `RS512` `PS256` `PS384` `PS512` `ES256` `ES384` `ES512`. For `enc` keys: `RSA-OAEP-256` `ECDH-ES` `ECDH-ES+A128KW` `ECDH-ES+A192KW` `ECDH-ES+A256KW`. Determines the key type, the use and, for `ES*`, the curve. |
|
|
55
|
+
| curve? | `JwkEcCurve` | Implied by `algorithm`; `P-256` for `ECDH-ES*` | The EC curve: `P-256`, `P-384` or `P-521`. Can only be chosen for `ECDH-ES` and `ECDH-ES+A*KW`. For `ES*` it must match the algorithm. |
|
|
56
|
+
| rsaModulusLength? | `number` | `2048` | The RSA modulus length in bits, a multiple of 8 from 2048 to 4096. For RSA algorithms only. |
|
|
57
|
+
| secretProps? | [`Pick<SecretProps, 'secretName' \| 'description' \| 'encryptionKey' \| 'removalPolicy'>`](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_secretsmanager.SecretProps.html) | A generated name, no description, the AWS managed key `aws/secretsmanager` and `RemovalPolicy.RETAIN` | Overrides for the secret. Its value is always managed by the construct. It is retained by default because deleting it loses the keys the client registration relies on; see [Removal and cost](#removal-and-cost). |
|
|
58
|
+
| rotationLambdaProps? | [`Pick<FunctionProps, 'memorySize' \| 'vpc' \| 'vpcSubnets' \| 'securityGroups'>`](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_lambda.FunctionProps.html) | Not in a VPC. `memorySize` is 512 for RSA keys larger than 2048 bits, 128 otherwise. | Overrides for the rotation Lambda. Lambda allocates CPU in proportion to memory, which is what RSA key generation needs. In a VPC, the subnets need a route to Secrets Manager through a VPC endpoint or NAT. |
|
|
59
|
+
| rotationLogGroupProps? | [`Pick<LogGroupProps, 'retention' \| 'removalPolicy'>`](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_logs.LogGroupProps.html) | `RetentionDays.ONE_YEAR` and the secret's removal policy | Overrides for the rotation Lambda's log group. |
|
|
60
|
+
| rotationScheduleProps? | [`Pick<RotationScheduleOptions, 'automaticallyAfter'>`](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_secretsmanager.RotationScheduleOptions.html) | `automaticallyAfter: Duration.days(28)` | Overrides for the rotation schedule. `automaticallyAfter` must be from 4 hours to 1000 days. It is also how long a new key is published before it is used, so it should be longer than the OpenID Connect server's JWKS cache lifetime. The schedule always rotates immediately when it is created or updated, which initialises the secret. |
|
|
61
|
+
|
|
62
|
+
The key options (`use`, `algorithm`, `curve`, `rsaModulusLength`) cannot be
|
|
63
|
+
changed on an existing secret: the next rotation fails and leaves the secret
|
|
64
|
+
unchanged. See [Changing the key options](docs/how-rotation-works.md#changing-the-key-options).
|
|
65
|
+
|
|
66
|
+
## Construct Properties
|
|
67
|
+
|
|
68
|
+
| **Name** | **Type** | **Description** |
|
|
69
|
+
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
70
|
+
| secret | [`secretsmanager.Secret`](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_secretsmanager.Secret.html) | The secret holding the JWKS. |
|
|
71
|
+
| rotationSchedule | [`secretsmanager.RotationSchedule`](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_secretsmanager.RotationSchedule.html) | The secret's rotation schedule. |
|
|
72
|
+
| rotationLambda | [`lambda.Function`](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_lambda.Function.html) | The rotation Lambda. |
|
|
73
|
+
| rotationLogGroup | [`logs.LogGroup`](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_logs.LogGroup.html) | The rotation Lambda's log group. |
|
|
74
|
+
| rotationLambdaRole | [`iam.Role`](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_iam.Role.html) | The rotation Lambda's execution role. |
|
|
75
|
+
| jwkOptions | `ResolvedJwkOptions` | The resolved key options: `algorithm`, `use`, `keyType`, and `curve` or `rsaModulusLength`. |
|
|
76
|
+
| grantRead(grantee) | method, returns [`iam.Grant`](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_iam.Grant.html) | Grants `grantee` read access to the secret (and decrypt on `encryptionKey`), e.g. the server that serves the JWKS and signs with the keys. |
|
|
77
|
+
|
|
78
|
+
## AWS resources
|
|
79
|
+
|
|
80
|
+
`JwksSecret` creates:
|
|
81
|
+
|
|
82
|
+
| **AWS resource** | **CloudFormation type** | **Purpose** | **Cost** |
|
|
83
|
+
| ------------------------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
|
84
|
+
| Secret | `AWS::SecretsManager::Secret` | Holds the JWKS. **Retained** by default. | [$0.40 per month and $0.05 per 10,000 API calls](https://aws.amazon.com/secrets-manager/pricing/) |
|
|
85
|
+
| Secret resource policy | `AWS::SecretsManager::ResourcePolicy` | Added by the CDK: denies `DeleteSecret` to the account while the stack exists | Free |
|
|
86
|
+
| Rotation schedule | `AWS::SecretsManager::RotationSchedule` | Rotates every 28 days, and immediately on creation | Free |
|
|
87
|
+
| Rotation Lambda | `AWS::Lambda::Function` | Generates and rotates the keys | [Per invocation](https://aws.amazon.com/lambda/pricing/); a few seconds per rotation |
|
|
88
|
+
| Lambda permission | `AWS::Lambda::Permission` | Lets Secrets Manager invoke the rotation Lambda | Free |
|
|
89
|
+
| Lambda role | `AWS::IAM::Role`, `AWS::IAM::Policy` | Lets the rotation Lambda read and update the secret, and write its own logs | Free |
|
|
90
|
+
| Log group | `AWS::Logs::LogGroup` | Rotation Lambda logs, kept 1 year. **Retained** by default. | [Storage](https://aws.amazon.com/cloudwatch/pricing/); a few KB per rotation |
|
|
91
|
+
| Security group (with `rotationLambdaProps.vpc` only) | `AWS::EC2::SecurityGroup` | Network access for the rotation Lambda in the VPC | Free |
|
|
92
|
+
| Key policy statements (with `secretProps.encryptionKey` only) | modifies your `AWS::KMS::Key` | Let the rotation Lambda use the key through Secrets Manager | None beyond [the key's own cost](https://aws.amazon.com/kms/pricing/) |
|
|
93
|
+
|
|
94
|
+
## Default settings
|
|
95
|
+
|
|
96
|
+
Out of the box implementation of the Construct without any override will set
|
|
97
|
+
the following defaults:
|
|
98
|
+
|
|
99
|
+
### AWS Secrets Manager secret
|
|
100
|
+
|
|
101
|
+
- Created with the secret string `{"keys":[]}`; no key material passes through CloudFormation
|
|
102
|
+
- Encrypted with the AWS managed key `aws/secretsmanager`
|
|
103
|
+
- Retained when removed from the stack (`DeletionPolicy: Retain`)
|
|
104
|
+
- A resource policy, added by the CDK, denies `secretsmanager:DeleteSecret` to every principal in the account while the stack exists
|
|
105
|
+
|
|
106
|
+
### Secrets Manager rotation schedule
|
|
107
|
+
|
|
108
|
+
- Rotates every 28 days
|
|
109
|
+
- Rotates immediately when the schedule is created, which initialises the secret with 2 keys, and whenever the schedule is updated (see [Timing rules](docs/how-rotation-works.md#timing-rules))
|
|
110
|
+
- Each rotation adds a new private key, keeps at most 3 keys and, for `sig` keys, removes the private part of the oldest key
|
|
111
|
+
|
|
112
|
+
### AWS Lambda function (rotation)
|
|
113
|
+
|
|
114
|
+
- Node.js 24 on arm64, 1 minute timeout
|
|
115
|
+
- 128 MB memory, or 512 MB for RSA keys larger than 2048 bits, which take far more CPU to generate
|
|
116
|
+
- Code pre-bundled in the package; nothing is bundled when the consumer synthesizes
|
|
117
|
+
- Generates `ES256` keys on P-256 (`ECDH-ES+A128KW` on P-256 with `use: 'enc'`), each with `kid` (its RFC 7638 thumbprint), `use` and `alg`
|
|
118
|
+
- Logs only key ids, never key material
|
|
119
|
+
- Not in a VPC
|
|
120
|
+
|
|
121
|
+
### AWS Lambda permission
|
|
122
|
+
|
|
123
|
+
- Allows `secretsmanager.amazonaws.com` to invoke the rotation Lambda
|
|
124
|
+
|
|
125
|
+
### AWS IAM role and policy
|
|
126
|
+
|
|
127
|
+
- Execution role without AWS managed policies
|
|
128
|
+
- Allows `logs:CreateLogStream` and `logs:PutLogEvents` on the rotation Lambda's own log group only
|
|
129
|
+
- Allows `secretsmanager:DescribeSecret`, `GetSecretValue`, `PutSecretValue` and `UpdateSecretVersionStage` on the secret
|
|
130
|
+
- Allows `secretsmanager:GetRandomPassword` (added by the CDK's rotation schedule; unused)
|
|
131
|
+
|
|
132
|
+
### Amazon CloudWatch Logs log group
|
|
133
|
+
|
|
134
|
+
- Holds the rotation Lambda's logs for 1 year
|
|
135
|
+
- Retained when removed from the stack
|
|
136
|
+
|
|
137
|
+
### Optional resources
|
|
138
|
+
|
|
139
|
+
- With `rotationLambdaProps.vpc`: a security group for the rotation Lambda (unless `securityGroups` is given), allowing all outbound traffic, and the `AWSLambdaVPCAccessExecutionRole` managed policy on its role
|
|
140
|
+
- With `secretProps.encryptionKey`: key policy statements allowing the rotation Lambda to encrypt and decrypt with the key through Secrets Manager
|
|
141
|
+
|
|
142
|
+
## cdk-nag
|
|
143
|
+
|
|
144
|
+
The construct passes the [cdk-nag](https://github.com/cdklabs/cdk-nag)
|
|
145
|
+
`AwsSolutionsChecks` rules. It acknowledges two findings on the rotation
|
|
146
|
+
Lambda's role with CDK's `Validations.of(...).acknowledge(...)`, so they
|
|
147
|
+
appear as acknowledged, with these reasons, in your validation report:
|
|
148
|
+
|
|
149
|
+
| **Finding** | **Reason** |
|
|
150
|
+
| --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
|
151
|
+
| `AwsSolutions-IAM5[Resource::*]` | `secretsmanager:GetRandomPassword`, added by the CDK rotation schedule, does not support resource-level permissions and reads no data. |
|
|
152
|
+
| `AwsSolutions-IAM4[Policy::…AWSLambdaVPCAccessExecutionRole]` (with `rotationLambdaProps.vpc` only) | Lambda needs these network interface permissions to run in a VPC; they do not support resource-level permissions. |
|
|
153
|
+
|
|
154
|
+
cdk-nag identifies these findings only by rule and policy, not by statement,
|
|
155
|
+
so the acknowledgements cover the whole `rotationLambdaRole`. Don't add your
|
|
156
|
+
own permissions to that role (e.g. with `rotationLambda.addToRolePolicy`): a
|
|
157
|
+
wildcard permission you add would be acknowledged with the reasons above
|
|
158
|
+
instead of being reported.
|
|
159
|
+
|
|
160
|
+
## Architecture
|
|
161
|
+
|
|
162
|
+
```mermaid
|
|
163
|
+
flowchart LR
|
|
164
|
+
subgraph construct["JwksSecret"]
|
|
165
|
+
schedule["Rotation schedule<br/>every 28 days"] -- invokes --> rotation["Rotation Lambda"]
|
|
166
|
+
rotation -- "reads and writes versions" --> secret[("Secret<br/>JWKS")]
|
|
167
|
+
rotation -- logs --> logGroup["Log group"]
|
|
168
|
+
end
|
|
169
|
+
server["Your server"] -- GetSecretValue --> secret
|
|
170
|
+
server -- "serves public keys" --> jwksUri["jwks_uri"]
|
|
171
|
+
op["OpenID Connect server"] -- "fetches and caches" --> jwksUri
|
|
172
|
+
server -- "private_key_jwt" --> op
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Removal and cost
|
|
176
|
+
|
|
177
|
+
By default the secret and the rotation Lambda's log group are **retained**
|
|
178
|
+
when they are removed from the stack or the stack is destroyed, because
|
|
179
|
+
deleting the secret loses the keys the client registration relies on.
|
|
180
|
+
|
|
181
|
+
A retained secret keeps costing
|
|
182
|
+
[$0.40 per month](https://aws.amazon.com/secrets-manager/pricing/) until it is
|
|
183
|
+
deleted, although it no longer rotates. The resource policy that prevents
|
|
184
|
+
deletion is removed with the stack, so the secret can then be deleted:
|
|
185
|
+
|
|
186
|
+
```sh
|
|
187
|
+
aws secretsmanager delete-secret --secret-id <arn> --recovery-window-in-days 7
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
(or `--force-delete-without-recovery`). A secret scheduled for deletion is not
|
|
191
|
+
charged. For development and test stacks, pass
|
|
192
|
+
`secretProps: { removalPolicy: RemovalPolicy.DESTROY }` instead; the log group
|
|
193
|
+
follows it.
|
|
194
|
+
|
|
195
|
+
## Guides
|
|
196
|
+
|
|
197
|
+
- [How rotation works](docs/how-rotation-works.md): the key lifecycle for `sig` and `enc` keys, the rotation steps and caveats
|
|
198
|
+
- [Using the keys](docs/using-the-keys.md): what your server must do to serve the JWKS endpoint and sign or decrypt, and the `cdk-jwks-secret/jwks` helpers
|
|
199
|
+
|
|
200
|
+
## Example
|
|
201
|
+
|
|
202
|
+
The repository's [`bin/`](bin) folder contains a deployable example app,
|
|
203
|
+
`JwksSecretExampleApp`, for an OpenID Connect client that both signs and
|
|
204
|
+
decrypts:
|
|
205
|
+
|
|
206
|
+
- a `sig` `JwksSecret` (`ES256`) and an `enc` `JwksSecret` (`ECDH-ES+A128KW`),
|
|
207
|
+
both destroyed with the stack
|
|
208
|
+
- a JWKS endpoint Lambda behind a public Function URL that serves the public
|
|
209
|
+
keys of both secrets in one JWKS, as a client's `jwks_uri` would
|
|
210
|
+
|
|
211
|
+
So that a rotation shows up straight away, the example doesn't cache the
|
|
212
|
+
secrets by default; a real endpoint would cache them for a few minutes.
|
|
213
|
+
|
|
214
|
+
On top of the resources of the two `JwksSecret`s, it creates:
|
|
215
|
+
|
|
216
|
+
| **AWS resource** | **CloudFormation type** | **Purpose** | **Cost** |
|
|
217
|
+
| ------------------------ | ------------------------------------ | ------------------------------------------------------------ | ----------------------------------------------------- |
|
|
218
|
+
| Endpoint Lambda | `AWS::Lambda::Function` | Serves the public keys of both secrets | [Per request](https://aws.amazon.com/lambda/pricing/) |
|
|
219
|
+
| Function URL | `AWS::Lambda::Url` | Public HTTPS address of the endpoint (output `JwksUrl`) | Free |
|
|
220
|
+
| Function URL permissions | `AWS::Lambda::Permission` (2) | Allow anyone to invoke the endpoint through the Function URL | Free |
|
|
221
|
+
| Endpoint role | `AWS::IAM::Role`, `AWS::IAM::Policy` | Lets the endpoint read both secrets | Free |
|
|
222
|
+
| Endpoint log group | `AWS::Logs::LogGroup` | The endpoint's logs, kept 1 week | [Storage](https://aws.amazon.com/cloudwatch/pricing/) |
|
|
223
|
+
|
|
224
|
+
The example's secrets and all its log groups (kept 1 week) are destroyed with
|
|
225
|
+
the stack, so `cdk destroy` leaves nothing behind.
|
|
226
|
+
|
|
227
|
+
To try it, clone the repository and deploy it to the AWS account and region of
|
|
228
|
+
your current credentials:
|
|
229
|
+
|
|
230
|
+
```sh
|
|
231
|
+
npm ci
|
|
232
|
+
npm run cdk:deploy # outputs JwksUrl, SigSecretArn and EncSecretArn
|
|
233
|
+
curl <JwksUrl> # 2 sig + 2 enc keys, shortly after the deployment
|
|
234
|
+
aws secretsmanager rotate-secret --secret-id <SigSecretArn>
|
|
235
|
+
curl <JwksUrl> # 3 sig + 2 enc keys, once the rotation completes (seconds)
|
|
236
|
+
aws secretsmanager rotate-secret --secret-id <EncSecretArn>
|
|
237
|
+
curl <JwksUrl> # 3 sig + 2 enc keys: the oldest of 3 enc keys is not published
|
|
238
|
+
npm run cdk:destroy
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Choose the algorithms, rotation interval and endpoint cache duration with CDK
|
|
242
|
+
context, e.g.
|
|
243
|
+
`npm run cdk:deploy -- -c sigAlgorithm=PS256 -c encAlgorithm=RSA-OAEP-256 -c rotationIntervalDays=1 -c jwksCacheSeconds=300`.
|
|
244
|
+
|
|
245
|
+
## Contributing
|
|
246
|
+
|
|
247
|
+
Bug reports and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md)
|
|
248
|
+
for how to build and test the project, and [SECURITY.md](SECURITY.md) for
|
|
249
|
+
reporting vulnerabilities.
|
|
250
|
+
|
|
251
|
+
## License
|
|
252
|
+
|
|
253
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# How rotation works
|
|
2
|
+
|
|
3
|
+
Rotating keys is easy; rotating them without breaking anyone is the hard part,
|
|
4
|
+
because two parties cache keys:
|
|
5
|
+
|
|
6
|
+
- **The OpenID Connect server caches your client's JWKS** (fetched from its
|
|
7
|
+
`jwks_uri`). It can only verify a signature, or encrypt to you, with a key
|
|
8
|
+
it has already cached.
|
|
9
|
+
- **Your server caches the secret.** For a while after a rotation it may still
|
|
10
|
+
sign with the previous key, or receive tokens encrypted to it.
|
|
11
|
+
|
|
12
|
+
`JwksSecret` keeps up to 3 keys and moves each one through a fixed lifecycle,
|
|
13
|
+
one step per rotation (every 28 days by default), so that neither cache ever
|
|
14
|
+
needs a key that is missing.
|
|
15
|
+
|
|
16
|
+
In the timelines below, `*` marks a key that still has its private part.
|
|
17
|
+
|
|
18
|
+
## Signing keys (`sig`)
|
|
19
|
+
|
|
20
|
+
| Day | Keys in the secret | Published | Your server signs with |
|
|
21
|
+
| --- | ------------------ | --------- | ---------------------- |
|
|
22
|
+
| 0 | `[A*, B*]` | A, B | A |
|
|
23
|
+
| 28 | `[A, B*, C*]` | A, B, C | B |
|
|
24
|
+
| 56 | `[B, C*, D*]` | B, C, D | C |
|
|
25
|
+
| 84 | `[C, D*, E*]` | C, D, E | D |
|
|
26
|
+
|
|
27
|
+
Each key goes through three stages:
|
|
28
|
+
|
|
29
|
+
1. **Next:** added at the end and published, but not used yet. It spends a
|
|
30
|
+
whole rotation interval in the JWKS, so the OpenID Connect server has
|
|
31
|
+
cached it before its first use.
|
|
32
|
+
2. **Signing:** the first key with a private part.
|
|
33
|
+
3. **Retired:** its private part is removed, but it stays published for one
|
|
34
|
+
more interval, so assertions signed by a server still holding the previous
|
|
35
|
+
secret keep verifying. At the next rotation it is dropped.
|
|
36
|
+
|
|
37
|
+
Day 0 is the exception: both keys are new, so the OpenID Connect server has
|
|
38
|
+
not cached either of them. That doesn't matter for a new client, because the
|
|
39
|
+
server fetches a client's JWKS the first time it needs it.
|
|
40
|
+
|
|
41
|
+
## Encryption keys (`enc`)
|
|
42
|
+
|
|
43
|
+
| Day | Keys in the secret | Published | Your server decrypts with |
|
|
44
|
+
| --- | ------------------ | --------- | ------------------------- |
|
|
45
|
+
| 0 | `[A*, B*]` | A, B | A, B |
|
|
46
|
+
| 28 | `[A*, B*, C*]` | B, C | A, B, C |
|
|
47
|
+
| 56 | `[B*, C*, D*]` | C, D | B, C, D |
|
|
48
|
+
|
|
49
|
+
Here the OpenID Connect server chooses which published key to encrypt to, so
|
|
50
|
+
the rule is reversed: a key is **unpublished one interval before its private
|
|
51
|
+
part is deleted**. During that interval the server can no longer pick it from
|
|
52
|
+
a fresh JWKS, but anything it encrypted to it from a cached JWKS still
|
|
53
|
+
decrypts. Every key in the secret keeps its private part; your server decrypts
|
|
54
|
+
with whichever key the JWE's `kid` names.
|
|
55
|
+
|
|
56
|
+
## Timing rules
|
|
57
|
+
|
|
58
|
+
The lifecycle only works if rotations are far enough apart:
|
|
59
|
+
|
|
60
|
+
- **Rotation interval > the OpenID Connect server's JWKS cache lifetime.**
|
|
61
|
+
Otherwise a `sig` key can start signing before the server has cached it,
|
|
62
|
+
and an `enc` key can be deleted while the server still encrypts to it.
|
|
63
|
+
- **Rotation interval > how long your server caches the secret.** See
|
|
64
|
+
[Using the keys](using-the-keys.md).
|
|
65
|
+
- **Don't rotate twice within the OpenID Connect server's JWKS cache
|
|
66
|
+
lifetime.** Each rotation moves every key one stage on, so two quick
|
|
67
|
+
rotations skip the waiting periods above: a `sig` key starts signing moments
|
|
68
|
+
after it was published, and an `enc` key is deleted moments after it was
|
|
69
|
+
unpublished.
|
|
70
|
+
|
|
71
|
+
Watch out for rotations you don't trigger on purpose. The construct creates the
|
|
72
|
+
schedule with `rotateImmediatelyOnUpdate`, which is what initialises the
|
|
73
|
+
secret at deployment, but it also means that **any change to the schedule
|
|
74
|
+
(e.g. `rotationScheduleProps.automaticallyAfter`) rotates immediately**. A single extra rotation after a
|
|
75
|
+
normal interval is fine; one shortly after another rotation, such as a manual
|
|
76
|
+
`rotate-secret` or a second schedule change, is not.
|
|
77
|
+
|
|
78
|
+
## What the rotation Lambda does
|
|
79
|
+
|
|
80
|
+
Secrets Manager calls the Lambda once for each step of a rotation, with a
|
|
81
|
+
`ClientRequestToken` identifying the new, pending version of the secret. Steps
|
|
82
|
+
can be retried, so each one is safe to run more than once.
|
|
83
|
+
|
|
84
|
+
| Step | What it does |
|
|
85
|
+
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
86
|
+
| `createSecret` | Unless the pending version already exists, reads the current JWKS, computes the next one (initialising an empty secret with 2 keys) and stores it as pending. |
|
|
87
|
+
| `setSecret` | Nothing: there is no other system to update, since the OpenID Connect server fetches the public keys itself. |
|
|
88
|
+
| `testSecret` | Checks the pending JWKS (see below). A failure stops the rotation. |
|
|
89
|
+
| `finishSecret` | Makes the pending version current (moves the `AWSCURRENT` label to it). |
|
|
90
|
+
|
|
91
|
+
`testSecret` checks that the pending JWKS:
|
|
92
|
+
|
|
93
|
+
- is well-formed: 2 or 3 keys, each `kid` unique and equal to the key's
|
|
94
|
+
RFC 7638 thumbprint, RSA keys of at least 2048 bits
|
|
95
|
+
- follows the layout above, with no private members left on a retired `sig` key
|
|
96
|
+
- matches the configured key options
|
|
97
|
+
- is exactly the rotation of the current JWKS: the kept keys unchanged and in
|
|
98
|
+
order, the retired `sig` key stripped of its private part, and one new key
|
|
99
|
+
|
|
100
|
+
The Lambda logs key ids only, never key material.
|
|
101
|
+
|
|
102
|
+
## When a rotation fails
|
|
103
|
+
|
|
104
|
+
A failed rotation never changes the current secret: your server and the
|
|
105
|
+
OpenID Connect server carry on with the existing keys. The error is in the
|
|
106
|
+
rotation Lambda's log group, and the secret's `LastRotatedDate` stays at the
|
|
107
|
+
last successful rotation. Once the cause is fixed, rotate again with
|
|
108
|
+
`aws secretsmanager rotate-secret --secret-id <arn>`. If Secrets Manager
|
|
109
|
+
reports that a previous rotation isn't complete, first remove the `AWSPENDING`
|
|
110
|
+
label from the failed version (its id is in `describe-secret` output):
|
|
111
|
+
|
|
112
|
+
```sh
|
|
113
|
+
aws secretsmanager update-secret-version-stage --secret-id <arn> --version-stage AWSPENDING --remove-from-version-id <version-id>
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Changing the key options
|
|
117
|
+
|
|
118
|
+
The key options (`use`, `algorithm`, `curve`, `rsaModulusLength`) can't be
|
|
119
|
+
changed on an existing secret. If they no longer match the keys in the secret,
|
|
120
|
+
`createSecret` fails with an error listing the differences. To change them,
|
|
121
|
+
create a new `JwksSecret` (a new construct id), register its JWKS with the
|
|
122
|
+
OpenID Connect server, and switch your server over to it.
|
|
123
|
+
|
|
124
|
+
## See also
|
|
125
|
+
|
|
126
|
+
- [README](../README.md): props, including the supported algorithms, and
|
|
127
|
+
the AWS resources it creates
|
|
128
|
+
- [Using the keys](using-the-keys.md): serving the JWKS and signing or
|
|
129
|
+
decrypting with the keys
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Using the keys in your server
|
|
2
|
+
|
|
3
|
+
Your server reads the secret, serves the public keys from its JWKS endpoint
|
|
4
|
+
(registered as the client's `jwks_uri` with the OpenID Connect server) and uses
|
|
5
|
+
the private keys. See [How rotation works](how-rotation-works.md) for why the
|
|
6
|
+
rules below are what they are.
|
|
7
|
+
|
|
8
|
+
## Requirements
|
|
9
|
+
|
|
10
|
+
1. **Read the `AWSCURRENT` version of the secret.** Grant access with
|
|
11
|
+
`jwksSecret.grantRead(role)`.
|
|
12
|
+
2. **Re-read it periodically.** Rotation changes the keys; cache the secret for
|
|
13
|
+
at most a fraction of the rotation interval (e.g. an hour).
|
|
14
|
+
3. **Handle an uninitialised secret.** Until the first rotation completes,
|
|
15
|
+
shortly after the stack is deployed, the secret is `{"keys":[]}`.
|
|
16
|
+
4. **Never serve private members.** Remove `d`, `p`, `q`, `dp`, `dq`, `qi`
|
|
17
|
+
(and `oth`) from every published key.
|
|
18
|
+
|
|
19
|
+
### `sig` keys (e.g. `private_key_jwt`)
|
|
20
|
+
|
|
21
|
+
- **Publish:** every key in the secret, public members only.
|
|
22
|
+
- **Sign with:** the first key that has `d`. Put its `kid` and `alg` in the JWS
|
|
23
|
+
header.
|
|
24
|
+
|
|
25
|
+
### `enc` keys
|
|
26
|
+
|
|
27
|
+
- **Publish:** every key, except the first one when there are 3 keys. Public
|
|
28
|
+
members only.
|
|
29
|
+
- **Decrypt with:** the key whose `kid` matches the JWE header. Every key in the
|
|
30
|
+
secret has its private part.
|
|
31
|
+
- **Re-read the secret on an unknown `kid`.** A rotation publishes a new key
|
|
32
|
+
straight away, so the OpenID Connect server may encrypt to it before your
|
|
33
|
+
server's cached copy of the secret has it. Re-read the secret once and try
|
|
34
|
+
again before rejecting the JWE. (Signing keys don't need this: a new `sig`
|
|
35
|
+
key isn't used for a whole rotation interval.)
|
|
36
|
+
|
|
37
|
+
## Helpers (JavaScript / TypeScript)
|
|
38
|
+
|
|
39
|
+
The rules above are implemented in `cdk-jwks-secret/jwks`, which does not
|
|
40
|
+
depend on the AWS CDK or the AWS SDK:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager';
|
|
44
|
+
import { parseJwks, selectDecryptionKey, selectSigningKey, toPublicJwks } from 'cdk-jwks-secret/jwks';
|
|
45
|
+
|
|
46
|
+
const client = new SecretsManagerClient({});
|
|
47
|
+
const { SecretString } = await client.send(
|
|
48
|
+
new GetSecretValueCommand({ SecretId: process.env.JWKS_SECRET_ARN }),
|
|
49
|
+
);
|
|
50
|
+
const jwks = parseJwks(SecretString!); // validates structure and kids
|
|
51
|
+
|
|
52
|
+
// JWKS endpoint (both sig and enc)
|
|
53
|
+
const body = JSON.stringify(toPublicJwks(jwks));
|
|
54
|
+
|
|
55
|
+
// sig: private_key_jwt client assertion
|
|
56
|
+
const signingKey = selectSigningKey(jwks); // { kid, alg, ...private JWK }
|
|
57
|
+
|
|
58
|
+
// enc: decrypt a JWE
|
|
59
|
+
const decryptionKey = selectDecryptionKey(jwks, protectedHeader.kid);
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`selectSigningKey` throws on an uninitialised secret or `enc` keys;
|
|
63
|
+
`selectDecryptionKey` returns `undefined` for an unknown `kid`, which is when to
|
|
64
|
+
re-read the secret, and throws on `sig` keys. `toPublicJwks` returns `{"keys":[]}` for an uninitialised secret.
|
|
65
|
+
|
|
66
|
+
## Other languages
|
|
67
|
+
|
|
68
|
+
Implement the two rules for your key use:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
sig: publish = keys.map(publicMembers)
|
|
72
|
+
signingKey = first key with "d"
|
|
73
|
+
|
|
74
|
+
enc: publish = (keys.length == 3 ? keys[1..] : keys).map(publicMembers)
|
|
75
|
+
decryptionKey = key with matching "kid" (re-read the secret once if none)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The key use is the `use` member of any key; all keys in a secret share it.
|
|
79
|
+
|
|
80
|
+
## Signing and encryption keys on one `jwks_uri`
|
|
81
|
+
|
|
82
|
+
A client registers a single `jwks_uri`, so a client that both signs (e.g.
|
|
83
|
+
`private_key_jwt`) and decrypts (e.g. encrypted ID tokens) serves the keys of
|
|
84
|
+
two secrets, one with `use: 'sig'` and one with `use: 'enc'`, in one JWKS.
|
|
85
|
+
Apply each secret's rule and concatenate the keys:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
const jwksList = [sigSecretString, encSecretString].map(parseJwks);
|
|
89
|
+
const body = JSON.stringify({ keys: jwksList.flatMap((jwks) => toPublicJwks(jwks).keys) });
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The OpenID Connect server tells the keys apart by their `use`. The example app
|
|
93
|
+
in [`bin/`](../bin) serves its two secrets this way.
|