Skip to content

Commit 0fa1998

Browse files
author
Phil Varner
authored
Enhance pre-hook auth example to use SecretsManager (#380)
1 parent 255a060 commit 0fa1998

File tree

8 files changed

+7790
-5434
lines changed

8 files changed

+7790
-5434
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1010
### Changed
1111

1212
- ESM modules are used instead of CommonJS
13+
- Updated pre-hook auth token example to use SecretsManager rather than single values.
1314

1415
## [0.6.0] - 2023-01-24
1516

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -907,10 +907,12 @@ which provides an example rudimentary authorization mechanism via a hard-coded t
907907

908908
To enable this example pre-hook:
909909

910-
- Modify bin/build.sh to not exclude the "pre-hook" package from being built.
910+
- Either (1) in package.json, pass the env var `BUILD_PRE_HOOK=true` in the `build`
911+
command, or (2) modify bin/build.sh to always build the "pre-hook" package.
911912
- In the serverless.yml file, uncomment the `preHook` function, the `preHook` IAM
912-
permissions and the environment variables
913-
`PRE_HOOK`, `PRE_HOOK_AUTH_TOKEN`, and `PRE_HOOK_AUTH_TOKEN_TXN`.
913+
permissions, and the environment variables `PRE_HOOK` and `API_KEYS_SECRET_ID`
914+
- Create a Secrets Manager secret with the name used in `API_KEYS_SECRET_ID` with
915+
the keys as the strings allowed for API Keys and the values as `read`.
914916
- Build and deploy.
915917

916918
### Post-Hook

bin/build.sh

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
#!/bin/sh
1+
#!/bin/bash
22

33
set -e
44

5-
for x in src/lambdas/*; do
6-
if [ "$x" != "src/lambdas/pre-hook" ] && [ "$x" != "src/lambdas/post-hook" ]; then
7-
(cd "$x" && webpack)
8-
fi
9-
done
5+
(cd src/lambdas/api && webpack)
6+
(cd src/lambdas/ingest && webpack)
7+
8+
if [[ -n "${BUILD_PRE_HOOK}" ]]; then (cd src/lambdas/pre-hook && webpack); fi
9+
if [[ -n "${BUILD_POST_HOOK}" ]]; then (cd src/lambdas/post-hook && webpack); fi

package-lock.json

Lines changed: 7583 additions & 5384 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
},
6262
"dependencies": {
6363
"@acuris/aws-es-connection": "^1.1.0",
64+
"@aws-sdk/client-secrets-manager": "^3.258.0",
6465
"@elastic/elasticsearch": "^7.9.0",
6566
"@mapbox/extent": "^0.4.0",
6667
"@opensearch-project/opensearch": "^2.2.0",
@@ -99,7 +100,8 @@
99100
"@typescript-eslint/parser": "^5.49.0",
100101
"ava": "^5.1.1",
101102
"aws-event-mocks": "^0.0.0",
102-
"aws-sdk": "^2.1301.0",
103+
"aws-sdk": "^2.1302.0",
104+
"aws-sdk-client-mock": "^2.0.1",
103105
"c8": "^7.12.0",
104106
"copy-webpack-plugin": "^11.0.0",
105107
"crypto-random-string": "^5.0.0",

serverless.example.yml

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,7 @@ provider:
2020
# comment STAC_API_ROOTPATH if deployed with a custom domain
2121
STAC_API_ROOTPATH: "/${self:provider.stage}"
2222
# PRE_HOOK: ${self:service}-${self:provider.stage}-preHook
23-
# PRE_HOOK_AUTH_TOKEN: xxx
24-
# PRE_HOOK_AUTH_TOKEN_TXN: xxx
23+
# API_KEYS_SECRET_ID: ${self:service}-${self:provider.stage}-api-keys
2524
# POST_HOOK: ${self:service}-${self:provider.stage}-postHook
2625
iam:
2726
role:
@@ -40,12 +39,15 @@ provider:
4039
- Effect: Allow
4140
Action: s3:GetObject
4241
Resource: 'arn:aws:s3:::usgs-landsat/*'
43-
# - Effect: "Allow"
44-
# Action: "lambda:InvokeFunction"
45-
# Resource: "arn:aws:lambda:${aws:region}:${aws:accountId}:function:${self:service}-${self:provider.stage}-preHook"
46-
# - Effect: "Allow"
47-
# Action: "lambda:InvokeFunction"
48-
# Resource: "arn:aws:lambda:${aws:region}:${aws:accountId}:function:${self:service}-${self:provider.stage}-postHook"
42+
# - Effect: Allow
43+
# Action: lambda:InvokeFunction
44+
# Resource: arn:aws:lambda:${aws:region}:${aws:accountId}:function:${self:service}-${self:provider.stage}-preHook
45+
# - Effect: Allow
46+
# Action: secretsmanager:GetSecretValue
47+
# Resource: arn:aws:secretsmanager:${aws:region}:${aws:accountId}:secret:${self:service}-${self:provider.stage}-api-keys-*
48+
# - Effect: Allow
49+
# Action: lambda:InvokeFunction
50+
# Resource: arn:aws:lambda:${aws:region}:${aws:accountId}:function:${self:service}-${self:provider.stage}-postHook
4951

5052
package:
5153
individually: true

src/lambdas/pre-hook/index.js

Lines changed: 48 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,62 @@
11
/* eslint-disable import/prefer-default-export */
2-
import logger from '../../lib/logger.js'
2+
import {
3+
SecretsManagerClient,
4+
GetSecretValueCommand,
5+
} from '@aws-sdk/client-secrets-manager'
36

4-
export const handler = async (event, _context) => {
5-
logger.debug('Event: %j', event)
7+
const TTL = 60 * 1000 // in ms
68

7-
const authTokenValue = process.env['PRE_HOOK_AUTH_TOKEN']
8-
const authTokenTxnValue = process.env['PRE_HOOK_AUTH_TOKEN_TXN']
9-
const txnEnabled = process.env['ENABLE_TRANSACTIONS_EXTENSION'] === 'true'
9+
const response401 = {
10+
statusCode: 401,
11+
body: '',
12+
headers: { 'access-control-allow-origin': '*' },
13+
}
1014

11-
if (!authTokenValue || (txnEnabled && !authTokenTxnValue)) {
12-
return {
13-
statusCode: 500,
14-
body: 'auth token(s) are not configured'
15-
}
16-
}
15+
// eslint-disable-next-line import/no-mutable-exports
16+
export let apiKeys = new Map()
17+
18+
const updateApiKeys = async () => {
19+
await new SecretsManagerClient({ region: process.env['AWS_REGION'] || 'us-west-2' })
20+
.send(
21+
new GetSecretValueCommand({
22+
SecretId: process.env['API_KEYS_SECRET_ID'],
23+
})
24+
)
25+
.then((data) => {
26+
apiKeys = new Map(Object.entries(JSON.parse(data.SecretString || '')))
27+
})
28+
.catch((error) => {
29+
console.error(
30+
`Error updating API keys: ${JSON.stringify(error, undefined, 2)}`
31+
)
32+
})
33+
.finally(() => {
34+
setTimeout(() => updateApiKeys(), TTL)
35+
})
36+
}
1737

18-
let token = null
38+
const READ = ['read']
39+
const isValidReadToken = (token) => READ.includes(apiKeys.get(token))
1940

20-
const authHeader = event.headers['Authorization']
41+
export const handler = async (event, _context) => {
42+
let token = null
2143

22-
if (authHeader) {
23-
token = authHeader.split('Bearer ')[1]
24-
} else if (event.queryStringParameters) {
44+
if (event.headers && event.headers['Authorization']) {
45+
token = event.headers['Authorization'].split('Bearer ')[1]
46+
} else if (
47+
event.queryStringParameters
48+
&& event.queryStringParameters['auth_token']
49+
) {
2550
token = event.queryStringParameters['auth_token']
26-
} else {
27-
return {
28-
statusCode: 401,
29-
body: '',
30-
headers: { 'access-control-allow-origin': '*' }
31-
}
3251
}
3352

34-
if (event.httpMethod !== 'GET' && event.path.startsWith('/collections')) {
35-
if (token === authTokenTxnValue) {
36-
return event
37-
}
38-
} else if (token === authTokenValue || token === authTokenTxnValue) {
39-
return event
53+
if (!apiKeys.size) {
54+
await updateApiKeys()
4055
}
4156

42-
return {
43-
statusCode: 403,
44-
body: ''
57+
if (isValidReadToken(token)) {
58+
return event
4559
}
60+
61+
return response401
4662
}

tests/unit/test-pre-hook.js

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/* eslint-disable @typescript-eslint/no-empty-function */
2+
3+
import test from 'ava'
4+
import { mockClient } from 'aws-sdk-client-mock'
5+
import {
6+
SecretsManagerClient,
7+
GetSecretValueCommand,
8+
} from '@aws-sdk/client-secrets-manager'
9+
import { handler, apiKeys } from '../../src/lambdas/pre-hook/index.js'
10+
11+
const DEFAULT_EVENT = {
12+
body: 'eyJ0ZXN0IjoiYm9keSJ9',
13+
resource: '/{proxy+}',
14+
path: '/path/to/resource',
15+
httpMethod: 'POST',
16+
isBase64Encoded: true,
17+
queryStringParameters: {
18+
foo: 'bar',
19+
},
20+
pathParameters: {
21+
proxy: '/path/to/resource',
22+
},
23+
headers: {},
24+
multiValueHeaders: {},
25+
multiValueQueryStringParameters: null,
26+
stageVariables: null,
27+
requestContext: {
28+
authorizer: null,
29+
accountId: '123456789012',
30+
resourceId: '123456',
31+
stage: 'prod',
32+
requestId: 'c6af9ac6-7b61-11e6-9a41-93e8deadbeef',
33+
requestTime: '09/Apr/2015:12:34:56 +0000',
34+
requestTimeEpoch: 1428582896000,
35+
identity: {
36+
cognitoIdentityPoolId: null,
37+
accountId: null,
38+
cognitoIdentityId: null,
39+
caller: null,
40+
accessKey: null,
41+
sourceIp: '127.0.0.1',
42+
cognitoAuthenticationType: null,
43+
cognitoAuthenticationProvider: null,
44+
userArn: null,
45+
userAgent: 'Custom User Agent String',
46+
user: null,
47+
apiKey: null,
48+
apiKeyId: null,
49+
clientCert: null,
50+
principalOrgId: null,
51+
},
52+
path: '/prod/path/to/resource',
53+
resourcePath: '/{proxy+}',
54+
httpMethod: 'POST',
55+
apiId: '1234567890',
56+
protocol: 'HTTP/1.1',
57+
},
58+
}
59+
const DEFAULT_CONTEXT = {
60+
callbackWaitsForEmptyEventLoop: false,
61+
functionName: '',
62+
functionVersion: '',
63+
invokedFunctionArn: '',
64+
memoryLimitInMB: '',
65+
awsRequestId: '',
66+
logGroupName: '',
67+
logStreamName: '',
68+
getRemainingTimeInMillis: () => 0,
69+
done: () => {},
70+
fail: () => {},
71+
succeed: () => {},
72+
}
73+
74+
// @ts-ignore
75+
const secretsManagerMock = mockClient(SecretsManagerClient)
76+
77+
const response401 = {
78+
statusCode: 401,
79+
body: '',
80+
headers: { 'access-control-allow-origin': '*' },
81+
}
82+
83+
test.beforeEach(() => {
84+
secretsManagerMock.reset()
85+
apiKeys.clear()
86+
})
87+
88+
test.serial('authenticate cases', async (t) => {
89+
secretsManagerMock
90+
// @ts-ignore
91+
.on(GetSecretValueCommand)
92+
// @ts-ignore
93+
.resolves({ SecretString: JSON.stringify({ ABC: 'read', DEF: 'other' }) })
94+
95+
const event = { ...DEFAULT_EVENT }
96+
const context = { ...DEFAULT_CONTEXT }
97+
98+
// no credentials
99+
t.deepEqual(await handler(event, context), response401)
100+
101+
// invalid credentials
102+
event.headers['Authorization'] = 'Bearer invalid'
103+
t.deepEqual(await handler(event, context), response401)
104+
105+
// valid credentials
106+
event.headers['Authorization'] = 'Bearer ABC'
107+
t.deepEqual(await handler(event, context), event)
108+
109+
delete event.headers['Authorization']
110+
111+
// credentials don't have read permissions
112+
event.headers['Authorization'] = 'Bearer DEF'
113+
t.deepEqual(await handler(event, context), response401)
114+
115+
delete event.headers['Authorization']
116+
117+
// invalid credentials
118+
event.queryStringParameters['auth_token'] = 'invalid'
119+
t.deepEqual(await handler(event, context), response401)
120+
121+
// valid credentials
122+
event.queryStringParameters['auth_token'] = 'ABC'
123+
t.deepEqual(await handler(event, context), event)
124+
})
125+
126+
test.serial('authenticate failure with retrieving keys', async (t) => {
127+
// @ts-ignore
128+
secretsManagerMock.on(GetSecretValueCommand).rejectsOnce('mocked rejection')
129+
130+
const event = { ...DEFAULT_EVENT }
131+
const context = { ...DEFAULT_CONTEXT }
132+
133+
t.deepEqual(await handler(event, context), response401)
134+
})

0 commit comments

Comments
 (0)