mirror of
https://github.com/docker/login-action.git
synced 2026-04-24 15:38:21 +08:00
Merge 4bcfaae3254ac98002f4a7629e319bfa53e3049c into 4a8376e001c7725687f0624d1c3698a3f6ab337e
This commit is contained in:
commit
df3dd8986a
87
README.md
87
README.md
@ -24,6 +24,7 @@ ___
|
||||
* [OCI Oracle Cloud Infrastructure Registry (OCIR)](#oci-oracle-cloud-infrastructure-registry-ocir)
|
||||
* [Quay.io](#quayio)
|
||||
* [DigitalOcean](#digitalocean-container-registry)
|
||||
* [Chainguard](#chainguard-registry)
|
||||
* [Authenticate to multiple registries](#authenticate-to-multiple-registries)
|
||||
* [Set scopes for the authentication token](#set-scopes-for-the-authentication-token)
|
||||
* [Customizing](#customizing)
|
||||
@ -496,6 +497,72 @@ jobs:
|
||||
password: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
|
||||
```
|
||||
|
||||
### Chainguard Registry
|
||||
|
||||
To authenticate to the [Chainguard Registry](https://edu.chainguard.dev/chainguard/chainguard-registry/authenticating/)
|
||||
(`cgr.dev`) using OIDC federation with GitHub Actions, first create a Chainguard
|
||||
identity scoped to your repository:
|
||||
|
||||
```shell
|
||||
chainctl iam identity create github <identity-name> \
|
||||
--github-repo=<org>/<repo> \
|
||||
--github-ref=refs/heads/main \
|
||||
--role=registry.pull
|
||||
```
|
||||
|
||||
Then use the identity ID in your workflow. The action will automatically exchange
|
||||
a GitHub Actions OIDC token for a Chainguard registry token:
|
||||
|
||||
```yaml
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # Required for OIDC federation
|
||||
|
||||
jobs:
|
||||
login:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
name: Login to Chainguard Registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: cgr.dev
|
||||
chainguard-identity: ${{ secrets.CHAINGUARD_IDENTITY }}
|
||||
```
|
||||
|
||||
> The `id-token: write` permission is required for the GitHub Actions runner to
|
||||
> request an OIDC token for Chainguard's token exchange.
|
||||
|
||||
You can also authenticate using a [pull token](https://edu.chainguard.dev/chainguard/chainguard-registry/authenticating/#authenticating-with-a-pull-token)
|
||||
with standard username/password login:
|
||||
|
||||
```yaml
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: main
|
||||
|
||||
jobs:
|
||||
login:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
name: Login to Chainguard Registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: cgr.dev
|
||||
username: ${{ vars.CHAINGUARD_USERNAME }}
|
||||
password: ${{ secrets.CHAINGUARD_PULL_TOKEN }}
|
||||
chainguard: false
|
||||
```
|
||||
|
||||
### Authenticate to multiple registries
|
||||
|
||||
To authenticate against multiple registries, you can specify the login-action
|
||||
@ -618,15 +685,17 @@ credentials, while authenticated access is used only to push `myorg/myimage`.
|
||||
|
||||
The following inputs can be used as `step.with` keys:
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|-----------------|--------|-------------|-------------------------------------------------------------------------------|
|
||||
| `registry` | String | `docker.io` | Server address of Docker registry. If not set then will default to Docker Hub |
|
||||
| `username` | String | | Username for authenticating to the Docker registry |
|
||||
| `password` | String | | Password or personal access token for authenticating the Docker registry |
|
||||
| `scope` | String | | Scope for the authentication token |
|
||||
| `ecr` | String | `auto` | Specifies whether the given registry is ECR (`auto`, `true` or `false`) |
|
||||
| `logout` | Bool | `true` | Log out from the Docker registry at the end of a job |
|
||||
| `registry-auth` | YAML | | Raw authentication to registries, defined as YAML objects |
|
||||
| Name | Type | Default | Description |
|
||||
|------------------------|--------|-------------|-------------------------------------------------------------------------------|
|
||||
| `registry` | String | `docker.io` | Server address of Docker registry. If not set then will default to Docker Hub |
|
||||
| `username` | String | | Username for authenticating to the Docker registry |
|
||||
| `password` | String | | Password or personal access token for authenticating the Docker registry |
|
||||
| `scope` | String | | Scope for the authentication token |
|
||||
| `ecr` | String | `auto` | Specifies whether the given registry is ECR (`auto`, `true` or `false`) |
|
||||
| `chainguard` | String | `auto` | Specifies whether the given registry is Chainguard (`auto`, `true` or `false`)|
|
||||
| `chainguard-identity` | String | | Chainguard identity to assume for OIDC-based authentication |
|
||||
| `logout` | Bool | `true` | Log out from the Docker registry at the end of a job |
|
||||
| `registry-auth` | YAML | | Raw authentication to registries, defined as YAML objects |
|
||||
|
||||
> [!NOTE]
|
||||
> The `registry-auth` input cannot be used with other inputs except `logout`.
|
||||
|
||||
99
__tests__/chainguard.test.ts
Normal file
99
__tests__/chainguard.test.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import {beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
import * as chainguard from '../src/chainguard.js';
|
||||
|
||||
describe('isChainguard', () => {
|
||||
test.each([
|
||||
['cgr.dev', true],
|
||||
['registry.gitlab.com', false],
|
||||
['gcr.io', false],
|
||||
['docker.io', false],
|
||||
['ghcr.io', false],
|
||||
['public.ecr.aws', false],
|
||||
['012345678901.dkr.ecr.eu-west-3.amazonaws.com', false],
|
||||
['not-cgr.dev', false],
|
||||
['cgr.dev.example.com', false]
|
||||
])('given registry %p returns %p', (registry, expected) => {
|
||||
expect(chainguard.isChainguard(registry)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
const mockGetIDToken = vi.fn();
|
||||
vi.mock('@actions/core', () => ({
|
||||
info: vi.fn(),
|
||||
setSecret: vi.fn(),
|
||||
getIDToken: (...args: unknown[]) => mockGetIDToken(...args)
|
||||
}));
|
||||
|
||||
const mockGetJson = vi.fn();
|
||||
vi.mock('@actions/http-client', () => {
|
||||
return {
|
||||
HttpClient: class {
|
||||
getJson = mockGetJson;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
describe('getRegistryToken', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('exchanges OIDC token for Chainguard token', async () => {
|
||||
const fakeOIDCToken = 'oidc-token-123';
|
||||
const fakeChainguardToken = 'chainguard-token-456';
|
||||
const identity = 'abc123/def456';
|
||||
|
||||
mockGetIDToken.mockResolvedValue(fakeOIDCToken);
|
||||
mockGetJson.mockResolvedValue({
|
||||
statusCode: 200,
|
||||
result: {token: fakeChainguardToken}
|
||||
});
|
||||
|
||||
const result = await chainguard.getRegistryToken(identity);
|
||||
|
||||
expect(mockGetIDToken).toHaveBeenCalledWith('cgr.dev');
|
||||
expect(mockGetJson).toHaveBeenCalledWith(`https://issuer.enforce.dev/sts/exchange?aud=cgr.dev&identity=${encodeURIComponent(identity)}`, {Authorization: `Bearer ${fakeOIDCToken}`});
|
||||
expect(result).toEqual({
|
||||
username: 'user',
|
||||
password: fakeChainguardToken
|
||||
});
|
||||
});
|
||||
|
||||
test('uses custom issuer URL when provided', async () => {
|
||||
const fakeOIDCToken = 'oidc-token-123';
|
||||
const fakeChainguardToken = 'chainguard-token-456';
|
||||
const identity = 'abc123/def456';
|
||||
const customIssuer = 'https://custom-issuer.example.dev';
|
||||
|
||||
mockGetIDToken.mockResolvedValue(fakeOIDCToken);
|
||||
mockGetJson.mockResolvedValue({
|
||||
statusCode: 200,
|
||||
result: {token: fakeChainguardToken}
|
||||
});
|
||||
|
||||
await chainguard.getRegistryToken(identity, customIssuer);
|
||||
|
||||
expect(mockGetJson).toHaveBeenCalledWith(`${customIssuer}/sts/exchange?aud=cgr.dev&identity=${encodeURIComponent(identity)}`, {Authorization: `Bearer ${fakeOIDCToken}`});
|
||||
});
|
||||
|
||||
test('throws on non-200 response', async () => {
|
||||
mockGetIDToken.mockResolvedValue('oidc-token');
|
||||
mockGetJson.mockResolvedValue({
|
||||
statusCode: 401,
|
||||
result: null
|
||||
});
|
||||
|
||||
await expect(chainguard.getRegistryToken('identity-id')).rejects.toThrow('Failed to exchange OIDC token with Chainguard (HTTP 401)');
|
||||
});
|
||||
|
||||
test('throws when response has no token', async () => {
|
||||
mockGetIDToken.mockResolvedValue('oidc-token');
|
||||
mockGetJson.mockResolvedValue({
|
||||
statusCode: 200,
|
||||
result: {}
|
||||
});
|
||||
|
||||
await expect(chainguard.getRegistryToken('identity-id')).rejects.toThrow('Failed to exchange OIDC token with Chainguard (HTTP 200)');
|
||||
});
|
||||
});
|
||||
@ -19,6 +19,12 @@ inputs:
|
||||
ecr:
|
||||
description: 'Specifies whether the given registry is ECR (auto, true or false)'
|
||||
required: false
|
||||
chainguard:
|
||||
description: 'Specifies whether the given registry is Chainguard (auto, true or false)'
|
||||
required: false
|
||||
chainguard-identity:
|
||||
description: 'Chainguard identity to assume for OIDC-based authentication'
|
||||
required: false
|
||||
scope:
|
||||
description: 'Scope for the authentication token'
|
||||
required: false
|
||||
|
||||
7112
package-lock.json
generated
Normal file
7112
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -23,6 +23,7 @@
|
||||
"packageManager": "yarn@4.9.2",
|
||||
"dependencies": {
|
||||
"@actions/core": "^3.0.0",
|
||||
"@actions/http-client": "^4.0.0",
|
||||
"@aws-sdk/client-ecr": "^3.1020.0",
|
||||
"@aws-sdk/client-ecr-public": "^3.1020.0",
|
||||
"@docker/actions-toolkit": "^0.86.0",
|
||||
|
||||
41
src/chainguard.ts
Normal file
41
src/chainguard.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import * as core from '@actions/core';
|
||||
import * as http from '@actions/http-client';
|
||||
|
||||
const chainguardRegistryRegex = /^cgr\.dev$/;
|
||||
|
||||
const DEFAULT_ISSUER = 'https://issuer.enforce.dev';
|
||||
const DEFAULT_AUDIENCE = 'cgr.dev';
|
||||
|
||||
export const isChainguard = (registry: string): boolean => {
|
||||
return chainguardRegistryRegex.test(registry);
|
||||
};
|
||||
|
||||
export interface ChainguardTokenResponse {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export const getRegistryToken = async (identity: string, issuerURL?: string): Promise<{username: string; password: string}> => {
|
||||
const issuer = issuerURL || DEFAULT_ISSUER;
|
||||
|
||||
core.info('Requesting GitHub Actions OIDC token...');
|
||||
const oidcToken = await core.getIDToken(DEFAULT_AUDIENCE);
|
||||
|
||||
core.info(`Exchanging OIDC token with Chainguard (${issuer})...`);
|
||||
const client = new http.HttpClient('docker-login-action');
|
||||
const url = `${issuer}/sts/exchange?aud=${encodeURIComponent(DEFAULT_AUDIENCE)}&identity=${encodeURIComponent(identity)}`;
|
||||
const response = await client.getJson<ChainguardTokenResponse>(url, {
|
||||
Authorization: `Bearer ${oidcToken}`
|
||||
});
|
||||
|
||||
if (response.statusCode !== 200 || !response.result?.token) {
|
||||
throw new Error(`Failed to exchange OIDC token with Chainguard (HTTP ${response.statusCode})`);
|
||||
}
|
||||
|
||||
const token = response.result.token;
|
||||
core.setSecret(token);
|
||||
|
||||
return {
|
||||
username: 'user',
|
||||
password: token
|
||||
};
|
||||
};
|
||||
@ -11,6 +11,8 @@ export interface Inputs {
|
||||
password: string;
|
||||
scope: string;
|
||||
ecr: string;
|
||||
chainguard: string;
|
||||
chainguardIdentity: string;
|
||||
logout: boolean;
|
||||
registryAuth: string;
|
||||
}
|
||||
@ -21,6 +23,8 @@ export interface Auth {
|
||||
password: string;
|
||||
scope: string;
|
||||
ecr: string;
|
||||
chainguard: string;
|
||||
chainguardIdentity: string;
|
||||
configDir: string;
|
||||
}
|
||||
|
||||
@ -31,13 +35,15 @@ export function getInputs(): Inputs {
|
||||
password: core.getInput('password'),
|
||||
scope: core.getInput('scope'),
|
||||
ecr: core.getInput('ecr'),
|
||||
chainguard: core.getInput('chainguard'),
|
||||
chainguardIdentity: core.getInput('chainguard-identity'),
|
||||
logout: core.getBooleanInput('logout'),
|
||||
registryAuth: core.getInput('registry-auth')
|
||||
};
|
||||
}
|
||||
|
||||
export function getAuthList(inputs: Inputs): Array<Auth> {
|
||||
if (inputs.registryAuth && (inputs.registry || inputs.username || inputs.password || inputs.scope || inputs.ecr)) {
|
||||
if (inputs.registryAuth && (inputs.registry || inputs.username || inputs.password || inputs.scope || inputs.ecr || inputs.chainguard || inputs.chainguardIdentity)) {
|
||||
throw new Error('Cannot use registry-auth with other inputs');
|
||||
}
|
||||
let auths: Array<Auth> = [];
|
||||
@ -49,11 +55,15 @@ export function getAuthList(inputs: Inputs): Array<Auth> {
|
||||
password: inputs.password,
|
||||
scope: inputs.scope,
|
||||
ecr: inputs.ecr || 'auto',
|
||||
chainguard: inputs.chainguard || 'auto',
|
||||
chainguardIdentity: inputs.chainguardIdentity,
|
||||
configDir: scopeToConfigDir(registry, inputs.scope)
|
||||
});
|
||||
} else {
|
||||
auths = (yaml.load(inputs.registryAuth) as Array<Auth>).map(auth => {
|
||||
core.setSecret(auth.password); // redacted in workflow logs
|
||||
if (auth.password) {
|
||||
core.setSecret(auth.password);
|
||||
}
|
||||
const registry = auth.registry || 'docker.io';
|
||||
return {
|
||||
registry,
|
||||
@ -61,6 +71,8 @@ export function getAuthList(inputs: Inputs): Array<Auth> {
|
||||
password: auth.password,
|
||||
scope: auth.scope,
|
||||
ecr: auth.ecr || 'auto',
|
||||
chainguard: auth.chainguard || 'auto',
|
||||
chainguardIdentity: auth.chainguardIdentity,
|
||||
configDir: scopeToConfigDir(registry, auth.scope)
|
||||
};
|
||||
});
|
||||
|
||||
@ -3,11 +3,14 @@ import * as core from '@actions/core';
|
||||
import {Docker} from '@docker/actions-toolkit/lib/docker/docker.js';
|
||||
|
||||
import * as aws from './aws.js';
|
||||
import * as chainguard from './chainguard.js';
|
||||
import * as context from './context.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 if (/true/i.test(auth.chainguard) || (auth.chainguard == 'auto' && chainguard.isChainguard(auth.registry))) {
|
||||
await loginChainguard(auth.registry, auth.chainguardIdentity, auth.scope);
|
||||
} else {
|
||||
await loginStandard(auth.registry, auth.username, auth.password, auth.scope);
|
||||
}
|
||||
@ -54,6 +57,15 @@ export async function loginECR(registry: string, username: string, password: str
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginChainguard(registry: string, identity: string, scope?: string): Promise<void> {
|
||||
if (!identity) {
|
||||
throw new Error('Chainguard identity is required for Chainguard registry login. Set the chainguard-identity input.');
|
||||
}
|
||||
core.info(`Retrieving Chainguard registry token via OIDC exchange...`);
|
||||
const creds = await chainguard.getRegistryToken(identity);
|
||||
await loginExec(registry, creds.username, creds.password, scope);
|
||||
}
|
||||
|
||||
async function loginExec(registry: string, username: string, password: string, scope?: string): Promise<void> {
|
||||
let envs: {[key: string]: string} | undefined;
|
||||
const configDir = context.scopeToConfigDir(registry, scope);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user