From 42d605cb00986c6775e6ec1732b114e065266ae9 Mon Sep 17 00:00:00 2001 From: Jon Jensen Date: Mon, 18 Jul 2022 14:29:03 -0600 Subject: [PATCH] feat: respect registry-scoped certfile and keyfile options (#125) Closes #118 RFC: https://github.com/npm/rfcs/pull/591 Add support for registry-scoped certfile and keyfile options, e.g. ``` { "//my.registry.example/npm/:certfile": "~/.secret/stuff.crt", "//my.registry.example/npm/:keyfile": "~/.secret/stuff.key" } ``` Since these are registry-specific, they will override top-level cert and key options (if set). Like the top-level `cafile` option, these registry-scoped options are silently ignored if invalid. --- lib/auth.js | 37 ++++++++++++++++++++-- lib/index.js | 4 +-- test/auth.js | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 5 deletions(-) diff --git a/lib/auth.js b/lib/auth.js index 17da6a17..870ce0d9 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -1,4 +1,5 @@ 'use strict' +const fs = require('fs') const npa = require('npm-package-arg') const { URL } = require('url') @@ -7,7 +8,8 @@ const { URL } = require('url') const regKeyFromURI = (uri, opts) => { const parsed = new URL(uri) // try to find a config key indicating we have auth for this registry - // can be one of :_authToken, :_auth, or :_password and :username + // can be one of :_authToken, :_auth, :_password and :username, or + // :certfile and :keyfile // We walk up the "path" until we're left with just //[:], // stopping when we reach '//'. let regKey = `//${parsed.host}${parsed.pathname}` @@ -26,7 +28,8 @@ const regKeyFromURI = (uri, opts) => { const hasAuth = (regKey, opts) => ( opts[`${regKey}:_authToken`] || opts[`${regKey}:_auth`] || - opts[`${regKey}:username`] && opts[`${regKey}:_password`] + opts[`${regKey}:username`] && opts[`${regKey}:_password`] || + opts[`${regKey}:certfile`] && opts[`${regKey}:keyfile`] ) const sameHost = (a, b) => { @@ -44,6 +47,17 @@ const getRegistry = opts => { return scopeReg || opts.registry } +const maybeReadFile = file => { + try { + return fs.readFileSync(file, 'utf8') + } catch (er) { + if (er.code !== 'ENOENT') { + throw er + } + return null + } +} + const getAuth = (uri, opts = {}) => { const { forceAuth } = opts if (!uri) { @@ -59,6 +73,8 @@ const getAuth = (uri, opts = {}) => { username: forceAuth.username, password: forceAuth._password || forceAuth.password, auth: forceAuth._auth || forceAuth.auth, + certfile: forceAuth.certfile, + keyfile: forceAuth.keyfile, }) } @@ -82,6 +98,8 @@ const getAuth = (uri, opts = {}) => { [`${regKey}:username`]: username, [`${regKey}:_password`]: password, [`${regKey}:_auth`]: auth, + [`${regKey}:certfile`]: certfile, + [`${regKey}:keyfile`]: keyfile, } = opts return new Auth({ @@ -90,15 +108,19 @@ const getAuth = (uri, opts = {}) => { auth, username, password, + certfile, + keyfile, }) } class Auth { - constructor ({ token, auth, username, password, scopeAuthKey }) { + constructor ({ token, auth, username, password, scopeAuthKey, certfile, keyfile }) { this.scopeAuthKey = scopeAuthKey this.token = null this.auth = null this.isBasicAuth = false + this.cert = null + this.key = null if (token) { this.token = token } else if (auth) { @@ -108,6 +130,15 @@ class Auth { this.auth = Buffer.from(`${username}:${p}`, 'utf8').toString('base64') this.isBasicAuth = true } + // mTLS may be used in conjunction with another auth method above + if (certfile && keyfile) { + const cert = maybeReadFile(certfile, 'utf-8') + const key = maybeReadFile(keyfile, 'utf-8') + if (cert && key) { + this.cert = cert + this.key = key + } + } } } diff --git a/lib/index.js b/lib/index.js index c788febc..cc331a50 100644 --- a/lib/index.js +++ b/lib/index.js @@ -112,10 +112,10 @@ function regFetch (uri, /* istanbul ignore next */ opts_ = {}) { cache: getCacheMode(opts), cachePath: opts.cache, ca: opts.ca, - cert: opts.cert, + cert: auth.cert || opts.cert, headers, integrity: opts.integrity, - key: opts.key, + key: auth.key || opts.key, localAddress: opts.localAddress, maxSockets: opts.maxSockets, memoize: opts.memoize, diff --git a/test/auth.js b/test/auth.js index 39567be5..1d786bec 100644 --- a/test/auth.js +++ b/test/auth.js @@ -33,6 +33,8 @@ t.test('basic auth', t => { token: null, isBasicAuth: true, auth: Buffer.from('user:pass').toString('base64'), + cert: null, + key: null, }, 'basic auth details generated') const opts = Object.assign({}, OPTS, config) @@ -62,6 +64,8 @@ t.test('token auth', t => { isBasicAuth: false, token: 'c0ffee', auth: null, + cert: null, + key: null, }, 'correct auth token picked out') const opts = Object.assign({}, OPTS, config) @@ -77,17 +81,28 @@ t.test('token auth', t => { }) t.test('forceAuth', t => { + const dir = t.testdir({ + 'my.cert': 'my cert', + 'my.key': 'my key', + 'other.cert': 'other cert', + 'other.key': 'other key', + }) + const config = { registry: 'https://my.custom.registry/here/', token: 'deadbeef', 'always-auth': false, '//my.custom.registry/here/:_authToken': 'c0ffee', '//my.custom.registry/here/:token': 'nope', + '//my.custom.registry/here/:certfile': `${dir}/my.cert`, + '//my.custom.registry/here/:keyfile': `${dir}/my.key`, forceAuth: { username: 'user', password: Buffer.from('pass', 'utf8').toString('base64'), email: 'e@ma.il', 'always-auth': true, + certfile: `${dir}/other.cert`, + keyfile: `${dir}/other.key`, }, } t.same(getAuth(config.registry, config), { @@ -95,6 +110,8 @@ t.test('forceAuth', t => { token: null, isBasicAuth: true, auth: Buffer.from('user:pass').toString('base64'), + cert: 'other cert', + key: 'other key', }, 'only forceAuth details included') const opts = Object.assign({}, OPTS, config) @@ -126,6 +143,8 @@ t.test('forceAuth token', t => { isBasicAuth: false, token: 'cafebad', auth: null, + cert: null, + key: null, }, 'correct forceAuth token picked out') const opts = Object.assign({}, OPTS, config) @@ -152,6 +171,8 @@ t.test('_auth auth', t => { token: null, isBasicAuth: false, auth: 'c0ffee', + cert: null, + key: null, }, 'correct _auth picked out') const opts = Object.assign({}, OPTS, config) @@ -177,6 +198,8 @@ t.test('_auth username:pass auth', t => { token: null, isBasicAuth: false, auth: auth, + cert: null, + key: null, }, 'correct _auth picked out') const opts = Object.assign({}, OPTS, config) @@ -226,6 +249,8 @@ t.test('globally-configured auth', t => { token: null, isBasicAuth: true, auth: Buffer.from('globaluser:globalpass').toString('base64'), + cert: null, + key: null, }, 'basic auth details generated from global settings') const tokenConfig = { @@ -239,6 +264,8 @@ t.test('globally-configured auth', t => { token: 'deadbeef', isBasicAuth: false, auth: null, + cert: null, + key: null, }, 'correct global auth token picked out') const _authConfig = { @@ -252,6 +279,8 @@ t.test('globally-configured auth', t => { token: null, isBasicAuth: false, auth: 'deadbeef', + cert: null, + key: null, }, 'correct _auth picked out') t.end() @@ -270,6 +299,8 @@ t.test('otp token passed through', t => { token: 'c0ffee', isBasicAuth: false, auth: null, + cert: null, + key: null, }, 'correct auth token picked out') const opts = Object.assign({}, OPTS, config) @@ -337,6 +368,8 @@ t.test('always-auth', t => { token: 'c0ffee', isBasicAuth: false, auth: null, + cert: null, + key: null, }, 'correct auth token picked out') const opts = Object.assign({}, OPTS, config) @@ -349,6 +382,11 @@ t.test('always-auth', t => { }) t.test('scope-based auth', t => { + const dir = t.testdir({ + 'my.cert': 'my cert', + 'my.key': 'my key', + }) + const config = { registry: 'https://my.custom.registry/here/', scope: '@myscope', @@ -356,18 +394,24 @@ t.test('scope-based auth', t => { token: 'deadbeef', '//my.custom.registry/here/:_authToken': 'c0ffee', '//my.custom.registry/here/:token': 'nope', + '//my.custom.registry/here/:certfile': `${dir}/my.cert`, + '//my.custom.registry/here/:keyfile': `${dir}/my.key`, } t.same(getAuth(config['@myscope:registry'], config), { scopeAuthKey: null, auth: null, isBasicAuth: false, token: 'c0ffee', + cert: 'my cert', + key: 'my key', }, 'correct auth token picked out') t.same(getAuth(config['@myscope:registry'], config), { scopeAuthKey: null, auth: null, isBasicAuth: false, token: 'c0ffee', + cert: 'my cert', + key: 'my key', }, 'correct auth token picked out without scope config having an @') const opts = Object.assign({}, OPTS, config) @@ -392,6 +436,32 @@ t.test('auth needs a uri', t => { t.end() }) +t.test('certfile and keyfile errors', t => { + const dir = t.testdir({ + 'my.cert': 'my cert', + }) + + t.same(getAuth('https://my.custom.registry/here/', { + '//my.custom.registry/here/:certfile': `${dir}/my.cert`, + '//my.custom.registry/here/:keyfile': `${dir}/nosuch.key`, + }), { + scopeAuthKey: null, + auth: null, + isBasicAuth: false, + token: null, + cert: null, + key: null, + }, 'cert and key ignored if one doesn\'t exist') + + t.throws(() => { + getAuth('https://my.custom.registry/here/', { + '//my.custom.registry/here/:certfile': `${dir}/my.cert`, + '//my.custom.registry/here/:keyfile': dir, + }) + }, /EISDIR/, 'other read errors are propagated') + t.end() +}) + t.test('do not be thrown by other weird configs', t => { const opts = { scope: '@asdf', @@ -412,6 +482,8 @@ t.test('do not be thrown by other weird configs', t => { token: 'correct bearer token', isBasicAuth: false, auth: null, + cert: null, + key: null, }) t.end() }) @@ -430,6 +502,8 @@ t.test('scopeAuthKey tests', t => { auth: null, isBasicAuth: false, token: null, + cert: null, + key: null, }, 'regular scoped spec') t.same(getAuth(uri, { ...opts, spec: 'foo@npm:@scope/foo@latest' }), { @@ -437,6 +511,8 @@ t.test('scopeAuthKey tests', t => { auth: null, isBasicAuth: false, token: null, + cert: null, + key: null, }, 'scoped pkg aliased to unscoped name') t.same(getAuth(uri, { ...opts, spec: '@other-scope/foo@npm:@scope/foo@latest' }), { @@ -444,6 +520,8 @@ t.test('scopeAuthKey tests', t => { auth: null, isBasicAuth: false, token: null, + cert: null, + key: null, }, 'scoped name aliased to other scope with auth') t.same(getAuth(uri, { ...opts, spec: '@scope/foo@npm:foo@latest' }), { @@ -451,6 +529,8 @@ t.test('scopeAuthKey tests', t => { auth: null, isBasicAuth: false, token: null, + cert: null, + key: null, }, 'unscoped aliased to scoped name') t.end() @@ -470,18 +550,24 @@ t.test('registry host matches, path does not, send auth', t => { token: 'c0ffee', auth: null, isBasicAuth: false, + cert: null, + key: null, }) t.same(getAuth(uri, { ...opts, spec: '@other-scope/foo' }), { scopeAuthKey: '//other-scope-registry.com/other/scope/', token: null, auth: null, isBasicAuth: false, + cert: null, + key: null, }) t.same(getAuth(uri, { ...opts, registry: 'https://scope-host.com/scope/host/' }), { scopeAuthKey: null, token: 'c0ffee', auth: null, isBasicAuth: false, + cert: null, + key: null, }) t.end() })