Skip to content

Commit

Permalink
Personal access token middleware (#2069)
Browse files Browse the repository at this point in the history
* Middleware first version

* Middleware tests

* Add tests

* Finish middleware tests

* Add type for request

* Add flagresolver

* Fix snapshot

* Update flags and tests

* Put it back as default

* Update snapshot
  • Loading branch information
sjaanus committed Sep 28, 2022
1 parent 7fbe227 commit d79ace5
Show file tree
Hide file tree
Showing 14 changed files with 296 additions and 24 deletions.
2 changes: 2 additions & 0 deletions src/lib/__snapshots__/create-config.test.ts.snap
Expand Up @@ -71,6 +71,7 @@ exports[`should create default config 1`] = `
"batchMetrics": false,
"embedProxy": false,
"embedProxyFrontend": false,
"personalAccessTokens": false,
"publicSignup": false,
},
},
Expand All @@ -81,6 +82,7 @@ exports[`should create default config 1`] = `
"batchMetrics": false,
"embedProxy": false,
"embedProxyFrontend": false,
"personalAccessTokens": false,
"publicSignup": false,
},
"externalResolver": {
Expand Down
3 changes: 3 additions & 0 deletions src/lib/app.ts
Expand Up @@ -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,
Expand Down Expand Up @@ -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));
Expand Down
14 changes: 14 additions & 0 deletions src/lib/db/user-store.ts
Expand Up @@ -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<User> {
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;
Expand Down
12 changes: 11 additions & 1 deletion src/lib/middleware/api-token-middleware.test.ts
Expand Up @@ -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();
});
Expand Down
31 changes: 18 additions & 13 deletions 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');
Expand Down Expand Up @@ -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);
Expand Down
140 changes: 140 additions & 0 deletions 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);
});
35 changes: 35 additions & 0 deletions 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<IUnleashConfig, 'getLogger' | 'flagResolver'>,
{ 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;
4 changes: 4 additions & 0 deletions src/lib/services/user-service.ts
Expand Up @@ -422,6 +422,10 @@ class UserService {
);
return resetLink;
}

async getUserByPersonalAccessToken(secret: string): Promise<IUser> {
return this.store.getUserByPersonalAccessToken(secret);
}
}

module.exports = UserService;
Expand Down
4 changes: 4 additions & 0 deletions src/lib/types/experimental.ts
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/lib/types/stores/user-store.ts
Expand Up @@ -32,4 +32,5 @@ export interface IUserStore extends Store<IUser, number> {
incLoginAttempts(user: IUser): Promise<void>;
successfullyLogin(user: IUser): Promise<void>;
count(): Promise<number>;
getUserByPersonalAccessToken(secret: string): Promise<IUser>;
}
1 change: 1 addition & 0 deletions src/test/config/test-config.ts
Expand Up @@ -27,6 +27,7 @@ export function createTestConfig(config?: IUnleashOptions): IUnleashConfig {
embedProxy: true,
embedProxyFrontend: true,
batchMetrics: true,
personalAccessTokens: true,
},
},
};
Expand Down

0 comments on commit d79ace5

Please sign in to comment.