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
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down
133 changes: 133 additions & 0 deletions __tests__/dockerhub.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {}): 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<typeof vi.spyOn>;

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');
});
});
230 changes: 117 additions & 113 deletions dist/index.cjs

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions dist/index.cjs.map

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions dist/licenses.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 9 additions & 1 deletion src/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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);
}
}

Expand Down
Loading