Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/app/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ module.exports = {
'src/server/util/**',
'src/server/app.ts',
'src/server/repl.ts',
'src/server/middlewares/**',
],
settings: {
// resolve path aliases by eslint-import-resolver-typescript
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { faker } from '@faker-js/faker';
import { SCOPE } from '@growi/core/dist/interfaces';
import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
import type { Response } from 'express';
import { mock } from 'vitest-mock-extended';

import { SCOPE } from '@growi/core/dist/interfaces';
import type Crowi from '~/server/crowi';
import type UserEvent from '~/server/events/user';
import { AccessToken } from '~/server/models/access-token';
Expand All @@ -13,12 +13,11 @@ import type { AccessTokenParserReq } from './interfaces';

vi.mock('@growi/core/dist/models/serializers', { spy: true });


describe('access-token-parser middleware for access token with scopes', () => {

// biome-ignore lint/suspicious/noImplicitAnyLet: ignore
let User;

beforeAll(async() => {
beforeAll(async () => {
const crowiMock = mock<Crowi>({
event: vi.fn().mockImplementation((eventName) => {
if (eventName === 'user') {
Expand All @@ -32,7 +31,7 @@ describe('access-token-parser middleware for access token with scopes', () => {
User = userModelFactory(crowiMock);
});

it('should call next if no access token is provided', async() => {
it('should call next if no access token is provided', async () => {
// arrange
const reqMock = mock<AccessTokenParserReq>({
user: undefined,
Expand All @@ -44,7 +43,7 @@ describe('access-token-parser middleware for access token with scopes', () => {
expect(reqMock.user).toBeUndefined();
});

it('should not authenticate with no scopes', async() => {
it('should not authenticate with no scopes', async () => {
// arrange
const reqMock = mock<AccessTokenParserReq>({
user: undefined,
Expand Down Expand Up @@ -76,7 +75,7 @@ describe('access-token-parser middleware for access token with scopes', () => {
expect(serializeUserSecurely).not.toHaveBeenCalled();
});

it('should authenticate with specific scope', async() => {
it('should authenticate with specific scope', async () => {
// arrange
const reqMock = mock<AccessTokenParserReq>({
user: undefined,
Expand All @@ -102,15 +101,18 @@ describe('access-token-parser middleware for access token with scopes', () => {

// act
reqMock.query.access_token = token;
await parserForAccessToken([SCOPE.READ.USER_SETTINGS.INFO])(reqMock, resMock);
await parserForAccessToken([SCOPE.READ.USER_SETTINGS.INFO])(
reqMock,
resMock,
);

// assert
expect(reqMock.user).toBeDefined();
expect(reqMock.user?._id).toStrictEqual(targetUser._id);
expect(serializeUserSecurely).toHaveBeenCalledOnce();
});

it('should reject with insufficient scopes', async() => {
it('should reject with insufficient scopes', async () => {
// arrange
const reqMock = mock<AccessTokenParserReq>({
user: undefined,
Expand All @@ -119,7 +121,6 @@ describe('access-token-parser middleware for access token with scopes', () => {

expect(reqMock.user).toBeUndefined();


// prepare a user
const targetUser = await User.create({
name: faker.person.fullName(),
Expand All @@ -137,14 +138,17 @@ describe('access-token-parser middleware for access token with scopes', () => {

// act - try to access with write:user:info scope
reqMock.query.access_token = token;
await parserForAccessToken([SCOPE.WRITE.USER_SETTINGS.INFO])(reqMock, resMock);
await parserForAccessToken([SCOPE.WRITE.USER_SETTINGS.INFO])(
reqMock,
resMock,
);

// // assert
expect(reqMock.user).toBeUndefined();
expect(serializeUserSecurely).not.toHaveBeenCalled();
});

it('should authenticate with write scope implying read scope', async() => {
it('should authenticate with write scope implying read scope', async () => {
// arrange
const reqMock = mock<AccessTokenParserReq>({
user: undefined,
Expand All @@ -170,15 +174,18 @@ describe('access-token-parser middleware for access token with scopes', () => {

// act - try to access with read:user:info scope
reqMock.query.access_token = token;
await parserForAccessToken([SCOPE.READ.USER_SETTINGS.INFO])(reqMock, resMock);
await parserForAccessToken([SCOPE.READ.USER_SETTINGS.INFO])(
reqMock,
resMock,
);

// assert
expect(reqMock.user).toBeDefined();
expect(reqMock.user?._id).toStrictEqual(targetUser._id);
expect(serializeUserSecurely).toHaveBeenCalledOnce();
});

it('should authenticate with wildcard scope', async() => {
it('should authenticate with wildcard scope', async () => {
// arrange
const reqMock = mock<AccessTokenParserReq>({
user: undefined,
Expand All @@ -202,12 +209,14 @@ describe('access-token-parser middleware for access token with scopes', () => {

// act - try to access with read:user:info scope
reqMock.query.access_token = token;
await parserForAccessToken([SCOPE.READ.USER_SETTINGS.INFO, SCOPE.READ.USER_SETTINGS.API.ACCESS_TOKEN])(reqMock, resMock);
await parserForAccessToken([
SCOPE.READ.USER_SETTINGS.INFO,
SCOPE.READ.USER_SETTINGS.API.ACCESS_TOKEN,
])(reqMock, resMock);

// assert
expect(reqMock.user).toBeDefined();
expect(reqMock.user?._id).toStrictEqual(targetUser._id);
expect(serializeUserSecurely).toHaveBeenCalledOnce();
});

});
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,18 @@ import loggerFactory from '~/utils/logger';
import { extractBearerToken } from './extract-bearer-token';
import type { AccessTokenParserReq } from './interfaces';

const logger = loggerFactory('growi:middleware:access-token-parser:access-token');
const logger = loggerFactory(
'growi:middleware:access-token-parser:access-token',
);

export const parserForAccessToken = (scopes: Scope[]) => {
return async(req: AccessTokenParserReq, res: Response): Promise<void> => {
return async (req: AccessTokenParserReq, res: Response): Promise<void> => {
// Extract token from Authorization header first
// It is more efficient to call it only once in "AccessTokenParser," which is the caller of the method
const bearerToken = extractBearerToken(req.headers.authorization);

const accessToken = bearerToken ?? req.query.access_token ?? req.body.access_token;
const accessToken =
bearerToken ?? req.query.access_token ?? req.body.access_token;
if (accessToken == null || typeof accessToken !== 'string') {
return;
}
Expand All @@ -33,14 +36,15 @@ export const parserForAccessToken = (scopes: Scope[]) => {
}

// check the user is valid
const { user: userByAccessToken }: {user: IUserHasId} = await userId.populate('user');
const { user: userByAccessToken }: { user: IUserHasId } =
await userId.populate('user');
if (userByAccessToken == null) {
logger.debug('The access token\'s associated user is invalid');
logger.debug("The access token's associated user is invalid");
return;
}

if (userByAccessToken.readOnly) {
logger.debug('The access token\'s associated user is read-only');
logger.debug("The access token's associated user is read-only");
return;
}

Expand All @@ -52,6 +56,5 @@ export const parserForAccessToken = (scopes: Scope[]) => {

logger.debug('Access token parsed.');
return;

};
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

import { faker } from '@faker-js/faker';
import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
import type { Response } from 'express';
Expand All @@ -10,15 +9,13 @@ import type UserEvent from '~/server/events/user';
import { parserForApiToken } from './api-token';
import type { AccessTokenParserReq } from './interfaces';


vi.mock('@growi/core/dist/models/serializers', { spy: true });


describe('access-token-parser middleware', () => {

// biome-ignore lint/suspicious/noImplicitAnyLet: ignore
let User;

beforeAll(async() => {
beforeAll(async () => {
const crowiMock = mock<Crowi>({
event: vi.fn().mockImplementation((eventName) => {
if (eventName === 'user') {
Expand All @@ -32,7 +29,7 @@ describe('access-token-parser middleware', () => {
User = userModelFactory(crowiMock);
});

it('should call next if no access token is provided', async() => {
it('should call next if no access token is provided', async () => {
// arrange
const reqMock = mock<AccessTokenParserReq>({
user: undefined,
Expand All @@ -49,7 +46,7 @@ describe('access-token-parser middleware', () => {
expect(serializeUserSecurely).not.toHaveBeenCalled();
});

it('should call next if the given access token is invalid', async() => {
it('should call next if the given access token is invalid', async () => {
// arrange
const reqMock = mock<AccessTokenParserReq>({
user: undefined,
Expand All @@ -67,7 +64,7 @@ describe('access-token-parser middleware', () => {
expect(serializeUserSecurely).not.toHaveBeenCalled();
});

it('should set req.user with a valid api token in query', async() => {
it('should set req.user with a valid api token in query', async () => {
// arrange
const reqMock = mock<AccessTokenParserReq>({
user: undefined,
Expand Down Expand Up @@ -96,7 +93,7 @@ describe('access-token-parser middleware', () => {
expect(serializeUserSecurely).toHaveBeenCalledOnce();
});

it('should set req.user with a valid api token in body', async() => {
it('should set req.user with a valid api token in body', async () => {
// arrange
const reqMock = mock<AccessTokenParserReq>({
user: undefined,
Expand Down Expand Up @@ -124,7 +121,7 @@ describe('access-token-parser middleware', () => {
expect(serializeUserSecurely).toHaveBeenCalledOnce();
});

it('should set req.user with a valid Bearer token in Authorization header', async() => {
it('should set req.user with a valid Bearer token in Authorization header', async () => {
// arrange
const reqMock = mock<AccessTokenParserReq>({
user: undefined,
Expand Down Expand Up @@ -155,7 +152,7 @@ describe('access-token-parser middleware', () => {
expect(serializeUserSecurely).toHaveBeenCalledOnce();
});

it('should ignore non-Bearer Authorization header', async() => {
it('should ignore non-Bearer Authorization header', async () => {
// arrange
const reqMock = mock<AccessTokenParserReq>({
user: undefined,
Expand All @@ -178,5 +175,4 @@ describe('access-token-parser middleware', () => {
expect(reqMock.user).toBeUndefined();
expect(serializeUserSecurely).not.toHaveBeenCalled();
});

});
13 changes: 9 additions & 4 deletions apps/app/src/server/middlewares/access-token-parser/api-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,27 @@ import type { AccessTokenParserReq } from './interfaces';

const logger = loggerFactory('growi:middleware:access-token-parser:api-token');


export const parserForApiToken = async(req: AccessTokenParserReq, res: Response): Promise<void> => {
export const parserForApiToken = async (
req: AccessTokenParserReq,
res: Response,
): Promise<void> => {
// Extract token from Authorization header first
// It is more efficient to call it only once in "AccessTokenParser," which is the caller of the method
const bearerToken = extractBearerToken(req.headers.authorization);

// Try all possible token sources in order of priority
const accessToken = bearerToken ?? req.query.access_token ?? req.body.access_token;
const accessToken =
bearerToken ?? req.query.access_token ?? req.body.access_token;

if (accessToken == null || typeof accessToken !== 'string') {
return;
}

logger.debug('accessToken is', accessToken);

const User = mongoose.model<HydratedDocument<IUser>, { findUserByApiToken }>('User');
const User = mongoose.model<HydratedDocument<IUser>, { findUserByApiToken }>(
'User',
);
const userByApiToken: IUserHasId = await User.findUserByApiToken(accessToken);

if (userByApiToken == null) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export const extractBearerToken = (authHeader: string | undefined): string | null => {
export const extractBearerToken = (
authHeader: string | undefined,
): string | null => {
if (authHeader == null) {
return null;
}
Expand Down
12 changes: 9 additions & 3 deletions apps/app/src/server/middlewares/access-token-parser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,17 @@ import type { AccessTokenParserReq } from './interfaces';

const logger = loggerFactory('growi:middleware:access-token-parser');

export type AccessTokenParser = (scopes?: Scope[], opts?: {acceptLegacy: boolean})
=> (req: AccessTokenParserReq, res: Response, next: NextFunction) => Promise<void>
export type AccessTokenParser = (
scopes?: Scope[],
opts?: { acceptLegacy: boolean },
) => (
req: AccessTokenParserReq,
res: Response,
next: NextFunction,
) => Promise<void>;

export const accessTokenParser: AccessTokenParser = (scopes, opts) => {
return async(req, res, next): Promise<void> => {
return async (req, res, next): Promise<void> => {
if (scopes == null || scopes.length === 0) {
logger.warn('scopes is empty');
return next();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import type { IUserSerializedSecurely } from '@growi/core/dist/models/serializer
import type { Request } from 'express';

type ReqQuery = {
access_token?: string,
}
access_token?: string;
};
type ReqBody = {
access_token?: string,
}
access_token?: string;
};

export interface AccessTokenParserReq extends Request<undefined, undefined, ReqBody, ReqQuery> {
user?: IUserSerializedSecurely<IUserHasId>,
export interface AccessTokenParserReq
extends Request<undefined, undefined, ReqBody, ReqQuery> {
user?: IUserSerializedSecurely<IUserHasId>;
}
Loading
Loading