diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02985bb..bf95775 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 }} diff --git a/test/aws-credentials.js b/test/aws-credentials.js new file mode 100644 index 0000000..85258c2 --- /dev/null +++ b/test/aws-credentials.js @@ -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}; +}; diff --git a/test/aws.js b/test/aws.js index 916d6d7..5e28743 100644 --- a/test/aws.js +++ b/test/aws.js @@ -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'); diff --git a/test/list-voices.js b/test/list-voices.js index d51ab87..39e9a4c 100644 --- a/test/list-voices.js +++ b/test/list-voices.js @@ -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`); diff --git a/test/synth.js b/test/synth.js index 3a59d4e..e0cb486 100644 --- a/test/synth.js +++ b/test/synth.js @@ -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) {