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 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
+ [![CI](https://github.com/justin-tay/cdk-jwks-secret/actions/workflows/ci.yml/badge.svg)](https://github.com/justin-tay/cdk-jwks-secret/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/cdk-jwks-secret)](https://www.npmjs.com/package/cdk-jwks-secret)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](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.