Skip to content

Commit

Permalink
feat: respect registry-scoped certfile and keyfile options (#125)
Browse files Browse the repository at this point in the history
Closes #118
RFC: npm/rfcs#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.
  • Loading branch information
jenseng committed Jul 18, 2022
1 parent 43c91f5 commit 42d605c
Show file tree
Hide file tree
Showing 3 changed files with 122 additions and 5 deletions.
37 changes: 34 additions & 3 deletions lib/auth.js
@@ -1,4 +1,5 @@
'use strict'
const fs = require('fs')
const npa = require('npm-package-arg')
const { URL } = require('url')

Expand All @@ -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 //<host>[:<port>],
// stopping when we reach '//'.
let regKey = `//${parsed.host}${parsed.pathname}`
Expand All @@ -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) => {
Expand All @@ -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) {
Expand All @@ -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,
})
}

Expand All @@ -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({
Expand All @@ -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) {
Expand All @@ -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
}
}
}
}

Expand Down
4 changes: 2 additions & 2 deletions lib/index.js
Expand Up @@ -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,
Expand Down
86 changes: 86 additions & 0 deletions test/auth.js
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -77,24 +81,37 @@ 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), {
scopeAuthKey: null,
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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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 = {
Expand All @@ -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 = {
Expand All @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -349,25 +382,36 @@ 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',
'@myscope:registry': 'https://my.custom.registry/here/',
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)
Expand All @@ -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',
Expand All @@ -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()
})
Expand All @@ -430,27 +502,35 @@ 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' }), {
scopeAuthKey: '//scope-host.com/',
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' }), {
scopeAuthKey: '//scope-host.com/',
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' }), {
scopeAuthKey: null,
auth: null,
isBasicAuth: false,
token: null,
cert: null,
key: null,
}, 'unscoped aliased to scoped name')

t.end()
Expand All @@ -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()
})

0 comments on commit 42d605c

Please sign in to comment.