From 76185a9109b878ec01b7dfade3bba00bdbb408e2 Mon Sep 17 00:00:00 2001 From: Tom Keller Date: Thu, 7 May 2026 14:37:05 -0700 Subject: [PATCH] feat: support custom STS endpoints Closes #1067. This is a advanced option and is not needed for most deployments. --- README.md | 6 + action.yml | 3 + src/CredentialsClient.ts | 7 ++ src/index.ts | 10 +- test/index.test.ts | 52 +++++++- test/profileManager.test.ts | 240 +++++++++++++++++++----------------- 6 files changed, 201 insertions(+), 117 deletions(-) diff --git a/README.md b/README.md index a05eb6f..aaf5991 100644 --- a/README.md +++ b/README.md @@ -467,6 +467,12 @@ with: +### Custom STS endpoint + +Use the `sts-endpoint` input to override the AWS STS endpoint URL. Most users +should not set this option and instead let the SDK derive the correct endpoint +from the specified region. + ## OIDC Configuration Details We recommend using diff --git a/action.yml b/action.yml index 1228533..318c99d 100644 --- a/action.yml +++ b/action.yml @@ -103,6 +103,9 @@ inputs: custom-tags: description: Additional tags to apply to the assumed role session. Must be a JSON object provided as a string. required: false + sts-endpoint: + description: Custom STS endpoint URL. Use this to point to an STS-compatible API (e.g. MinIO, LocalStack) instead of the default AWS STS endpoint for the region. + required: false outputs: aws-account-id: diff --git a/src/CredentialsClient.ts b/src/CredentialsClient.ts index 2d78b49..e065292 100644 --- a/src/CredentialsClient.ts +++ b/src/CredentialsClient.ts @@ -12,6 +12,7 @@ export interface CredentialsClientProps { region?: string; proxyServer?: string; noProxy?: string; + stsEndpoint?: string; roleChaining: boolean; } @@ -19,6 +20,7 @@ export class CredentialsClient { public region?: string; private _stsClient?: STSClient; private readonly requestHandler?: NodeHttpHandler; + private readonly stsEndpoint?: string; private roleChaining?: boolean; constructor(props: CredentialsClientProps) { @@ -41,6 +43,9 @@ export class CredentialsClient { httpAgent: handler, }); } + if (props.stsEndpoint) { + this.stsEndpoint = props.stsEndpoint; + } this.roleChaining = props.roleChaining; } @@ -49,9 +54,11 @@ export class CredentialsClient { const config = { customUserAgent: USER_AGENT } as { customUserAgent: string; region?: string; + endpoint?: string; requestHandler?: NodeHttpHandler; }; if (this.region !== undefined) config.region = this.region; + if (this.stsEndpoint !== undefined) config.endpoint = this.stsEndpoint; if (this.requestHandler !== undefined) config.requestHandler = this.requestHandler; this._stsClient = new STSClient(config); } diff --git a/src/index.ts b/src/index.ts index b7e5c9d..b87ef84 100644 --- a/src/index.ts +++ b/src/index.ts @@ -64,6 +64,7 @@ export async function run() { .map((s) => s.trim()); const forceSkipOidc = getBooleanInput('force-skip-oidc', { required: false }); const noProxy = core.getInput('no-proxy', { required: false }); + const stsEndpoint = core.getInput('sts-endpoint', { required: false }); const globalTimeout = Number.parseInt(core.getInput('action-timeout-s', { required: false })) || 0; let timeoutId: NodeJS.Timeout | undefined; @@ -128,12 +129,19 @@ export async function run() { exportRegion(region, outputEnvCredentials); // Instantiate credentials client - const clientProps: { region: string; proxyServer?: string; noProxy?: string; roleChaining: boolean } = { + const clientProps: { + region: string; + proxyServer?: string; + noProxy?: string; + stsEndpoint?: string; + roleChaining: boolean; + } = { region, roleChaining, }; if (proxyServer) clientProps.proxyServer = proxyServer; if (noProxy) clientProps.noProxy = noProxy; + if (stsEndpoint) clientProps.stsEndpoint = stsEndpoint; const credentialsClient = new CredentialsClient(clientProps); let sourceAccountId: string; let webIdentityToken: string; diff --git a/test/index.test.ts b/test/index.test.ts index 84e25a1..0ae9193 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -311,9 +311,7 @@ describe('Configure AWS Credentials', {}, () => { }); process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token'; await run(); - expect(core.warning).toHaveBeenCalledWith( - expect.stringContaining("'custom-tags' is set but will be ignored"), - ); + expect(core.warning).toHaveBeenCalledWith(expect.stringContaining("'custom-tags' is set but will be ignored")); }); }); @@ -801,6 +799,54 @@ describe('Configure AWS Credentials', {}, () => { }); }); + describe('Custom STS Endpoint', {}, () => { + it('passes sts-endpoint to the STS client', async () => { + const client = new CredentialsClient({ + region: 'us-east-1', + stsEndpoint: 'https://sts.custom.example.com', + roleChaining: false, + }); + const endpoint = await client.stsClient.config.endpoint(); + expect(endpoint).toMatchObject({ hostname: 'sts.custom.example.com', protocol: 'https:' }); + }); + + it('does not override endpoint when sts-endpoint is not provided', () => { + const client = new CredentialsClient({ + region: 'us-east-1', + roleChaining: false, + }); + expect(client.stsClient.config.endpoint).toBeUndefined(); + }); + + it('works with http endpoints for local services', async () => { + const client = new CredentialsClient({ + region: 'us-east-1', + stsEndpoint: 'http://localhost:9000', + roleChaining: false, + }); + const endpoint = await client.stsClient.config.endpoint(); + expect(endpoint).toMatchObject({ hostname: 'localhost', protocol: 'http:', port: 9000 }); + }); + + it('succeeds in a full action run with sts-endpoint input', async () => { + vi.mocked(core.getInput).mockImplementation( + mocks.getInput({ + ...mocks.GH_OIDC_INPUTS, + 'sts-endpoint': 'https://sts.custom.example.com', + }), + ); + vi.mocked(core.getIDToken).mockResolvedValue('testoidctoken'); + mockedSTSClient.on(GetCallerIdentityCommand).resolvesOnce({ ...mocks.outputs.GET_CALLER_IDENTITY }); + mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolvesOnce(mocks.outputs.STS_CREDENTIALS); + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token'; + + await run(); + + expect(core.setFailed).not.toHaveBeenCalled(); + expect(core.info).toHaveBeenCalledWith('Authenticated as assumedRoleId AROAFAKEASSUMEDROLEID'); + }); + }); + describe('HTTP Proxy Configuration', {}, () => { beforeEach(() => { vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.GH_OIDC_INPUTS)); diff --git a/test/profileManager.test.ts b/test/profileManager.test.ts index 5bf665d..5702684 100644 --- a/test/profileManager.test.ts +++ b/test/profileManager.test.ts @@ -95,7 +95,10 @@ describe('Profile Manager', {}, () => { }); it('round-trips through parseIni', {}, () => { - const data = { dev: { aws_access_key_id: 'AKIA', aws_secret_access_key: 'secret' }, 'profile prod': { region: 'us-west-2' } }; + const data = { + dev: { aws_access_key_id: 'AKIA', aws_secret_access_key: 'secret' }, + 'profile prod': { region: 'us-west-2' }, + }; const roundTripped = parseIni(stringifyIni(data)); expect(roundTripped).toEqual(data); }); @@ -196,10 +199,15 @@ describe('Profile Manager', {}, () => { const filePath = '/home/runner/.aws/credentials'; fs.mkdirSync('/home/runner/.aws', { recursive: true }); - mergeProfileSection(filePath, 'dev', { - aws_access_key_id: 'AKIAIOSFODNN7EXAMPLE', - aws_secret_access_key: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', - }, false); + mergeProfileSection( + filePath, + 'dev', + { + aws_access_key_id: 'AKIAIOSFODNN7EXAMPLE', + aws_secret_access_key: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + }, + false, + ); const content = fs.readFileSync(filePath, 'utf-8'); const parsed = parseIni(content); @@ -214,16 +222,26 @@ describe('Profile Manager', {}, () => { fs.mkdirSync('/home/runner/.aws', { recursive: true }); // Create initial profile - mergeProfileSection(filePath, 'dev', { - aws_access_key_id: 'AKIAIOSFODNN7EXAMPLE', - aws_secret_access_key: 'devSecretKey', - }, false); + mergeProfileSection( + filePath, + 'dev', + { + aws_access_key_id: 'AKIAIOSFODNN7EXAMPLE', + aws_secret_access_key: 'devSecretKey', + }, + false, + ); // Add second profile - mergeProfileSection(filePath, 'prod', { - aws_access_key_id: 'AKIAPRODEXAMPLE', - aws_secret_access_key: 'prodSecretKey', - }, false); + mergeProfileSection( + filePath, + 'prod', + { + aws_access_key_id: 'AKIAPRODEXAMPLE', + aws_secret_access_key: 'prodSecretKey', + }, + false, + ); const content = fs.readFileSync(filePath, 'utf-8'); const parsed = parseIni(content); @@ -239,18 +257,28 @@ describe('Profile Manager', {}, () => { fs.mkdirSync('/home/runner/.aws', { recursive: true }); // Create initial profile - mergeProfileSection(filePath, 'dev', { - aws_access_key_id: 'OLD_KEY', - aws_secret_access_key: 'oldSecretKey', - aws_session_token: 'oldSessionToken' - }, false); + mergeProfileSection( + filePath, + 'dev', + { + aws_access_key_id: 'OLD_KEY', + aws_secret_access_key: 'oldSecretKey', + aws_session_token: 'oldSessionToken', + }, + false, + ); // Overwrite with new credentials - mergeProfileSection(filePath, 'dev', { - aws_access_key_id: 'NEW_KEY', - aws_secret_access_key: 'newSecretKey', - aws_session_token: 'newSessionToken', - }, true); + mergeProfileSection( + filePath, + 'dev', + { + aws_access_key_id: 'NEW_KEY', + aws_secret_access_key: 'newSecretKey', + aws_session_token: 'newSessionToken', + }, + true, + ); const content = fs.readFileSync(filePath, 'utf-8'); const parsed = parseIni(content); @@ -265,17 +293,27 @@ describe('Profile Manager', {}, () => { fs.mkdirSync('/home/runner/.aws', { recursive: true }); // Create profile with session token - mergeProfileSection(filePath, 'dev', { - aws_access_key_id: 'AKIA', - aws_secret_access_key: 'secret', - aws_session_token: 'old-token', - }, false); + mergeProfileSection( + filePath, + 'dev', + { + aws_access_key_id: 'AKIA', + aws_secret_access_key: 'secret', + aws_session_token: 'old-token', + }, + false, + ); // Overwrite without session token - mergeProfileSection(filePath, 'dev', { - aws_access_key_id: 'AKIA2', - aws_secret_access_key: 'secret2', - }, true); + mergeProfileSection( + filePath, + 'dev', + { + aws_access_key_id: 'AKIA2', + aws_secret_access_key: 'secret2', + }, + true, + ); const content = fs.readFileSync(filePath, 'utf-8'); const parsed = parseIni(content); @@ -290,10 +328,15 @@ describe('Profile Manager', {}, () => { fs.mkdirSync('/home/runner/.aws', { recursive: true }); fs.writeFileSync(filePath, '', { mode: 0o600 }); - mergeProfileSection(filePath, 'dev', { - aws_access_key_id: 'AKIA', - aws_secret_access_key: 'secret', - }, false); + mergeProfileSection( + filePath, + 'dev', + { + aws_access_key_id: 'AKIA', + aws_secret_access_key: 'secret', + }, + false, + ); const content = fs.readFileSync(filePath, 'utf-8'); const parsed = parseIni(content); @@ -307,17 +350,31 @@ describe('Profile Manager', {}, () => { fs.mkdirSync('/home/runner/.aws', { recursive: true }); // Create initial profile - mergeProfileSection(filePath, 'dev', { - aws_access_key_id: 'OLD_KEY', - aws_secret_access_key: 'oldSecretKey', - }, false); + mergeProfileSection( + filePath, + 'dev', + { + aws_access_key_id: 'OLD_KEY', + aws_secret_access_key: 'oldSecretKey', + }, + false, + ); // Overwrite with new credentials - expect(() => mergeProfileSection(filePath, 'dev', { - aws_access_key_id: 'NEW_KEY', - aws_secret_access_key: 'newSecretKey', - aws_session_token: 'sessionToken', - }, false)).toThrow(`Profile with name "dev" already exists. Please use the overwrite-aws-profile input if you want to overwrite existing profiles.`); + expect(() => + mergeProfileSection( + filePath, + 'dev', + { + aws_access_key_id: 'NEW_KEY', + aws_secret_access_key: 'newSecretKey', + aws_session_token: 'sessionToken', + }, + false, + ), + ).toThrow( + `Profile with name "dev" already exists. Please use the overwrite-aws-profile input if you want to overwrite existing profiles.`, + ); const content = fs.readFileSync(filePath, 'utf-8'); const parsed = parseIni(content); @@ -468,7 +525,7 @@ describe('Profile Manager', {}, () => { SecretAccessKey: 'secret', }, 'us-east-1', - false + false, ), ).toThrow('whitespace'); }); @@ -486,7 +543,7 @@ describe('Profile Manager', {}, () => { SecretAccessKey: 'secret', }, 'us-east-1', - false + false, ); expect(fs.existsSync('/custom/credentials')).toBe(true); @@ -501,7 +558,7 @@ describe('Profile Manager', {}, () => { SecretAccessKey: 'secret', }, 'us-east-1', - false + false, ); expect(core.info).toHaveBeenCalledWith('Writing credentials to profile: dev'); @@ -517,12 +574,7 @@ describe('Profile Manager', {}, () => { '[personal]\naws_access_key_id=AKIAPERSONAL\naws_secret_access_key=personalSecret\naws_session_token=personalToken\n', ); - writeProfileFiles( - 'dev', - { AccessKeyId: 'AKIADEV', SecretAccessKey: 'devSecret' }, - 'us-east-1', - false, - ); + writeProfileFiles('dev', { AccessKeyId: 'AKIADEV', SecretAccessKey: 'devSecret' }, 'us-east-1', false); const content = fs.readFileSync(credsPath, 'utf-8'); const parsed = parseIni(content); @@ -540,17 +592,9 @@ describe('Profile Manager', {}, () => { it('preserves pre-existing config with extra keys', {}, () => { const configPath = getProfileFilePaths().config; fs.mkdirSync(require('node:path').dirname(configPath), { recursive: true }); - fs.writeFileSync( - configPath, - '[profile personal]\nregion=eu-west-1\noutput=json\ncli_pager=\n', - ); + fs.writeFileSync(configPath, '[profile personal]\nregion=eu-west-1\noutput=json\ncli_pager=\n'); - writeProfileFiles( - 'dev', - { AccessKeyId: 'AKIA', SecretAccessKey: 'secret' }, - 'us-east-1', - false - ); + writeProfileFiles('dev', { AccessKeyId: 'AKIA', SecretAccessKey: 'secret' }, 'us-east-1', false); const content = fs.readFileSync(configPath, 'utf-8'); const parsed = parseIni(content); @@ -566,17 +610,9 @@ describe('Profile Manager', {}, () => { it('preserves pre-existing default profile when writing a named profile', {}, () => { const credsPath = getProfileFilePaths().credentials; fs.mkdirSync(require('node:path').dirname(credsPath), { recursive: true }); - fs.writeFileSync( - credsPath, - '[default]\naws_access_key_id=AKIADEFAULT\naws_secret_access_key=defaultSecret\n', - ); + fs.writeFileSync(credsPath, '[default]\naws_access_key_id=AKIADEFAULT\naws_secret_access_key=defaultSecret\n'); - writeProfileFiles( - 'dev', - { AccessKeyId: 'AKIADEV', SecretAccessKey: 'devSecret' }, - 'us-west-2', - false - ); + writeProfileFiles('dev', { AccessKeyId: 'AKIADEV', SecretAccessKey: 'devSecret' }, 'us-west-2', false); const content = fs.readFileSync(credsPath, 'utf-8'); const parsed = parseIni(content); @@ -596,12 +632,7 @@ describe('Profile Manager', {}, () => { '# My important comment\n[personal]\naws_access_key_id=AKIA\naws_secret_access_key=secret\n', ); - writeProfileFiles( - 'dev', - { AccessKeyId: 'AKIADEV', SecretAccessKey: 'devSecret' }, - 'us-east-1', - false - ); + writeProfileFiles('dev', { AccessKeyId: 'AKIADEV', SecretAccessKey: 'devSecret' }, 'us-east-1', false); const content = fs.readFileSync(credsPath, 'utf-8') as string; @@ -635,12 +666,7 @@ describe('Profile Manager', {}, () => { fs.mkdirSync('/custom-creds', { recursive: true }); - writeProfileFiles( - 'dev', - { AccessKeyId: 'AKIA', SecretAccessKey: 'secret' }, - 'us-east-1', - false - ); + writeProfileFiles('dev', { AccessKeyId: 'AKIA', SecretAccessKey: 'secret' }, 'us-east-1', false); expect(fs.existsSync('/custom-creds/credentials')).toBe(true); // Config file should be at the default path (under homedir) @@ -657,7 +683,7 @@ describe('Profile Manager', {}, () => { SessionToken: 'FwoGZXIvYXdzEBYaDEXAMPLE', }, 'us-east-1', - false + false, ); const credsPath = getProfileFilePaths().credentials; @@ -672,28 +698,20 @@ describe('Profile Manager', {}, () => { // - LF line endings, trailing newline expect(credContent).toBe( '[dev]\n' + - 'aws_access_key_id = AKIAIOSFODNN7EXAMPLE\n' + - 'aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\n' + - 'aws_session_token = FwoGZXIvYXdzEBYaDEXAMPLE\n', - ); - expect(configContent).toBe( - '[profile dev]\n' + - 'region = us-east-1\n', + 'aws_access_key_id = AKIAIOSFODNN7EXAMPLE\n' + + 'aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\n' + + 'aws_session_token = FwoGZXIvYXdzEBYaDEXAMPLE\n', ); + expect(configContent).toBe('[profile dev]\n' + 'region = us-east-1\n'); }); it('golden file for multi-profile output', {}, () => { - writeProfileFiles( - 'dev', - { AccessKeyId: 'AKIADEV', SecretAccessKey: 'devSecret' }, - 'us-east-1', - false - ); + writeProfileFiles('dev', { AccessKeyId: 'AKIADEV', SecretAccessKey: 'devSecret' }, 'us-east-1', false); writeProfileFiles( 'prod', { AccessKeyId: 'AKIAPROD', SecretAccessKey: 'prodSecret', SessionToken: 'prodToken' }, 'us-west-2', - false + false, ); const credsPath = getProfileFilePaths().credentials; @@ -704,20 +722,16 @@ describe('Profile Manager', {}, () => { expect(credContent).toBe( '[dev]\n' + - 'aws_access_key_id = AKIADEV\n' + - 'aws_secret_access_key = devSecret\n' + - '\n' + - '[prod]\n' + - 'aws_access_key_id = AKIAPROD\n' + - 'aws_secret_access_key = prodSecret\n' + - 'aws_session_token = prodToken\n', + 'aws_access_key_id = AKIADEV\n' + + 'aws_secret_access_key = devSecret\n' + + '\n' + + '[prod]\n' + + 'aws_access_key_id = AKIAPROD\n' + + 'aws_secret_access_key = prodSecret\n' + + 'aws_session_token = prodToken\n', ); expect(configContent).toBe( - '[profile dev]\n' + - 'region = us-east-1\n' + - '\n' + - '[profile prod]\n' + - 'region = us-west-2\n', + '[profile dev]\n' + 'region = us-east-1\n' + '\n' + '[profile prod]\n' + 'region = us-west-2\n', ); }); });