@alliander-opensource/aws-jwt-sts 0.2.6

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.
@@ -0,0 +1,168 @@
1
+ // SPDX-FileCopyrightText: 2023 Alliander NV
2
+ //
3
+ // SPDX-License-Identifier: Apache-2.0
4
+
5
+ import { mockClient } from 'aws-sdk-client-mock'
6
+ import { KMSClient, GetPublicKeyCommand, DescribeKeyCommand } from '@aws-sdk/client-kms'
7
+ import { S3Client } from '@aws-sdk/client-s3'
8
+
9
+ import { handler } from '../index.keyrotate'
10
+
11
+ const kmsMock = mockClient(KMSClient)
12
+ const s3Mock = mockClient(S3Client)
13
+
14
+ const pubKeys = {
15
+ PREVIOUS: {
16
+ pem: 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt0O+biOuAYD5FrM2R6dAliN1v9HA5XpsuoAtXTn8OVKsLvvBFEhBFlghvSXPpu71vE/JYpUj0lL7J54o/RmCz9ZRDzojLU7aWEYM2sEC9nO2ITdu8it+rr3faa70+7PGW09o4iFD+mXYUgadYT8VWxrKQ3eV/LQrSM+6/KYl3BhlNZNxwjtbHGWAldOlzvy14I59GU5W/zDPgOIWSQBbpRvoJKT2rzOZYDtn7C62197hJYAU7QIZ4mOz/ia10ayFFI7p2Uogku3tY5cyYEtSWGzlTL3EiEzSvvsfQ0717bA5ybbDqCWtShg8+IoOxmby4K9X7XuGAQZYE/fgNAXg3wIDAQAB',
17
+ jwk_kid: 'reND9IAI5hj2pe8UfKm2X6r-SjW1v7s23oC3_N5WPiQ'
18
+ },
19
+ CURRENT: {
20
+ pem: 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA/PC3f+8XOs6yway2FhPLdZrWU67RIqFACSPJ0A4q/eJ8GlGXDj8cxHcJBJyvTxEU/rttSe3f44ZfrvwlDUbgAmTi2zYEDrBRHr+LmR6qoyvczLNZkiMmJZygdeOMT87gPx1fb8hhFAXQkOL8dHKiBZ+s4Hls8yu5eMuBhjh+hUYxEQWw0ilDgaXCaGRjooHPSU6+I+Qbm73MuCbBAyzSIAGDKyyD50Kx9Z9Cc0i+6ZfXwWU/2Sda7u4U4R2B/PkhAy0fIjn7kMaw9sgpdQHHxygxQ8y7PduNgDBF/C1zOeKJuRa3QGoMXY9kn/OVBwnZG7bQ9Enz3RnTkM3q0nf9JQIDAQAB',
21
+ jwk_kid: '-NIJE4RQ8NYWrOOh5_JyGKFAobfY5_oCKo1MrNXoQOg'
22
+ },
23
+ PENDING: {
24
+ pem: 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzeWAt2aRiX57vDd78OwF+83IdEI0mWh05hXvAzQXMqt+QR49hiIWjJtYh1B3sYvbp9BWC8yo+BlWWtsI5fu5mCXsBBp/Q/sgfArEsji+dWEXc+xGRN3hptb9tT+sabIWmd6Qyw4dYCksrBzJvSLO+Hi10Otd2NtzYbAqjZ6soaaClSnrOiw9+J4/GFHuY5gOw8P0uaMclI5sDLGN+G/ayGpUK7xegfEAd9VB6mhdgWoYEAT6yEDnFt0BwvTOYT6TI/5v6scRE7Bywsq5V2Mz5VZe43POcSt1n7vIZ9cXXHSGW8JPv1KKcniHsxIc3Fc74OjcEbqWKw49kVGCE3ayfQIDAQAB',
25
+ jwk_kid: 'bHyjPYB3AfII8o_X3tGkOCVzThZzQN2UKwKVCO9E9gY'
26
+ }
27
+ }
28
+
29
+ describe('handlers/keyrotate/keyrotate.test.ts', () => {
30
+ const OLD_ENV = process.env
31
+
32
+ beforeEach(() => {
33
+ jest.resetModules()
34
+ kmsMock.reset()
35
+ s3Mock.reset()
36
+ process.env = { ...OLD_ENV }
37
+ })
38
+
39
+ afterEach(() => {
40
+ kmsMock.reset()
41
+ s3Mock.reset()
42
+ process.env = OLD_ENV
43
+ })
44
+
45
+ test('should generate & upload correct JWKS file to S3', async () => {
46
+ kmsMock
47
+ .on(GetPublicKeyCommand, { KeyId: 'alias/sts/PREVIOUS' }).resolves({
48
+ PublicKey: base64ToArrayBuffer(pubKeys.PREVIOUS.pem)
49
+ })
50
+ .on(GetPublicKeyCommand, { KeyId: 'alias/sts/CURRENT' }).resolves({
51
+ PublicKey: base64ToArrayBuffer(pubKeys.CURRENT.pem)
52
+ })
53
+ .on(GetPublicKeyCommand, { KeyId: 'alias/sts/PENDING' }).resolves({
54
+ PublicKey: base64ToArrayBuffer(pubKeys.PENDING.pem)
55
+ })
56
+ .on(DescribeKeyCommand, { KeyId: 'alias/sts/PREVIOUS' }).resolves({
57
+ KeyMetadata: {
58
+ KeyId: 'key-1'
59
+ }
60
+ })
61
+ .on(DescribeKeyCommand, { KeyId: 'alias/sts/CURRENT' }).resolves({
62
+ KeyMetadata: {
63
+ KeyId: 'key-2'
64
+ }
65
+ })
66
+ .on(DescribeKeyCommand, { KeyId: 'alias/sts/PENDING' }).resolves({
67
+ KeyMetadata: {
68
+ KeyId: 'key-3'
69
+ }
70
+ })
71
+
72
+ process.env.S3_BUCKET = 'test-bucket-name'
73
+ process.env.ISSUER = 'test-issuer.com'
74
+
75
+ await handler({ step: 'generateArtifacts' })
76
+
77
+ // @ts-ignore
78
+ const tagsPrevious = kmsMock.call(2).args[0].input.Tags
79
+ expect(tagsPrevious[0].TagKey).toBe('jwk_kid')
80
+ expect(tagsPrevious[0].TagValue).toBe(pubKeys.PREVIOUS.jwk_kid)
81
+
82
+ // @ts-ignore
83
+ const tagsCurrent = kmsMock.call(5).args[0].input.Tags
84
+ expect(tagsCurrent[0].TagKey).toBe('jwk_kid')
85
+ expect(tagsCurrent[0].TagValue).toBe(pubKeys.CURRENT.jwk_kid)
86
+
87
+ // @ts-ignore
88
+ const tagsPending = kmsMock.call(8).args[0].input.Tags
89
+ expect(tagsPending[0].TagKey).toBe('jwk_kid')
90
+ expect(tagsPending[0].TagValue).toBe(pubKeys.PENDING.jwk_kid)
91
+
92
+ // @ts-ignore
93
+ const s3Key = s3Mock.call(0).args[0].input.Key
94
+ expect(s3Key).toBe('discovery/keys')
95
+
96
+ // @ts-ignore
97
+ const s3Bucket = s3Mock.call(0).args[0].input.Bucket
98
+ expect(s3Bucket).toBe('test-bucket-name')
99
+
100
+ // @ts-ignore
101
+ const s3Body = JSON.parse(s3Mock.call(0).args[0].input.Body.toString())
102
+ expect(s3Body).toEqual({
103
+ keys: [
104
+ {
105
+ e: 'AQAB',
106
+ kid: 'reND9IAI5hj2pe8UfKm2X6r-SjW1v7s23oC3_N5WPiQ',
107
+ kty: 'RSA',
108
+ n: 't0O-biOuAYD5FrM2R6dAliN1v9HA5XpsuoAtXTn8OVKsLvvBFEhBFlghvSXPpu71vE_JYpUj0lL7J54o_RmCz9ZRDzojLU7aWEYM2sEC9nO2ITdu8it-rr3faa70-7PGW09o4iFD-mXYUgadYT8VWxrKQ3eV_LQrSM-6_KYl3BhlNZNxwjtbHGWAldOlzvy14I59GU5W_zDPgOIWSQBbpRvoJKT2rzOZYDtn7C62197hJYAU7QIZ4mOz_ia10ayFFI7p2Uogku3tY5cyYEtSWGzlTL3EiEzSvvsfQ0717bA5ybbDqCWtShg8-IoOxmby4K9X7XuGAQZYE_fgNAXg3w',
109
+ alg: 'RS256',
110
+ use: 'sig'
111
+ }, {
112
+ e: 'AQAB',
113
+ kid: '-NIJE4RQ8NYWrOOh5_JyGKFAobfY5_oCKo1MrNXoQOg',
114
+ kty: 'RSA',
115
+ n: '_PC3f-8XOs6yway2FhPLdZrWU67RIqFACSPJ0A4q_eJ8GlGXDj8cxHcJBJyvTxEU_rttSe3f44ZfrvwlDUbgAmTi2zYEDrBRHr-LmR6qoyvczLNZkiMmJZygdeOMT87gPx1fb8hhFAXQkOL8dHKiBZ-s4Hls8yu5eMuBhjh-hUYxEQWw0ilDgaXCaGRjooHPSU6-I-Qbm73MuCbBAyzSIAGDKyyD50Kx9Z9Cc0i-6ZfXwWU_2Sda7u4U4R2B_PkhAy0fIjn7kMaw9sgpdQHHxygxQ8y7PduNgDBF_C1zOeKJuRa3QGoMXY9kn_OVBwnZG7bQ9Enz3RnTkM3q0nf9JQ',
116
+ alg: 'RS256',
117
+ use: 'sig'
118
+ }, {
119
+ e: 'AQAB',
120
+ kid: 'bHyjPYB3AfII8o_X3tGkOCVzThZzQN2UKwKVCO9E9gY',
121
+ kty: 'RSA',
122
+ n: 'zeWAt2aRiX57vDd78OwF-83IdEI0mWh05hXvAzQXMqt-QR49hiIWjJtYh1B3sYvbp9BWC8yo-BlWWtsI5fu5mCXsBBp_Q_sgfArEsji-dWEXc-xGRN3hptb9tT-sabIWmd6Qyw4dYCksrBzJvSLO-Hi10Otd2NtzYbAqjZ6soaaClSnrOiw9-J4_GFHuY5gOw8P0uaMclI5sDLGN-G_ayGpUK7xegfEAd9VB6mhdgWoYEAT6yEDnFt0BwvTOYT6TI_5v6scRE7Bywsq5V2Mz5VZe43POcSt1n7vIZ9cXXHSGW8JPv1KKcniHsxIc3Fc74OjcEbqWKw49kVGCE3ayfQ',
123
+ alg: 'RS256',
124
+ use: 'sig'
125
+ }]
126
+ })
127
+
128
+ // @ts-ignore
129
+ const s3KeyOpenidConfiguration = s3Mock.call(1).args[0].input.Key
130
+ expect(s3KeyOpenidConfiguration).toBe('.well-known/openid-configuration')
131
+
132
+ // @ts-ignore
133
+ const s3BodyOpenidConfiguration = JSON.parse(s3Mock.call(1).args[0].input.Body.toString())
134
+ expect(s3BodyOpenidConfiguration).toEqual({
135
+ issuer: 'test-issuer.com',
136
+ jwks_uri: 'test-issuer.com/discovery/keys',
137
+ response_types_supported: [
138
+ 'token'
139
+ ],
140
+ id_token_signing_alg_values_supported: [
141
+ 'RS256'
142
+ ],
143
+ scopes_supported: [
144
+ 'openid'
145
+ ],
146
+ token_endpoint_auth_methods_supported: [
147
+ 'client_secret_basic'
148
+ ],
149
+ claims_supported: [
150
+ 'aud',
151
+ 'exp',
152
+ 'iat',
153
+ 'iss',
154
+ 'sub'
155
+ ]
156
+ })
157
+ })
158
+ })
159
+
160
+ function base64ToArrayBuffer (b64: string) {
161
+ const byteString = atob(b64)
162
+ const byteArray = new Uint8Array(byteString.length)
163
+ for (let i = 0; i < byteString.length; i++) {
164
+ byteArray[i] = byteString.charCodeAt(i)
165
+ }
166
+
167
+ return byteArray
168
+ }
@@ -0,0 +1,187 @@
1
+ // SPDX-FileCopyrightText: 2023 Alliander NV
2
+ //
3
+ // SPDX-License-Identifier: Apache-2.0
4
+
5
+ import { APIGatewayProxyEvent, Context } from 'aws-lambda'
6
+ import { mockClient } from 'aws-sdk-client-mock'
7
+ /* eslint-disable camelcase */
8
+ import jwt_decode from 'jwt-decode'
9
+
10
+ import {
11
+ KMSClient,
12
+ DescribeKeyCommand,
13
+ ListResourceTagsCommand,
14
+ SignCommand
15
+ } from '@aws-sdk/client-kms'
16
+
17
+ import { handler } from '../index.sign'
18
+
19
+ const kmsMock = mockClient(KMSClient)
20
+
21
+ const VALID_IDENTITY_USER_ARN = 'arn:aws:sts:eu-central-1:123456789012:assumed-role/this-is-my-role-name/this-is-my-username'
22
+
23
+ const VALID_EVENT: APIGatewayProxyEvent = {
24
+ requestContext: {
25
+ identity: {
26
+ userArn: VALID_IDENTITY_USER_ARN
27
+ }
28
+ }
29
+ } as any
30
+
31
+ const CONTEXT: Context = {} as any
32
+
33
+ describe('handlers/sign/sign.ts', () => {
34
+ const OLD_ENV = process.env
35
+
36
+ beforeEach(() => {
37
+ jest.resetModules()
38
+ kmsMock.reset()
39
+ process.env = { ...OLD_ENV }
40
+ })
41
+
42
+ afterEach(() => {
43
+ kmsMock.reset()
44
+ process.env = OLD_ENV
45
+ })
46
+
47
+ test('it should respond bad request if no userIdentity is passed', async () => {
48
+ const event: APIGatewayProxyEvent = {
49
+ requestContext: {
50
+ }
51
+ } as any
52
+
53
+ const response = await handler(event, CONTEXT)
54
+
55
+ expect(response.statusCode).toEqual(400)
56
+ expect(response.body).toEqual('Unable to resolve identity')
57
+ })
58
+
59
+ test('it should respond bad request if an invalid userIdentity is passed', async () => {
60
+ const invalidServiceResponse = await handler({
61
+ requestContext: {
62
+ identity: {
63
+ userArn: 'arn:aws:invalid-service:eu-central-1:123456789012:assumed-role/this-is-my-role-name/this-is-my-username'
64
+ }
65
+ }
66
+ } as any, CONTEXT)
67
+
68
+ expect(invalidServiceResponse.statusCode).toEqual(400)
69
+ expect(invalidServiceResponse.body).toEqual('Unable to resolve identity')
70
+
71
+ const invalidAccountIdResponse = await handler({
72
+ requestContext: {
73
+ identity: {
74
+ userArn: 'arn:aws:sts:eu-central-1:account-id:assumed-role/this-is-my-role-name/this-is-my-username'
75
+ }
76
+ }
77
+ } as any, CONTEXT)
78
+
79
+ expect(invalidAccountIdResponse.statusCode).toEqual(400)
80
+ expect(invalidAccountIdResponse.body).toEqual('Unable to resolve identity')
81
+
82
+ const completelyInvalidArn = await handler({
83
+ requestContext: {
84
+ identity: {
85
+ userArn: 'i-am-not-even-trying'
86
+ }
87
+ }
88
+ } as any, CONTEXT)
89
+
90
+ expect(completelyInvalidArn.statusCode).toEqual(400)
91
+ expect(completelyInvalidArn.body).toEqual('Unable to resolve identity')
92
+ })
93
+
94
+ test('it should respond internal server error if no tag is present on the KMS key', async () => {
95
+ kmsMock
96
+ .on(DescribeKeyCommand).resolves({
97
+ KeyMetadata: {
98
+ KeyId: 'key-1'
99
+ }
100
+ })
101
+ .on(ListResourceTagsCommand).resolves({
102
+ Tags: [
103
+ {
104
+ TagKey: 'NotTheKid',
105
+ TagValue: 'I won\'t be resolved'
106
+ }
107
+ ]
108
+ })
109
+
110
+ const response = await handler(VALID_EVENT, CONTEXT)
111
+
112
+ expect(response.statusCode).toEqual(500)
113
+ expect(response.body).toEqual('KMS key is not correctly tagged')
114
+ })
115
+
116
+ test('it should respond internal server error if the KeyId is not in the metadata', async () => {
117
+ kmsMock
118
+ .on(DescribeKeyCommand).resolves({})
119
+
120
+ const response = await handler(VALID_EVENT, CONTEXT)
121
+
122
+ expect(response.statusCode).toEqual(500)
123
+ expect(response.body).toEqual('KMS key could not be retrieved')
124
+ })
125
+
126
+ test('should sign correctly', async () => {
127
+ jest
128
+ .useFakeTimers()
129
+ .setSystemTime(new Date('2020-01-01'))
130
+
131
+ const b64Signature = Buffer.from('i-am-a-signature').toString('base64')
132
+ const signature = base64ToArrayBuffer(b64Signature)
133
+
134
+ kmsMock
135
+ .on(DescribeKeyCommand).resolves({
136
+ KeyMetadata: {
137
+ KeyId: 'key-1'
138
+ }
139
+ })
140
+ .on(ListResourceTagsCommand).resolves({
141
+ Tags: [
142
+ {
143
+ TagKey: 'jwk_kid',
144
+ TagValue: 'I am the KID from the JWK'
145
+ }
146
+ ]
147
+ })
148
+ .on(SignCommand).resolves({
149
+ Signature: signature
150
+ })
151
+
152
+ process.env.ISSUER = 'https://test-issuer.com'
153
+ process.env.DEFAULT_AUDIENCE = 'api://default-aud'
154
+
155
+ const response = await handler(VALID_EVENT, CONTEXT)
156
+
157
+ expect(response.statusCode).toEqual(200)
158
+ const responseBody = JSON.parse(response.body)
159
+ const token = responseBody.token
160
+
161
+ const decodedHeader: any = jwt_decode(token, { header: true })
162
+
163
+ expect(decodedHeader.alg).toEqual('RS256')
164
+ expect(decodedHeader.typ).toEqual('JWT')
165
+ expect(decodedHeader.kid).toEqual('I am the KID from the JWK')
166
+
167
+ const decodedToken: any = jwt_decode(token)
168
+ expect(decodedToken.sub).toEqual('arn:aws:iam:eu-central-1:123456789012:role/this-is-my-role-name')
169
+ expect(decodedToken.aud).toEqual('api://default-aud')
170
+ expect(decodedToken.iss).toEqual('https://test-issuer.com')
171
+ expect(decodedToken.exp - decodedToken.iat).toEqual(3600)
172
+ expect(decodedToken.iat - decodedToken.nbf).toEqual(300)
173
+
174
+ const tokenParts = responseBody.token.split('.')
175
+ expect(tokenParts[2]).toEqual(`${b64Signature.replace('==', '')}`)
176
+ })
177
+ })
178
+
179
+ function base64ToArrayBuffer (b64: string) {
180
+ const byteString = atob(b64)
181
+ const byteArray = new Uint8Array(byteString.length)
182
+ for (let i = 0; i < byteString.length; i++) {
183
+ byteArray[i] = byteString.charCodeAt(i)
184
+ }
185
+
186
+ return byteArray
187
+ }
@@ -0,0 +1,72 @@
1
+ // SPDX-FileCopyrightText: 2023 Alliander NV
2
+ //
3
+ // SPDX-License-Identifier: Apache-2.0
4
+
5
+ /* eslint-disable no-new */
6
+ import * as cdk from 'aws-cdk-lib'
7
+ import { Match, Template } from 'aws-cdk-lib/assertions'
8
+ import { AwsJwtSts } from '../index'
9
+
10
+ test('creates sts construct correctly', () => {
11
+ const stack = new cdk.Stack()
12
+ new AwsJwtSts(stack, 'AllianderIngress', {
13
+ defaultAudience: 'api://default-aud'
14
+ })
15
+
16
+ const template = Template.fromStack(stack)
17
+ template.hasResourceProperties('AWS::Lambda::Function', Match.objectLike({
18
+ Runtime: 'nodejs18.x'
19
+ }))
20
+
21
+ template.hasResourceProperties('AWS::Events::Rule', Match.objectLike(
22
+ {
23
+ EventPattern: {
24
+ 'detail-type': ['CloudFormation Stack Status Change']
25
+ },
26
+ State: 'ENABLED'
27
+ }
28
+ ))
29
+ })
30
+
31
+ test('creates sts construct with key rotation on create/update disabled', () => {
32
+ const stack = new cdk.Stack()
33
+ new AwsJwtSts(stack, 'AllianderIngress', {
34
+ defaultAudience: 'api://default-aud',
35
+ disableKeyRotateOnCreate: true
36
+ })
37
+
38
+ const template = Template.fromStack(stack)
39
+
40
+ template.resourcePropertiesCountIs('AWS::Events::Rule', Match.objectLike(
41
+ {
42
+ EventPattern: {
43
+ 'detail-type': ['CloudFormation Stack Status Change']
44
+ }
45
+ }
46
+ ), 0)
47
+ })
48
+
49
+ test('creates sts construct with custom alarm names', () => {
50
+ const stack = new cdk.Stack()
51
+ new AwsJwtSts(stack, 'AllianderIngress', {
52
+ defaultAudience: 'api://default-aud',
53
+ alarmNameApiGateway5xx: 'alarm-api-gw-5xx',
54
+ alarmNameKeyRotationLambdaFailed: 'alarm-key-rotation-lambda-failed',
55
+ alarmNameKeyRotationStepFunctionFailed: 'alarm-step-functions-failed',
56
+ alarmNameSignLambdaFailed: 'alarm-sign-lambda-failed'
57
+ })
58
+
59
+ const template = Template.fromStack(stack)
60
+ template.hasResourceProperties('AWS::CloudWatch::Alarm', Match.objectLike({
61
+ AlarmName: 'alarm-api-gw-5xx'
62
+ }))
63
+ template.hasResourceProperties('AWS::CloudWatch::Alarm', Match.objectLike({
64
+ AlarmName: 'alarm-key-rotation-lambda-failed'
65
+ }))
66
+ template.hasResourceProperties('AWS::CloudWatch::Alarm', Match.objectLike({
67
+ AlarmName: 'alarm-step-functions-failed'
68
+ }))
69
+ template.hasResourceProperties('AWS::CloudWatch::Alarm', Match.objectLike({
70
+ AlarmName: 'alarm-sign-lambda-failed'
71
+ }))
72
+ })