Просмотр исходного кода

implement access token and API token parsers in middleware

reiji-h 1 год назад
Родитель
Сommit
353fb7ea06

+ 16 - 188
apps/app/src/server/middlewares/access-token-parser/access-token-parser.integ.ts

@@ -45,7 +45,7 @@ describe('access-token-parser middleware', () => {
     expect(reqMock.user).toBeUndefined();
     expect(reqMock.user).toBeUndefined();
 
 
     // act
     // act
-    await accessTokenParser()(reqMock, resMock, nextMock);
+    await accessTokenParser([SCOPE.READ.USER.ALL])(reqMock, resMock, nextMock);
 
 
     // assert
     // assert
     expect(reqMock.user).toBeUndefined();
     expect(reqMock.user).toBeUndefined();
@@ -53,108 +53,7 @@ describe('access-token-parser middleware', () => {
     expect(nextMock).toHaveBeenCalled();
     expect(nextMock).toHaveBeenCalled();
   });
   });
 
 
-  it('should call next if the given access token is invalid', async() => {
-    // arrange
-    const reqMock = mock<AccessTokenParserReq>({
-      user: undefined,
-    });
-    const resMock = mock<Response>();
-    const nextMock = vi.fn();
-
-    expect(reqMock.user).toBeUndefined();
-
-    // act
-    reqMock.query.access_token = 'invalidToken';
-    await accessTokenParser()(reqMock, resMock, nextMock);
-
-    // assert
-    expect(reqMock.user).toBeUndefined();
-    expect(serializeUserSecurely).not.toHaveBeenCalled();
-    expect(nextMock).toHaveBeenCalled();
-  });
-
-  it('should set req.user with a valid api token in query', async() => {
-    // arrange
-    const reqMock = mock<AccessTokenParserReq>({
-      user: undefined,
-    });
-    const resMock = mock<Response>();
-    const nextMock = vi.fn();
-
-    expect(reqMock.user).toBeUndefined();
-
-    // prepare a user with an access token
-    const targetUser = await User.create({
-      name: faker.person.fullName(),
-      username: faker.string.uuid(),
-      password: faker.internet.password(),
-      lang: 'en_US',
-      apiToken: faker.internet.password(),
-    });
-
-    // act
-    reqMock.query.access_token = targetUser.apiToken;
-    await accessTokenParser()(reqMock, resMock, nextMock);
-
-    // assert
-    expect(reqMock.user).toBeDefined();
-    expect(reqMock.user?._id).toStrictEqual(targetUser._id);
-    expect(serializeUserSecurely).toHaveBeenCalledOnce();
-    expect(nextMock).toHaveBeenCalled();
-  });
-
-  it('should set req.user with a valid api token in body', async() => {
-    // arrange
-    const reqMock = mock<AccessTokenParserReq>({
-      user: undefined,
-    });
-    const resMock = mock<Response>();
-    const nextMock = vi.fn();
-
-    expect(reqMock.user).toBeUndefined();
-
-    // prepare a user with an access token
-    const targetUser = await User.create({
-      name: faker.person.fullName(),
-      username: faker.string.uuid(),
-      password: faker.internet.password(),
-      lang: 'en_US',
-      apiToken: faker.internet.password(),
-    });
-
-    // act
-    reqMock.body.access_token = targetUser.apiToken;
-    await accessTokenParser()(reqMock, resMock, nextMock);
-
-    // assert
-    expect(reqMock.user).toBeDefined();
-    expect(reqMock.user?._id).toStrictEqual(targetUser._id);
-    expect(serializeUserSecurely).toHaveBeenCalledOnce();
-    expect(nextMock).toHaveBeenCalled();
-  });
-
-});
-
-
-describe('access-token-parser middleware for access token with scopes', () => {
-
-  let User;
-
-  beforeAll(async() => {
-    const crowiMock = mock<Crowi>({
-      event: vi.fn().mockImplementation((eventName) => {
-        if (eventName === 'user') {
-          return mock<UserEvent>({
-            on: vi.fn(),
-          });
-        }
-      }),
-    });
-    const userModelFactory = (await import('../../models/user')).default;
-    User = userModelFactory(crowiMock);
-  });
-
-  it('should authenticate with no scopes', async() => {
+  it('should not authenticate with no scopes', async() => {
     // arrange
     // arrange
     const reqMock = mock<AccessTokenParserReq>({
     const reqMock = mock<AccessTokenParserReq>({
       user: undefined,
       user: undefined,
@@ -172,24 +71,24 @@ describe('access-token-parser middleware for access token with scopes', () => {
       lang: 'en_US',
       lang: 'en_US',
     });
     });
 
 
-    // generate token with read:user:info scope
+    // generate token with write:user:info scope
     const { token } = await AccessToken.generateToken(
     const { token } = await AccessToken.generateToken(
       targetUser._id,
       targetUser._id,
       new Date(Date.now() + 1000 * 60 * 60 * 24),
       new Date(Date.now() + 1000 * 60 * 60 * 24),
+      [SCOPE.WRITE.USER.INFO],
     );
     );
 
 
-    // act
+    // act - try to access with read:user:info scope
     reqMock.query.access_token = token;
     reqMock.query.access_token = token;
     await accessTokenParser()(reqMock, resMock, nextMock);
     await accessTokenParser()(reqMock, resMock, nextMock);
 
 
     // assert
     // assert
-    expect(reqMock.user).toBeDefined();
-    expect(reqMock.user?._id).toStrictEqual(targetUser._id);
-    expect(serializeUserSecurely).toHaveBeenCalledOnce();
+    expect(reqMock.user).toBeUndefined();
+    expect(serializeUserSecurely).not.toHaveBeenCalledOnce();
     expect(nextMock).toHaveBeenCalled();
     expect(nextMock).toHaveBeenCalled();
   });
   });
 
 
-  it('should authenticate with specific scope', async() => {
+  it('should authenticate from api-token', async() => {
     // arrange
     // arrange
     const reqMock = mock<AccessTokenParserReq>({
     const reqMock = mock<AccessTokenParserReq>({
       user: undefined,
       user: undefined,
@@ -205,18 +104,18 @@ describe('access-token-parser middleware for access token with scopes', () => {
       username: faker.string.uuid(),
       username: faker.string.uuid(),
       password: faker.internet.password(),
       password: faker.internet.password(),
       lang: 'en_US',
       lang: 'en_US',
+      apiToken: faker.internet.password(),
     });
     });
 
 
-    // generate token with read:user:info scope
-    const { token } = await AccessToken.generateToken(
+    // generate token with write:user:info scope
+    await AccessToken.generateToken(
       targetUser._id,
       targetUser._id,
       new Date(Date.now() + 1000 * 60 * 60 * 24),
       new Date(Date.now() + 1000 * 60 * 60 * 24),
       [SCOPE.READ.USER.INFO],
       [SCOPE.READ.USER.INFO],
     );
     );
 
 
-    // act
-    reqMock.query.access_token = token;
-    await accessTokenParser()(reqMock, resMock, nextMock);
+    reqMock.query.access_token = targetUser.apiToken;
+    await accessTokenParser([SCOPE.WRITE.USER.INFO])(reqMock, resMock, nextMock);
 
 
     // assert
     // assert
     expect(reqMock.user).toBeDefined();
     expect(reqMock.user).toBeDefined();
@@ -225,43 +124,7 @@ describe('access-token-parser middleware for access token with scopes', () => {
     expect(nextMock).toHaveBeenCalled();
     expect(nextMock).toHaveBeenCalled();
   });
   });
 
 
-  it('should reject with insufficient scopes', async() => {
-    // arrange
-    const reqMock = mock<AccessTokenParserReq>({
-      user: undefined,
-    });
-    const resMock = mock<Response>();
-    const nextMock = vi.fn();
-
-    expect(reqMock.user).toBeUndefined();
-
-
-    // prepare a user
-    const targetUser = await User.create({
-      name: faker.person.fullName(),
-      username: faker.string.uuid(),
-      password: faker.internet.password(),
-      lang: 'en_US',
-    });
-
-    // generate token with read:user:info scope
-    const { token } = await AccessToken.generateToken(
-      targetUser._id,
-      new Date(Date.now() + 1000 * 60 * 60 * 24),
-      [SCOPE.READ.USER.INFO],
-    );
-
-    // act - try to access with write:user:info scope
-    reqMock.query.access_token = token;
-    await accessTokenParser([SCOPE.WRITE.USER.INFO])(reqMock, resMock, nextMock);
-
-    // // assert
-    expect(reqMock.user).toBeUndefined();
-    expect(serializeUserSecurely).not.toHaveBeenCalled();
-    expect(nextMock).toHaveBeenCalled();
-  });
-
-  it('should authenticate with write scope implying read scope', async() => {
+  it('should authenticate from access-token', async() => {
     // arrange
     // arrange
     const reqMock = mock<AccessTokenParserReq>({
     const reqMock = mock<AccessTokenParserReq>({
       user: undefined,
       user: undefined,
@@ -277,16 +140,16 @@ describe('access-token-parser middleware for access token with scopes', () => {
       username: faker.string.uuid(),
       username: faker.string.uuid(),
       password: faker.internet.password(),
       password: faker.internet.password(),
       lang: 'en_US',
       lang: 'en_US',
+      apiToken: faker.internet.password(),
     });
     });
 
 
     // generate token with write:user:info scope
     // generate token with write:user:info scope
     const { token } = await AccessToken.generateToken(
     const { token } = await AccessToken.generateToken(
       targetUser._id,
       targetUser._id,
       new Date(Date.now() + 1000 * 60 * 60 * 24),
       new Date(Date.now() + 1000 * 60 * 60 * 24),
-      [SCOPE.WRITE.USER.INFO],
+      [SCOPE.READ.USER.INFO],
     );
     );
 
 
-    // act - try to access with read:user:info scope
     reqMock.query.access_token = token;
     reqMock.query.access_token = token;
     await accessTokenParser([SCOPE.READ.USER.INFO])(reqMock, resMock, nextMock);
     await accessTokenParser([SCOPE.READ.USER.INFO])(reqMock, resMock, nextMock);
 
 
@@ -296,39 +159,4 @@ describe('access-token-parser middleware for access token with scopes', () => {
     expect(serializeUserSecurely).toHaveBeenCalledOnce();
     expect(serializeUserSecurely).toHaveBeenCalledOnce();
     expect(nextMock).toHaveBeenCalled();
     expect(nextMock).toHaveBeenCalled();
   });
   });
-
-  it('should authenticate with wildcard scope', async() => {
-    // arrange
-    const reqMock = mock<AccessTokenParserReq>({
-      user: undefined,
-    });
-    const resMock = mock<Response>();
-    const nextMock = vi.fn();
-
-    // prepare a user
-    const targetUser = await User.create({
-      name: faker.person.fullName(),
-      username: faker.string.uuid(),
-      password: faker.internet.password(),
-      lang: 'en_US',
-    });
-
-    // generate token with read:user:* scope
-    const { token } = await AccessToken.generateToken(
-      targetUser._id,
-      new Date(Date.now() + 1000 * 60 * 60 * 24),
-      [SCOPE.READ.USER.ALL],
-    );
-
-    // act - try to access with read:user:info scope
-    reqMock.query.access_token = token;
-    await accessTokenParser([SCOPE.READ.USER.INFO, SCOPE.READ.USER.API.ACCESS_TOKEN])(reqMock, resMock, nextMock);
-
-    // assert
-    expect(reqMock.user).toBeDefined();
-    expect(reqMock.user?._id).toStrictEqual(targetUser._id);
-    expect(serializeUserSecurely).toHaveBeenCalledOnce();
-    expect(nextMock).toHaveBeenCalled();
-  });
-
 });
 });

+ 225 - 0
apps/app/src/server/middlewares/access-token-parser/access-token.integ.ts

@@ -0,0 +1,225 @@
+import { faker } from '@faker-js/faker';
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
+import type { Response } from 'express';
+import { mock } from 'vitest-mock-extended';
+
+import { SCOPE } from '~/interfaces/scope';
+import type Crowi from '~/server/crowi';
+import type UserEvent from '~/server/events/user';
+import { AccessToken } from '~/server/models/access-token';
+
+import { parserForAccessToken } from './access-token';
+import type { AccessTokenParserReq } from './interfaces';
+
+vi.mock('@growi/core/dist/models/serializers', { spy: true });
+
+
+describe('access-token-parser middleware for access token with scopes', () => {
+
+  let User;
+
+  beforeAll(async() => {
+    const crowiMock = mock<Crowi>({
+      event: vi.fn().mockImplementation((eventName) => {
+        if (eventName === 'user') {
+          return mock<UserEvent>({
+            on: vi.fn(),
+          });
+        }
+      }),
+    });
+    const userModelFactory = (await import('../../models/user')).default;
+    User = userModelFactory(crowiMock);
+  });
+
+  it('should call next if no access token is provided', async() => {
+    // arrange
+    const reqMock = mock<AccessTokenParserReq>({
+      user: undefined,
+    });
+    const resMock = mock<Response>();
+    const nextMock = vi.fn();
+
+    await parserForAccessToken([])(reqMock, resMock, nextMock);
+
+    expect(reqMock.user).toBeUndefined();
+    expect(nextMock).toHaveBeenCalled();
+  });
+
+  it('should not authenticate with no scopes', async() => {
+    // arrange
+    const reqMock = mock<AccessTokenParserReq>({
+      user: undefined,
+    });
+    const resMock = mock<Response>();
+    const nextMock = vi.fn();
+
+    expect(reqMock.user).toBeUndefined();
+
+    // prepare a user
+    const targetUser = await User.create({
+      name: faker.person.fullName(),
+      username: faker.string.uuid(),
+      password: faker.internet.password(),
+      lang: 'en_US',
+    });
+
+    // generate token with read:user:info scope
+    const { token } = await AccessToken.generateToken(
+      targetUser._id,
+      new Date(Date.now() + 1000 * 60 * 60 * 24),
+    );
+
+    // act
+    reqMock.query.access_token = token;
+    await parserForAccessToken([])(reqMock, resMock, nextMock);
+
+    // assert
+    expect(reqMock.user).toBeUndefined();
+    expect(serializeUserSecurely).not.toHaveBeenCalled();
+    expect(nextMock).not.toHaveBeenCalled();
+  });
+
+  it('should authenticate with specific scope', async() => {
+    // arrange
+    const reqMock = mock<AccessTokenParserReq>({
+      user: undefined,
+    });
+    const resMock = mock<Response>();
+    const nextMock = vi.fn();
+
+    expect(reqMock.user).toBeUndefined();
+
+    // prepare a user
+    const targetUser = await User.create({
+      name: faker.person.fullName(),
+      username: faker.string.uuid(),
+      password: faker.internet.password(),
+      lang: 'en_US',
+    });
+
+    // generate token with read:user:info scope
+    const { token } = await AccessToken.generateToken(
+      targetUser._id,
+      new Date(Date.now() + 1000 * 60 * 60 * 24),
+      [SCOPE.READ.USER.INFO],
+    );
+
+    // act
+    reqMock.query.access_token = token;
+    await parserForAccessToken([SCOPE.READ.USER.INFO])(reqMock, resMock, nextMock);
+
+    // assert
+    expect(reqMock.user).toBeDefined();
+    expect(reqMock.user?._id).toStrictEqual(targetUser._id);
+    expect(serializeUserSecurely).toHaveBeenCalledOnce();
+    expect(nextMock).toHaveBeenCalled();
+  });
+
+  it('should reject with insufficient scopes', async() => {
+    // arrange
+    const reqMock = mock<AccessTokenParserReq>({
+      user: undefined,
+    });
+    const resMock = mock<Response>();
+    const nextMock = vi.fn();
+
+    expect(reqMock.user).toBeUndefined();
+
+
+    // prepare a user
+    const targetUser = await User.create({
+      name: faker.person.fullName(),
+      username: faker.string.uuid(),
+      password: faker.internet.password(),
+      lang: 'en_US',
+    });
+
+    // generate token with read:user:info scope
+    const { token } = await AccessToken.generateToken(
+      targetUser._id,
+      new Date(Date.now() + 1000 * 60 * 60 * 24),
+      [SCOPE.READ.USER.INFO],
+    );
+
+    // act - try to access with write:user:info scope
+    reqMock.query.access_token = token;
+    await parserForAccessToken([SCOPE.WRITE.USER.INFO])(reqMock, resMock, nextMock);
+
+    // // assert
+    expect(reqMock.user).toBeUndefined();
+    expect(serializeUserSecurely).not.toHaveBeenCalled();
+    expect(nextMock).toHaveBeenCalled();
+  });
+
+  it('should authenticate with write scope implying read scope', async() => {
+    // arrange
+    const reqMock = mock<AccessTokenParserReq>({
+      user: undefined,
+    });
+    const resMock = mock<Response>();
+    const nextMock = vi.fn();
+
+    expect(reqMock.user).toBeUndefined();
+
+    // prepare a user
+    const targetUser = await User.create({
+      name: faker.person.fullName(),
+      username: faker.string.uuid(),
+      password: faker.internet.password(),
+      lang: 'en_US',
+    });
+
+    // generate token with write:user:info scope
+    const { token } = await AccessToken.generateToken(
+      targetUser._id,
+      new Date(Date.now() + 1000 * 60 * 60 * 24),
+      [SCOPE.WRITE.USER.INFO],
+    );
+
+    // act - try to access with read:user:info scope
+    reqMock.query.access_token = token;
+    await parserForAccessToken([SCOPE.READ.USER.INFO])(reqMock, resMock, nextMock);
+
+    // assert
+    expect(reqMock.user).toBeDefined();
+    expect(reqMock.user?._id).toStrictEqual(targetUser._id);
+    expect(serializeUserSecurely).toHaveBeenCalledOnce();
+    expect(nextMock).toHaveBeenCalled();
+  });
+
+  it('should authenticate with wildcard scope', async() => {
+    // arrange
+    const reqMock = mock<AccessTokenParserReq>({
+      user: undefined,
+    });
+    const resMock = mock<Response>();
+    const nextMock = vi.fn();
+
+    // prepare a user
+    const targetUser = await User.create({
+      name: faker.person.fullName(),
+      username: faker.string.uuid(),
+      password: faker.internet.password(),
+      lang: 'en_US',
+    });
+
+    // generate token with read:user:* scope
+    const { token } = await AccessToken.generateToken(
+      targetUser._id,
+      new Date(Date.now() + 1000 * 60 * 60 * 24),
+      [SCOPE.READ.USER.ALL],
+    );
+
+    // act - try to access with read:user:info scope
+    reqMock.query.access_token = token;
+    await parserForAccessToken([SCOPE.READ.USER.INFO, SCOPE.READ.USER.API.ACCESS_TOKEN])(reqMock, resMock, nextMock);
+
+    // assert
+    expect(reqMock.user).toBeDefined();
+    expect(reqMock.user?._id).toStrictEqual(targetUser._id);
+    expect(serializeUserSecurely).toHaveBeenCalledOnce();
+    expect(nextMock).toHaveBeenCalled();
+  });
+
+});

+ 16 - 23
apps/app/src/server/middlewares/access-token-parser/access-token-parser.ts → apps/app/src/server/middlewares/access-token-parser/access-token.ts

@@ -1,8 +1,6 @@
-import type { IUser, IUserHasId } from '@growi/core/dist/interfaces';
+import type { IUserHasId } from '@growi/core/dist/interfaces';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import type { NextFunction, Response } from 'express';
 import type { NextFunction, Response } from 'express';
-import type { HydratedDocument } from 'mongoose';
-import mongoose from 'mongoose';
 
 
 import type { Scope } from '~/interfaces/scope';
 import type { Scope } from '~/interfaces/scope';
 import { AccessToken } from '~/server/models/access-token';
 import { AccessToken } from '~/server/models/access-token';
@@ -10,47 +8,42 @@ import loggerFactory from '~/utils/logger';
 
 
 import type { AccessTokenParserReq } from './interfaces';
 import type { AccessTokenParserReq } from './interfaces';
 
 
-const logger = loggerFactory('growi:middleware:access-token-parser');
+const logger = loggerFactory('growi:middleware:access-token-parser:access-token');
 
 
-
-export const accessTokenParser = (scopes?: Scope[]) => {
+export const parserForAccessToken = (scopes: Scope[]) => {
   return async(req: AccessTokenParserReq, res: Response, next: NextFunction): Promise<void> => {
   return async(req: AccessTokenParserReq, res: Response, next: NextFunction): Promise<void> => {
-  // TODO: comply HTTP header of RFC6750 / Authorization: Bearer
+
     const accessToken = req.query.access_token ?? req.body.access_token;
     const accessToken = req.query.access_token ?? req.body.access_token;
     if (accessToken == null || typeof accessToken !== 'string') {
     if (accessToken == null || typeof accessToken !== 'string') {
       return next();
       return next();
     }
     }
-
-    logger.debug('accessToken is', accessToken);
-
-    // check the api token is valid
-    const User = mongoose.model<HydratedDocument<IUser>, { findUserByApiToken }>('User');
-    const userByApiToken: IUserHasId = await User.findUserByApiToken(accessToken);
-    if (userByApiToken != null) {
-      req.user = serializeUserSecurely(userByApiToken);
-      logger.debug('API token parsed.');
-      return next();
+    if (scopes == null || scopes.length === 0) {
+      logger.debug('scopes is empty');
+      return;
     }
     }
 
 
     // check the access token is valid
     // check the access token is valid
     const userId = await AccessToken.findUserIdByToken(accessToken, scopes);
     const userId = await AccessToken.findUserIdByToken(accessToken, scopes);
     if (userId == null) {
     if (userId == null) {
       logger.debug('The access token is invalid');
       logger.debug('The access token is invalid');
-      return next();
+      return;
     }
     }
 
 
     // check the user is valid
     // check the user is valid
-    const { user }: {user: IUserHasId} = await userId.populate('user');
-    if (user == null) {
+    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 next();
+      return;
     }
     }
 
 
     // transforming attributes
     // transforming attributes
-    req.user = serializeUserSecurely(user);
+    req.user = serializeUserSecurely(userByAccessToken);
+    if (req.user == null) {
+      return;
+    }
 
 
     logger.debug('Access token parsed.');
     logger.debug('Access token parsed.');
-
     return next();
     return next();
+
   };
   };
 };
 };

+ 134 - 0
apps/app/src/server/middlewares/access-token-parser/api-token.integ.ts

@@ -0,0 +1,134 @@
+
+import { faker } from '@faker-js/faker';
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
+import type { Response } from 'express';
+import { mock } from 'vitest-mock-extended';
+
+import type Crowi from '~/server/crowi';
+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', () => {
+
+  let User;
+
+  beforeAll(async() => {
+    const crowiMock = mock<Crowi>({
+      event: vi.fn().mockImplementation((eventName) => {
+        if (eventName === 'user') {
+          return mock<UserEvent>({
+            on: vi.fn(),
+          });
+        }
+      }),
+    });
+    const userModelFactory = (await import('../../models/user')).default;
+    User = userModelFactory(crowiMock);
+  });
+
+  it('should call next if no access token is provided', async() => {
+    // arrange
+    const reqMock = mock<AccessTokenParserReq>({
+      user: undefined,
+    });
+    const resMock = mock<Response>();
+    const nextMock = vi.fn();
+
+    expect(reqMock.user).toBeUndefined();
+
+    // act
+    await parserForApiToken(reqMock, resMock, nextMock);
+
+    // assert
+    expect(reqMock.user).toBeUndefined();
+    expect(serializeUserSecurely).not.toHaveBeenCalled();
+    expect(nextMock).toHaveBeenCalled();
+  });
+
+  it('should call next if the given access token is invalid', async() => {
+    // arrange
+    const reqMock = mock<AccessTokenParserReq>({
+      user: undefined,
+    });
+    const resMock = mock<Response>();
+    const nextMock = vi.fn();
+
+    expect(reqMock.user).toBeUndefined();
+
+    // act
+    reqMock.query.access_token = 'invalidToken';
+    await parserForApiToken(reqMock, resMock, nextMock);
+
+    // assert
+    expect(reqMock.user).toBeUndefined();
+    expect(serializeUserSecurely).not.toHaveBeenCalled();
+    expect(nextMock).not.toHaveBeenCalled();
+  });
+
+  it('should set req.user with a valid api token in query', async() => {
+    // arrange
+    const reqMock = mock<AccessTokenParserReq>({
+      user: undefined,
+    });
+    const resMock = mock<Response>();
+    const nextMock = vi.fn();
+
+    expect(reqMock.user).toBeUndefined();
+
+    // prepare a user with an access token
+    const targetUser = await User.create({
+      name: faker.person.fullName(),
+      username: faker.string.uuid(),
+      password: faker.internet.password(),
+      lang: 'en_US',
+      apiToken: faker.internet.password(),
+    });
+
+    // act
+    reqMock.query.access_token = targetUser.apiToken;
+    await parserForApiToken(reqMock, resMock, nextMock);
+
+    // assert
+    expect(reqMock.user).toBeDefined();
+    expect(reqMock.user?._id).toStrictEqual(targetUser._id);
+    expect(serializeUserSecurely).toHaveBeenCalledOnce();
+    expect(nextMock).toHaveBeenCalled();
+  });
+
+  it('should set req.user with a valid api token in body', async() => {
+    // arrange
+    const reqMock = mock<AccessTokenParserReq>({
+      user: undefined,
+    });
+    const resMock = mock<Response>();
+    const nextMock = vi.fn();
+
+    expect(reqMock.user).toBeUndefined();
+
+    // prepare a user with an access token
+    const targetUser = await User.create({
+      name: faker.person.fullName(),
+      username: faker.string.uuid(),
+      password: faker.internet.password(),
+      lang: 'en_US',
+      apiToken: faker.internet.password(),
+    });
+
+    // act
+    reqMock.body.access_token = targetUser.apiToken;
+    await parserForApiToken(reqMock, resMock, nextMock);
+
+    // assert
+    expect(reqMock.user).toBeDefined();
+    expect(reqMock.user?._id).toStrictEqual(targetUser._id);
+    expect(serializeUserSecurely).toHaveBeenCalledOnce();
+    expect(nextMock).toHaveBeenCalled();
+  });
+
+});

+ 35 - 0
apps/app/src/server/middlewares/access-token-parser/api-token.ts

@@ -0,0 +1,35 @@
+import type { IUser, IUserHasId } from '@growi/core/dist/interfaces';
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
+import type { NextFunction, Response } from 'express';
+import type { HydratedDocument } from 'mongoose';
+import mongoose from 'mongoose';
+
+import loggerFactory from '~/utils/logger';
+
+import type { AccessTokenParserReq } from './interfaces';
+
+const logger = loggerFactory('growi:middleware:access-token-parser:api-token');
+
+export const parserForApiToken = async(req: AccessTokenParserReq, res: Response, next: NextFunction): Promise<void> => {
+  const accessToken = req.query.access_token ?? req.body.access_token;
+  if (accessToken == null || typeof accessToken !== 'string') {
+    return next();
+  }
+
+  logger.debug('accessToken is', accessToken);
+
+  const User = mongoose.model<HydratedDocument<IUser>, { findUserByApiToken }>('User');
+  const userByApiToken: IUserHasId = await User.findUserByApiToken(accessToken);
+
+  if (userByApiToken == null) {
+    return;
+  }
+
+  req.user = serializeUserSecurely(userByApiToken);
+  if (req.user == null) {
+    return;
+  }
+
+  logger.debug('Access token parsed.');
+  return next();
+};

+ 20 - 1
apps/app/src/server/middlewares/access-token-parser/index.ts

@@ -1 +1,20 @@
-export * from './access-token-parser';
+import type { NextFunction, Response } from 'express';
+
+import type { Scope } from '~/interfaces/scope';
+
+import { parserForAccessToken } from './access-token';
+import { parserForApiToken } from './api-token';
+import type { AccessTokenParserReq } from './interfaces';
+
+export const accessTokenParser = (scopes?: Scope[]) => {
+  return async(req: AccessTokenParserReq, res: Response, next: NextFunction): Promise<void> => {
+    // TODO: comply HTTP header of RFC6750 / Authorization: Bearer
+
+    if (scopes != null) {
+      parserForAccessToken(scopes)(req, res, next);
+    }
+    parserForApiToken(req, res, next);
+
+    return next();
+  };
+};