mirror of
https://github.com/jambonz/speech-utils.git
synced 2026-10-03 23:33:59 +00:00
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:
co-authored by
Claude Opus 5
parent
f4c3c7dc8b
commit
b928820906
@@ -7,11 +7,18 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write # required to request the GitHub OIDC token for AWS
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
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 run jslint
|
||||
- run: sudo apt update && sudo apt install -y squid
|
||||
@@ -19,10 +26,12 @@ jobs:
|
||||
- run: sudo systemctl start squid
|
||||
- run: npm test
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_REGION: ${{ secrets.AWS_REGION }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
# AWS_* are exported by configure-aws-credentials above as short-lived
|
||||
# OIDC credentials; no AWS keys are stored as repository secrets.
|
||||
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_REGION: ${{ secrets.MICROSOFT_REGION }}
|
||||
|
||||
@@ -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
@@ -6,33 +6,33 @@ process.on('unhandledRejection', (reason, p) => {
|
||||
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
|
||||
});
|
||||
|
||||
const awsCredentials = require('./aws-credentials');
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
test('AWS - create and cache auth token', async(t) => {
|
||||
const fn = require('..');
|
||||
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.end();
|
||||
client.quit();
|
||||
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 {
|
||||
let obj = await getAwsAuthToken({
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
||||
region: process.env.AWS_REGION
|
||||
});
|
||||
let obj = await getAwsAuthToken(args);
|
||||
//console.log({obj}, 'received auth token from AWS');
|
||||
t.ok(obj.securityToken && !obj.servedFromCache, 'successfullY generated auth token from AWS');
|
||||
|
||||
await sleep(250);
|
||||
obj = await getAwsAuthToken({
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
||||
region: process.env.AWS_REGION
|
||||
});
|
||||
obj = await getAwsAuthToken(args);
|
||||
//console.log({obj}, 'received auth token from AWS - second request');
|
||||
t.ok(obj.securityToken && obj.servedFromCache, 'successfully received access token from cache');
|
||||
|
||||
|
||||
+5
-7
@@ -1,4 +1,5 @@
|
||||
const test = require('tape').test ;
|
||||
const awsCredentials = require('./aws-credentials');
|
||||
const config = require('config');
|
||||
const opts = config.get('redis');
|
||||
const fs = require('fs');
|
||||
@@ -45,18 +46,15 @@ test('AWS tests', async(t) => {
|
||||
const fn = require('..');
|
||||
const {client, getTtsVoices} = fn(opts, logger);
|
||||
|
||||
if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY || !process.env.AWS_REGION) {
|
||||
t.pass('skipping AWS speech synth tests since AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, or AWS_REGION not provided');
|
||||
const credentials = awsCredentials();
|
||||
if (!credentials) {
|
||||
t.pass('skipping AWS speech synth tests since AWS_REGION not provided');
|
||||
return t.end();
|
||||
}
|
||||
try {
|
||||
const opts = {
|
||||
vendor: 'aws',
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
||||
region: process.env.AWS_REGION,
|
||||
}
|
||||
credentials
|
||||
};
|
||||
let result = await getTtsVoices(opts);
|
||||
t.ok(result?.Voices?.length > 0, `GetVoices: successfully retrieved ${result.Voices.length} voices from AWS`);
|
||||
|
||||
+10
-13
@@ -1,4 +1,5 @@
|
||||
const test = require('tape').test;
|
||||
const awsCredentials = require('./aws-credentials');
|
||||
const config = require('config');
|
||||
const opts = config.get('redis');
|
||||
const fs = require('fs');
|
||||
@@ -668,18 +669,15 @@ test('AWS speech synth tests', async(t) => {
|
||||
const fn = require('..');
|
||||
const {synthAudio, client} = fn(opts, logger);
|
||||
|
||||
if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY || !process.env.AWS_REGION) {
|
||||
t.pass('skipping AWS speech synth tests since AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, or AWS_REGION not provided');
|
||||
const credentials = awsCredentials();
|
||||
if (!credentials) {
|
||||
t.pass('skipping AWS speech synth tests since AWS_REGION not provided');
|
||||
return t.end();
|
||||
}
|
||||
try {
|
||||
let opts = await synthAudio(stats, {
|
||||
vendor: 'aws',
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
||||
region: process.env.AWS_REGION,
|
||||
},
|
||||
credentials,
|
||||
language: 'en-US',
|
||||
voice: 'Joey',
|
||||
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, {
|
||||
vendor: 'aws',
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
||||
region: process.env.AWS_REGION,
|
||||
},
|
||||
credentials,
|
||||
language: 'en-US',
|
||||
voice: 'Joey',
|
||||
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',
|
||||
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}`);
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user