From c5b78bf494ab0f9abeeacc2a8a6069c5dee5d086 Mon Sep 17 00:00:00 2001 From: James Brumond Date: Fri, 18 Aug 2023 21:25:38 -0700 Subject: [PATCH] add ability to authenticate to registry --- action.yaml | 6 +++ index.js | 125 ++++++++++++++++++++++++++++++++++++++++++++-- package-lock.json | 8 ++- package.json | 15 +++--- 4 files changed, 142 insertions(+), 12 deletions(-) diff --git a/action.yaml b/action.yaml index ccc7391..be096cc 100644 --- a/action.yaml +++ b/action.yaml @@ -16,3 +16,9 @@ inputs: new-tags: description: Newline delimited list of new tags to apply to the image required: true + username: + description: Username to use to authenticate to the container registry + required: false + password: + description: Password to use to authenticate to the container registry + required: false diff --git a/index.js b/index.js index ad61bb8..a86bc8d 100644 --- a/index.js +++ b/index.js @@ -2,6 +2,8 @@ const core = require('@actions/core'); const http = require('http'); const https = require('https'); +const querystring = require('querystring'); +const form_urlencoded = require('form-urlencoded'); const req_timeout_ms = 30_000; const manifest_media_type = 'application/vnd.docker.distribution.manifest.v2+json'; @@ -15,6 +17,9 @@ async function main() { image: core.getInput('image'), old_tag: core.getInput('old-tag'), new_tags: core.getInput('new-tags').trim().split('\n'), + username: core.getInput('username'), + password: core.getInput('password'), + token: null, }; console.log('Tagging %s/%s:%s with new tags', input.registry, input.image, input.old_tag, input.new_tags); @@ -34,11 +39,20 @@ async function main() { } } -async function get_manifest(url_str) { - const { status, headers, body } = await http_req('GET', url_str, { +async function get_manifest(url_str, input) { + let { status, headers, body } = await http_req('GET', url_str, { accept: manifest_media_type, }); + if (status === 401 && headers['www-authenticate']) { + await get_token(headers['www-authenticate'], input); + + ({ status, headers, body } = await http_req('GET', url_str, { + accept: manifest_media_type, + authorization: `Bearer ${token}`, + })); + } + if (status !== 200) { console.error('get manifest response', { status, headers, body }); throw new Error('failed to fetch existing manifest'); @@ -48,9 +62,15 @@ async function get_manifest(url_str) { } async function put_manifest(url_str, manifest) { - const { status, headers, body } = await http_req('PUT', url_str, { + const req_headers = { 'content-type': manifest_media_type, - }, manifest); + }; + + if (token) { + req_headers.authorization = `Bearer ${token}`; + } + + const { status, headers, body } = await http_req('PUT', url_str, req_headers, manifest); if (status >= 400) { console.error('get manifest response', { status, headers, body }); @@ -58,6 +78,40 @@ async function put_manifest(url_str, manifest) { } } +async function get_token(www_authenticate, input) { + const params = parse_www_authenticate(www_authenticate); + + if (! input.username || ! input.password) { + throw new Error('registry requested authentication, but not credentials were provided'); + } + + const query_params = { + service: params.service, + scope: params.scope, + grant_type: 'password', + username: input.username, + password: input.password, + }; + + const { status, headers, body } = await http_req('POST', params.realm, { + 'content-type': 'application/x-www-form-urlencoded', + }, form_urlencoded(query_params)); + + if (status !== 200) { + console.error('get token response', { status, headers, body }); + throw new Error('error response from get token request'); + } + + try { + const { token } = JSON.parse(body); + input.token = token; + } + + catch (error) { + throw new Error('invalid response from get token request'); + } +} + async function http_req(method, url_str, headers, body) { const url = new URL(url_str); const make_request @@ -121,3 +175,66 @@ async function http_req(method, url_str, headers, body) { req.end(); }); } + +function parse_www_authenticate(www_authenticate) { + if (! www_authenticate.startsWith('Bearer ')) { + throw new Error('invalid www-authenticate header (no "Bearer " prefix)'); + } + + let remaining = www_authenticate.slice(7); + const parameters = Object.create(null); + + while (remaining) { + const eq_index = remaining.indexOf('='); + + if (eq_index < 0) { + parameters[remaining] = ''; + break; + } + + const key = remaining.slice(0, eq_index); + remaining = remaining.slice(eq_index + 1); + + if (! remaining) { + parameters[key] = ''; + break; + } + + if (remaining[0] === '"') { + const close_index = remaining.slice(1).indexOf('"'); + + if (close_index < 0) { + throw new Error('invalid www-authenticate header (unclosed quotes)'); + } + + parameters[key] = remaining.slice(1, close_index); + remaining = remaining.slice(close_index + 1); + + if (remaining && remaining[0] !== ',') { + throw new Error('invalid www-authenticate header (expected comma after closing quote)'); + } + + remaining = remaining.slice(1); + } + + else { + const comma_index = remaining.indexOf(','); + + if (comma_index < 0) { + parameters[key] = remaining; + break; + } + + parameters[key] = remaining.slice(1, comma_index); + remaining = remaining.slice(comma_index + 1); + } + } + + const { realm, service, scope } = parameters; + + if (! realm || ! service || ! scope) { + throw new Error('invalid www-authenticate header (missing "realm", "service", or "scope")'); + } + + return parameters; +} diff --git a/package-lock.json b/package-lock.json index 7cc57a8..fc845d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,7 +5,8 @@ "packages": { "": { "dependencies": { - "@actions/core": "^1.10.0" + "@actions/core": "^1.10.0", + "form-urlencoded": "^6.1.0" }, "devDependencies": { "@vercel/ncc": "^0.36.1", @@ -579,6 +580,11 @@ "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", "dev": true }, + "node_modules/form-urlencoded": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/form-urlencoded/-/form-urlencoded-6.1.0.tgz", + "integrity": "sha512-lc1Qd9nnEewXKoiPjIA1n38M5STbyY6krgoegsg7SsAt2b98HZKe25KaJvKFBwQaOcmh8FP7JbXVC7gocZw+XQ==" + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", diff --git a/package.json b/package.json index 346b45d..6bbcbf3 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,16 @@ { "private": true, "scripts": { - "lint": "eslint .", - "prepare": "ncc build index.js -o dist --source-map --license licenses.txt", - "all": "npm run lint && npm run prepare" + "lint": "eslint .", + "prepare": "ncc build index.js -o dist --source-map --license licenses.txt", + "all": "npm run lint && npm run prepare" }, "dependencies": { - "@actions/core": "^1.10.0" + "@actions/core": "^1.10.0", + "form-urlencoded": "^6.1.0" }, "devDependencies": { - "@vercel/ncc": "^0.36.1", - "eslint": "^8.37.0" + "@vercel/ncc": "^0.36.1", + "eslint": "^8.37.0" } - } \ No newline at end of file +}