Skip to content

Commit 32e4dba

Browse files
author
Simone Sanfratello
authored
fix: handle aws refresh credentials (#5)
* fix: handle aws refresh credentials * feat: aws client - call refresh credentials on token expired * release: v0.2.1
1 parent 135da77 commit 32e4dba

File tree

5 files changed

+213
-18
lines changed

5 files changed

+213
-18
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@ coverage
33

44
node_modules
55
package-lock.json
6+
test/fixtures/aws-identity-token
67

78
.vscode

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "e-ipfs-core-lib",
3-
"version": "0.2.0",
3+
"version": "0.2.1",
44
"description": "E-IPFS core library",
55
"license": "(Apache-2.0 AND MIT)",
66
"homepage": "https://github.com/elastic-ipfs/core-lib",

src/aws-client/Client.js

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11

22
import path from 'path'
3-
import fs from 'fs/promises'
3+
import fs from 'fs'
44
import { setTimeout as sleep } from 'timers/promises'
55
import { Piscina } from 'piscina'
66
import { Agent, request } from 'undici'
@@ -20,25 +20,31 @@ const signerWorker = new Piscina({
2020
* @see https://docs.aws.amazon.com/index.html
2121
*/
2222
class Client {
23-
constructor ({ agent, awsAgentOptions, s3Options, dynamoOptions, refreshCredentialsInterval, roleArn = process.env.AWS_ROLE_ARN, identityToken, roleSessionName, logger }) {
23+
constructor ({ agent, awsAgentOptions, s3Options, dynamoOptions, refreshCredentialsInterval, credentialDurationSeconds, roleArn = process.env.AWS_ROLE_ARN, identityToken, roleSessionName, logger }) {
2424
// TODO validate params
2525

2626
if (!dynamoOptions?.region) {
2727
throw new Error('missing dynamo region')
2828
}
2929

30-
this.agent = agent
30+
// custom agent is set for testing purpose only
31+
this.agent = agent ?? new Agent(this.awsAgentOptions)
3132
this.awsAgentOptions = awsAgentOptions
3233
this.s3Options = s3Options
3334
this.dynamoOptions = dynamoOptions
3435
this.dynamoUrl = `https://dynamodb.${dynamoOptions.region}.amazonaws.com`
3536

37+
this.credentialDurationSeconds = credentialDurationSeconds // in seconds
3638
this.refreshCredentialsInterval = refreshCredentialsInterval
3739
this.credentialRefreshTimer = null
3840
this.roleArn = roleArn
3941
this.identityToken = identityToken
4042
this.roleSessionName = roleSessionName
4143

44+
if (!this.identityToken && process.env.AWS_WEB_IDENTITY_TOKEN_FILE) {
45+
this.identityTokenFile = path.resolve(process.cwd(), process.env.AWS_WEB_IDENTITY_TOKEN_FILE)
46+
}
47+
4248
this.logger = logger
4349

4450
this.credentials = {
@@ -49,39 +55,46 @@ class Client {
4955
}
5056

5157
async init () {
52-
// custom agent is set for testing purpose only
53-
if (this.agent) {
54-
return
55-
}
56-
this.agent = new Agent(this.awsAgentOptions)
57-
5858
if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) {
5959
this.credentials.keyId = process.env.AWS_ACCESS_KEY_ID
6060
this.credentials.accessKey = process.env.AWS_SECRET_ACCESS_KEY
6161

6262
return
6363
}
6464

65-
if (!this.identityToken && process.env.AWS_WEB_IDENTITY_TOKEN_FILE) {
66-
this.identityToken = await fs.readFile(path.resolve(process.cwd(), process.env.AWS_WEB_IDENTITY_TOKEN_FILE), 'utf8')
65+
if (this.identityTokenFile) {
66+
this.identityToken = fs.readFileSync(this.identityTokenFile, 'utf8')
6767
}
6868

6969
if (!this.refreshCredentialsInterval) {
7070
return
7171
}
7272

73+
const credentials = await this.refreshCredentials()
74+
7375
// Every N minutes we rotate the keys using STS
74-
this.credentialRefreshTimer = setInterval(() => {
75-
this.refreshCredentials()
76+
this.credentialRefreshTimer = setInterval(async () => {
77+
try {
78+
if (this.identityTokenFile) {
79+
this.identityToken = fs.readFileSync(this.identityTokenFile, 'utf8')
80+
}
81+
await this.refreshCredentials()
82+
} catch (err) {
83+
this.logger.fatal({ err }, 'AwsClient.refreshCredentials failed')
84+
}
7685
}, this.refreshCredentialsInterval).unref()
7786

78-
return this.refreshCredentials()
87+
return credentials
7988
}
8089

8190
close () {
8291
this.credentialRefreshTimer && clearInterval(this.credentialRefreshTimer)
8392
}
8493

94+
/**
95+
* get credentials for dynamo and s3 requests
96+
* @see https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithWebIdentity.html
97+
*/
8598
async refreshCredentials () {
8699
const url = new URL('https://sts.amazonaws.com')
87100

@@ -90,6 +103,8 @@ class Client {
90103
this.roleArn && url.searchParams.append('RoleArn', this.roleArn)
91104
this.roleSessionName && url.searchParams.append('RoleSessionName', this.roleSessionName)
92105
this.identityToken && url.searchParams.append('WebIdentityToken', this.identityToken)
106+
// DurationSeconds default is 3600 seconds
107+
this.credentialDurationSeconds && url.searchParams.append('DurationSeconds', this.credentialDurationSeconds)
93108

94109
const { statusCode, body } = await request(url, { dispatcher: this.agent })
95110

@@ -202,7 +217,11 @@ class Client {
202217
throw new Error('NOT_FOUND')
203218
}
204219
if (statusCode >= 400) {
205-
throw new Error(`S3 request error - Status: ${statusCode} Body: ${buffer.slice().toString('utf-8')} `)
220+
const body = buffer.slice().toString('utf-8')
221+
if (body.includes('ExpiredToken')) {
222+
await this.refreshCredentials()
223+
}
224+
throw new Error(`S3 request error - Status: ${statusCode} Body: ${body} `)
206225
}
207226

208227
return buffer.slice()
@@ -357,6 +376,10 @@ class Client {
357376
const content = buffer.slice().toString('utf-8')
358377

359378
if (statusCode >= 400) {
379+
if (content.includes('ExpiredTokenException')) {
380+
await this.refreshCredentials()
381+
}
382+
360383
throw new Error(`Dynamo request error - Status: ${statusCode} Body: ${content} `)
361384
}
362385

test/aws-client.test.js

Lines changed: 173 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,40 @@ t.test('Client', async t => {
9292
t.equal(client.credentials.keyId, 'the-key')
9393
t.equal(client.credentials.accessKey, 'the-secret')
9494
})
95+
96+
t.test('should fails on failing acquire credentials', async t => {
97+
process.env.AWS_ROLE_ARN = ''
98+
process.env.AWS_WEB_IDENTITY_TOKEN_FILE = ''
99+
process.env.AWS_ACCESS_KEY_ID = ''
100+
process.env.AWS_SECRET_ACCESS_KEY = ''
101+
102+
const client = new Client({ refreshCredentialsInterval: 100, dynamoOptions: { region: 'dynamo-region' } })
103+
client.refreshCredentials = async () => { throw new Error('SOMETHING_WRONG') }
104+
await t.rejects(() => client.init(), { message: 'SOMETHING_WRONG' })
105+
})
106+
107+
t.test('should handle error on periodic credential refresh', async t => {
108+
process.env.AWS_ROLE_ARN = ''
109+
process.env.AWS_WEB_IDENTITY_TOKEN_FILE = ''
110+
process.env.AWS_ACCESS_KEY_ID = ''
111+
process.env.AWS_SECRET_ACCESS_KEY = ''
112+
113+
const logger = helper.spyLogger()
114+
const client = new Client({ refreshCredentialsInterval: 50, logger, dynamoOptions: { region: 'dynamo-region' } })
115+
let refresh = 0
116+
client.refreshCredentials = async () => {
117+
if (refresh > 0) {
118+
throw new Error('SOMETHING_WRONG')
119+
}
120+
refresh++
121+
}
122+
await client.init()
123+
await sleep(150)
124+
client.close()
125+
126+
// t.ok(logger.messages.fatal.length > 0)
127+
// t.equal(logger.messages.fatal[0][1], 'AwsClient.refreshCredentials failed')
128+
})
95129
})
96130

97131
t.test('refreshCredentials', async t => {
@@ -100,14 +134,51 @@ t.test('Client', async t => {
100134
options.roleArn = 'role'
101135
options.identityToken = 'identity'
102136
options.roleSessionName = 'eipf-service'
137+
options.credentialDurationSeconds = 123456
103138
options.agent = helper.createMockAgent()
104139

105140
const client = new Client(options)
106141
client.agent
107142
.get('https://sts.amazonaws.com')
108143
.intercept({
109144
method: 'GET',
110-
path: '/?Version=2011-06-15&Action=AssumeRoleWithWebIdentity&RoleArn=role&RoleSessionName=eipf-service&WebIdentityToken=identity'
145+
path: '/?Version=2011-06-15&Action=AssumeRoleWithWebIdentity&RoleArn=role&RoleSessionName=eipf-service&DurationSeconds=123456&WebIdentityToken=identity'
146+
})
147+
.reply(
148+
200,
149+
`
150+
<AssumeRoleWithWebIdentityResponse>
151+
<AssumeRoleWithWebIdentityResult>
152+
<Credentials>
153+
<SessionToken>sessionToken</SessionToken>
154+
<SecretAccessKey>accessKey</SecretAccessKey>
155+
<AccessKeyId>keyId</AccessKeyId>
156+
</Credentials>
157+
</AssumeRoleWithWebIdentityResult>
158+
</AssumeRoleWithWebIdentityResponse>
159+
`
160+
)
161+
162+
await client.refreshCredentials()
163+
t.equal(client.credentials.keyId, 'keyId')
164+
t.equal(client.credentials.accessKey, 'accessKey')
165+
t.equal(client.credentials.sessionToken, 'sessionToken')
166+
})
167+
168+
t.test('should not set optional options to refresh credential request', async t => {
169+
const options = awsClientOptions(defaultConfig, helper.dummyLogger())
170+
options.agent = helper.createMockAgent()
171+
options.roleArn = ''
172+
options.identityToken = ''
173+
options.roleSessionName = ''
174+
options.credentialDurationSeconds = null
175+
176+
const client = new Client(options)
177+
client.agent
178+
.get('https://sts.amazonaws.com')
179+
.intercept({
180+
method: 'GET',
181+
path: '/?Version=2011-06-15&Action=AssumeRoleWithWebIdentity'
111182
})
112183
.reply(
113184
200,
@@ -180,6 +251,64 @@ t.test('Client', async t => {
180251

181252
t.ok(refresh, times)
182253
})
254+
255+
t.test('should refresh identity token refreshing credentials', async t => {
256+
process.env.AWS_ROLE_ARN = ''
257+
process.env.AWS_WEB_IDENTITY_TOKEN_FILE = 'test/fixtures/aws-identity-token'
258+
process.env.AWS_ACCESS_KEY_ID = ''
259+
process.env.AWS_SECRET_ACCESS_KEY = ''
260+
261+
const tokenFile = path.resolve(process.cwd(), process.env.AWS_WEB_IDENTITY_TOKEN_FILE)
262+
const tokens = ['token1', 'token2']
263+
await fs.writeFile(tokenFile, tokens[0], 'utf8')
264+
265+
const options = awsClientOptions(defaultConfig, helper.dummyLogger())
266+
options.refreshCredentialsInterval = 50
267+
268+
const client = new Client(options)
269+
let refresh = 0
270+
client.refreshCredentials = async function () {
271+
refresh++
272+
}
273+
274+
await client.init()
275+
t.equal(client.identityToken, tokens[0])
276+
277+
await fs.writeFile(tokenFile, tokens[1], 'utf8')
278+
await sleep(options.refreshCredentialsInterval * 2)
279+
280+
t.equal(client.identityToken, tokens[1])
281+
t.ok(refresh > 1, 'refresh credentials function called more than once')
282+
283+
client.close()
284+
})
285+
286+
t.test('should not refresh identity token on refreshing credentials if AWS_WEB_IDENTITY_TOKEN_FILE is not set', async t => {
287+
process.env.AWS_ROLE_ARN = ''
288+
process.env.AWS_WEB_IDENTITY_TOKEN_FILE = ''
289+
process.env.AWS_ACCESS_KEY_ID = ''
290+
process.env.AWS_SECRET_ACCESS_KEY = ''
291+
const token = 'the-token'
292+
293+
const options = awsClientOptions(defaultConfig, helper.dummyLogger())
294+
options.refreshCredentialsInterval = 50
295+
options.identityToken = token
296+
297+
const client = new Client(options)
298+
let refresh = 0
299+
client.refreshCredentials = async function () {
300+
refresh++
301+
}
302+
await client.init()
303+
t.equal(client.identityToken, token)
304+
305+
await sleep(options.refreshCredentialsInterval * 2)
306+
307+
t.equal(client.identityToken, token)
308+
t.ok(refresh > 1, 'refresh credentials function called more than once')
309+
310+
client.close()
311+
})
183312
})
184313

185314
t.test('s3', async t => {
@@ -194,6 +323,28 @@ t.test('Client', async t => {
194323
})
195324
})
196325

326+
t.test('s3Request', async t => {
327+
t.test('should call refresh credential on expired token error', async t => {
328+
const body = `<?xml version="1.0" encoding="UTF-8"?>\n<Error><Code>ExpiredToken</Code>
329+
<Message>The provided token has expired.</Message><Token-0>Fwo...Aw==</Token-0>
330+
<RequestId>7A39TX8QWC98V71K</RequestId><HostId>elf...og==</HostId></Error>`
331+
let refresh
332+
const logger = helper.dummyLogger()
333+
const options = awsClientOptions(defaultConfig, logger)
334+
options.agent = helper.createMockAgent()
335+
336+
const client = new Client(options)
337+
client.refreshCredentials = async () => { refresh = true }
338+
client.agent
339+
.get(client.s3Url(region, bucket))
340+
.intercept({ method: 'GET', path: key })
341+
.reply(400, body)
342+
343+
await t.rejects(() => client.s3Fetch({ region, bucket, key, retries: 3, retryDelay: 10 }))
344+
t.ok(refresh)
345+
})
346+
})
347+
197348
t.test('s3Fetch', async t => {
198349
t.test('should fetch from s3', async t => {
199350
const logger = helper.spyLogger()
@@ -349,6 +500,27 @@ t.test('Client', async t => {
349500
})
350501

351502
t.test('dynamo', async t => {
503+
t.test('dynamoRequest', async t => {
504+
t.test('should call refresh credential on expired token error', async t => {
505+
const body = `{"__type":"com.amazon.coral.service#ExpiredTokenException",
506+
"message":"The security token included in the request is expired"}`
507+
let refresh
508+
const logger = helper.dummyLogger()
509+
const options = awsClientOptions(defaultConfig, logger)
510+
options.agent = helper.createMockAgent()
511+
512+
const client = new Client(options)
513+
client.refreshCredentials = async () => { refresh = true }
514+
client.agent
515+
.get(client.dynamoUrl)
516+
.intercept({ method: 'POST', path: '/' })
517+
.reply(400, body)
518+
519+
await t.rejects(() => client.dynamoGetItem({ table: 'table', keyName: 'key', keyValue: 'id', projection: 'items' }))
520+
t.ok(refresh)
521+
})
522+
})
523+
352524
t.test('dynamoQueryBySortKey', async t => {
353525
t.test('should query dynamo', async t => {
354526
const records = [{ a: 'b' }]

test/fixtures/aws-identity-token

Lines changed: 0 additions & 1 deletion
This file was deleted.

0 commit comments

Comments
 (0)