ci: authenticate to AWS via GitHub OIDC instead of stored access keys (#160)

* ci: authenticate to AWS via GitHub OIDC instead of stored access keys

CI held a long-lived AWS access key as repository secrets. This replaces it with
a short-lived credential obtained through the GitHub OIDC provider, so no AWS key
is stored in the repo at all.

The tests could not simply inherit the OIDC credentials. They passed
AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY explicitly, which routes
lib/get-aws-sts-token.js down its accessKeyId branch and calls GetSessionToken --
and AWS rejects GetSessionToken when it is called with session credentials.

test/aws-credentials.js centralises the decision. When AWS_SESSION_TOKEN is present
the credentials are temporary and only the region is passed, so the SDK's default
credential provider chain is used. Static keys still work unchanged, which keeps
local run-tests.sh working and also lets it run off an 'aws sso login' session with
no credentials in the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: supply a cache key when AWS credentials come from the default chain

getAwsAuthToken derives its cache key as roleArn || accessKeyId || speech_credential_sid.
With temporary credentials the test passes none of those, so makeAwsKey received
undefined and hash.update() threw ERR_INVALID_ARG_TYPE.

Production never hits this: the instance-profile path always carries a
speech_credential_sid from the database, which is exactly what the comment above
that line describes. The test now supplies one the same way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci: reference the CI role via secret rather than inlining the account id

speech-utils is public, so the role ARN (and with it the AWS account id) should not
be committed. It now comes from the AWS_ROLE_ARN secret, which also feeds the
'AWS speech synth tests by RoleArn' test -- previously always skipped, so the
AssumeRole credential path had no coverage here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: give the AWS RoleArn synth test its own cache key

The RoleArn test synthesized the same vendor/voice/language/text as the plain AWS
synth test that runs before it, so it hit that test's cache entry, servedFromCache
came back true and the !servedFromCache assertion failed.

Latent since the test was written -- it never ran, because AWS_ROLE_ARN was never
supplied. Wiring the secret in activated it and exposed the collision. Distinct text
makes the test independent of execution order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dave Horton
2026-08-26 13:38:32 -04:00
committed by GitHub
co-authored by Claude Opus 5
parent f4c3c7dc8b
commit b928820906
5 changed files with 61 additions and 34 deletions
+12 -3
View File
@@ -7,11 +7,18 @@ on:
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
id-token: write # required to request the GitHub OIDC token for AWS
contents: read
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: '20' node-version: '20'
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- run: npm install - run: npm install
- run: npm run jslint - run: npm run jslint
- run: sudo apt update && sudo apt install -y squid - run: sudo apt update && sudo apt install -y squid
@@ -19,10 +26,12 @@ jobs:
- run: sudo systemctl start squid - run: sudo systemctl start squid
- run: npm test - run: npm test
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} # AWS_* are exported by configure-aws-credentials above as short-lived
AWS_REGION: ${{ secrets.AWS_REGION }} # OIDC credentials; no AWS keys are stored as repository secrets.
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
GCP_JSON_KEY: ${{ secrets.GCP_JSON_KEY }} GCP_JSON_KEY: ${{ secrets.GCP_JSON_KEY }}
# Enables the "AWS speech synth tests by RoleArn" test, which exercises the
# AssumeRole credential path used in production.
AWS_ROLE_ARN: ${{ secrets.AWS_ROLE_ARN }}
MICROSOFT_API_KEY: ${{ secrets.MICROSOFT_API_KEY }} MICROSOFT_API_KEY: ${{ secrets.MICROSOFT_API_KEY }}
MICROSOFT_REGION: ${{ secrets.MICROSOFT_REGION }} MICROSOFT_REGION: ${{ secrets.MICROSOFT_REGION }}
+23
View File
@@ -0,0 +1,23 @@
/**
* Resolve AWS credentials for the test suite.
*
* Returns null when AWS is not configured, so the caller skips.
*
* When AWS_SESSION_TOKEN is set the credentials are temporary -- GitHub OIDC in CI, or
* `aws sso login` locally -- and the key pair must not be passed through. Doing so sends
* lib/get-aws-sts-token.js down its accessKeyId branch, which calls GetSessionToken, and
* AWS rejects GetSessionToken when it is called with session credentials. Returning the
* region alone routes to the SDK's default credential provider chain instead, which
* handles temporary credentials correctly.
*/
module.exports = () => {
const region = process.env.AWS_REGION;
if (!region) return null;
const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
if (process.env.AWS_SESSION_TOKEN) return {region};
if (accessKeyId && secretAccessKey) return {accessKeyId, secretAccessKey, region};
return {region};
};
+11 -11
View File
@@ -6,33 +6,33 @@ process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason); console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
}); });
const awsCredentials = require('./aws-credentials');
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
test('AWS - create and cache auth token', async(t) => { test('AWS - create and cache auth token', async(t) => {
const fn = require('..'); const fn = require('..');
const {client, getAwsAuthToken} = fn(opts, logger); const {client, getAwsAuthToken} = fn(opts, logger);
if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY || !process.env.AWS_REGION) { const credentials = awsCredentials();
if (!credentials) {
t.pass('skipping AWS auth token tests since no AWS credentials provided'); t.pass('skipping AWS auth token tests since no AWS credentials provided');
t.end(); t.end();
client.quit(); client.quit();
return; return;
} }
// getAwsAuthToken derives its cache key from roleArn || accessKeyId || speech_credential_sid.
// With temporary credentials none of the first two are passed, so supply a stable sid --
// which is what production does for instance-profile credentials.
const args = {...credentials, speech_credential_sid: 'test-aws-speech-credential'};
try { try {
let obj = await getAwsAuthToken({ let obj = await getAwsAuthToken(args);
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
region: process.env.AWS_REGION
});
//console.log({obj}, 'received auth token from AWS'); //console.log({obj}, 'received auth token from AWS');
t.ok(obj.securityToken && !obj.servedFromCache, 'successfullY generated auth token from AWS'); t.ok(obj.securityToken && !obj.servedFromCache, 'successfullY generated auth token from AWS');
await sleep(250); await sleep(250);
obj = await getAwsAuthToken({ obj = await getAwsAuthToken(args);
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
region: process.env.AWS_REGION
});
//console.log({obj}, 'received auth token from AWS - second request'); //console.log({obj}, 'received auth token from AWS - second request');
t.ok(obj.securityToken && obj.servedFromCache, 'successfully received access token from cache'); t.ok(obj.securityToken && obj.servedFromCache, 'successfully received access token from cache');
+5 -7
View File
@@ -1,4 +1,5 @@
const test = require('tape').test ; const test = require('tape').test ;
const awsCredentials = require('./aws-credentials');
const config = require('config'); const config = require('config');
const opts = config.get('redis'); const opts = config.get('redis');
const fs = require('fs'); const fs = require('fs');
@@ -45,18 +46,15 @@ test('AWS tests', async(t) => {
const fn = require('..'); const fn = require('..');
const {client, getTtsVoices} = fn(opts, logger); const {client, getTtsVoices} = fn(opts, logger);
if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY || !process.env.AWS_REGION) { const credentials = awsCredentials();
t.pass('skipping AWS speech synth tests since AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, or AWS_REGION not provided'); if (!credentials) {
t.pass('skipping AWS speech synth tests since AWS_REGION not provided');
return t.end(); return t.end();
} }
try { try {
const opts = { const opts = {
vendor: 'aws', vendor: 'aws',
credentials: { credentials
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
region: process.env.AWS_REGION,
}
}; };
let result = await getTtsVoices(opts); let result = await getTtsVoices(opts);
t.ok(result?.Voices?.length > 0, `GetVoices: successfully retrieved ${result.Voices.length} voices from AWS`); t.ok(result?.Voices?.length > 0, `GetVoices: successfully retrieved ${result.Voices.length} voices from AWS`);
+10 -13
View File
@@ -1,4 +1,5 @@
const test = require('tape').test; const test = require('tape').test;
const awsCredentials = require('./aws-credentials');
const config = require('config'); const config = require('config');
const opts = config.get('redis'); const opts = config.get('redis');
const fs = require('fs'); const fs = require('fs');
@@ -668,18 +669,15 @@ test('AWS speech synth tests', async(t) => {
const fn = require('..'); const fn = require('..');
const {synthAudio, client} = fn(opts, logger); const {synthAudio, client} = fn(opts, logger);
if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY || !process.env.AWS_REGION) { const credentials = awsCredentials();
t.pass('skipping AWS speech synth tests since AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, or AWS_REGION not provided'); if (!credentials) {
t.pass('skipping AWS speech synth tests since AWS_REGION not provided');
return t.end(); return t.end();
} }
try { try {
let opts = await synthAudio(stats, { let opts = await synthAudio(stats, {
vendor: 'aws', vendor: 'aws',
credentials: { credentials,
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
region: process.env.AWS_REGION,
},
language: 'en-US', language: 'en-US',
voice: 'Joey', voice: 'Joey',
text: 'This is a test. This is only a test', text: 'This is a test. This is only a test',
@@ -689,11 +687,7 @@ test('AWS speech synth tests', async(t) => {
opts = await synthAudio(stats, { opts = await synthAudio(stats, {
vendor: 'aws', vendor: 'aws',
credentials: { credentials,
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
region: process.env.AWS_REGION,
},
language: 'en-US', language: 'en-US',
voice: 'Joey', voice: 'Joey',
text: 'This is a test. This is only a test', text: 'This is a test. This is only a test',
@@ -724,7 +718,10 @@ test('AWS speech synth tests by RoleArn', async(t) => {
}, },
language: 'en-US', language: 'en-US',
voice: 'Joey', voice: 'Joey',
text: 'This is a test. This is only a test', // Distinct text on purpose: the 'AWS speech synth tests' above cache audio for
// the same vendor/voice/language, and identical text would hit that cache entry,
// making servedFromCache true and this assertion fail.
text: 'This is a roleArn test. This is only a roleArn test',
}); });
t.ok(!opts.servedFromCache, `successfully synthesized aws by roleArn audio to ${opts.filePath}`); t.ok(!opts.servedFromCache, `successfully synthesized aws by roleArn audio to ${opts.filePath}`);
} catch (err) { } catch (err) {