From f3a97d6e55d58890f4f993cbae7114daaf00a19d Mon Sep 17 00:00:00 2001 From: Tom Keller Date: Fri, 26 Jun 2026 15:09:12 -0700 Subject: [PATCH] feat: refresh OIDC token if expiring --- src/CredentialsClient.ts | 12 ++++++++++++ src/helpers.ts | 16 ++++++++++++++++ src/index.ts | 6 ++++++ test/helpers.test.ts | 27 +++++++++++++++++++++++++++ test/index.test.ts | 22 ++++++++++++++++++++++ 5 files changed, 83 insertions(+) diff --git a/src/CredentialsClient.ts b/src/CredentialsClient.ts index 8347a99..59682f7 100644 --- a/src/CredentialsClient.ts +++ b/src/CredentialsClient.ts @@ -10,6 +10,10 @@ if (!process.env.AWS_EXECUTION_ENV) { process.env.AWS_EXECUTION_ENV = 'GitHubActions'; } +// Bound how long a single STS call may hang. 60s per attempt keeps the total +// failure time predictable. +const STS_TIMEOUT_MS = 60_000; + export interface CredentialsClientProps { region?: string; proxyServer?: string; @@ -43,6 +47,14 @@ export class CredentialsClient { this.requestHandler = new NodeHttpHandler({ httpsAgent: handler, httpAgent: handler, + connectionTimeout: STS_TIMEOUT_MS, + requestTimeout: STS_TIMEOUT_MS, + }); + } else { + // No proxy + this.requestHandler = new NodeHttpHandler({ + connectionTimeout: STS_TIMEOUT_MS, + requestTimeout: STS_TIMEOUT_MS, }); } if (props.stsEndpoint) { diff --git a/src/helpers.ts b/src/helpers.ts index 18295e4..52fda4c 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -281,6 +281,22 @@ export function isDefined(i: T | undefined | null): i is T { } /* c8 ignore stop */ +// Reads the `exp` claim (Unix seconds) from a JWT and reports whether the token is already expired or will expire +// within `skewSeconds`. This is to decide whether to re-mint the OIDC token before an AssumeRole attempt. On any parse +// failure we return false so a malformed token can't start a re-mint loop. +export function jwtExpiresWithin(token: string, skewSeconds: number): boolean { + try { + const payload = token.split('.')[1]; + if (!payload) return false; + const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); + if (typeof decoded.exp !== 'number') return false; + const nowSeconds = Date.now() / 1000; + return decoded.exp <= nowSeconds + skewSeconds; + } catch (_) { + return false; + } +} + export async function areCredentialsValid(credentialsClient: CredentialsClient) { const client = credentialsClient.stsClient; try { diff --git a/src/index.ts b/src/index.ts index 9d77a52..8588c19 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import { exportCredentials, exportRegion, getBooleanInput, + jwtExpiresWithin, retryAndBackoff, toCredentialIdentity, translateEnvVariables, @@ -19,6 +20,7 @@ import { import { writeProfileFiles } from './profileManager'; const DEFAULT_ROLE_DURATION = 3600; // One hour (seconds) +const TOKEN_REFRESH_SKEW_SECONDS = 30; const ROLE_SESSION_NAME = 'GitHubActions'; const REGION_REGEX = /^[a-z0-9-]+$/g; const ROLE_SESSION_NAME_REGEX = /^[\w+=,.@-]*$/; @@ -246,6 +248,10 @@ export async function run() { let roleCredentials: AssumeRoleCommandOutput; do { roleCredentials = await withRetry(async () => { + if (useGitHubOIDCProvider() && jwtExpiresWithin(webIdentityToken, TOKEN_REFRESH_SKEW_SECONDS)) { + core.info('OIDC token has expired or is about to; requesting a fresh one before AssumeRole.'); + webIdentityToken = await core.getIDToken(audience); + } return assumeRole({ credentialsClient, sourceAccountId, diff --git a/test/helpers.test.ts b/test/helpers.test.ts index eefca3c..edd1f42 100644 --- a/test/helpers.test.ts +++ b/test/helpers.test.ts @@ -51,6 +51,33 @@ describe('Configure AWS Credentials helpers', {}, () => { expect(core.info).toHaveBeenCalledWith(expect.stringContaining('Retry: attempt 1 of 3 failed')); helpers.reset(); }); + describe('jwtExpiresWithin', {}, () => { + // Builds a JWT-shaped string (header.payload.signature) with the given payload. Only the payload + // segment is read by the helper; the signature is irrelevant since we never verify it. + const makeJwt = (payload: object) => { + const encode = (obj: object) => Buffer.from(JSON.stringify(obj)).toString('base64url'); + return `${encode({ alg: 'RS256' })}.${encode(payload)}.signature`; + }; + it('returns true when the token is already expired', {}, () => { + const exp = Math.floor(Date.now() / 1000) - 60; + expect(helpers.jwtExpiresWithin(makeJwt({ exp }), 30)).toBe(true); + }); + it('returns true when the token expires within the skew window', {}, () => { + const exp = Math.floor(Date.now() / 1000) + 10; + expect(helpers.jwtExpiresWithin(makeJwt({ exp }), 30)).toBe(true); + }); + it('returns false when the token is comfortably valid', {}, () => { + const exp = Math.floor(Date.now() / 1000) + 900; + expect(helpers.jwtExpiresWithin(makeJwt({ exp }), 30)).toBe(false); + }); + it('returns false for a token with no exp claim', {}, () => { + expect(helpers.jwtExpiresWithin(makeJwt({ sub: 'foo' }), 30)).toBe(false); + }); + it('returns false for a malformed token', {}, () => { + expect(helpers.jwtExpiresWithin('not-a-jwt', 30)).toBe(false); + expect(helpers.jwtExpiresWithin('', 30)).toBe(false); + }); + }); it('can output creds when told to', {}, () => { vi.spyOn(core, 'setOutput').mockImplementation(() => {}); vi.spyOn(core, 'setSecret').mockImplementation(() => {}); diff --git a/test/index.test.ts b/test/index.test.ts index 500538a..97ab9e5 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -89,6 +89,28 @@ describe('Configure AWS Credentials', {}, () => { expect(core.exportVariable).toHaveBeenCalledTimes(5); expect(core.setFailed).not.toHaveBeenCalled(); }); + it('re-mints the OIDC token when it has expired during retries', {}, async () => { + // Helper to build a JWT with a given exp (Unix seconds). Only the payload is read by the action. + const makeJwt = (exp: number) => { + const encode = (obj: object) => Buffer.from(JSON.stringify(obj)).toString('base64url'); + return `${encode({ alg: 'RS256' })}.${encode({ exp })}.sig`; + }; + const expiredToken = makeJwt(Math.floor(Date.now() / 1000) - 60); + const freshToken = makeJwt(Math.floor(Date.now() / 1000) + 900); + // First call returns an already-expired token; the refresh inside the retry loop returns a fresh one. + vi.mocked(core.getIDToken).mockResolvedValueOnce(expiredToken).mockResolvedValueOnce(freshToken); + mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolvesOnce(mocks.outputs.STS_CREDENTIALS); + await run(); + // Token fetched once up front, then re-minted because the first was expired. + expect(core.getIDToken).toHaveBeenCalledTimes(2); + expect(core.info).toHaveBeenCalledWith( + 'OIDC token has expired or is about to; requesting a fresh one before AssumeRole.', + ); + // The fresh token is the one actually sent to STS. + const call = mockedSTSClient.commandCalls(AssumeRoleWithWebIdentityCommand)[0]; + expect(call.args[0].input.WebIdentityToken).toBe(freshToken); + expect(core.setFailed).not.toHaveBeenCalled(); + }); }); describe('IAM User Authentication', {}, () => {