mirror of
https://github.com/docker/login-action.git
synced 2026-07-23 18:22:03 +02:00
14d6a7934e
Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com>
135 lines
4.6 KiB
TypeScript
135 lines
4.6 KiB
TypeScript
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<LoginCredentials> => {
|
|
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 = <OIDCTokenResponse>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<httpm.HttpClientResponse> => {
|
|
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<string> => {
|
|
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<string, string> => {
|
|
try {
|
|
return <Record<string, string>>JSON.parse(body);
|
|
} catch {
|
|
return {};
|
|
}
|
|
};
|