diff --git a/README.md b/README.md index 4a7ac973f..112fdaac2 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,7 @@ See [action.yml](./action.yml) for more detail. | retry-max-attempts | Limits the number of retry attempts before giving up. Defaults to 12. | No | | special-characters-workaround | Uncommonly, some environments cannot tolerate special characters in a secret key. This option will retry fetching credentials until the secret access key does not contain special characters. This option overrides disable-retry and retry-max-attempts. | No | | use-existing-credentials | When set, the action will check if existing credentials are valid and exit if they are. Defaults to false. | No | +| force-skip-oidc | When set, the action will skip using GitHub OIDC provider even if the id-token permission is set. | No | #### Adjust the retry mechanism diff --git a/action.yml b/action.yml index ac3fa4aa1..bfb732949 100644 --- a/action.yml +++ b/action.yml @@ -79,6 +79,10 @@ inputs: required: false use-existing-credentials: description: When enabled, this option will check if there are already valid credentials in the environment. If there are, new credentials will not be fetched. If there are not, the action will run as normal. + force-skip-oidc: + required: false + description: When enabled, this option will skip using GitHub OIDC provider even if the id-token permission is set. This is sometimes useful when using IAM instance credentials. + outputs: aws-account-id: description: The AWS account ID for the provided credentials diff --git a/src/index.ts b/src/index.ts index 18894f8bc..33c0bd3e3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -51,6 +51,13 @@ export async function run() { const specialCharacterWorkaround = getBooleanInput('special-characters-workaround', { required: false }); const useExistingCredentials = core.getInput('use-existing-credentials', { required: false }); let maxRetries = Number.parseInt(core.getInput('retry-max-attempts', { required: false })) || 12; + const forceSkipOidc = getBooleanInput('force-skip-oidc', { required: false }); + + if (forceSkipOidc && roleToAssume && !AccessKeyId && !webIdentityTokenFile) { + throw new Error( + "If 'force-skip-oidc' is true and 'role-to-assume' is set, 'aws-access-key-id' or 'web-identity-token-file' must be set", + ); + } if (specialCharacterWorkaround) { // 😳 @@ -62,6 +69,7 @@ export async function run() { // Logic to decide whether to attempt to use OIDC or not const useGitHubOIDCProvider = () => { + if (forceSkipOidc) return false; // The `ACTIONS_ID_TOKEN_REQUEST_TOKEN` environment variable is set when the `id-token` permission is granted. // This is necessary to authenticate with OIDC, but not strictly set just for OIDC. If it is not set and all other // checks pass, it is likely but not guaranteed that the user needs but lacks this permission in their workflow. diff --git a/test/index.test.ts b/test/index.test.ts index a7826ef86..521224b4d 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -334,6 +334,135 @@ describe('Configure AWS Credentials', {}, () => { }); }); + describe('Force Skip OIDC', {}, () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedSTSClient.reset(); + }); + + it('skips OIDC when force-skip-oidc is true with IAM credentials', async () => { + vi.spyOn(core, 'getInput').mockImplementation(mocks.getInput({ + ...mocks.IAM_ASSUMEROLE_INPUTS, + 'force-skip-oidc': 'true' + })); + vi.spyOn(core, 'getIDToken').mockResolvedValue('testoidctoken'); + mockedSTSClient.on(AssumeRoleCommand).resolves(mocks.outputs.STS_CREDENTIALS); + mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY }); + // biome-ignore lint/suspicious/noExplicitAny: any required to mock private method + vi.spyOn(CredentialsClient.prototype as any, 'loadCredentials') + .mockResolvedValueOnce({ accessKeyId: 'MYAWSACCESSKEYID' }) + .mockResolvedValueOnce({ accessKeyId: 'STSAWSACCESSKEYID' }); + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token'; + + await run(); + expect(core.getIDToken).not.toHaveBeenCalled(); + expect(core.setFailed).not.toHaveBeenCalled(); + }); + + it('skips OIDC when force-skip-oidc is true with web identity token file', async () => { + vi.spyOn(core, 'getInput').mockImplementation(mocks.getInput({ + ...mocks.WEBIDENTITY_TOKEN_FILE_INPUTS, + 'force-skip-oidc': 'true' + })); + vi.spyOn(core, 'getIDToken').mockResolvedValue('testoidctoken'); + mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolves(mocks.outputs.STS_CREDENTIALS); + mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY }); + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token'; + vi.mock('node:fs'); + vol.reset(); + fs.mkdirSync('/home/github', { recursive: true }); + fs.writeFileSync('/home/github/file.txt', 'test-token'); + + await run(); + expect(core.getIDToken).not.toHaveBeenCalled(); + expect(core.info).toHaveBeenCalledWith('Assuming role with web identity token file'); + expect(core.setFailed).not.toHaveBeenCalled(); + }); + + it('fails when force-skip-oidc is true but no alternative credentials provided', async () => { + vi.spyOn(core, 'getInput').mockImplementation(mocks.getInput({ + 'role-to-assume': 'arn:aws:iam::111111111111:role/MY-ROLE', + 'aws-region': 'fake-region-1', + 'force-skip-oidc': 'true' + })); + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token'; + + await run(); + expect(core.setFailed).toHaveBeenCalledWith( + "If 'force-skip-oidc' is true and 'role-to-assume' is set, 'aws-access-key-id' or 'web-identity-token-file' must be set" + ); + }); + + it('allows force-skip-oidc without role-to-assume', async () => { + vi.spyOn(core, 'getInput').mockImplementation(mocks.getInput({ + ...mocks.IAM_USER_INPUTS, + 'force-skip-oidc': 'true' + })); + vi.spyOn(core, 'getIDToken').mockResolvedValue('testoidctoken'); + mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY }); + // biome-ignore lint/suspicious/noExplicitAny: any required to mock private method + vi.spyOn(CredentialsClient.prototype as any, 'loadCredentials').mockResolvedValue({ + accessKeyId: 'MYAWSACCESSKEYID', + }); + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token'; + + await run(); + expect(core.getIDToken).not.toHaveBeenCalled(); + expect(core.info).toHaveBeenCalledWith('Proceeding with IAM user credentials'); + expect(core.setFailed).not.toHaveBeenCalled(); + }); + + it('uses OIDC when force-skip-oidc is false (default behavior)', async () => { + vi.spyOn(core, 'getInput').mockImplementation(mocks.getInput({ + ...mocks.GH_OIDC_INPUTS, + 'force-skip-oidc': 'false' + })); + vi.spyOn(core, 'getIDToken').mockResolvedValue('testoidctoken'); + mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolves(mocks.outputs.STS_CREDENTIALS); + mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY }); + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token'; + + await run(); + expect(core.getIDToken).toHaveBeenCalledWith(''); + expect(core.info).toHaveBeenCalledWith('Assuming role with OIDC'); + expect(core.setFailed).not.toHaveBeenCalled(); + }); + + it('uses OIDC when force-skip-oidc is not set (default behavior)', async () => { + vi.spyOn(core, 'getInput').mockImplementation(mocks.getInput(mocks.GH_OIDC_INPUTS)); + vi.spyOn(core, 'getIDToken').mockResolvedValue('testoidctoken'); + mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolves(mocks.outputs.STS_CREDENTIALS); + mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY }); + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token'; + + await run(); + expect(core.getIDToken).toHaveBeenCalledWith(''); + expect(core.info).toHaveBeenCalledWith('Assuming role with OIDC'); + expect(core.setFailed).not.toHaveBeenCalled(); + }); + + it('works with role chaining when force-skip-oidc is true', async () => { + vi.spyOn(core, 'getInput').mockImplementation(mocks.getInput({ + ...mocks.EXISTING_ROLE_INPUTS, + 'force-skip-oidc': 'true', + 'aws-access-key-id': 'MYAWSACCESSKEYID', + 'aws-secret-access-key': 'MYAWSSECRETACCESSKEY' + })); + vi.spyOn(core, 'getIDToken').mockResolvedValue('testoidctoken'); + mockedSTSClient.on(AssumeRoleCommand).resolves(mocks.outputs.STS_CREDENTIALS); + mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY }); + // biome-ignore lint/suspicious/noExplicitAny: any required to mock private method + vi.spyOn(CredentialsClient.prototype as any, 'loadCredentials') + .mockResolvedValueOnce({ accessKeyId: 'MYAWSACCESSKEYID' }) + .mockResolvedValueOnce({ accessKeyId: 'STSAWSACCESSKEYID' }); + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token'; + + await run(); + expect(core.getIDToken).not.toHaveBeenCalled(); + expect(core.setFailed).not.toHaveBeenCalled(); + }); + }); + describe('HTTP Proxy Configuration', {}, () => { beforeEach(() => { vi.spyOn(core, 'getInput').mockImplementation(mocks.getInput(mocks.GH_OIDC_INPUTS));