From 14d6a7934ecbf620dc05efeb42b57a93491a8615 Mon Sep 17 00:00:00 2001 From: CrazyMax <1951866+crazy-max@users.noreply.github.com> Date: Thu, 21 Aug 2025 11:16:21 +0200 Subject: [PATCH] docker hub oidc support Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com> --- .github/workflows/ci.yml | 23 +++++++ README.md | 40 +++++++++++ __tests__/dockerhub.test.ts | 133 +++++++++++++++++++++++++++++++++++ package.json | 4 +- src/docker.ts | 10 ++- src/dockerhub.ts | 134 ++++++++++++++++++++++++++++++++++++ yarn.lock | 11 +++ 7 files changed, 353 insertions(+), 2 deletions(-) create mode 100644 __tests__/dockerhub.test.ts create mode 100644 src/dockerhub.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29e1772..73264f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -150,6 +150,29 @@ jobs: username: ${{ vars.DOCKERPUBLICBOT_USERNAME }} password: ${{ secrets.DOCKERPUBLICBOT_READ_PAT }} + dockerhub-oidc: + permissions: + contents: read + id-token: write + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest + steps: + - + name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - + name: Login to Docker Hub with OIDC + uses: ./ + env: + DOCKERHUB_OIDC_CONNECTIONID: ${{ vars.DOCKERHUB_OIDC_CONNECTIONID }} + with: + username: ${{ vars.DOCKERHUB_OIDC_USERNAME }} + ecr: runs-on: ${{ matrix.os }} strategy: diff --git a/README.md b/README.md index 157e8f1..f9c4580 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ ___ * [Set scopes for the authentication token](#set-scopes-for-the-authentication-token) * [Customizing](#customizing) * [inputs](#inputs) + * [environment variables](#environment-variables) * [Contributing](#contributing) ## Usage @@ -57,6 +58,36 @@ jobs: password: ${{ secrets.DOCKERHUB_TOKEN }} ``` +You can also [authenticate to Docker Hub with OpenID Connect](https://docs.docker.com/enterprise/security/oidc-connections/) +when your Docker Hub organization has an OIDC connection configured. The +workflow must grant the `id-token: write` permission, pass the Docker Hub +organization name as `username`, omit `password`, and set the OIDC connection +ID in `DOCKERHUB_OIDC_CONNECTIONID` environment variable. + +```yaml +name: ci + +on: + push: + branches: main + +permissions: + contents: read + id-token: write + +jobs: + login: + runs-on: ubuntu-latest + steps: + - + name: Login to Docker Hub + uses: docker/login-action@v4 + env: + DOCKERHUB_OIDC_CONNECTIONID: ${{ vars.DOCKERHUB_OIDC_CONNECTIONID }} + with: + username: ${{ vars.DOCKERHUB_ORGANIZATION }} +``` + ### GitHub Container Registry To authenticate to the [GitHub Container Registry](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry), @@ -690,6 +721,15 @@ The following inputs can be used as `step.with` keys: > [!NOTE] > The `registry-auth` input cannot be used with other inputs except `logout`. +### environment variables + +The following environment variables can be set as `step.env` keys: + +| Name | Type | Default | Description | +|-------------------------------|--------|---------|-----------------------------------------------------------------------------| +| `DOCKERHUB_OIDC_CONNECTIONID` | String | | Docker Hub OIDC connection ID. Required for Docker Hub OIDC login | +| `DOCKERHUB_OIDC_EXPIREIN` | Number | `300` | Docker Hub OIDC token lifetime in seconds. Must be between `300` and `3600` | + ## Contributing Want to contribute? Awesome! You can find information about contributing to diff --git a/__tests__/dockerhub.test.ts b/__tests__/dockerhub.test.ts new file mode 100644 index 0000000..a7840a9 --- /dev/null +++ b/__tests__/dockerhub.test.ts @@ -0,0 +1,133 @@ +import * as core from '@actions/core'; +import * as httpm from '@actions/http-client'; +import {beforeEach, describe, expect, test, vi} from 'vitest'; + +import * as dockerhub from '../src/dockerhub.js'; + +vi.mock('@actions/core', () => ({ + getIDToken: vi.fn(), + info: vi.fn(), + setSecret: vi.fn() +})); + +const validConnectionID = '123e4567-e89b-42d3-a456-426614174000'; + +const httpResponse = (statusCode: number, body: string, headers: Record = {}): httpm.HttpClientResponse => { + return { + message: { + statusCode, + headers + }, + readBody: vi.fn(async () => body) + } as unknown as httpm.HttpClientResponse; +}; + +describe('isDockerHubOIDC', () => { + beforeEach(() => { + delete process.env.DOCKERHUB_OIDC_CONNECTIONID; + }); + + test.each(['', 'docker.io', 'registry-1.docker.io', 'registry-1-stage.docker.io'])('detects Docker Hub registry %p with empty password', registry => { + process.env.DOCKERHUB_OIDC_CONNECTIONID = validConnectionID; + expect(dockerhub.isDockerHubOIDC(registry, '')).toBe(true); + }); + + test('requires connection ID env var', () => { + expect(dockerhub.isDockerHubOIDC('docker.io', '')).toBe(false); + }); + + test('requires empty password', () => { + process.env.DOCKERHUB_OIDC_CONNECTIONID = validConnectionID; + expect(dockerhub.isDockerHubOIDC('docker.io', 'groundcontrol')).toBe(false); + }); + + test('ignores non-Docker Hub registries', () => { + process.env.DOCKERHUB_OIDC_CONNECTIONID = validConnectionID; + expect(dockerhub.isDockerHubOIDC('ghcr.io', '')).toBe(false); + }); +}); + +describe('getOIDCToken', () => { + const getIDTokenMock = vi.mocked(core.getIDToken); + const setSecretMock = vi.mocked(core.setSecret); + let postSpy: ReturnType; + + beforeEach(() => { + process.env.DOCKERHUB_OIDC_CONNECTIONID = validConnectionID; + delete process.env.DOCKERHUB_OIDC_EXPIREIN; + getIDTokenMock.mockResolvedValue('github-id-token'); + postSpy = vi.spyOn(httpm.HttpClient.prototype, 'post').mockResolvedValue(httpResponse(200, JSON.stringify({access_token: 'hub-token'}))); + }); + + test('exchanges GitHub OIDC token for Docker Hub token', async () => { + const credentials = await dockerhub.getOIDCToken('docker.io', 'dbowie'); + + expect(credentials).toEqual({ + username: 'dbowie', + token: 'hub-token' + }); + expect(getIDTokenMock).toHaveBeenCalledWith('https://identity.docker.com'); + expect(postSpy).toHaveBeenCalledTimes(1); + expect(postSpy.mock.calls[0][0]).toBe('https://identity.docker.com/oauth/token'); + + const http = postSpy.mock.contexts[0] as httpm.HttpClient; + expect(http.userAgent).toBe('github.com/docker/login-action'); + expect(http.requestOptions?.headers).toEqual({ + 'Content-Type': 'application/x-www-form-urlencoded' + }); + + const body = new URLSearchParams(postSpy.mock.calls[0][1]); + expect(body.get('grant_type')).toBe('urn:ietf:params:oauth:grant-type:token-exchange'); + expect(body.get('subject_token_type')).toBe('urn:ietf:params:oauth:token-type:id_token'); + expect(body.get('subject_token')).toBe('github-id-token'); + expect(body.get('connection_id')).toBe(validConnectionID); + expect(body.get('expires_in')).toBe('300'); + expect(setSecretMock).toHaveBeenCalledWith('hub-token'); + }); + + test('uses custom token expiration', async () => { + process.env.DOCKERHUB_OIDC_EXPIREIN = '900'; + await dockerhub.getOIDCToken('docker.io', 'dbowie'); + const body = new URLSearchParams(postSpy.mock.calls[0][1]); + expect(body.get('expires_in')).toBe('900'); + }); + + test('uses stage identity host for stage registry', async () => { + await dockerhub.getOIDCToken('registry-1-stage.docker.io', 'dbowie'); + expect(getIDTokenMock).toHaveBeenCalledWith('https://identity-stage.docker.com'); + expect(postSpy.mock.calls[0][0]).toBe('https://identity-stage.docker.com/oauth/token'); + }); + + test('requires connection ID env var', async () => { + delete process.env.DOCKERHUB_OIDC_CONNECTIONID; + await expect(dockerhub.getOIDCToken('docker.io', 'dbowie')).rejects.toThrow('DOCKERHUB_OIDC_CONNECTIONID is required for Docker Hub OIDC login'); + expect(getIDTokenMock).not.toHaveBeenCalled(); + expect(postSpy).not.toHaveBeenCalled(); + }); + + test('validates connection ID', async () => { + process.env.DOCKERHUB_OIDC_CONNECTIONID = 'not-a-uuid'; + await expect(dockerhub.getOIDCToken('docker.io', 'dbowie')).rejects.toThrow('Invalid DOCKERHUB_OIDC_CONNECTIONID. Must be a valid UUID.'); + expect(getIDTokenMock).not.toHaveBeenCalled(); + expect(postSpy).not.toHaveBeenCalled(); + }); + + test.each(['not-a-number', '299', '3601'])('validates token expiration %p', async expiresIn => { + process.env.DOCKERHUB_OIDC_EXPIREIN = expiresIn; + await expect(dockerhub.getOIDCToken('docker.io', 'dbowie')).rejects.toThrow(`Invalid DOCKERHUB_OIDC_EXPIREIN: ${expiresIn}. Must be between 300 and 3600`); + expect(getIDTokenMock).not.toHaveBeenCalled(); + expect(postSpy).not.toHaveBeenCalled(); + }); + + test('retries rate limited token requests with Retry-After', async () => { + postSpy.mockResolvedValueOnce(httpResponse(429, '', {'retry-after': '0'})).mockResolvedValueOnce(httpResponse(200, JSON.stringify({access_token: 'hub-token'}))); + await dockerhub.getOIDCToken('docker.io', 'dbowie'); + expect(postSpy).toHaveBeenCalledTimes(2); + expect(core.info).toHaveBeenCalledWith('Docker Hub OIDC token request rate limited, retrying in 0ms (attempt 1/5)'); + }); + + test('throws Docker Hub API errors', async () => { + postSpy.mockResolvedValue(httpResponse(400, JSON.stringify({description: 'bad connection'}))); + await expect(dockerhub.getOIDCToken('docker.io', 'dbowie')).rejects.toThrow('Docker Hub API: bad status code 400: bad connection'); + }); +}); diff --git a/package.json b/package.json index a1fde26..461fa16 100644 --- a/package.json +++ b/package.json @@ -24,12 +24,14 @@ "packageManager": "yarn@4.15.0", "dependencies": { "@actions/core": "^3.0.1", + "@actions/http-client": "^4.0.1", "@aws-sdk/client-ecr": "^3.1077.0", "@aws-sdk/client-ecr-public": "^3.1077.0", "@docker/actions-toolkit": "^0.93.0", "http-proxy-agent": "^9.1.0", "https-proxy-agent": "^9.1.0", - "js-yaml": "^5.2.1" + "js-yaml": "^5.2.1", + "uuid": "^14.0.1" }, "devDependencies": { "@eslint/js": "^9.39.3", diff --git a/src/docker.ts b/src/docker.ts index e81349b..c251384 100644 --- a/src/docker.ts +++ b/src/docker.ts @@ -4,12 +4,20 @@ import {Docker} from '@docker/actions-toolkit/lib/docker/docker.js'; import * as aws from './aws.js'; import * as context from './context.js'; +import * as dockerhub from './dockerhub.js'; export async function login(auth: context.Auth): Promise { if (/true/i.test(auth.ecr) || (auth.ecr == 'auto' && aws.isECR(auth.registry))) { await loginECR(auth.registry, auth.username, auth.password, auth.scope); } else { - await loginStandard(auth.registry, auth.username, auth.password, auth.scope); + let username = auth.username; + let password = auth.password; + if (dockerhub.isDockerHubOIDC(auth.registry, password)) { + const credentials = await dockerhub.getOIDCToken(auth.registry, username); + username = credentials.username; + password = credentials.token; + } + await loginStandard(auth.registry, username, password, auth.scope); } } diff --git a/src/dockerhub.ts b/src/dockerhub.ts new file mode 100644 index 0000000..0c2ba4f --- /dev/null +++ b/src/dockerhub.ts @@ -0,0 +1,134 @@ +import * as core from '@actions/core'; +import * as httpm from '@actions/http-client'; +import {HttpCodes} from '@actions/http-client'; +import {validate as uuidValidate} from 'uuid'; + +export interface LoginCredentials { + username: string; + token: string; +} + +interface OIDCTokenResponse { + access_token: string; +} + +const defaultExpiresIn = 300; +const minExpiresIn = 300; +const maxExpiresIn = 3600; +const maxRetries = 5; + +export const isDockerHubOIDC = (registry: string, password: string): boolean => { + return process.env.DOCKERHUB_OIDC_CONNECTIONID !== undefined && !password && isDockerHubRegistry(registry); +}; + +const isDockerHubRegistry = (registry: string): boolean => { + return registry === '' || registry === 'docker.io' || registry === 'registry-1.docker.io' || registry === 'registry-1-stage.docker.io'; +}; + +export const getOIDCToken = async (registry: string, username: string): Promise => { + const connectionID = process.env.DOCKERHUB_OIDC_CONNECTIONID?.trim(); + if (!connectionID) { + throw new Error('DOCKERHUB_OIDC_CONNECTIONID is required for Docker Hub OIDC login'); + } + + if (!uuidValidate(connectionID)) { + throw new Error('Invalid DOCKERHUB_OIDC_CONNECTIONID. Must be a valid UUID.'); + } + + const expiresIn = getExpiresIn(); + const identityHost = registry === 'registry-1-stage.docker.io' ? 'identity-stage.docker.com' : 'identity.docker.com'; + const audience = `https://${identityHost}`; + const idToken = await core.getIDToken(audience); + const http: httpm.HttpClient = new httpm.HttpClient('github.com/docker/login-action', [], { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + }); + + const data = new URLSearchParams(); + data.set('grant_type', 'urn:ietf:params:oauth:grant-type:token-exchange'); + data.set('subject_token_type', 'urn:ietf:params:oauth:token-type:id_token'); + data.set('subject_token', idToken); + data.set('connection_id', connectionID); + data.set('expires_in', expiresIn.toString()); + + const resp = await postWithRetry(http, `https://${identityHost}/oauth/token`, data.toString()); + + const tokenResp = JSON.parse(await handleResponse(resp)); + core.setSecret(tokenResp.access_token); + + return { + username, + token: tokenResp.access_token + }; +}; + +const getExpiresIn = (): number => { + const expiresInInput = process.env.DOCKERHUB_OIDC_EXPIREIN?.trim() || defaultExpiresIn.toString(); + const expiresIn = Number(expiresInInput); + if (isNaN(expiresIn) || expiresIn < minExpiresIn || expiresIn > maxExpiresIn) { + throw new Error(`Invalid DOCKERHUB_OIDC_EXPIREIN: ${expiresInInput}. Must be between ${minExpiresIn} and ${maxExpiresIn}`); + } + return expiresIn; +}; + +const postWithRetry = async (http: httpm.HttpClient, url: string, data: string): Promise => { + let resp = await http.post(url, data); + for (let attempt = 0; (resp.message.statusCode || HttpCodes.InternalServerError) === HttpCodes.TooManyRequests && attempt < maxRetries; attempt++) { + const delay = parseRetryAfter(resp.message.headers['retry-after']); + if (delay === null) { + break; + } + await resp.readBody(); + core.info(`Docker Hub OIDC token request rate limited, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`); + await new Promise(resolve => setTimeout(resolve, delay)); + resp = await http.post(url, data); + } + return resp; +}; + +const parseRetryAfter = (value: string | string[] | undefined): number | null => { + if (value === undefined) { + return null; + } + if (Array.isArray(value)) { + value = value[0]; + } + const seconds = Number(value); + if (isNaN(seconds)) { + return null; + } + return Math.max(0, seconds * 1000); +}; + +const handleResponse = async (resp: httpm.HttpClientResponse): Promise => { + const body = await resp.readBody(); + const statusCode = resp.message.statusCode || HttpCodes.InternalServerError; + if (statusCode < HttpCodes.OK || statusCode >= HttpCodes.MultipleChoices) { + throw parseError(statusCode, body); + } + return body; +}; + +const parseError = (statusCode: number, body: string): Error => { + if (statusCode === 401) { + throw new Error(`Docker Hub API: operation not permitted`); + } + if (body) { + const errResp = parseErrorBody(body); + for (const k of ['description', 'message', 'detail', 'error']) { + if (errResp[k]) { + throw new Error(`Docker Hub API: bad status code ${statusCode}: ${errResp[k]}`); + } + } + } + throw new Error(`Docker Hub API: bad status code ${statusCode}`); +}; + +const parseErrorBody = (body: string): Record => { + try { + return >JSON.parse(body); + } catch { + return {}; + } +}; diff --git a/yarn.lock b/yarn.lock index 770b171..00577e0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3155,6 +3155,7 @@ __metadata: resolution: "docker-login@workspace:." dependencies: "@actions/core": "npm:^3.0.1" + "@actions/http-client": "npm:^4.0.1" "@aws-sdk/client-ecr": "npm:^3.1077.0" "@aws-sdk/client-ecr-public": "npm:^3.1077.0" "@docker/actions-toolkit": "npm:^0.93.0" @@ -3176,6 +3177,7 @@ __metadata: js-yaml: "npm:^5.2.1" prettier: "npm:^3.8.1" typescript: "npm:^5.9.3" + uuid: "npm:^14.0.1" vitest: "npm:^4.0.18" languageName: unknown linkType: soft @@ -6300,6 +6302,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:^14.0.1": + version: 14.0.1 + resolution: "uuid@npm:14.0.1" + bin: + uuid: dist-node/bin/uuid + checksum: 10/0f978fd5b0269d7acb615342aeb131f0b8d85eeb8ec34f2915d906baa3befcc14a079a430d0f8116aec6fd20c850cbfab65d1b3d1643fa81ed373c0cf9574f18 + languageName: node + linkType: hard + "validate-npm-package-name@npm:^7.0.0": version: 7.0.2 resolution: "validate-npm-package-name@npm:7.0.2"