Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions __tests__/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ vi.mock('@actions/core', () => ({
info: vi.fn(),
setSecret: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
}));

vi.mock('@aws-sdk/client-cognito-identity', () => {
Expand All @@ -20,6 +21,9 @@ vi.mock('@aws-sdk/client-cognito-identity', () => {
import * as core from '@actions/core';
import { __sendMock as sendMock } from '@aws-sdk/client-cognito-identity';
import { getCognitoCredentials } from '../src/auth';
import { DEFAULT_MAX_ATTEMPTS } from '../src/retry';

const fastRetry = { retryOptions: { baseDelayMs: 1 } };

describe('getCognitoCredentials', () => {
beforeEach(() => {
Expand Down Expand Up @@ -76,4 +80,98 @@ describe('getCognitoCredentials', () => {
getCognitoCredentials({ poolId: 'pool', accountId: '123', region: 'eu-central-1' })
).rejects.toThrow('Failed to obtain AWS credentials');
});

it('retries on transient OIDC token failure', async () => {
vi.mocked(core.getIDToken)
.mockRejectedValueOnce(new Error('OIDC timeout'))
.mockResolvedValueOnce('oidc-token-retry');

sendMock
.mockResolvedValueOnce({ IdentityId: 'eu-central-1:identity-abc' })
.mockResolvedValueOnce({
Credentials: {
AccessKeyId: 'AKIARETRY',
SecretKey: 'secret-retry',
SessionToken: 'token-retry',
Expiration: new Date('2026-01-01T00:00:00Z'),
},
});

const result = await getCognitoCredentials({
poolId: 'eu-central-1:pool-123',
accountId: '123456789',
region: 'eu-central-1',
...fastRetry,
});

expect(result.accessKeyId).toBe('AKIARETRY');
expect(core.getIDToken).toHaveBeenCalledTimes(2);
expect(core.warning).toHaveBeenCalledWith(
expect.stringContaining(`GitHub OIDC token failed (attempt 1/${DEFAULT_MAX_ATTEMPTS})`)
);
});

it('retries on transient Cognito GetId failure', async () => {
vi.mocked(core.getIDToken).mockResolvedValue('token');

sendMock
.mockRejectedValueOnce(new Error('Cognito throttle'))
.mockResolvedValueOnce({ IdentityId: 'id-recovered' })
.mockResolvedValueOnce({
Credentials: {
AccessKeyId: 'AKIA2',
SecretKey: 'secret2',
SessionToken: 'token2',
Expiration: new Date('2026-01-01T00:00:00Z'),
},
});

const result = await getCognitoCredentials({
poolId: 'pool',
accountId: '123',
region: 'eu-central-1',
...fastRetry,
});

expect(result.accessKeyId).toBe('AKIA2');
// 1 fail + 1 GetId success + 1 GetCreds success
expect(sendMock).toHaveBeenCalledTimes(DEFAULT_MAX_ATTEMPTS);
});

it('retries on transient Cognito GetCredentials failure', async () => {
vi.mocked(core.getIDToken).mockResolvedValue('token');

sendMock
.mockResolvedValueOnce({ IdentityId: 'id-123' })
.mockRejectedValueOnce(new Error('Cognito 500'))
.mockResolvedValueOnce({
Credentials: {
AccessKeyId: 'AKIA3',
SecretKey: 'secret3',
SessionToken: 'token3',
Expiration: new Date('2026-01-01T00:00:00Z'),
},
});

const result = await getCognitoCredentials({
poolId: 'pool',
accountId: '123',
region: 'eu-central-1',
...fastRetry,
});

expect(result.accessKeyId).toBe('AKIA3');
// 1 GetId + 1 GetCreds fail + 1 GetCreds success
expect(sendMock).toHaveBeenCalledTimes(DEFAULT_MAX_ATTEMPTS);
});

it('throws after all OIDC retries exhausted', async () => {
vi.mocked(core.getIDToken).mockRejectedValue(new Error('OIDC down'));

await expect(
getCognitoCredentials({ poolId: 'pool', accountId: '123', region: 'eu-central-1', ...fastRetry })
).rejects.toThrow('OIDC down');

expect(core.getIDToken).toHaveBeenCalledTimes(DEFAULT_MAX_ATTEMPTS);
});
});
9 changes: 9 additions & 0 deletions __tests__/credential-setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@ import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';

vi.mock('../src/retry', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/retry')>();
return {
...actual,
retryWithBackoff: (fn: () => Promise<unknown>, opts: Record<string, unknown>) =>
actual.retryWithBackoff(fn, { ...opts, baseDelayMs: 1 }),
};
});

vi.mock('@actions/core', () => ({
getInput: vi.fn(),
setOutput: vi.fn(),
Expand Down
83 changes: 83 additions & 0 deletions __tests__/retry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

vi.mock('@actions/core', () => ({
warning: vi.fn(),
}));

import * as core from '@actions/core';
import { retryWithBackoff, DEFAULT_BASE_DELAY_MS, DEFAULT_MAX_ATTEMPTS } from '../src/retry';

const TIMER_MARGIN_MS = 100;

describe('retryWithBackoff', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
});

afterEach(() => {
vi.useRealTimers();
});

it('returns result on first success', async () => {
const fn = vi.fn().mockResolvedValue('ok');
const promise = retryWithBackoff(fn, { label: 'test-op' });
const result = await promise;
expect(result).toBe('ok');
expect(fn).toHaveBeenCalledTimes(1);
});

it('retries on failure and succeeds', async () => {
const fn = vi.fn()
.mockRejectedValueOnce(new Error('transient'))
.mockResolvedValueOnce('recovered');

const promise = retryWithBackoff(fn, { label: 'test-op' });
await vi.advanceTimersByTimeAsync(DEFAULT_BASE_DELAY_MS + TIMER_MARGIN_MS);
const result = await promise;

expect(result).toBe('recovered');
expect(fn).toHaveBeenCalledTimes(2);
expect(core.warning).toHaveBeenCalledWith(
expect.stringContaining(`test-op failed (attempt 1/${DEFAULT_MAX_ATTEMPTS})`)
);
});

it('throws after all retries exhausted', async () => {
const fn = vi.fn()
.mockRejectedValueOnce(new Error('persistent'))
.mockRejectedValueOnce(new Error('persistent'));

const promise = retryWithBackoff(fn, { label: 'test-op', maxAttempts: 2 });
const resultPromise = promise.catch((e: Error) => e);
await vi.advanceTimersByTimeAsync(DEFAULT_BASE_DELAY_MS + TIMER_MARGIN_MS);
const result = await resultPromise;

expect(result).toBeInstanceOf(Error);
expect((result as Error).message).toBe('persistent');
expect(fn).toHaveBeenCalledTimes(2);
});

it('respects custom maxAttempts and baseDelayMs', async () => {
const fn = vi.fn()
.mockRejectedValueOnce(new Error('fail-1'))
.mockRejectedValueOnce(new Error('fail-2'))
.mockRejectedValueOnce(new Error('fail-3'))
.mockResolvedValueOnce('ok');

const customBaseDelay = 500;
const customMaxAttempts = 4;
const promise = retryWithBackoff(fn, {
label: 'custom',
maxAttempts: customMaxAttempts,
baseDelayMs: customBaseDelay,
});
// Max total delay: 500 + 1000 + 2000 = 3500ms
const maxTotalDelay = customBaseDelay + customBaseDelay * 2 + customBaseDelay * 4;
await vi.advanceTimersByTimeAsync(maxTotalDelay + TIMER_MARGIN_MS);
const result = await promise;

expect(result).toBe('ok');
expect(fn).toHaveBeenCalledTimes(customMaxAttempts);
});
});
83 changes: 78 additions & 5 deletions credential-setup/dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -43995,28 +43995,30 @@ Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.getCognitoCredentials = getCognitoCredentials;
const core = __importStar(__nccwpck_require__(7484));
const client_cognito_identity_1 = __nccwpck_require__(6473);
const retry_1 = __nccwpck_require__(9809);
const IDENTITY_PROVIDER = 'token.actions.githubusercontent.com';
const AUDIENCE = 'cognito-identity.amazonaws.com';
async function getCognitoCredentials(config) {
const retryOpts = { ...config.retryOptions };
core.info('Requesting GitHub OIDC token...');
const oidcToken = await core.getIDToken(AUDIENCE);
const oidcToken = await (0, retry_1.retryWithBackoff)(() => core.getIDToken(AUDIENCE), { label: 'GitHub OIDC token', ...retryOpts });
core.setSecret(oidcToken);
const client = new client_cognito_identity_1.CognitoIdentityClient({ region: config.region });
const logins = { [IDENTITY_PROVIDER]: oidcToken };
core.info('Exchanging OIDC token for Cognito identity...');
const { IdentityId } = await client.send(new client_cognito_identity_1.GetIdCommand({
const { IdentityId } = await (0, retry_1.retryWithBackoff)(() => client.send(new client_cognito_identity_1.GetIdCommand({
IdentityPoolId: config.poolId,
AccountId: config.accountId,
Logins: logins,
}));
})), { label: 'Cognito GetId', ...retryOpts });
if (!IdentityId) {
throw new Error('Failed to obtain Identity ID from Cognito Identity Pool');
}
core.info('Obtaining AWS credentials from Cognito...');
const { Credentials } = await client.send(new client_cognito_identity_1.GetCredentialsForIdentityCommand({
const { Credentials } = await (0, retry_1.retryWithBackoff)(() => client.send(new client_cognito_identity_1.GetCredentialsForIdentityCommand({
IdentityId,
Logins: logins,
}));
})), { label: 'Cognito GetCredentials', ...retryOpts });
if (!Credentials?.AccessKeyId || !Credentials?.SecretKey || !Credentials?.SessionToken) {
throw new Error('Failed to obtain AWS credentials from Cognito');
}
Expand Down Expand Up @@ -44134,6 +44136,77 @@ if (!process.env.VITEST) {
}


/***/ }),

/***/ 9809:
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {

"use strict";

var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.DEFAULT_BASE_DELAY_MS = exports.DEFAULT_MAX_ATTEMPTS = void 0;
exports.retryWithBackoff = retryWithBackoff;
const core = __importStar(__nccwpck_require__(7484));
const node_crypto_1 = __nccwpck_require__(7598);
exports.DEFAULT_MAX_ATTEMPTS = 3;
exports.DEFAULT_BASE_DELAY_MS = 5000;
const JITTER_MIN_PCT = 50;
const JITTER_RANGE_PCT = 50;
async function retryWithBackoff(fn, options) {
const { label, maxAttempts = exports.DEFAULT_MAX_ATTEMPTS, baseDelayMs = exports.DEFAULT_BASE_DELAY_MS } = options;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
}
catch (error) {
if (attempt === maxAttempts) {
throw error;
}
const jitterPct = JITTER_MIN_PCT + (0, node_crypto_1.randomInt)(JITTER_RANGE_PCT + 1);
const delayMs = Math.round(baseDelayMs * Math.pow(2, attempt - 1) * jitterPct / 100);
const message = error instanceof Error ? error.message : String(error);
core.warning(`${label} failed (attempt ${attempt}/${maxAttempts}): ${message}. Retrying in ${delayMs}ms...`);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
// Unreachable, but satisfies TypeScript
throw new Error(`${label} failed after ${maxAttempts} attempts`);
}


/***/ }),

/***/ 2613:
Expand Down
23 changes: 16 additions & 7 deletions src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
GetIdCommand,
GetCredentialsForIdentityCommand,
} from '@aws-sdk/client-cognito-identity';
import { retryWithBackoff, RetryOptions } from './retry';

const IDENTITY_PROVIDER = 'token.actions.githubusercontent.com';
const AUDIENCE = 'cognito-identity.amazonaws.com';
Expand All @@ -12,6 +13,7 @@ export interface AuthConfig {
poolId: string;
accountId: string;
region: string;
retryOptions?: Partial<Omit<RetryOptions, 'label'>>;
}

export interface AwsCredentials {
Expand All @@ -22,32 +24,39 @@ export interface AwsCredentials {
}

export async function getCognitoCredentials(config: AuthConfig): Promise<AwsCredentials> {
const retryOpts = { ...config.retryOptions };

core.info('Requesting GitHub OIDC token...');
const oidcToken = await core.getIDToken(AUDIENCE);
const oidcToken = await retryWithBackoff(
() => core.getIDToken(AUDIENCE),
{ label: 'GitHub OIDC token', ...retryOpts }
);
core.setSecret(oidcToken);

const client = new CognitoIdentityClient({ region: config.region });
const logins = { [IDENTITY_PROVIDER]: oidcToken };

core.info('Exchanging OIDC token for Cognito identity...');
const { IdentityId } = await client.send(
new GetIdCommand({
const { IdentityId } = await retryWithBackoff(
() => client.send(new GetIdCommand({
IdentityPoolId: config.poolId,
AccountId: config.accountId,
Logins: logins,
})
})),
{ label: 'Cognito GetId', ...retryOpts }
);

if (!IdentityId) {
throw new Error('Failed to obtain Identity ID from Cognito Identity Pool');
}

core.info('Obtaining AWS credentials from Cognito...');
const { Credentials } = await client.send(
new GetCredentialsForIdentityCommand({
const { Credentials } = await retryWithBackoff(
() => client.send(new GetCredentialsForIdentityCommand({
IdentityId,
Logins: logins,
})
})),
{ label: 'Cognito GetCredentials', ...retryOpts }
);

if (!Credentials?.AccessKeyId || !Credentials?.SecretKey || !Credentials?.SessionToken) {
Expand Down
Loading
Loading