diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap index 883d7b1cc2..86ade12382 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -71,6 +71,7 @@ exports[`should create default config 1`] = ` "batchMetrics": false, "embedProxy": false, "embedProxyFrontend": false, + "personalAccessTokens": false, "publicSignup": false, }, }, @@ -81,6 +82,7 @@ exports[`should create default config 1`] = ` "batchMetrics": false, "embedProxy": false, "embedProxyFrontend": false, + "personalAccessTokens": false, "publicSignup": false, }, "externalResolver": { diff --git a/src/lib/app.ts b/src/lib/app.ts index 1d54d39334..a92ef7eaef 100644 --- a/src/lib/app.ts +++ b/src/lib/app.ts @@ -23,6 +23,7 @@ import secureHeaders from './middleware/secure-headers'; import { loadIndexHTML } from './util/load-index-html'; import { findPublicFolder } from './util/findPublicFolder'; import { conditionalMiddleware } from './middleware/conditional-middleware'; +import patMiddleware from './middleware/pat-middleware'; export default async function getApp( config: IUnleashConfig, @@ -81,6 +82,8 @@ export default async function getApp( ), ); + app.use(baseUriPath, patMiddleware(config, services)); + switch (config.authentication.type) { case IAuthType.OPEN_SOURCE: { app.use(baseUriPath, apiTokenMiddleware(config, services)); diff --git a/src/lib/db/user-store.ts b/src/lib/db/user-store.ts index b97b917e22..bfe60c4662 100644 --- a/src/lib/db/user-store.ts +++ b/src/lib/db/user-store.ts @@ -204,6 +204,20 @@ class UserStore implements IUserStore { const row = await this.db(TABLE).where({ id }).first(); return rowToUser(row); } + + async getUserByPersonalAccessToken(secret: string): Promise { + const row = await this.db + .select(USER_COLUMNS.map((column) => `${TABLE}.${column}`)) + .from(TABLE) + .leftJoin( + 'personal_access_tokens', + 'personal_access_tokens.user_id', + `${TABLE}.id`, + ) + .where('secret', secret) + .first(); + return rowToUser(row); + } } module.exports = UserStore; diff --git a/src/lib/middleware/api-token-middleware.test.ts b/src/lib/middleware/api-token-middleware.test.ts index 1be03a31e6..79ec88d729 100644 --- a/src/lib/middleware/api-token-middleware.test.ts +++ b/src/lib/middleware/api-token-middleware.test.ts @@ -159,8 +159,18 @@ test('should not add user if disabled', async () => { user: undefined, }; - await func(req, undefined, cb); + const send = jest.fn(); + const res = { + status: () => { + return { + send: send, + }; + }, + }; + await func(req, res, cb); + + expect(send).not.toHaveBeenCalled(); expect(cb).toHaveBeenCalled(); expect(req.user).toBeFalsy(); }); diff --git a/src/lib/middleware/api-token-middleware.ts b/src/lib/middleware/api-token-middleware.ts index 33bf563ed3..9b000019c4 100644 --- a/src/lib/middleware/api-token-middleware.ts +++ b/src/lib/middleware/api-token-middleware.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import { ApiTokenType } from '../types/models/api-token'; import { IUnleashConfig } from '../types/option'; +import { IAuthRequest } from '../routes/unleash-types'; const isClientApi = ({ path }) => { return path && path.startsWith('/api/client'); @@ -39,27 +40,31 @@ const apiAccessMiddleware = ( return (req, res, next) => next(); } - return (req, res, next) => { + return (req: IAuthRequest, res, next) => { if (req.user) { return next(); } try { const apiToken = req.header('authorization'); - const apiUser = apiTokenService.getUserForToken(apiToken); - const { CLIENT, FRONTEND } = ApiTokenType; + if (!apiToken?.startsWith('user:')) { + const apiUser = apiTokenService.getUserForToken(apiToken); + const { CLIENT, FRONTEND } = ApiTokenType; - if (apiUser) { - if ( - (apiUser.type === CLIENT && !isClientApi(req)) || - (apiUser.type === FRONTEND && !isProxyApi(req)) || - (apiUser.type === FRONTEND && - !flagResolver.isEnabled('embedProxy')) - ) { - res.status(403).send({ message: TOKEN_TYPE_ERROR_MESSAGE }); - return; + if (apiUser) { + if ( + (apiUser.type === CLIENT && !isClientApi(req)) || + (apiUser.type === FRONTEND && !isProxyApi(req)) || + (apiUser.type === FRONTEND && + !flagResolver.isEnabled('embedProxy')) + ) { + res.status(403).send({ + message: TOKEN_TYPE_ERROR_MESSAGE, + }); + return; + } + req.user = apiUser; } - req.user = apiUser; } } catch (error) { logger.error(error); diff --git a/src/lib/middleware/pat-middleware.test.ts b/src/lib/middleware/pat-middleware.test.ts new file mode 100644 index 0000000000..4dc26a4a0f --- /dev/null +++ b/src/lib/middleware/pat-middleware.test.ts @@ -0,0 +1,140 @@ +import getLogger from '../../test/fixtures/no-logger'; +import { createTestConfig } from '../../test/config/test-config'; +import patMiddleware from './pat-middleware'; +import User from '../types/user'; + +let config: any; + +beforeEach(() => { + config = { + getLogger, + flagResolver: { + isEnabled: jest.fn().mockReturnValue(true), + }, + }; +}); + +test('should not set user if unknown token', async () => { + const userService = { + getUserByPersonalAccessToken: jest.fn(), + }; + + const func = patMiddleware(config, { userService }); + + const cb = jest.fn(); + + const req = { + header: jest.fn().mockReturnValue('user:some-token'), + user: undefined, + }; + + await func(req, undefined, cb); + + expect(cb).toHaveBeenCalled(); + expect(req.header).toHaveBeenCalled(); + expect(req.user).toBeFalsy(); +}); + +test('should not set user if token wrong format', async () => { + const userService = { + getUserByPersonalAccessToken: jest.fn(), + }; + + const func = patMiddleware(config, { userService }); + + const cb = jest.fn(); + + const req = { + header: jest.fn().mockReturnValue('token-not-starting-with-user'), + user: undefined, + }; + + await func(req, undefined, cb); + + expect(userService.getUserByPersonalAccessToken).not.toHaveBeenCalled(); + expect(cb).toHaveBeenCalled(); + expect(req.header).toHaveBeenCalled(); + expect(req.user).toBeFalsy(); +}); + +test('should add user if known token', async () => { + const apiUser = new User({ + id: 44, + username: 'my-user', + }); + const userService = { + getUserByPersonalAccessToken: jest.fn().mockReturnValue(apiUser), + }; + + const func = patMiddleware(config, { userService }); + + const cb = jest.fn(); + + const req = { + header: jest.fn().mockReturnValue('user:some-known-token'), + user: undefined, + path: '/api/client', + }; + + await func(req, undefined, cb); + + expect(cb).toHaveBeenCalled(); + expect(req.header).toHaveBeenCalled(); + expect(req.user).toBe(apiUser); +}); + +test('should not add user if disabled', async () => { + const apiUser = new User({ + id: 44, + username: 'my-user', + }); + const userService = { + getUserByPersonalAccessToken: jest.fn().mockReturnValue(apiUser), + }; + + const disabledConfig = createTestConfig({ + getLogger, + experimental: { + flags: { + personalAccessTokens: false, + }, + }, + }); + + const func = patMiddleware(disabledConfig, { userService }); + + const cb = jest.fn(); + + const req = { + header: jest.fn().mockReturnValue('user:some-known-token'), + user: undefined, + }; + + await func(req, undefined, cb); + + expect(cb).toHaveBeenCalled(); + expect(req.user).toBeFalsy(); +}); + +test('should call next if userService throws exception', async () => { + getLogger.setMuteError(true); + const userService = { + getUserByPersonalAccessToken: () => { + throw new Error('Error occurred'); + }, + }; + + const func = patMiddleware(config, { userService }); + + const cb = jest.fn(); + + const req = { + header: jest.fn().mockReturnValue('user:some-token'), + user: undefined, + }; + + await func(req, undefined, cb); + + expect(cb).toHaveBeenCalled(); + getLogger.setMuteError(false); +}); diff --git a/src/lib/middleware/pat-middleware.ts b/src/lib/middleware/pat-middleware.ts new file mode 100644 index 0000000000..84277ddbc9 --- /dev/null +++ b/src/lib/middleware/pat-middleware.ts @@ -0,0 +1,35 @@ +import { IUnleashConfig } from '../types'; +import { IAuthRequest } from '../routes/unleash-types'; + +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +const patMiddleware = ( + { + getLogger, + flagResolver, + }: Pick, + { userService }: any, +): any => { + const logger = getLogger('/middleware/pat-middleware.ts'); + logger.debug('Enabling PAT middleware'); + + if (!flagResolver.isEnabled('personalAccessTokens')) { + return (req, res, next) => next(); + } + + return async (req: IAuthRequest, res, next) => { + try { + const apiToken = req.header('authorization'); + if (apiToken?.startsWith('user:')) { + const user = await userService.getUserByPersonalAccessToken( + apiToken, + ); + req.user = user; + } + } catch (error) { + logger.error(error); + } + next(); + }; +}; + +export default patMiddleware; diff --git a/src/lib/services/user-service.ts b/src/lib/services/user-service.ts index 4be3795533..937e37fdaa 100644 --- a/src/lib/services/user-service.ts +++ b/src/lib/services/user-service.ts @@ -422,6 +422,10 @@ class UserService { ); return resetLink; } + + async getUserByPersonalAccessToken(secret: string): Promise { + return this.store.getUserByPersonalAccessToken(secret); + } } module.exports = UserService; diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index 037aa9fdd9..697a19015e 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -10,6 +10,10 @@ export const defaultExperimentalOptions = { process.env.UNLEASH_EXPERIMENTAL_EMBED_PROXY, false, ), + personalAccessTokens: parseEnvVarBoolean( + process.env.UNLEASH_EXPERIMENTAL_PERSONAL_ACCESS_TOKENS, + false, + ), embedProxyFrontend: parseEnvVarBoolean( process.env.UNLEASH_EXPERIMENTAL_EMBED_PROXY_FRONTEND, false, diff --git a/src/lib/types/stores/user-store.ts b/src/lib/types/stores/user-store.ts index 32b3beaa83..6441a8d147 100644 --- a/src/lib/types/stores/user-store.ts +++ b/src/lib/types/stores/user-store.ts @@ -32,4 +32,5 @@ export interface IUserStore extends Store { incLoginAttempts(user: IUser): Promise; successfullyLogin(user: IUser): Promise; count(): Promise; + getUserByPersonalAccessToken(secret: string): Promise; } diff --git a/src/test/config/test-config.ts b/src/test/config/test-config.ts index 39d9636278..d11ee942a0 100644 --- a/src/test/config/test-config.ts +++ b/src/test/config/test-config.ts @@ -27,6 +27,7 @@ export function createTestConfig(config?: IUnleashOptions): IUnleashConfig { embedProxy: true, embedProxyFrontend: true, batchMetrics: true, + personalAccessTokens: true, }, }, }; diff --git a/src/test/e2e/api/user/pat.e2e.test.ts b/src/test/e2e/api/admin/user/pat.e2e.test.ts similarity index 59% rename from src/test/e2e/api/user/pat.e2e.test.ts rename to src/test/e2e/api/admin/user/pat.e2e.test.ts index faa362d663..9527501fcf 100644 --- a/src/test/e2e/api/user/pat.e2e.test.ts +++ b/src/test/e2e/api/admin/user/pat.e2e.test.ts @@ -1,17 +1,20 @@ -import { IUnleashTest, setupAppWithAuth } from '../../helpers/test-helper'; -import dbInit, { ITestDb } from '../../helpers/database-init'; -import getLogger from '../../../fixtures/no-logger'; -import { IPat } from '../../../../lib/types/models/pat'; +import { IUnleashTest, setupAppWithAuth } from '../../../helpers/test-helper'; +import dbInit, { ITestDb } from '../../../helpers/database-init'; +import getLogger from '../../../../fixtures/no-logger'; +import { IPat } from '../../../../../lib/types/models/pat'; let app: IUnleashTest; let db: ITestDb; let tomorrow = new Date(); +let firstSecret; tomorrow.setDate(tomorrow.getDate() + 1); beforeAll(async () => { db = await dbInit('user_pat', getLogger); - app = await setupAppWithAuth(db.stores); + app = await setupAppWithAuth(db.stores, { + experimental: { flags: { personalAccessTokens: true } }, + }); await app.request .post(`/auth/demo/login`) @@ -40,12 +43,20 @@ test('should create a PAT', async () => { expect(new Date(body.expiresAt)).toEqual(tomorrow); expect(body.description).toEqual(description); + firstSecret = body.secret; + + const response = await request + .get('/api/admin/user/tokens') + .expect('Content-Type', /json/) + .expect(200); + + expect(response.body.pats).toHaveLength(1); }); test('should delete the PAT', async () => { const { request } = app; - const response = await request + const { body } = await request .post('/api/admin/user/tokens') .send({ expiresAt: tomorrow, @@ -53,7 +64,7 @@ test('should delete the PAT', async () => { .set('Content-Type', 'application/json') .expect(201); - const createdSecret = response.body.secret; + const createdSecret = body.secret; await request.delete(`/api/admin/user/tokens/${createdSecret}`).expect(200); }); @@ -108,3 +119,40 @@ test('should fail creation of PAT with passed expiry', async () => { .set('Content-Type', 'application/json') .expect(500); }); + +test('should get user id 1', async () => { + await app.request.get('/logout').expect(302); + await app.request + .get('/api/admin/user') + .set('Authorization', firstSecret) + .expect(200) + .expect((res) => { + expect(res.body.user.email).toBe('user@getunleash.io'); + expect(res.body.user.id).toBe(1); + }); +}); + +test('should be able to get projects', async () => { + await app.request + .get('/api/admin/projects') + .set('Authorization', firstSecret) + .expect(200); +}); + +test('should be able to create a toggle', async () => { + await app.request + .post('/api/admin/projects/default/features') + .set('Authorization', firstSecret) + .send({ + name: 'test-toggle', + type: 'release', + }) + .expect(201); +}); + +test('should not get user with invalid token', async () => { + await app.request + .get('/api/admin/user') + .set('Authorization', 'randomtoken') + .expect(401); +}); diff --git a/src/test/e2e/api/admin/user.test.ts b/src/test/e2e/api/admin/user/user.test.ts similarity index 80% rename from src/test/e2e/api/admin/user.test.ts rename to src/test/e2e/api/admin/user/user.test.ts index 4afd602a44..401af655bb 100644 --- a/src/test/e2e/api/admin/user.test.ts +++ b/src/test/e2e/api/admin/user/user.test.ts @@ -1,6 +1,6 @@ -import dbInit from '../../helpers/database-init'; -import getLogger from '../../../fixtures/no-logger'; -import { setupAppWithAuth } from '../../helpers/test-helper'; +import dbInit from '../../../helpers/database-init'; +import getLogger from '../../../../fixtures/no-logger'; +import { setupAppWithAuth } from '../../../helpers/test-helper'; let app; let db; diff --git a/src/test/fixtures/fake-user-store.ts b/src/test/fixtures/fake-user-store.ts index 554f5e8d59..b6f3fc5de2 100644 --- a/src/test/fixtures/fake-user-store.ts +++ b/src/test/fixtures/fake-user-store.ts @@ -137,6 +137,11 @@ class UserStoreMock implements IUserStore { }); return Promise.resolve(undefined); } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getUserByPersonalAccessToken(secret: string): Promise { + return Promise.resolve(undefined); + } } module.exports = UserStoreMock;