0
Fork 0
mirror of https://github.com/verdaccio/verdaccio.git synced 2025-01-13 22:48:31 -05:00
verdaccio/packages/auth/test/auth.spec.ts
Juan Picado f047cc8c25
refactor: auth with legacy sign support (#4113)
* refactor: auth with legacy sign support

refactor: auth with legacy sign support

add tests

add tests

clean up lock fil

clean up lock fil

add more ci to test

update ci

update ci

update ci

update ci

update ci

* chore: add test for deprecated legacy signature

* chore: add test for deprecated legacy signature

* chore: add test for deprecated legacy signature

* chore: add test for deprecated legacy signature

* chore: add test for deprecated legacy signature
2023-12-31 14:34:29 +01:00

715 lines
24 KiB
TypeScript

import express from 'express';
import path from 'path';
import supertest from 'supertest';
import { Config as AppConfig, ROLES, createRemoteUser, getDefaultConfig } from '@verdaccio/config';
import {
API_ERROR,
HEADERS,
HTTP_STATUS,
SUPPORT_ERRORS,
TOKEN_BEARER,
errorUtils,
} from '@verdaccio/core';
import { logger, setup } from '@verdaccio/logger';
import { errorReportingMiddleware, final, handleError } from '@verdaccio/middleware';
import { Config } from '@verdaccio/types';
import { buildToken } from '@verdaccio/utils';
import { $RequestExtend, Auth } from '../src';
import {
authChangePasswordConf,
authPluginFailureConf,
authPluginPassThrougConf,
authProfileConf,
} from './helper/plugin';
setup({});
// to avoid flaky test generate same ramdom key
jest.mock('@verdaccio/utils', () => {
return {
...jest.requireActual('@verdaccio/utils'),
// used by enhanced legacy aes signature (minimum 32 characters)
generateRandomSecretKey: () => 'GCYW/3IJzQI6GvPmy9sbMkFoiL7QLVw',
// used by legacy aes signature
generateRandomHexString: () =>
'ff065fcf7a8330ae37d3ea116328852f387ad7aa6defbe47fb68b1ea25f97446',
};
});
describe('AuthTest', () => {
describe('default', () => {
test('should init correctly', async () => {
const config: Config = new AppConfig({ ...authProfileConf });
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
});
test('should load default auth plugin', async () => {
const config: Config = new AppConfig({ ...authProfileConf, auth: undefined });
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
});
});
describe('utils', () => {
test('should load custom algorithm', async () => {
const config: Config = new AppConfig({
...authProfileConf,
auth: { htpasswd: { algorithm: 'sha1', file: './foo' } },
});
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
});
});
describe('authenticate', () => {
describe('test authenticate states', () => {
test('should be a success login', async () => {
const config: Config = new AppConfig({ ...authProfileConf });
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
const callback = jest.fn();
const groups = ['test'];
auth.authenticate('foo', 'bar', callback);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(null, {
groups: [
'test',
ROLES.$ALL,
ROLES.$AUTH,
ROLES.DEPRECATED_ALL,
ROLES.DEPRECATED_AUTH,
ROLES.ALL,
],
name: 'foo',
real_groups: groups,
});
});
test('should be a fail on login', async () => {
const config: Config = new AppConfig(authPluginFailureConf);
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
const callback = jest.fn();
auth.authenticate('foo', 'bar', callback);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(errorUtils.getInternalError());
});
});
// plugins are free to send whatever they want, so, we need to test some scenarios
// that might make break the request
// the @ts-ignore below are intended
describe('test authenticate out of control inputs from plugins', () => {
test('should skip falsy values', async () => {
const config: Config = new AppConfig({ ...authPluginPassThrougConf });
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
const callback = jest.fn();
let index = 0;
// as defined by https://developer.mozilla.org/en-US/docs/Glossary/Falsy
for (const value of [false, 0, '', null, undefined, NaN]) {
// @ts-ignore
auth.authenticate(null, value, callback);
const call = callback.mock.calls[index++];
expect(call[0]).toBeDefined();
expect(call[1]).toBeUndefined();
}
});
test('should error truthy non-array', async () => {
const config: Config = new AppConfig({ ...authPluginPassThrougConf });
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
const callback = jest.fn();
for (const value of [true, 1, 'test', {}]) {
expect(function () {
// @ts-ignore
auth.authenticate(null, value, callback);
}).toThrow(TypeError);
expect(callback).not.toHaveBeenCalled();
}
});
test('should skip empty array', async () => {
const config: Config = new AppConfig({ ...authPluginPassThrougConf });
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
const callback = jest.fn();
const value = [];
// @ts-ignore
auth.authenticate(null, value, callback);
expect(callback.mock.calls).toHaveLength(1);
expect(callback.mock.calls[0][0]).toBeDefined();
expect(callback.mock.calls[0][1]).toBeUndefined();
});
test('should accept valid array', async () => {
const config: Config = new AppConfig({ ...authPluginPassThrougConf });
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
const callback = jest.fn();
let index = 0;
for (const value of [[''], ['1'], ['0'], ['000']]) {
// @ts-ignore
auth.authenticate(null, value, callback);
const call = callback.mock.calls[index++];
expect(call[0]).toBeNull();
expect(call[1].real_groups).toBe(value);
}
});
});
describe('test multiple authenticate methods', () => {
test('should skip falsy values', async () => {
const config: Config = new AppConfig({
...getDefaultConfig(),
plugins: path.join(__dirname, './partials/plugin'),
auth: {
success: {},
'fail-invalid-method': {},
},
});
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
return new Promise((resolve) => {
auth.authenticate('foo', 'bar', (err, value) => {
expect(value).toEqual({
name: 'foo',
groups: ['test', ROLES.$ALL, '$authenticated', '@all', '@authenticated', 'all'],
real_groups: ['test'],
});
resolve(value);
});
});
});
});
});
describe('changePassword', () => {
test('should fail if the plugin does not provide implementation', async () => {
const config: Config = new AppConfig({ ...authProfileConf });
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
const callback = jest.fn();
auth.changePassword('foo', 'bar', 'newFoo', callback);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(
errorUtils.getInternalError(SUPPORT_ERRORS.PLUGIN_MISSING_INTERFACE)
);
});
test('should handle plugin does provide implementation', async () => {
const config: Config = new AppConfig({ ...authChangePasswordConf });
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
const callback = jest.fn();
auth.add_user('foo', 'bar', jest.fn());
auth.changePassword('foo', 'bar', 'newFoo', callback);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(null, true);
});
});
describe('allow_access', () => {
describe('no custom allow_access implementation provided', () => {
// when allow_access is not implemented, the groups must match
// exactly with the packages access group
test('should fails if groups do not match exactly', async () => {
const config: Config = new AppConfig({ ...authProfileConf });
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
const callback = jest.fn();
const groups = ['test'];
auth.allow_access(
{ packageName: 'foo' },
{ name: 'foo', groups, real_groups: groups },
callback
);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(
errorUtils.getForbidden('user foo is not allowed to access package foo')
);
});
test('should success if groups do not match exactly', async () => {
const config: Config = new AppConfig({ ...authProfileConf });
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
const callback = jest.fn();
// $all comes from configuration file
const groups = [ROLES.$ALL];
auth.allow_access(
{ packageName: 'foo' },
{ name: 'foo', groups, real_groups: groups },
callback
);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(null, true);
});
});
});
describe('allow_publish', () => {
describe('no custom allow_publish implementation provided', () => {
// when allow_access is not implemented, the groups must match
// exactly with the packages access group
test('should fails if groups do not match exactly', async () => {
const config: Config = new AppConfig({ ...authProfileConf });
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
const callback = jest.fn();
const groups = ['test'];
auth.allow_publish(
{ packageName: 'foo' },
{ name: 'foo', groups, real_groups: groups },
callback
);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(
errorUtils.getForbidden('user foo is not allowed to publish package foo')
);
});
test('should success if groups do match exactly', async () => {
const config: Config = new AppConfig({ ...authProfileConf });
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
const callback = jest.fn();
// $all comes from configuration file
const groups = [ROLES.$AUTH];
auth.allow_publish(
{ packageName: 'foo' },
{ name: 'foo', groups, real_groups: groups },
callback
);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(null, true);
});
});
});
describe('allow_unpublish', () => {
describe('no custom allow_unpublish implementation provided', () => {
test('should fails if groups do not match exactly', async () => {
const config: Config = new AppConfig({ ...authProfileConf });
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
const callback = jest.fn();
const groups = ['test'];
auth.allow_unpublish(
{ packageName: 'foo' },
{ name: 'foo', groups, real_groups: groups },
callback
);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(
errorUtils.getForbidden('user foo is not allowed to unpublish package foo')
);
});
test('should handle missing unpublish method (special case to handle legacy configurations)', async () => {
const config: Config = new AppConfig({
...authProfileConf,
packages: {
...authProfileConf.packages,
'**': {
access: ['$all'],
publish: ['$authenticated'],
// it forces publish handle the access
unpublish: undefined,
proxy: ['npmjs'],
},
},
});
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
const callback = jest.fn();
const groups = ['test'];
auth.allow_unpublish(
{ packageName: 'foo' },
{ name: 'foo', groups, real_groups: groups },
callback
);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(
errorUtils.getForbidden('user foo is not allowed to publish package foo')
);
});
test('should success if groups do match exactly', async () => {
const config: Config = new AppConfig({ ...authProfileConf });
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
const callback = jest.fn();
// $all comes from configuration file
const groups = [ROLES.$AUTH];
auth.allow_unpublish(
{ packageName: 'foo' },
{ name: 'foo', groups, real_groups: groups },
callback
);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(null, true);
});
});
});
describe('add_user', () => {
describe('error handling', () => {
// when allow_access is not implemented, the groups must match
// exactly with the packages access group
test('should fails with bad password if adduser is not implemented', async () => {
const config: Config = new AppConfig({ ...authProfileConf });
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
const callback = jest.fn();
auth.add_user('juan', 'password', callback);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(
errorUtils.getConflict(API_ERROR.BAD_USERNAME_PASSWORD)
);
});
test('should fails if adduser fails internally (exception)', async () => {
const config: Config = new AppConfig({
...getDefaultConfig(),
plugins: path.join(__dirname, './partials/plugin'),
auth: {
adduser: {},
},
});
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
const callback = jest.fn();
// note: fail uas username make plugin fails
auth.add_user('fail', 'password', callback);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(new Error('bad username'));
});
test('should skip to the next plugin and fails', async () => {
const config: Config = new AppConfig({
...getDefaultConfig(),
plugins: path.join(__dirname, './partials/plugin'),
auth: {
adduser: {},
// plugin implement adduser with fail auth
fail: {},
},
});
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
const callback = jest.fn();
// note: fail uas username make plugin fails
auth.add_user('skip', 'password', callback);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(
errorUtils.getConflict(API_ERROR.BAD_USERNAME_PASSWORD)
);
});
});
test('should success if adduser', async () => {
const config: Config = new AppConfig({
...getDefaultConfig(),
plugins: path.join(__dirname, './partials/plugin'),
auth: {
adduser: {},
},
});
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
const callback = jest.fn();
auth.add_user('something', 'password', callback);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(null, {
groups: ['test', '$all', '$authenticated', '@all', '@authenticated', 'all'],
name: 'something',
real_groups: ['test'],
});
});
test('should handle legacy add_user method', async () => {
const config: Config = new AppConfig({
...getDefaultConfig(),
plugins: path.join(__dirname, './partials/plugin'),
auth: {
'adduser-legacy': {},
},
});
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
const callback = jest.fn();
auth.add_user('something', 'password', callback);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(null, {
groups: ['test', '$all', '$authenticated', '@all', '@authenticated', 'all'],
name: 'something',
real_groups: ['test'],
});
});
});
describe('middlewares', () => {
describe('apiJWTmiddleware', () => {
const secret = '12345';
const getServer = async function (auth) {
const app = express();
app.use(express.json({ strict: false, limit: '10mb' }));
// @ts-expect-error
app.use(errorReportingMiddleware(logger));
app.use(auth.apiJWTmiddleware());
app.get('/*', (req, res, next) => {
if ((req as $RequestExtend).remote_user.error) {
next(new Error((req as $RequestExtend).remote_user.error));
} else {
// @ts-expect-error
next({ user: req?.remote_user });
}
});
// @ts-expect-error
app.use(handleError(logger));
// @ts-expect-error
app.use(final);
return app;
};
describe('legacy signature', () => {
describe('error cases', () => {
test('should handle invalid auth token', async () => {
const config: Config = new AppConfig({ ...authProfileConf });
config.checkSecretKey(secret);
const auth = new Auth(config);
await auth.init();
const app = await getServer(auth);
return supertest(app)
.get(`/`)
.set(HEADERS.AUTHORIZATION, 'Bearer foo')
.expect(HTTP_STATUS.INTERNAL_ERROR);
});
test('should handle missing auth header', async () => {
const config: Config = new AppConfig({ ...authProfileConf });
config.checkSecretKey(secret);
const auth = new Auth(config);
await auth.init();
const app = await getServer(auth);
return supertest(app).get(`/`).expect(HTTP_STATUS.OK);
});
});
describe('deprecated legacy handling forceEnhancedLegacySignature=false', () => {
test('should handle valid auth token', async () => {
const payload = 'juan:password';
// const token = await signPayload(remoteUser, '12345');
const config: Config = new AppConfig(
{ ...authProfileConf },
{ forceEnhancedLegacySignature: false }
);
// intended to force key generator (associated with mocks above)
config.checkSecretKey(undefined);
const auth = new Auth(config);
await auth.init();
const token = auth.aesEncrypt(payload) as string;
const app = await getServer(auth);
const res = await supertest(app)
.get(`/`)
.set(HEADERS.AUTHORIZATION, buildToken(TOKEN_BEARER, token))
.expect(HTTP_STATUS.OK);
expect(res.body.user.name).toEqual('juan');
});
test('should handle invalid auth token', async () => {
const payload = 'juan:password';
const config: Config = new AppConfig(
{ ...authPluginFailureConf },
{ forceEnhancedLegacySignature: false }
);
// intended to force key generator (associated with mocks above)
config.checkSecretKey(undefined);
const auth = new Auth(config);
await auth.init();
const token = auth.aesEncrypt(payload) as string;
const app = await getServer(auth);
return await supertest(app)
.get(`/`)
.set(HEADERS.AUTHORIZATION, buildToken(TOKEN_BEARER, token))
.expect(HTTP_STATUS.INTERNAL_ERROR);
});
});
});
describe('jwt signature', () => {
describe('error cases', () => {
test('should handle invalid auth token and return anonymous', async () => {
// @ts-expect-error
const config: Config = new AppConfig({
...authProfileConf,
...{ security: { api: { jwt: { sign: { expiresIn: '29d' } } } } },
});
config.checkSecretKey(secret);
const auth = new Auth(config);
await auth.init();
const app = await getServer(auth);
const res = await supertest(app)
.get(`/`)
.set(HEADERS.AUTHORIZATION, 'Bearer foo')
.expect(HTTP_STATUS.OK);
expect(res.body.user.groups).toEqual([
ROLES.$ALL,
ROLES.$ANONYMOUS,
ROLES.DEPRECATED_ALL,
ROLES.DEPRECATED_ANONYMOUS,
]);
});
test('should handle missing auth header', async () => {
// @ts-expect-error
const config: Config = new AppConfig({
...authProfileConf,
...{ security: { api: { jwt: { sign: { expiresIn: '29d' } } } } },
});
config.checkSecretKey(secret);
const auth = new Auth(config);
await auth.init();
const app = await getServer(auth);
const res = await supertest(app).get(`/`).expect(HTTP_STATUS.OK);
expect(res.body.user.groups).toEqual([
ROLES.$ALL,
ROLES.$ANONYMOUS,
ROLES.DEPRECATED_ALL,
ROLES.DEPRECATED_ANONYMOUS,
]);
});
});
describe('valid signature handlers', () => {
test('should handle valid auth token', async () => {
const config: Config = new AppConfig(
// @ts-expect-error
{
...authProfileConf,
...{ security: { api: { jwt: { sign: { expiresIn: '29d' } } } } },
},
{ forceEnhancedLegacySignature: false }
);
// intended to force key generator (associated with mocks above)
config.checkSecretKey(undefined);
const auth = new Auth(config);
await auth.init();
const token = (await auth.jwtEncrypt(
createRemoteUser('jwt_user', [ROLES.ALL]),
// @ts-expect-error
config.security.api.jwt.sign
)) as string;
const app = await getServer(auth);
const res = await supertest(app)
.get(`/`)
.set(HEADERS.AUTHORIZATION, buildToken(TOKEN_BEARER, token))
.expect(HTTP_STATUS.OK);
expect(res.body.user.name).toEqual('jwt_user');
});
});
});
});
});
});