Browse Source

Merge pull request #9752 from weseek/feat/162829-162857-scope

feat: Add scope to access token
Yuki Takei 1 year ago
parent
commit
6b3b441c40
46 changed files with 777 additions and 228 deletions
  1. 23 4
      apps/app/src/client/components/Me/AccessTokenForm.tsx
  2. 1 1
      apps/app/src/client/components/Me/AccessTokenList.tsx
  3. 1 1
      apps/app/src/features/openai/server/routes/message.ts
  4. 1 1
      apps/app/src/features/openai/server/routes/thread.ts
  5. 6 6
      apps/app/src/features/questionnaire/server/routes/apiv3/questionnaire.ts
  6. 2 1
      apps/app/src/interfaces/access-token.ts
  7. 124 0
      apps/app/src/interfaces/scope.ts
  8. 71 62
      apps/app/src/server/middlewares/access-token-parser/access-token.integ.ts
  9. 49 0
      apps/app/src/server/middlewares/access-token-parser/access-token.ts
  10. 134 0
      apps/app/src/server/middlewares/access-token-parser/api-token.integ.ts
  11. 7 25
      apps/app/src/server/middlewares/access-token-parser/api-token.ts
  12. 20 1
      apps/app/src/server/middlewares/access-token-parser/index.ts
  13. 21 17
      apps/app/src/server/models/access-token.ts
  14. 1 1
      apps/app/src/server/routes/apiv3/activity.ts
  15. 3 3
      apps/app/src/server/routes/apiv3/app-settings.js
  16. 4 4
      apps/app/src/server/routes/apiv3/attachment.js
  17. 6 6
      apps/app/src/server/routes/apiv3/bookmark-folder.ts
  18. 3 3
      apps/app/src/server/routes/apiv3/bookmarks.js
  19. 3 3
      apps/app/src/server/routes/apiv3/export.js
  20. 2 2
      apps/app/src/server/routes/apiv3/g2g-transfer.ts
  21. 5 5
      apps/app/src/server/routes/apiv3/import.js
  22. 4 4
      apps/app/src/server/routes/apiv3/in-app-notification.ts
  23. 4 4
      apps/app/src/server/routes/apiv3/page-listing.ts
  24. 1 1
      apps/app/src/server/routes/apiv3/page/check-page-existence.ts
  25. 1 1
      apps/app/src/server/routes/apiv3/page/create-page.ts
  26. 1 1
      apps/app/src/server/routes/apiv3/page/get-yjs-data.ts
  27. 4 4
      apps/app/src/server/routes/apiv3/page/index.ts
  28. 1 1
      apps/app/src/server/routes/apiv3/page/publish-page.ts
  29. 1 1
      apps/app/src/server/routes/apiv3/page/sync-latest-revision-body-to-yjs-draft.ts
  30. 1 1
      apps/app/src/server/routes/apiv3/page/unpublish-page.ts
  31. 1 1
      apps/app/src/server/routes/apiv3/page/update-page.ts
  32. 11 11
      apps/app/src/server/routes/apiv3/pages/index.js
  33. 2 1
      apps/app/src/server/routes/apiv3/personal-setting/delete-access-token.ts
  34. 3 1
      apps/app/src/server/routes/apiv3/personal-setting/delete-all-access-tokens.ts
  35. 14 7
      apps/app/src/server/routes/apiv3/personal-setting/generate-access-token.ts
  36. 3 1
      apps/app/src/server/routes/apiv3/personal-setting/get-access-tokens.ts
  37. 13 13
      apps/app/src/server/routes/apiv3/personal-setting/index.js
  38. 2 2
      apps/app/src/server/routes/apiv3/revisions.js
  39. 3 3
      apps/app/src/server/routes/apiv3/search.js
  40. 3 3
      apps/app/src/server/routes/apiv3/slack-integration-settings.js
  41. 4 4
      apps/app/src/server/routes/apiv3/users.js
  42. 14 14
      apps/app/src/server/routes/index.js
  43. 90 0
      apps/app/src/server/util/scope-util.spec.ts
  44. 106 0
      apps/app/src/server/util/scope-utils.ts
  45. 2 2
      packages/remark-attachment-refs/src/server/routes/refs.ts
  46. 1 1
      packages/remark-lsx/src/server/index.ts

+ 23 - 4
apps/app/src/client/components/Me/AccessTokenForm.tsx

@@ -5,6 +5,8 @@ import { useForm } from 'react-hook-form';
 
 
 import type { IAccessTokenInfo } from '~/interfaces/access-token';
+import type { Scope } from '~/interfaces/scope';
+import { SCOPE } from '~/interfaces/scope';
 
 const MAX_DESCRIPTION_LENGTH = 200;
 
@@ -16,7 +18,7 @@ type FormInputs = {
   expiredAt: string;
   description: string;
   // TODO: Implement scope selection
-  // scopes: string[];
+  scopes: Scope[];
 }
 
 // TODO: Implement scope selection
@@ -42,12 +44,12 @@ export const AccessTokenForm = React.memo((props: AccessTokenFormProps): JSX.Ele
 
   const onSubmit = (data: FormInputs) => {
     const expiredAtDate = new Date(data.expiredAt);
-    const scope = []; // TODO: Implement scope selection
+    const scopes: Scope[] = data.scopes ? data.scopes : [];
 
     submitHandler({
       expiredAt: expiredAtDate,
       description: data.description,
-      scope,
+      scopes,
     });
   };
 
@@ -111,7 +113,24 @@ export const AccessTokenForm = React.memo((props: AccessTokenFormProps): JSX.Ele
           <div className="mb-3">
             <label htmlFor="scope" className="form-label">{t('page_me_access_token.scope')}</label>
             <div className="form-text mb-2">{t('page_me_access_token.form.scope_desc')}</div>
-            <div className="form-text mb-2">(TBD)</div>
+            <div className="form-text mb-2">
+              <input
+                type="checkbox"
+                id="scope-read-user"
+                value={SCOPE.READ.USER.ALL}
+                {...register('scopes')}
+              />
+              <label htmlFor="scope-read-user" className="ms-2">Read User</label>
+            </div>
+            <div className="form-text mb-2">
+              <input
+                type="checkbox"
+                id="scope-write-user"
+                value={SCOPE.WRITE.USER.ALL}
+                {...register('scopes')}
+              />
+              <label htmlFor="scope-write-user" className="ms-2">Write User</label>
+            </div>
           </div>
 
           <button

+ 1 - 1
apps/app/src/client/components/Me/AccessTokenList.tsx

@@ -60,7 +60,7 @@ export const AccessTokenList = React.memo((props: AccessTokenListProps): JSX.Ele
                     <tr key={token._id}>
                       <td className="text-break">{token.description}</td>
                       <td>{token.expiredAt.toString().split('T')[0]}</td>
-                      <td>{token.scope.join(', ')}</td>
+                      <td>{token.scopes.join(', ')}</td>
                       <td>
                         <button
                           className="btn btn-danger"

+ 1 - 1
apps/app/src/features/openai/server/routes/message.ts

@@ -54,7 +54,7 @@ export const postMessageHandlersFactory: PostMessageHandlersFactory = (crowi) =>
   ];
 
   return [
-    accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
+    accessTokenParser(), loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
     async(req: Req, res: ApiV3Response) => {
       const { aiAssistantId, threadId } = req.body;
 

+ 1 - 1
apps/app/src/features/openai/server/routes/thread.ts

@@ -34,7 +34,7 @@ export const createThreadHandlersFactory: CreateThreadFactory = (crowi) => {
   ];
 
   return [
-    accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
+    accessTokenParser(), loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
     async(req: CreateThreadReq, res: ApiV3Response) => {
 
       const openaiService = getOpenaiService();

+ 6 - 6
apps/app/src/features/questionnaire/server/routes/apiv3/questionnaire.ts

@@ -61,7 +61,7 @@ module.exports = (crowi: Crowi): Router => {
     return 404;
   };
 
-  router.get('/orders', accessTokenParser, loginRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
+  router.get('/orders', accessTokenParser(), loginRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const growiInfo = await growiInfoService.getGrowiInfo(true);
     const userInfo = crowi.questionnaireService.getUserInfo(req.user ?? null, getSiteUrlHashed(growiInfo.appSiteUrl));
 
@@ -76,12 +76,12 @@ module.exports = (crowi: Crowi): Router => {
     }
   });
 
-  router.get('/is-enabled', accessTokenParser, loginRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
+  router.get('/is-enabled', accessTokenParser(), loginRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const isEnabled = configManager.getConfig('questionnaire:isQuestionnaireEnabled');
     return res.apiv3({ isEnabled });
   });
 
-  router.post('/proactive/answer', accessTokenParser, loginRequired, validators.proactiveAnswer, async(req: AuthorizedRequest, res: ApiV3Response) => {
+  router.post('/proactive/answer', accessTokenParser(), loginRequired, validators.proactiveAnswer, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const sendQuestionnaireAnswer = async() => {
       const questionnaireServerOrigin = configManager.getConfig('app:questionnaireServerOrigin');
       const isAppSiteUrlHashed = configManager.getConfig('questionnaire:isAppSiteUrlHashed');
@@ -130,7 +130,7 @@ module.exports = (crowi: Crowi): Router => {
     }
   });
 
-  router.put('/answer', accessTokenParser, loginRequired, validators.answer, async(req: AuthorizedRequest, res: ApiV3Response) => {
+  router.put('/answer', accessTokenParser(), loginRequired, validators.answer, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const sendQuestionnaireAnswer = async(user: IUserHasId, answers: IAnswer[]) => {
       const questionnaireServerOrigin = crowi.configManager.getConfig('app:questionnaireServerOrigin');
       const isAppSiteUrlHashed = configManager.getConfig('questionnaire:isAppSiteUrlHashed');
@@ -177,7 +177,7 @@ module.exports = (crowi: Crowi): Router => {
     }
   });
 
-  router.put('/skip', accessTokenParser, loginRequired, validators.skipDeny, async(req: AuthorizedRequest, res: ApiV3Response) => {
+  router.put('/skip', accessTokenParser(), loginRequired, validators.skipDeny, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const errors = validationResult(req);
     if (!errors.isEmpty()) {
       return res.status(400).json({ errors: errors.array() });
@@ -193,7 +193,7 @@ module.exports = (crowi: Crowi): Router => {
     }
   });
 
-  router.put('/deny', accessTokenParser, loginRequired, validators.skipDeny, async(req: AuthorizedRequest, res: ApiV3Response) => {
+  router.put('/deny', accessTokenParser(), loginRequired, validators.skipDeny, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const errors = validationResult(req);
     if (!errors.isEmpty()) {
       return res.status(400).json({ errors: errors.array() });

+ 2 - 1
apps/app/src/interfaces/access-token.ts

@@ -1,8 +1,9 @@
+import type { Scope } from './scope';
 
 export type IAccessTokenInfo = {
   expiredAt: Date,
   description: string,
-  scope: string[],
+  scopes: Scope[],
 }
 
 export type IResGenerateAccessToken = IAccessTokenInfo & {

+ 124 - 0
apps/app/src/interfaces/scope.ts

@@ -0,0 +1,124 @@
+// If you want to add a new scope, you only need to add a new key to the ORIGINAL_SCOPE object.
+export const ORIGINAL_SCOPE_ADMIN = {
+  admin: {
+    top: {},
+    app: {},
+    security: {},
+    markdown: {},
+    customize: {},
+    import_data: {},
+    exporet_data: {},
+    data_transfer: {},
+    external_notification: {},
+    slack_integration: {},
+    legacy_slack_integration: {},
+    user_management: {},
+    user_group_management: {},
+    audit_log: {},
+    plugin: {},
+    ai_integration: {},
+    full_text_search: {},
+  },
+} as const;
+
+export const ORIGINAL_SCOPE_USER = {
+  user: {
+    info: {},
+    external_account: {},
+    password: {},
+    api: {
+      api_token: {},
+      access_token: {},
+    },
+    in_app_notification: {},
+    other: {},
+  },
+  base: {
+  },
+} as const;
+
+export const ORIGINAL_SCOPE = {
+  ...ORIGINAL_SCOPE_ADMIN,
+  ...ORIGINAL_SCOPE_USER,
+} as const;
+
+export const ACTION = {
+  READ: 'read',
+  WRITE: 'write',
+} as const;
+
+type ACTION_TYPE = typeof ACTION[keyof typeof ACTION];
+export const ALL_SIGN = '*';
+
+export const ORIGINAL_SCOPE_WITH_ACTION = Object.values(ACTION).reduce(
+  (acc, action) => {
+    acc[action] = ORIGINAL_SCOPE;
+    return acc;
+  },
+  {} as Record<ACTION_TYPE, typeof ORIGINAL_SCOPE>,
+);
+
+type FlattenObject<T> = {
+  [K in keyof T]: T[K] extends object
+    ? (keyof T[K] extends never
+      ? K
+      : `${K & string}:${FlattenObject<T[K]> & string}`)
+    : K
+}[keyof T];
+
+type AddAllToScope<S extends string> =
+  S extends `${infer X}:${infer Y}`
+    ? `${X}:${typeof ALL_SIGN}` | `${X}:${AddAllToScope<Y>}` | S
+    : S;
+
+type ScopeOnly = FlattenObject<typeof ORIGINAL_SCOPE_WITH_ACTION>;
+type ScopeWithAll = AddAllToScope<ScopeOnly>;
+export type Scope = ScopeOnly | ScopeWithAll;
+
+// ScopeConstantsの型定義
+type ScopeConstantLeaf = Scope;
+
+type ScopeConstantNode<T> = {
+  [K in keyof T as Uppercase<string & K>]: T[K] extends object
+    ? (keyof T[K] extends never
+      ? ScopeConstantLeaf
+      : ScopeConstantNode<T[K]> & { ALL: Scope })
+    : ScopeConstantLeaf
+};
+
+type ScopeConstantType = {
+  [A in keyof typeof ORIGINAL_SCOPE_WITH_ACTION as Uppercase<string & A>]:
+    ScopeConstantNode<typeof ORIGINAL_SCOPE> & { ALL: Scope }
+};
+
+const buildScopeConstants = (): ScopeConstantType => {
+  const result = {} as Partial<ScopeConstantType>;
+
+  const processObject = (obj: Record<string, any>, path: string[] = [], resultObj: Record<string, any>) => {
+    Object.entries(obj).forEach(([key, value]) => {
+      const upperKey = key.toUpperCase();
+      const currentPath = [...path, key];
+      const scopePath = currentPath.join(':');
+
+      if (value == null) {
+        return;
+      }
+
+      if (typeof value === 'object' && Object.keys(value).length === 0) {
+        resultObj[upperKey] = `${scopePath}` as Scope;
+      }
+      else if (typeof value === 'object') {
+        resultObj[upperKey] = {
+          ALL: `${scopePath}:${ALL_SIGN}` as Scope,
+        };
+
+        processObject(value, currentPath, resultObj[upperKey]);
+      }
+    });
+  };
+  processObject(ORIGINAL_SCOPE_WITH_ACTION, [], result);
+
+  return result as ScopeConstantType;
+};
+
+export const SCOPE = buildScopeConstants();

+ 71 - 62
apps/app/src/server/middlewares/access-token-parser/access-token-parser.integ.ts → apps/app/src/server/middlewares/access-token-parser/access-token.integ.ts

@@ -3,19 +3,18 @@ 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';
 
-import { accessTokenParser } from '.';
-
-
 vi.mock('@growi/core/dist/models/serializers', { spy: true });
 
 
-describe('access-token-parser middleware', () => {
+describe('access-token-parser middleware for access token with scopes', () => {
 
   let User;
 
@@ -41,18 +40,13 @@ describe('access-token-parser middleware', () => {
     const resMock = mock<Response>();
     const nextMock = vi.fn();
 
-    expect(reqMock.user).toBeUndefined();
+    await parserForAccessToken([])(reqMock, resMock, nextMock);
 
-    // act
-    await accessTokenParser(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() => {
+  it('should not authenticate with no scopes', async() => {
     // arrange
     const reqMock = mock<AccessTokenParserReq>({
       user: undefined,
@@ -62,17 +56,31 @@ describe('access-token-parser middleware', () => {
 
     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 = 'invalidToken';
-    await accessTokenParser(reqMock, resMock, nextMock);
+    reqMock.query.access_token = token;
+    await parserForAccessToken([])(reqMock, resMock, nextMock);
 
     // assert
     expect(reqMock.user).toBeUndefined();
     expect(serializeUserSecurely).not.toHaveBeenCalled();
-    expect(nextMock).toHaveBeenCalled();
+    expect(nextMock).not.toHaveBeenCalled();
   });
 
-  it('should set req.user with a valid api token in query', async() => {
+  it('should authenticate with specific scope', async() => {
     // arrange
     const reqMock = mock<AccessTokenParserReq>({
       user: undefined,
@@ -82,18 +90,24 @@ describe('access-token-parser middleware', () => {
 
     expect(reqMock.user).toBeUndefined();
 
-    // prepare a user with an access token
+    // prepare a user
     const targetUser = await User.create({
       name: faker.person.fullName(),
       username: faker.string.uuid(),
       password: faker.internet.password(),
       lang: 'en_US',
-      apiToken: faker.internet.password(),
     });
 
+    // 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 = targetUser.apiToken;
-    await accessTokenParser(reqMock, resMock, nextMock);
+    reqMock.query.access_token = token;
+    await parserForAccessToken([SCOPE.READ.USER.INFO])(reqMock, resMock, nextMock);
 
     // assert
     expect(reqMock.user).toBeDefined();
@@ -102,7 +116,7 @@ describe('access-token-parser middleware', () => {
     expect(nextMock).toHaveBeenCalled();
   });
 
-  it('should set req.user with a valid api token in body', async() => {
+  it('should reject with insufficient scopes', async() => {
     // arrange
     const reqMock = mock<AccessTokenParserReq>({
       user: undefined,
@@ -112,48 +126,33 @@ describe('access-token-parser middleware', () => {
 
     expect(reqMock.user).toBeUndefined();
 
-    // prepare a user with an access token
+
+    // prepare a user
     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();
-  });
-
-});
-
+    // 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],
+    );
 
-describe('access-token-parser middleware for access token', () => {
-
-  let User;
+    // act - try to access with write:user:info scope
+    reqMock.query.access_token = token;
+    await parserForAccessToken([SCOPE.WRITE.USER.INFO])(reqMock, resMock, nextMock);
 
-  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);
+    // // assert
+    expect(reqMock.user).toBeUndefined();
+    expect(serializeUserSecurely).not.toHaveBeenCalled();
+    expect(nextMock).not.toHaveBeenCalled();
   });
 
-  it('should set req.user with a valid access token in query', async() => {
+  it('should authenticate with write scope implying read scope', async() => {
     // arrange
     const reqMock = mock<AccessTokenParserReq>({
       user: undefined,
@@ -163,7 +162,7 @@ describe('access-token-parser middleware for access token', () => {
 
     expect(reqMock.user).toBeUndefined();
 
-    // prepare a user with an access token
+    // prepare a user
     const targetUser = await User.create({
       name: faker.person.fullName(),
       username: faker.string.uuid(),
@@ -171,10 +170,16 @@ describe('access-token-parser middleware for access token', () => {
       lang: 'en_US',
     });
 
-    // act
-    const { token } = await AccessToken.generateToken(targetUser._id, new Date(Date.now() + 1000 * 60 * 60 * 24), []);
+    // 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 accessTokenParser(reqMock, resMock, nextMock);
+    await parserForAccessToken([SCOPE.READ.USER.INFO])(reqMock, resMock, nextMock);
 
     // assert
     expect(reqMock.user).toBeDefined();
@@ -183,7 +188,7 @@ describe('access-token-parser middleware for access token', () => {
     expect(nextMock).toHaveBeenCalled();
   });
 
-  it('should set req.user with a valid access token in body', async() => {
+  it('should authenticate with wildcard scope', async() => {
     // arrange
     const reqMock = mock<AccessTokenParserReq>({
       user: undefined,
@@ -191,9 +196,7 @@ describe('access-token-parser middleware for access token', () => {
     const resMock = mock<Response>();
     const nextMock = vi.fn();
 
-    expect(reqMock.user).toBeUndefined();
-
-    // prepare a user with an access token
+    // prepare a user
     const targetUser = await User.create({
       name: faker.person.fullName(),
       username: faker.string.uuid(),
@@ -201,10 +204,16 @@ describe('access-token-parser middleware for access token', () => {
       lang: 'en_US',
     });
 
-    // act
-    const { token } = await AccessToken.generateToken(targetUser._id, new Date(Date.now() + 1000 * 60 * 60 * 24), []);
+    // 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(reqMock, resMock, nextMock);
+    await parserForAccessToken([SCOPE.READ.USER.INFO, SCOPE.READ.USER.API.ACCESS_TOKEN])(reqMock, resMock, nextMock);
 
     // assert
     expect(reqMock.user).toBeDefined();

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

@@ -0,0 +1,49 @@
+import type { IUserHasId } from '@growi/core/dist/interfaces';
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
+import type { NextFunction, Response } from 'express';
+
+import type { Scope } from '~/interfaces/scope';
+import { AccessToken } from '~/server/models/access-token';
+import loggerFactory from '~/utils/logger';
+
+import type { AccessTokenParserReq } from './interfaces';
+
+const logger = loggerFactory('growi:middleware:access-token-parser:access-token');
+
+export const parserForAccessToken = (scopes: Scope[]) => {
+  return 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();
+    }
+    if (scopes == null || scopes.length === 0) {
+      logger.debug('scopes is empty');
+      return;
+    }
+
+    // check the access token is valid
+    const userId = await AccessToken.findUserIdByToken(accessToken, scopes);
+    if (userId == null) {
+      logger.debug('The access token is invalid');
+      return;
+    }
+
+    // check the user is valid
+    const { user: userByAccessToken }: {user: IUserHasId} = await userId.populate('user');
+    if (userByAccessToken == null) {
+      logger.debug('The access token\'s associated user is invalid');
+      return;
+    }
+
+    // transforming attributes
+    req.user = serializeUserSecurely(userByAccessToken);
+    if (req.user == null) {
+      return;
+    }
+
+    logger.debug('Access token parsed.');
+    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();
+  });
+
+});

+ 7 - 25
apps/app/src/server/middlewares/access-token-parser/access-token-parser.ts → apps/app/src/server/middlewares/access-token-parser/api-token.ts

@@ -4,16 +4,13 @@ import type { NextFunction, Response } from 'express';
 import type { HydratedDocument } from 'mongoose';
 import mongoose from 'mongoose';
 
-import { AccessToken } from '~/server/models/access-token';
 import loggerFactory from '~/utils/logger';
 
 import type { AccessTokenParserReq } from './interfaces';
 
-const logger = loggerFactory('growi:middleware:access-token-parser');
+const logger = loggerFactory('growi:middleware:access-token-parser:api-token');
 
-
-export const accessTokenParser = async(req: AccessTokenParserReq, res: Response, next: NextFunction): Promise<void> => {
-  // TODO: comply HTTP header of RFC6750 / Authorization: Bearer
+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();
@@ -21,33 +18,18 @@ export const accessTokenParser = async(req: AccessTokenParserReq, res: Response,
 
   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();
-  }
 
-  // check the access token is valid
-  const userId = await AccessToken.findUserIdByToken(accessToken);
-  if (userId == null) {
-    logger.debug('The access token is invalid');
-    return next();
+  if (userByApiToken == null) {
+    return;
   }
 
-  // check the user is valid
-  const { user }: {user: IUserHasId} = await userId.populate('user');
-  if (user == null) {
-    logger.debug('The access token\'s associated user is invalid');
-    return next();
+  req.user = serializeUserSecurely(userByApiToken);
+  if (req.user == null) {
+    return;
   }
 
-  // transforming attributes
-  req.user = serializeUserSecurely(user);
-
   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();
+  };
+};

+ 21 - 17
apps/app/src/server/models/access-token.ts

@@ -8,9 +8,11 @@ import { Schema } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
 
+import type { Scope } from '~/interfaces/scope';
 import loggerFactory from '~/utils/logger';
 
 import { getOrCreateModel } from '../util/mongoose-utils';
+import { extractScopes } from '../util/scope-utils';
 
 const logger = loggerFactory('growi:models:access-token');
 
@@ -20,7 +22,7 @@ type GenerateTokenResult = {
   token: string,
   _id: Types.ObjectId,
   expiredAt: Date,
-  scope?: string[],
+  scopes?: Scope[],
   description?: string,
 }
 
@@ -28,7 +30,7 @@ export type IAccessToken = {
   user: Ref<IUserHasId>,
   tokenHash: string,
   expiredAt: Date,
-  scope?: string[],
+  scopes?: Scope[],
   description?: string,
 }
 
@@ -37,14 +39,14 @@ export interface IAccessTokenDocument extends IAccessToken, Document {
 }
 
 export interface IAccessTokenModel extends Model<IAccessTokenDocument> {
-  generateToken: (userId: Types.ObjectId | string, expiredAt: Date, scope?: string[], description?: string,) => Promise<GenerateTokenResult>
+  generateToken: (userId: Types.ObjectId | string, expiredAt: Date, scopes?: Scope[], description?: string,) => Promise<GenerateTokenResult>
   deleteToken: (token: string) => Promise<void>
   deleteTokenById: (tokenId: Types.ObjectId | string) => Promise<void>
   deleteAllTokensByUserId: (userId: Types.ObjectId | string) => Promise<void>
   deleteExpiredToken: () => Promise<void>
-  findUserIdByToken: (token: string) => Promise<HydratedDocument<IAccessTokenDocument> | null>
+  findUserIdByToken: (token: string, requiredScopes: Scope[]) => Promise<HydratedDocument<IAccessTokenDocument> | null>
   findTokenByUserId: (userId: Types.ObjectId | string) => Promise<HydratedDocument<IAccessTokenDocument>[] | null>
-  validateTokenScopes: (token: string, requiredScope: string[]) => Promise<boolean>
+  validateTokenScopes: (token: string, requiredScopes: Scope[]) => Promise<boolean>
 }
 
 const accessTokenSchema = new Schema<IAccessTokenDocument, IAccessTokenModel>({
@@ -53,26 +55,27 @@ const accessTokenSchema = new Schema<IAccessTokenDocument, IAccessTokenModel>({
   },
   tokenHash: { type: String, required: true, unique: true },
   expiredAt: { type: Date, required: true, index: true },
-  scope: [{ type: String, default: '' }],
+  scopes: [{ type: String, default: '' }],
   description: { type: String, default: '' },
 });
 
 accessTokenSchema.plugin(mongoosePaginate);
 accessTokenSchema.plugin(uniqueValidator);
 
-accessTokenSchema.statics.generateToken = async function(userId: Types.ObjectId | string, expiredAt: Date, scope?: string[], description?: string) {
+accessTokenSchema.statics.generateToken = async function(userId: Types.ObjectId | string, expiredAt: Date, scopes?: Scope[], description?: string) {
 
+  const extractedScopes = extractScopes(scopes ?? []);
   const token = crypto.randomBytes(32).toString('hex');
   const tokenHash = generateTokenHash(token);
 
   try {
     const { _id } = await this.create({
-      user: userId, tokenHash, expiredAt, scope, description,
+      user: userId, tokenHash, expiredAt, scopes: extractedScopes, description,
     });
 
     logger.debug('Token generated');
     return {
-      token, _id, expiredAt, scope, description,
+      token, _id, expiredAt, scopes: extractedScopes, description,
     };
   }
   catch (err) {
@@ -99,22 +102,23 @@ accessTokenSchema.statics.deleteExpiredToken = async function() {
   await this.deleteMany({ expiredAt: { $lte: now } });
 };
 
-accessTokenSchema.statics.findUserIdByToken = async function(token: string) {
+accessTokenSchema.statics.findUserIdByToken = async function(token: string, requiredScopes: Scope[]) {
   const tokenHash = generateTokenHash(token);
   const now = new Date();
-  return this.findOne({ tokenHash, expiredAt: { $gt: now } }).select('user');
+  if (requiredScopes.length === 0) {
+    return;
+  }
+  const extractedScopes = extractScopes(requiredScopes);
+  return this.findOne({ tokenHash, expiredAt: { $gt: now }, scopes: { $all: extractedScopes } }).select('user');
 };
 
 accessTokenSchema.statics.findTokenByUserId = async function(userId: Types.ObjectId | string) {
   const now = new Date();
-  return this.find({ user: userId, expiredAt: { $gt: now } }).select('_id expiredAt scope description');
+  return this.find({ user: userId, expiredAt: { $gt: now } }).select('_id expiredAt scopes description');
 };
 
-accessTokenSchema.statics.validateTokenScopes = async function(token: string, requiredScopes: string[]) {
-  const tokenHash = generateTokenHash(token);
-  const now = new Date();
-  const tokenData = await this.findOne({ tokenHash, expiredAt: { $gt: now }, scope: { $all: requiredScopes } });
-  return tokenData != null;
+accessTokenSchema.statics.validateTokenScopes = async function(token: string, requiredScopes: Scope[]) {
+  return this.findUserIdByToken(token, requiredScopes) != null;
 };
 
 accessTokenSchema.methods.isExpired = function() {

+ 1 - 1
apps/app/src/server/routes/apiv3/activity.ts

@@ -34,7 +34,7 @@ module.exports = (crowi: Crowi): Router => {
   const router = express.Router();
 
   // eslint-disable-next-line max-len
-  router.get('/', accessTokenParser, loginRequiredStrictly, adminRequired, validator.list, apiV3FormValidator, async(req: Request, res: ApiV3Response) => {
+  router.get('/', accessTokenParser(), loginRequiredStrictly, adminRequired, validator.list, apiV3FormValidator, async(req: Request, res: ApiV3Response) => {
     const auditLogEnabled = configManager.getConfig('app:auditLogEnabled');
     if (!auditLogEnabled) {
       const msg = 'AuditLog is not enabled';

+ 3 - 3
apps/app/src/server/routes/apiv3/app-settings.js

@@ -430,7 +430,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      $ref: '#/components/schemas/AppSettingParams'
    */
-  router.get('/', accessTokenParser, loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.get('/', accessTokenParser(), loginRequiredStrictly, adminRequired, async(req, res) => {
     const appSettingsParams = {
       title: configManager.getConfig('app:title'),
       confidential: configManager.getConfig('app:confidential'),
@@ -1044,7 +1044,7 @@ module.exports = (crowi) => {
    *                      description: is V5 compatible, or not
    *                      example: true
    */
-  router.post('/v5-schema-migration', accessTokenParser, loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.post('/v5-schema-migration', accessTokenParser(), loginRequiredStrictly, adminRequired, async(req, res) => {
     const isMaintenanceMode = crowi.appService.isMaintenanceMode();
     if (!isMaintenanceMode) {
       return res.apiv3Err(new ErrorV3('GROWI is not maintenance mode. To import data, please activate the maintenance mode first.', 'not_maintenance_mode'));
@@ -1099,7 +1099,7 @@ module.exports = (crowi) => {
    *                      example: true
    */
   // eslint-disable-next-line max-len
-  router.post('/maintenance-mode', accessTokenParser, loginRequiredStrictly, adminRequired, addActivity, validator.maintenanceMode, apiV3FormValidator, async(req, res) => {
+  router.post('/maintenance-mode', accessTokenParser(), loginRequiredStrictly, adminRequired, addActivity, validator.maintenanceMode, apiV3FormValidator, async(req, res) => {
     const { flag } = req.body;
     const parameters = {};
     try {

+ 4 - 4
apps/app/src/server/routes/apiv3/attachment.js

@@ -198,7 +198,7 @@ module.exports = (crowi) => {
    *                  type: object
    *                  $ref: '#/components/schemas/AttachmentPaginateResult'
    */
-  router.get('/list', accessTokenParser, loginRequired, validator.retrieveAttachments, apiV3FormValidator, async(req, res) => {
+  router.get('/list', accessTokenParser(), loginRequired, validator.retrieveAttachments, apiV3FormValidator, async(req, res) => {
 
     const limit = req.query.limit || await crowi.configManager.getConfig('customize:showPageLimitationS') || 10;
     const pageNumber = req.query.pageNumber || 1;
@@ -272,7 +272,7 @@ module.exports = (crowi) => {
    *          500:
    *            $ref: '#/components/responses/500'
    */
-  router.get('/limit', accessTokenParser, loginRequiredStrictly, validator.retrieveFileLimit, apiV3FormValidator, async(req, res) => {
+  router.get('/limit', accessTokenParser(), loginRequiredStrictly, validator.retrieveFileLimit, apiV3FormValidator, async(req, res) => {
     const { fileUploadService } = crowi;
     const fileSize = Number(req.query.fileSize);
     try {
@@ -339,7 +339,7 @@ module.exports = (crowi) => {
    *          500:
    *            $ref: '#/components/responses/500'
    */
-  router.post('/', uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser,
+  router.post('/', uploads.single('file'), autoReap, accessTokenParser(), loginRequiredStrictly, excludeReadOnlyUser,
     validator.retrieveAddAttachment, apiV3FormValidator, addActivity,
     async(req, res) => {
 
@@ -403,7 +403,7 @@ module.exports = (crowi) => {
    *            schema:
    *              type: string
    */
-  router.get('/:id', accessTokenParser, certifySharedPageAttachmentMiddleware, loginRequired, validator.retrieveAttachment, apiV3FormValidator,
+  router.get('/:id', accessTokenParser(), certifySharedPageAttachmentMiddleware, loginRequired, validator.retrieveAttachment, apiV3FormValidator,
     async(req, res) => {
       try {
         const attachmentId = req.params.id;

+ 6 - 6
apps/app/src/server/routes/apiv3/bookmark-folder.ts

@@ -156,7 +156,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      $ref: '#/components/schemas/BookmarkFolder'
    */
-  router.post('/', accessTokenParser, loginRequiredStrictly, validator.bookmarkFolder, apiV3FormValidator, async(req, res) => {
+  router.post('/', accessTokenParser(), loginRequiredStrictly, validator.bookmarkFolder, apiV3FormValidator, async(req, res) => {
     const owner = req.user?._id;
     const { name, parent } = req.body;
     const params = {
@@ -208,7 +208,7 @@ module.exports = (crowi) => {
    *                        type: object
    *                        $ref: '#/components/schemas/BookmarkFolder'
    */
-  router.get('/list/:userId', accessTokenParser, loginRequiredStrictly, async(req, res) => {
+  router.get('/list/:userId', accessTokenParser(), loginRequiredStrictly, async(req, res) => {
     const { userId } = req.params;
 
     const getBookmarkFolders = async(
@@ -296,7 +296,7 @@ module.exports = (crowi) => {
    *                      description: Number of deleted folders
    *                      example: 1
    */
-  router.delete('/:id', accessTokenParser, loginRequiredStrictly, async(req, res) => {
+  router.delete('/:id', accessTokenParser(), loginRequiredStrictly, async(req, res) => {
     const { id } = req.params;
     try {
       const result = await BookmarkFolder.deleteFolderAndChildren(id);
@@ -352,7 +352,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      $ref: '#/components/schemas/BookmarkFolder'
    */
-  router.put('/', accessTokenParser, loginRequiredStrictly, validator.bookmarkFolder, async(req, res) => {
+  router.put('/', accessTokenParser(), loginRequiredStrictly, validator.bookmarkFolder, async(req, res) => {
     const {
       bookmarkFolderId, name, parent, childFolder,
     } = req.body;
@@ -401,7 +401,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      $ref: '#/components/schemas/BookmarkFolder'
    */
-  router.post('/add-bookmark-to-folder', accessTokenParser, loginRequiredStrictly, validator.bookmarkPage, apiV3FormValidator, async(req, res) => {
+  router.post('/add-bookmark-to-folder', accessTokenParser(), loginRequiredStrictly, validator.bookmarkPage, apiV3FormValidator, async(req, res) => {
     const userId = req.user?._id;
     const { pageId, folderId } = req.body;
 
@@ -450,7 +450,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      $ref: '#/components/schemas/BookmarkFolder'
    */
-  router.put('/update-bookmark', accessTokenParser, loginRequiredStrictly, validator.bookmark, async(req, res) => {
+  router.put('/update-bookmark', accessTokenParser(), loginRequiredStrictly, validator.bookmark, async(req, res) => {
     const { pageId, status } = req.body;
     const userId = req.user?._id;
     try {

+ 3 - 3
apps/app/src/server/routes/apiv3/bookmarks.js

@@ -110,7 +110,7 @@ module.exports = (crowi) => {
    *                schema:
    *                  $ref: '#/components/schemas/BookmarkInfo'
    */
-  router.get('/info', accessTokenParser, loginRequired, validator.bookmarkInfo, apiV3FormValidator, async(req, res) => {
+  router.get('/info', accessTokenParser(), loginRequired, validator.bookmarkInfo, apiV3FormValidator, async(req, res) => {
     const { user } = req;
     const { pageId } = req.query;
 
@@ -192,7 +192,7 @@ module.exports = (crowi) => {
     param('userId').isMongoId().withMessage('userId is required'),
   ];
 
-  router.get('/:userId', accessTokenParser, loginRequired, validator.userBookmarkList, apiV3FormValidator, async(req, res) => {
+  router.get('/:userId', accessTokenParser(), loginRequired, validator.userBookmarkList, apiV3FormValidator, async(req, res) => {
     const { userId } = req.params;
 
     if (userId == null) {
@@ -246,7 +246,7 @@ module.exports = (crowi) => {
    *                schema:
    *                  $ref: '#/components/schemas/Bookmark'
    */
-  router.put('/', accessTokenParser, loginRequiredStrictly, addActivity, validator.bookmarks, apiV3FormValidator, async(req, res) => {
+  router.put('/', accessTokenParser(), loginRequiredStrictly, addActivity, validator.bookmarks, apiV3FormValidator, async(req, res) => {
     const { pageId, bool } = req.body;
     const userId = req.user?._id;
 

+ 3 - 3
apps/app/src/server/routes/apiv3/export.js

@@ -168,7 +168,7 @@ module.exports = (crowi) => {
    *                  status:
    *                    $ref: '#/components/schemas/ExportStatus'
    */
-  router.get('/status', accessTokenParser, loginRequired, adminRequired, async(req, res) => {
+  router.get('/status', accessTokenParser(), loginRequired, adminRequired, async(req, res) => {
     const status = await exportService.getStatus();
 
     // TODO: use res.apiv3
@@ -209,7 +209,7 @@ module.exports = (crowi) => {
    *                    type: boolean
    *                    description: whether the request is succeeded
    */
-  router.post('/', accessTokenParser, loginRequired, adminRequired, addActivity, async(req, res) => {
+  router.post('/', accessTokenParser(), loginRequired, adminRequired, addActivity, async(req, res) => {
     // TODO: add express validator
     try {
       const { collections } = req.body;
@@ -259,7 +259,7 @@ module.exports = (crowi) => {
    *                    type: boolean
    *                    description: whether the request is succeeded
    */
-  router.delete('/:fileName', accessTokenParser, loginRequired, adminRequired, validator.deleteFile, apiV3FormValidator, addActivity, async(req, res) => {
+  router.delete('/:fileName', accessTokenParser(), loginRequired, adminRequired, validator.deleteFile, apiV3FormValidator, addActivity, async(req, res) => {
     // TODO: add express validator
     const { fileName } = req.params;
 

+ 2 - 2
apps/app/src/server/routes/apiv3/g2g-transfer.ts

@@ -269,7 +269,7 @@ module.exports = (crowi: Crowi): Router => {
   });
 
   // eslint-disable-next-line max-len
-  receiveRouter.post('/generate-key', accessTokenParser, adminRequiredIfInstalled, appSiteUrlRequiredIfNotInstalled, async(req: Request, res: ApiV3Response) => {
+  receiveRouter.post('/generate-key', accessTokenParser(), adminRequiredIfInstalled, appSiteUrlRequiredIfNotInstalled, async(req: Request, res: ApiV3Response) => {
     const appSiteUrl = req.body.appSiteUrl ?? configManager.getConfig('app:siteUrl');
 
     let appSiteUrlOrigin: string;
@@ -295,7 +295,7 @@ module.exports = (crowi: Crowi): Router => {
   });
 
   // eslint-disable-next-line max-len
-  pushRouter.post('/transfer', accessTokenParser, loginRequiredStrictly, adminRequired, validator.transfer, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
+  pushRouter.post('/transfer', accessTokenParser(), loginRequiredStrictly, adminRequired, validator.transfer, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const { transferKey, collections, optionsMap } = req.body;
 
     // Parse transfer key

+ 5 - 5
apps/app/src/server/routes/apiv3/import.js

@@ -115,7 +115,7 @@ export default function route(crowi) {
    *                    type: object
    *                    description: import settings params
    */
-  router.get('/', accessTokenParser, loginRequired, adminRequired, async(req, res) => {
+  router.get('/', accessTokenParser(), loginRequired, adminRequired, async(req, res) => {
     try {
       const importSettingsParams = {
         esaTeamName: await crowi.configManager.getConfig('importer:esa:team_name'),
@@ -151,7 +151,7 @@ export default function route(crowi) {
    *                  status:
    *                    $ref: '#/components/schemas/ImportStatus'
    */
-  router.get('/status', accessTokenParser, loginRequired, adminRequired, async(req, res) => {
+  router.get('/status', accessTokenParser(), loginRequired, adminRequired, async(req, res) => {
     try {
       const status = await importService.getStatus();
       return res.apiv3(status);
@@ -196,7 +196,7 @@ export default function route(crowi) {
    *        200:
    *          description: Import process has requested
    */
-  router.post('/', accessTokenParser, loginRequired, adminRequired, addActivity, async(req, res) => {
+  router.post('/', accessTokenParser(), loginRequired, adminRequired, addActivity, async(req, res) => {
     // TODO: add express validator
     const { fileName, collections, options } = req.body;
 
@@ -319,7 +319,7 @@ export default function route(crowi) {
    *                      type: object
    *                      description: the property of each extracted file
    */
-  router.post('/upload', accessTokenParser, loginRequired, adminRequired, uploads.single('file'), addActivity, async(req, res) => {
+  router.post('/upload', accessTokenParser(), loginRequired, adminRequired, uploads.single('file'), addActivity, async(req, res) => {
     const { file } = req;
     const zipFile = importService.getFile(file.filename);
     let data = null;
@@ -361,7 +361,7 @@ export default function route(crowi) {
    *        200:
    *          description: all files are deleted
    */
-  router.delete('/all', accessTokenParser, loginRequired, adminRequired, async(req, res) => {
+  router.delete('/all', accessTokenParser(), loginRequired, adminRequired, async(req, res) => {
     try {
       importService.deleteAllZipFiles();
 

+ 4 - 4
apps/app/src/server/routes/apiv3/in-app-notification.ts

@@ -24,7 +24,7 @@ module.exports = (crowi) => {
 
   const activityEvent = crowi.event('activity');
 
-  router.get('/list', accessTokenParser, loginRequiredStrictly, async(req: CrowiRequest, res: ApiV3Response) => {
+  router.get('/list', accessTokenParser(), loginRequiredStrictly, async(req: CrowiRequest, res: ApiV3Response) => {
     // user must be set by loginRequiredStrictly
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     const user = req.user!;
@@ -79,7 +79,7 @@ module.exports = (crowi) => {
     return res.apiv3(serializedPaginationResult);
   });
 
-  router.get('/status', accessTokenParser, loginRequiredStrictly, async(req: CrowiRequest, res: ApiV3Response) => {
+  router.get('/status', accessTokenParser(), loginRequiredStrictly, async(req: CrowiRequest, res: ApiV3Response) => {
     // user must be set by loginRequiredStrictly
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     const user = req.user!;
@@ -93,7 +93,7 @@ module.exports = (crowi) => {
     }
   });
 
-  router.post('/open', accessTokenParser, loginRequiredStrictly, async(req: CrowiRequest, res: ApiV3Response) => {
+  router.post('/open', accessTokenParser(), loginRequiredStrictly, async(req: CrowiRequest, res: ApiV3Response) => {
     // user must be set by loginRequiredStrictly
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     const user = req.user!;
@@ -110,7 +110,7 @@ module.exports = (crowi) => {
     }
   });
 
-  router.put('/all-statuses-open', accessTokenParser, loginRequiredStrictly, addActivity, async(req: CrowiRequest, res: ApiV3Response) => {
+  router.put('/all-statuses-open', accessTokenParser(), loginRequiredStrictly, addActivity, async(req: CrowiRequest, res: ApiV3Response) => {
     // user must be set by loginRequiredStrictly
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     const user = req.user!;

+ 4 - 4
apps/app/src/server/routes/apiv3/page-listing.ts

@@ -66,7 +66,7 @@ const routerFactory = (crowi: Crowi): Router => {
   const router = express.Router();
 
 
-  router.get('/root', accessTokenParser, loginRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
+  router.get('/root', accessTokenParser(), loginRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const Page = mongoose.model<IPage, PageModel>('Page');
 
     let rootPage;
@@ -81,7 +81,7 @@ const routerFactory = (crowi: Crowi): Router => {
   });
 
   // eslint-disable-next-line max-len
-  router.get('/ancestors-children', accessTokenParser, loginRequired, ...validator.pagePathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response): Promise<any> => {
+  router.get('/ancestors-children', accessTokenParser(), loginRequired, ...validator.pagePathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response): Promise<any> => {
     const { path } = req.query;
 
     const pageService = crowi.pageService;
@@ -100,7 +100,7 @@ const routerFactory = (crowi: Crowi): Router => {
    * In most cases, using id should be prioritized
    */
   // eslint-disable-next-line max-len
-  router.get('/children', accessTokenParser, loginRequired, validator.pageIdOrPathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
+  router.get('/children', accessTokenParser(), loginRequired, validator.pageIdOrPathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const { id, path } = req.query;
 
     const pageService = crowi.pageService;
@@ -121,7 +121,7 @@ const routerFactory = (crowi: Crowi): Router => {
   });
 
   // eslint-disable-next-line max-len
-  router.get('/info', accessTokenParser, loginRequired, validator.pageIdsOrPathRequired, validator.infoParams, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
+  router.get('/info', accessTokenParser(), loginRequired, validator.pageIdsOrPathRequired, validator.infoParams, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const {
       pageIds, path, attachBookmarkCount: attachBookmarkCountParam, attachShortBody: attachShortBodyParam,
     } = req.query;

+ 1 - 1
apps/app/src/server/routes/apiv3/page/check-page-existence.ts

@@ -39,7 +39,7 @@ export const checkPageExistenceHandlersFactory: CreatePageHandlersFactory = (cro
   ];
 
   return [
-    accessTokenParser, loginRequired,
+    accessTokenParser(), loginRequired,
     validator, apiV3FormValidator,
     async(req: Req, res: ApiV3Response) => {
       const { path } = req.query;

+ 1 - 1
apps/app/src/server/routes/apiv3/page/create-page.ts

@@ -217,7 +217,7 @@ export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
   const addActivity = generateAddActivityMiddleware();
 
   return [
-    accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity,
+    accessTokenParser(), loginRequiredStrictly, excludeReadOnlyUser, addActivity,
     validator, apiV3FormValidator,
     async(req: CreatePageRequest, res: ApiV3Response) => {
       const {

+ 1 - 1
apps/app/src/server/routes/apiv3/page/get-yjs-data.ts

@@ -34,7 +34,7 @@ export const getYjsDataHandlerFactory: GetYjsDataHandlerFactory = (crowi) => {
   ];
 
   return [
-    accessTokenParser, loginRequiredStrictly,
+    accessTokenParser(), loginRequiredStrictly,
     validator, apiV3FormValidator,
     async(req: Req, res: ApiV3Response) => {
       const { pageId } = req.params;

+ 4 - 4
apps/app/src/server/routes/apiv3/page/index.ts

@@ -211,7 +211,7 @@ module.exports = (crowi) => {
    *                schema:
    *                  $ref: '#/components/schemas/Page'
    */
-  router.get('/', certifySharedPage, accessTokenParser, loginRequired, validator.getPage, apiV3FormValidator, async(req, res) => {
+  router.get('/', certifySharedPage, accessTokenParser(), loginRequired, validator.getPage, apiV3FormValidator, async(req, res) => {
     const { user, isSharedPage } = req;
     const {
       pageId, path, findAll, revisionId, shareLinkId, includeEmpty,
@@ -442,7 +442,7 @@ module.exports = (crowi) => {
    *                schema:
    *                  $ref: '#/components/schemas/Page'
    */
-  router.put('/likes', accessTokenParser, loginRequiredStrictly, addActivity, validator.likes, apiV3FormValidator, async(req, res) => {
+  router.put('/likes', accessTokenParser(), loginRequiredStrictly, addActivity, validator.likes, apiV3FormValidator, async(req, res) => {
     const { pageId, bool: isLiked } = req.body;
 
     let page;
@@ -1012,7 +1012,7 @@ module.exports = (crowi) => {
    *          500:
    *            description: Internal server error.
    */
-  router.put('/subscribe', accessTokenParser, loginRequiredStrictly, addActivity, validator.subscribe, apiV3FormValidator, async(req, res) => {
+  router.put('/subscribe', accessTokenParser(), loginRequiredStrictly, addActivity, validator.subscribe, apiV3FormValidator, async(req, res) => {
     const { pageId, status } = req.body;
     const userId = req.user._id;
 
@@ -1072,7 +1072,7 @@ module.exports = (crowi) => {
    *                   page:
    *                     $ref: '#/components/schemas/Page'
    */
-  router.put('/:pageId/content-width', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser,
+  router.put('/:pageId/content-width', accessTokenParser(), loginRequiredStrictly, excludeReadOnlyUser,
     validator.contentWidth, apiV3FormValidator, async(req, res) => {
       const { pageId } = req.params;
       const { expandContentWidth } = req.body;

+ 1 - 1
apps/app/src/server/routes/apiv3/page/publish-page.ts

@@ -38,7 +38,7 @@ export const publishPageHandlersFactory: PublishPageHandlersFactory = (crowi) =>
   ];
 
   return [
-    accessTokenParser, loginRequiredStrictly,
+    accessTokenParser(), loginRequiredStrictly,
     validator, apiV3FormValidator,
     async(req: Req, res: ApiV3Response) => {
       const { pageId } = req.params;

+ 1 - 1
apps/app/src/server/routes/apiv3/page/sync-latest-revision-body-to-yjs-draft.ts

@@ -39,7 +39,7 @@ export const syncLatestRevisionBodyToYjsDraftHandlerFactory: SyncLatestRevisionB
   ];
 
   return [
-    accessTokenParser, loginRequiredStrictly,
+    accessTokenParser(), loginRequiredStrictly,
     validator, apiV3FormValidator,
     async(req: Req, res: ApiV3Response) => {
       const { pageId } = req.params;

+ 1 - 1
apps/app/src/server/routes/apiv3/page/unpublish-page.ts

@@ -38,7 +38,7 @@ export const unpublishPageHandlersFactory: UnpublishPageHandlersFactory = (crowi
   ];
 
   return [
-    accessTokenParser, loginRequiredStrictly,
+    accessTokenParser(), loginRequiredStrictly,
     validator, apiV3FormValidator,
     async(req: Req, res: ApiV3Response) => {
       const { pageId } = req.params;

+ 1 - 1
apps/app/src/server/routes/apiv3/page/update-page.ts

@@ -133,7 +133,7 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
   const addActivity = generateAddActivityMiddleware();
 
   return [
-    accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity,
+    accessTokenParser(), loginRequiredStrictly, excludeReadOnlyUser, addActivity,
     validator, apiV3FormValidator,
     async(req: UpdatePageRequest, res: ApiV3Response) => {
       const {

+ 11 - 11
apps/app/src/server/routes/apiv3/pages/index.js

@@ -151,7 +151,7 @@ module.exports = (crowi) => {
    *          200:
    *            description: Return pages recently updated
    */
-  router.get('/recent', accessTokenParser, loginRequired, validator.recent, apiV3FormValidator, async(req, res) => {
+  router.get('/recent', accessTokenParser(), loginRequired, validator.recent, apiV3FormValidator, async(req, res) => {
     const limit = parseInt(req.query.limit) || 20;
     const offset = parseInt(req.query.offset) || 0;
     const includeWipPage = req.query.includeWipPage === 'true'; // Need validation using express-validator
@@ -270,7 +270,7 @@ module.exports = (crowi) => {
    *          409:
    *            description: page path is already existed
    */
-  router.put('/rename', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, validator.renamePage, apiV3FormValidator, async(req, res) => {
+  router.put('/rename', accessTokenParser(), loginRequiredStrictly, excludeReadOnlyUser, validator.renamePage, apiV3FormValidator, async(req, res) => {
     const { pageId, revisionId } = req.body;
 
     let newPagePath = normalizePath(req.body.newPagePath);
@@ -370,7 +370,7 @@ module.exports = (crowi) => {
     *            content:
     *              description: Empty response
     */
-  router.post('/resume-rename', accessTokenParser, loginRequiredStrictly, validator.resumeRenamePage, apiV3FormValidator,
+  router.post('/resume-rename', accessTokenParser(), loginRequiredStrictly, validator.resumeRenamePage, apiV3FormValidator,
     async(req, res) => {
 
       const { pageId } = req.body;
@@ -420,7 +420,7 @@ module.exports = (crowi) => {
    *                      items:
    *                        $ref: '#/components/schemas/Page'
    */
-  router.delete('/empty-trash', accessTokenParser, loginRequired, excludeReadOnlyUser, addActivity, apiV3FormValidator, async(req, res) => {
+  router.delete('/empty-trash', accessTokenParser(), loginRequired, excludeReadOnlyUser, addActivity, apiV3FormValidator, async(req, res) => {
     const options = {};
 
     const pagesInTrash = await crowi.pageService.findAllTrashPages(req.user);
@@ -526,7 +526,7 @@ module.exports = (crowi) => {
     *                              lastUpdateUser:
     *                                $ref: '#/components/schemas/User'
     */
-  router.get('/list', accessTokenParser, loginRequired, validator.displayList, apiV3FormValidator, async(req, res) => {
+  router.get('/list', accessTokenParser(), loginRequired, validator.displayList, apiV3FormValidator, async(req, res) => {
 
     const { path } = req.query;
     const limit = parseInt(req.query.limit) || await crowi.configManager.getConfig('customize:showPageLimitationS') || 10;
@@ -603,7 +603,7 @@ module.exports = (crowi) => {
    *          500:
    *            description: Internal server error.
    */
-  router.post('/duplicate', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity, validator.duplicatePage, apiV3FormValidator,
+  router.post('/duplicate', accessTokenParser(), loginRequiredStrictly, excludeReadOnlyUser, addActivity, validator.duplicatePage, apiV3FormValidator,
     async(req, res) => {
       const { pageId, isRecursively, onlyDuplicateUserRelatedResources } = req.body;
 
@@ -700,7 +700,7 @@ module.exports = (crowi) => {
    *                      items:
    *                        $ref: '#/components/schemas/Page'
    */
-  router.get('/subordinated-list', accessTokenParser, loginRequired, async(req, res) => {
+  router.get('/subordinated-list', accessTokenParser(), loginRequired, async(req, res) => {
     const { path } = req.query;
     const limit = parseInt(req.query.limit) || LIMIT_FOR_LIST;
 
@@ -760,7 +760,7 @@ module.exports = (crowi) => {
     *                      type: boolean
     *                      description: Whether pages were deleted completely
     */
-  router.post('/delete', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, validator.deletePages, apiV3FormValidator, async(req, res) => {
+  router.post('/delete', accessTokenParser(), loginRequiredStrictly, excludeReadOnlyUser, validator.deletePages, apiV3FormValidator, async(req, res) => {
     const {
       pageIdToRevisionIdMap, isCompletely, isRecursively, isAnyoneWithTheLink,
     } = req.body;
@@ -841,7 +841,7 @@ module.exports = (crowi) => {
    *                  description: Empty object
    */
   // eslint-disable-next-line max-len
-  router.post('/convert-pages-by-path', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, adminRequired, validator.convertPagesByPath, apiV3FormValidator, async(req, res) => {
+  router.post('/convert-pages-by-path', accessTokenParser(), loginRequiredStrictly, excludeReadOnlyUser, adminRequired, validator.convertPagesByPath, apiV3FormValidator, async(req, res) => {
     const { convertPath } = req.body;
 
     // Convert by path
@@ -893,7 +893,7 @@ module.exports = (crowi) => {
    *                  description: Empty object
   */
   // eslint-disable-next-line max-len
-  router.post('/legacy-pages-migration', accessTokenParser, loginRequired, excludeReadOnlyUser, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
+  router.post('/legacy-pages-migration', accessTokenParser(), loginRequired, excludeReadOnlyUser, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
     const { pageIds: _pageIds, isRecursively } = req.body;
 
     // Convert by pageIds
@@ -942,7 +942,7 @@ module.exports = (crowi) => {
    *                      type: number
    *                      description: Number of pages that can be migrated
    */
-  router.get('/v5-migration-status', accessTokenParser, loginRequired, async(req, res) => {
+  router.get('/v5-migration-status', accessTokenParser(), loginRequired, async(req, res) => {
     try {
       const isV5Compatible = crowi.configManager.getConfig('app:isV5Compatible');
       const migratablePagesCount = req.user != null ? await crowi.pageService.countPagesCanNormalizeParentByUser(req.user) : null; // null check since not using loginRequiredStrictly

+ 2 - 1
apps/app/src/server/routes/apiv3/personal-setting/delete-access-token.ts

@@ -3,6 +3,7 @@ import type { Request, RequestHandler } from 'express';
 import { query } from 'express-validator';
 
 import { SupportedAction } from '~/interfaces/activity';
+import { SCOPE } from '~/interfaces/scope';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
@@ -37,7 +38,7 @@ export const deleteAccessTokenHandlersFactory: DeleteAccessTokenHandlersFactory
   const activityEvent = crowi.event('activity');
 
   return [
-    accessTokenParser,
+    accessTokenParser([SCOPE.WRITE.USER.API.ACCESS_TOKEN]),
     loginRequiredStrictly,
     addActivity,
     validator,

+ 3 - 1
apps/app/src/server/routes/apiv3/personal-setting/delete-all-access-tokens.ts

@@ -3,6 +3,7 @@ import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
 
 import { SupportedAction } from '~/interfaces/activity';
+import { SCOPE } from '~/interfaces/scope';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
@@ -26,7 +27,8 @@ export const deleteAllAccessTokensHandlersFactory: DeleteAllAccessTokensHandlers
   const activityEvent = crowi.event('activity');
 
   return [
-    accessTokenParser, loginRequiredStrictly,
+    accessTokenParser([SCOPE.WRITE.USER.API.ACCESS_TOKEN]),
+    loginRequiredStrictly,
     addActivity,
     async(req: DeleteAllAccessTokensRequest, res: ApiV3Response) => {
       const { user } = req;

+ 14 - 7
apps/app/src/server/routes/apiv3/personal-setting/generate-access-token.ts

@@ -6,9 +6,11 @@ import type { Request, RequestHandler } from 'express';
 import { body } from 'express-validator';
 
 import { SupportedAction } from '~/interfaces/activity';
+import type { Scope } from '~/interfaces/scope';
 import type Crowi from '~/server/crowi';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { AccessToken } from '~/server/models/access-token';
+import { isValidScope } from '~/server/util/scope-utils';
 import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
@@ -19,7 +21,7 @@ const logger = loggerFactory('growi:routes:apiv3:personal-setting:generate-acces
 type ReqBody = {
   expiredAt: Date,
   description?: string,
-  scope?: string[],
+  scopes?: Scope[],
 }
 
 interface GenerateAccessTokenRequest extends Request<undefined, ApiV3Response, ReqBody> {
@@ -56,14 +58,19 @@ const validator = [
     .isLength({ max: 200 })
     .withMessage('description must be less than or equal to 200 characters'),
 
-  body('scope')
+  body('scopes')
     .optional()
     .isArray()
     .withMessage('scope must be an array')
-    .custom(() => {
-      // TODO: Check if all values are valid
+    .custom((scopes: Scope[]) => {
+      scopes.forEach((scope) => {
+        if (!isValidScope(scope)) {
+          throw new Error(`Invalid scope: ${scope}}`);
+        }
+      });
       return true;
-    }),
+    })
+    .withMessage('Invalid scope'),
 ];
 
 export const generateAccessTokenHandlerFactory: GenerateAccessTokenHandlerFactory = (crowi) => {
@@ -81,10 +88,10 @@ export const generateAccessTokenHandlerFactory: GenerateAccessTokenHandlerFactor
     async(req: GenerateAccessTokenRequest, res: ApiV3Response) => {
 
       const { user, body } = req;
-      const { expiredAt, description, scope } = body;
+      const { expiredAt, description, scopes } = body;
 
       try {
-        const tokenData = await AccessToken.generateToken(user._id, expiredAt, scope, description);
+        const tokenData = await AccessToken.generateToken(user._id, expiredAt, scopes, description);
 
         const parameters = { action: SupportedAction.ACTION_USER_ACCESS_TOKEN_CREATE };
         activityEvent.emit('update', res.locals.activity._id, parameters);

+ 3 - 1
apps/app/src/server/routes/apiv3/personal-setting/get-access-tokens.ts

@@ -2,6 +2,7 @@ import type { IUserHasId } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
 
+import { SCOPE } from '~/interfaces/scope';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
@@ -24,7 +25,8 @@ export const getAccessTokenHandlerFactory: GetAccessTokenHandlerFactory = (crowi
   const addActivity = generateAddActivityMiddleware();
 
   return [
-    accessTokenParser, loginRequiredStrictly,
+    accessTokenParser([SCOPE.READ.USER.API.ACCESS_TOKEN]),
+    loginRequiredStrictly,
     addActivity,
     async(req: GetAccessTokenRequest, res: ApiV3Response) => {
       const { user } = req;

+ 13 - 13
apps/app/src/server/routes/apiv3/personal-setting/index.js

@@ -151,7 +151,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: personal params
    */
-  router.get('/', accessTokenParser, loginRequiredStrictly, async(req, res) => {
+  router.get('/', accessTokenParser(), loginRequiredStrictly, async(req, res) => {
     const { username } = req.user;
     try {
       const user = await User.findUserByUsername(username);
@@ -189,7 +189,7 @@ module.exports = (crowi) => {
    *                    isPasswordSet:
    *                      type: boolean
    */
-  router.get('/is-password-set', accessTokenParser, loginRequiredStrictly, async(req, res) => {
+  router.get('/is-password-set', accessTokenParser(), loginRequiredStrictly, async(req, res) => {
     const { username } = req.user;
 
     try {
@@ -231,7 +231,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: personal params
    */
-  router.put('/', accessTokenParser, loginRequiredStrictly, addActivity, validator.personal, apiV3FormValidator, async(req, res) => {
+  router.put('/', accessTokenParser(), loginRequiredStrictly, addActivity, validator.personal, apiV3FormValidator, async(req, res) => {
 
     try {
       const user = await User.findOne({ _id: req.user.id });
@@ -282,7 +282,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: user data
    */
-  router.put('/image-type', accessTokenParser, loginRequiredStrictly, addActivity, validator.imageType, apiV3FormValidator, async(req, res) => {
+  router.put('/image-type', accessTokenParser(), loginRequiredStrictly, addActivity, validator.imageType, apiV3FormValidator, async(req, res) => {
     const { isGravatarEnabled } = req.body;
 
     try {
@@ -319,7 +319,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: array of external accounts
    */
-  router.get('/external-accounts', accessTokenParser, loginRequiredStrictly, async(req, res) => {
+  router.get('/external-accounts', accessTokenParser(), loginRequiredStrictly, async(req, res) => {
     const userData = req.user;
 
     try {
@@ -359,7 +359,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: user data updated
    */
-  router.put('/password', accessTokenParser, loginRequiredStrictly, addActivity, validator.password, apiV3FormValidator, async(req, res) => {
+  router.put('/password', accessTokenParser(), loginRequiredStrictly, addActivity, validator.password, apiV3FormValidator, async(req, res) => {
     const { body, user } = req;
     const { oldPassword, newPassword } = body;
 
@@ -528,7 +528,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: Ldap account associate to me
    */
-  router.put('/associate-ldap', accessTokenParser, loginRequiredStrictly, addActivity, validator.associateLdap, apiV3FormValidator, async(req, res) => {
+  router.put('/associate-ldap', accessTokenParser(), loginRequiredStrictly, addActivity, validator.associateLdap, apiV3FormValidator, async(req, res) => {
     const { passportService } = crowi;
     const { user, body } = req;
     const { username } = body;
@@ -581,7 +581,7 @@ module.exports = (crowi) => {
    *                      description: Ldap account disassociate to me
    */
   // eslint-disable-next-line max-len
-  router.put('/disassociate-ldap', accessTokenParser, loginRequiredStrictly, addActivity, validator.disassociateLdap, apiV3FormValidator, async(req, res) => {
+  router.put('/disassociate-ldap', accessTokenParser(), loginRequiredStrictly, addActivity, validator.disassociateLdap, apiV3FormValidator, async(req, res) => {
     const { user, body } = req;
     const { providerType, accountId } = body;
 
@@ -625,7 +625,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: editor settings
    */
-  router.put('/editor-settings', accessTokenParser, loginRequiredStrictly, addActivity, validator.editorSettings, apiV3FormValidator, async(req, res) => {
+  router.put('/editor-settings', accessTokenParser(), loginRequiredStrictly, addActivity, validator.editorSettings, apiV3FormValidator, async(req, res) => {
     const query = { userId: req.user.id };
     const { body } = req;
 
@@ -675,7 +675,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: editor settings
    */
-  router.get('/editor-settings', accessTokenParser, loginRequiredStrictly, async(req, res) => {
+  router.get('/editor-settings', accessTokenParser(), loginRequiredStrictly, async(req, res) => {
     try {
       const query = { userId: req.user.id };
       const editorSettings = await EditorSettings.findOne(query) ?? new EditorSettings();
@@ -708,7 +708,7 @@ module.exports = (crowi) => {
    *                      description: in-app-notification-settings
    */
   // eslint-disable-next-line max-len
-  router.put('/in-app-notification-settings', accessTokenParser, loginRequiredStrictly, addActivity, validator.inAppNotificationSettings, apiV3FormValidator, async(req, res) => {
+  router.put('/in-app-notification-settings', accessTokenParser(), loginRequiredStrictly, addActivity, validator.inAppNotificationSettings, apiV3FormValidator, async(req, res) => {
     const query = { userId: req.user.id };
     const subscribeRules = req.body.subscribeRules;
 
@@ -751,7 +751,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: InAppNotificationSettings
    */
-  router.get('/in-app-notification-settings', accessTokenParser, loginRequiredStrictly, async(req, res) => {
+  router.get('/in-app-notification-settings', accessTokenParser(), loginRequiredStrictly, async(req, res) => {
     const query = { userId: req.user.id };
     try {
       const response = await InAppNotificationSettings.findOne(query);
@@ -764,7 +764,7 @@ module.exports = (crowi) => {
   });
 
   // eslint-disable-next-line max-len
-  router.put('/questionnaire-settings', accessTokenParser, loginRequiredStrictly, validator.questionnaireSettings, apiV3FormValidator, async(req, res) => {
+  router.put('/questionnaire-settings', accessTokenParser(), loginRequiredStrictly, validator.questionnaireSettings, apiV3FormValidator, async(req, res) => {
     const { isQuestionnaireEnabled } = req.body;
     const { user } = req;
     try {

+ 2 - 2
apps/app/src/server/routes/apiv3/revisions.js

@@ -134,7 +134,7 @@ module.exports = (crowi) => {
    *                    type: number
    *                    description: offset of the revisions
    */
-  router.get('/list', certifySharedPage, accessTokenParser, loginRequired, validator.retrieveRevisions, apiV3FormValidator, async(req, res) => {
+  router.get('/list', certifySharedPage, accessTokenParser(), loginRequired, validator.retrieveRevisions, apiV3FormValidator, async(req, res) => {
     const pageId = req.query.pageId;
     const limit = req.query.limit || await crowi.configManager.getConfig('customize:showPageLimitationS') || 10;
     const { isSharedPage } = req;
@@ -233,7 +233,7 @@ module.exports = (crowi) => {
    *                    revision:
    *                      $ref: '#/components/schemas/Revision'
    */
-  router.get('/:id', certifySharedPage, accessTokenParser, loginRequired, validator.retrieveRevisionById, apiV3FormValidator, async(req, res) => {
+  router.get('/:id', certifySharedPage, accessTokenParser(), loginRequired, validator.retrieveRevisionById, apiV3FormValidator, async(req, res) => {
     const revisionId = req.params.id;
     const pageId = req.query.pageId;
     const { isSharedPage } = req;

+ 3 - 3
apps/app/src/server/routes/apiv3/search.js

@@ -126,7 +126,7 @@ module.exports = (crowi) => {
    *                    description: Status of indices
    *                    $ref: '#/components/schemas/Indices'
    */
-  router.get('/indices', noCache(), accessTokenParser, loginRequired, adminRequired, async(req, res) => {
+  router.get('/indices', noCache(), accessTokenParser(), loginRequired, adminRequired, async(req, res) => {
     const { searchService } = crowi;
 
     if (!searchService.isConfigured) {
@@ -154,7 +154,7 @@ module.exports = (crowi) => {
    *        200:
    *          description: Successfully connected
    */
-  router.post('/connection', accessTokenParser, loginRequired, adminRequired, addActivity, async(req, res) => {
+  router.post('/connection', accessTokenParser(), loginRequired, adminRequired, addActivity, async(req, res) => {
     const { searchService } = crowi;
 
     if (!searchService.isConfigured) {
@@ -208,7 +208,7 @@ module.exports = (crowi) => {
    *                    type: string
    *                    description: Operation is successfully processed, or requested
    */
-  router.put('/indices', accessTokenParser, loginRequired, adminRequired, addActivity, validatorForPutIndices, apiV3FormValidator, async(req, res) => {
+  router.put('/indices', accessTokenParser(), loginRequired, adminRequired, addActivity, validatorForPutIndices, apiV3FormValidator, async(req, res) => {
     const operation = req.body.operation;
 
     const { searchService } = crowi;

+ 3 - 3
apps/app/src/server/routes/apiv3/slack-integration-settings.js

@@ -169,7 +169,7 @@ module.exports = (crowi) => {
    *          200:
    *            description: Succeeded to get info.
    */
-  router.get('/', accessTokenParser, loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.get('/', accessTokenParser(), loginRequiredStrictly, adminRequired, async(req, res) => {
 
     const { configManager, slackIntegrationService } = crowi;
     const currentBotType = configManager.getConfig('slackbot:currentBotType');
@@ -305,7 +305,7 @@ module.exports = (crowi) => {
    *             description: Succeeded to put botType setting.
    */
   // eslint-disable-next-line max-len
-  router.put('/bot-type', accessTokenParser, loginRequiredStrictly, adminRequired, addActivity, validator.botType, apiV3FormValidator, async(req, res) => {
+  router.put('/bot-type', accessTokenParser(), loginRequiredStrictly, adminRequired, addActivity, validator.botType, apiV3FormValidator, async(req, res) => {
     const { currentBotType } = req.body;
 
     if (currentBotType == null) {
@@ -342,7 +342,7 @@ module.exports = (crowi) => {
    *           200:
    *             description: Succeeded to delete botType setting.
    */
-  router.delete('/bot-type', accessTokenParser, loginRequiredStrictly, adminRequired, addActivity, apiV3FormValidator, async(req, res) => {
+  router.delete('/bot-type', accessTokenParser(), loginRequiredStrictly, adminRequired, addActivity, apiV3FormValidator, async(req, res) => {
     try {
       await handleBotTypeChanging(req, res, null);
 

+ 4 - 4
apps/app/src/server/routes/apiv3/users.js

@@ -246,7 +246,7 @@ module.exports = (crowi) => {
    *                      $ref: '#/components/schemas/PaginateResult'
    */
 
-  router.get('/', accessTokenParser, loginRequired, validator.statusList, apiV3FormValidator, async(req, res) => {
+  router.get('/', accessTokenParser(), loginRequired, validator.statusList, apiV3FormValidator, async(req, res) => {
 
     const page = parseInt(req.query.page) || 1;
     // status
@@ -351,7 +351,7 @@ module.exports = (crowi) => {
    *                    paginateResult:
    *                      $ref: '#/components/schemas/PaginateResult'
    */
-  router.get('/:id/recent', accessTokenParser, loginRequired, validator.recentCreatedByUser, apiV3FormValidator, async(req, res) => {
+  router.get('/:id/recent', accessTokenParser(), loginRequired, validator.recentCreatedByUser, apiV3FormValidator, async(req, res) => {
     const { id } = req.params;
 
     let user;
@@ -1131,7 +1131,7 @@ module.exports = (crowi) => {
    *            500:
    *              $ref: '#/components/responses/500'
    */
-  router.get('/list', accessTokenParser, loginRequired, async(req, res) => {
+  router.get('/list', accessTokenParser(), loginRequired, async(req, res) => {
     const userIds = req.query.userIds ?? null;
 
     let userFetcher;
@@ -1160,7 +1160,7 @@ module.exports = (crowi) => {
     return res.apiv3(data);
   });
 
-  router.get('/usernames', accessTokenParser, loginRequired, validator.usernames, apiV3FormValidator, async(req, res) => {
+  router.get('/usernames', accessTokenParser(), loginRequired, validator.usernames, apiV3FormValidator, async(req, res) => {
     const q = req.query.q;
     const offset = +req.query.offset || 0;
     const limit = +req.query.limit || 10;

+ 14 - 14
apps/app/src/server/routes/index.js

@@ -120,26 +120,26 @@ module.exports = function(crowi, app) {
 
   const apiV1Router = express.Router();
 
-  apiV1Router.get('/search'                        , accessTokenParser , loginRequired , search.api.search);
+  apiV1Router.get('/search'                        , accessTokenParser() , loginRequired , search.api.search);
 
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
-  apiV1Router.get('/pages.updatePost'    , accessTokenParser, loginRequired, page.api.getUpdatePost);
-  apiV1Router.get('/pages.getPageTag'    , accessTokenParser , loginRequired , page.api.getPageTag);
+  apiV1Router.get('/pages.updatePost'    , accessTokenParser(), loginRequired, page.api.getUpdatePost);
+  apiV1Router.get('/pages.getPageTag'    , accessTokenParser() , loginRequired , page.api.getPageTag);
   // allow posting to guests because the client doesn't know whether the user logged in
   apiV1Router.post('/pages.remove'       , loginRequiredStrictly , excludeReadOnlyUser, page.validator.remove, apiV1FormValidator, page.api.remove); // (Avoid from API Token)
   apiV1Router.post('/pages.revertRemove' , loginRequiredStrictly , excludeReadOnlyUser, page.validator.revertRemove, apiV1FormValidator, page.api.revertRemove); // (Avoid from API Token)
   apiV1Router.post('/pages.unlink'       , loginRequiredStrictly , excludeReadOnlyUser, page.api.unlink); // (Avoid from API Token)
-  apiV1Router.get('/tags.list'           , accessTokenParser, loginRequired, tag.api.list);
-  apiV1Router.get('/tags.search'         , accessTokenParser, loginRequired, tag.api.search);
-  apiV1Router.post('/tags.update'        , accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity, tag.api.update);
-  apiV1Router.get('/comments.get'        , accessTokenParser , loginRequired , comment.api.get);
-  apiV1Router.post('/comments.add'       , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , excludeReadOnlyUserIfCommentNotAllowed, addActivity, comment.api.add);
-  apiV1Router.post('/comments.update'    , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , excludeReadOnlyUserIfCommentNotAllowed, addActivity, comment.api.update);
-  apiV1Router.post('/comments.remove'    , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUserIfCommentNotAllowed, addActivity, comment.api.remove);
-
-  apiV1Router.post('/attachments.uploadProfileImage'   , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly , excludeReadOnlyUser, attachmentApi.uploadProfileImage);
-  apiV1Router.post('/attachments.remove'               , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity ,attachmentApi.remove);
-  apiV1Router.post('/attachments.removeProfileImage'   , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, attachmentApi.removeProfileImage);
+  apiV1Router.get('/tags.list'           , accessTokenParser(), loginRequired, tag.api.list);
+  apiV1Router.get('/tags.search'         , accessTokenParser(), loginRequired, tag.api.search);
+  apiV1Router.post('/tags.update'        , accessTokenParser(), loginRequiredStrictly, excludeReadOnlyUser, addActivity, tag.api.update);
+  apiV1Router.get('/comments.get'        , accessTokenParser() , loginRequired , comment.api.get);
+  apiV1Router.post('/comments.add'       , comment.api.validators.add(), accessTokenParser() , loginRequiredStrictly , excludeReadOnlyUserIfCommentNotAllowed, addActivity, comment.api.add);
+  apiV1Router.post('/comments.update'    , comment.api.validators.add(), accessTokenParser() , loginRequiredStrictly , excludeReadOnlyUserIfCommentNotAllowed, addActivity, comment.api.update);
+  apiV1Router.post('/comments.remove'    , accessTokenParser() , loginRequiredStrictly , excludeReadOnlyUserIfCommentNotAllowed, addActivity, comment.api.remove);
+
+  apiV1Router.post('/attachments.uploadProfileImage'   , uploads.single('file'), autoReap, accessTokenParser(), loginRequiredStrictly , excludeReadOnlyUser, attachmentApi.uploadProfileImage);
+  apiV1Router.post('/attachments.remove'               , accessTokenParser() , loginRequiredStrictly , excludeReadOnlyUser, addActivity ,attachmentApi.remove);
+  apiV1Router.post('/attachments.removeProfileImage'   , accessTokenParser() , loginRequiredStrictly , excludeReadOnlyUser, attachmentApi.removeProfileImage);
 
   // API v1
   app.use('/_api', unavailableWhenMaintenanceModeForApi, apiV1Router);

+ 90 - 0
apps/app/src/server/util/scope-util.spec.ts

@@ -0,0 +1,90 @@
+import { describe, it, expect } from 'vitest';
+
+import { SCOPE } from '../../interfaces/scope';
+
+import {
+  isValidScope, hasAllScope, extractAllScope, extractScopes,
+} from './scope-utils';
+
+describe('scope-utils', () => {
+  describe('isValidScope', () => {
+    it('should return true for valid scopes', () => {
+      expect(isValidScope(SCOPE.READ.USER.API.API_TOKEN)).toBe(true);
+      expect(isValidScope(SCOPE.WRITE.USER.API.ACCESS_TOKEN)).toBe(true);
+      expect(isValidScope(SCOPE.READ.ADMIN.APP)).toBe(true);
+    });
+
+    it('should return false for invalid scopes', () => {
+      expect(isValidScope('invalid:scope' as any)).toBe(false);
+      expect(isValidScope('read:invalid:path' as any)).toBe(false);
+      expect(isValidScope('write:user:invalid' as any)).toBe(false);
+    });
+  });
+
+  describe('hasAllScope', () => {
+    it('should return true for scopes ending with *', () => {
+      expect(hasAllScope(SCOPE.READ.USER.API.ALL)).toBe(true);
+      expect(hasAllScope(SCOPE.WRITE.ADMIN.ALL)).toBe(true);
+    });
+
+    it('should return false for specific scopes', () => {
+      expect(hasAllScope(SCOPE.READ.USER.API.API_TOKEN)).toBe(false);
+      expect(hasAllScope(SCOPE.WRITE.USER.API.ACCESS_TOKEN)).toBe(false);
+    });
+  });
+
+  describe('extractAllScope', () => {
+    it('should extract all specific scopes from ALL scope', () => {
+      const extracted = extractAllScope(SCOPE.READ.USER.API.ALL);
+      expect(extracted).toContain(SCOPE.READ.USER.API.API_TOKEN);
+      expect(extracted).toContain(SCOPE.READ.USER.API.ACCESS_TOKEN);
+      expect(extracted).not.toContain(SCOPE.READ.USER.API.ALL);
+    });
+
+    it('should return array with single scope for specific scope', () => {
+      const scope = SCOPE.READ.USER.API.API_TOKEN;
+      const extracted = extractAllScope(scope);
+      expect(extracted).toEqual([scope]);
+    });
+  });
+
+  describe('extractScopes', () => {
+    it('should return empty array for undefined input', () => {
+      expect(extractScopes()).toEqual([]);
+    });
+
+    it('should extract all implied scopes including READ permission for WRITE scopes', () => {
+      const scopes = [SCOPE.WRITE.USER.API.ACCESS_TOKEN];
+      const extracted = extractScopes(scopes);
+
+      expect(extracted).toContain(SCOPE.WRITE.USER.API.ACCESS_TOKEN);
+      expect(extracted).toContain(SCOPE.READ.USER.API.ACCESS_TOKEN);
+    });
+
+    it('should extract all specific scopes from ALL scope with implied permissions', () => {
+      const scopes = [SCOPE.WRITE.USER.API.ALL];
+      const extracted = extractScopes(scopes);
+
+      // Should include both WRITE and READ permissions for all specific scopes
+      expect(extracted).toContain(SCOPE.WRITE.USER.API.API_TOKEN);
+      expect(extracted).toContain(SCOPE.WRITE.USER.API.ACCESS_TOKEN);
+      expect(extracted).toContain(SCOPE.READ.USER.API.API_TOKEN);
+      expect(extracted).toContain(SCOPE.READ.USER.API.ACCESS_TOKEN);
+
+      // Should not include ALL scopes
+      expect(extracted).not.toContain(SCOPE.WRITE.USER.API.ALL);
+      expect(extracted).not.toContain(SCOPE.READ.USER.API.ALL);
+    });
+
+    it('should remove duplicate scopes', () => {
+      const scopes = [
+        SCOPE.WRITE.USER.API.ACCESS_TOKEN,
+        SCOPE.READ.USER.API.ACCESS_TOKEN, // This is implied by WRITE
+      ];
+      const extracted = extractScopes(scopes);
+
+      const accessTokenScopes = extracted.filter(s => s.endsWith('access_token'));
+      expect(accessTokenScopes).toHaveLength(2); // Only READ and WRITE, no duplicates
+    });
+  });
+});

+ 106 - 0
apps/app/src/server/util/scope-utils.ts

@@ -0,0 +1,106 @@
+import {
+  ACTION, ALL_SIGN, SCOPE, type Scope,
+} from '../../interfaces/scope';
+
+export const isValidScope = (scope: Scope): boolean => {
+  const scopeParts = scope.split(':').map(x => (x === ALL_SIGN ? 'ALL' : x.toUpperCase()));
+  let obj: any = SCOPE;
+  scopeParts.forEach((part) => {
+    if (obj[part] == null) {
+      return false;
+    }
+    obj = obj[part];
+  });
+  return obj === scope;
+};
+
+export const hasAllScope = (scope: Scope): scope is Scope => {
+  return scope.endsWith(`:${ALL_SIGN}`);
+};
+
+/**
+ * Returns all values of the scope object
+ * For example, SCOPE.READ.USER.API.ALL returns ['read:user:api:access_token', 'read:user:api:api_token']
+ */
+const getAllScopeValuesFromObj = (scopeObj: any): Scope[] => {
+  const result: Scope[] = [];
+
+  const traverse = (current: any): void => {
+    if (typeof current !== 'object' || current === null) {
+      if (typeof current === 'string') {
+        result.push(current as Scope);
+      }
+      return;
+    }
+    Object.values(current).forEach((value) => {
+      traverse(value);
+    });
+  };
+  traverse(scopeObj);
+  return result;
+};
+
+/**
+ * Returns all implied scopes for a given scope
+ * For example, WRITE permission implies READ permission
+ */
+const getImpliedScopes = (scope: Scope): Scope[] => {
+  const scopeParts = scope.split(':');
+  if (scopeParts[0] === ACTION.READ) {
+    return [scope];
+  }
+  if (scopeParts[0] === ACTION.WRITE) {
+    return [scope, `${ACTION.READ}:${scopeParts.slice(1).join(':')}` as Scope];
+  }
+  return [];
+};
+
+export const extractAllScope = (scope: Scope): Scope[] => {
+  if (!hasAllScope(scope)) {
+    return [scope];
+  }
+  const result = [] as Scope[];
+  const scopeParts = scope.split(':').map(x => (x.toUpperCase()));
+  let obj: any = SCOPE;
+  scopeParts.forEach((part) => {
+    if (part === ALL_SIGN) {
+      return;
+    }
+    obj = obj[part];
+  });
+  getAllScopeValuesFromObj(obj).forEach((value) => {
+    result.push(value);
+  });
+  return result.filter(scope => !hasAllScope(scope));
+};
+
+
+/**
+ * Extracts scopes from a given array of scopes
+ * And delete all scopes
+ * For example, [SCOPE.WRITE.USER.API.ALL] === ['write:user:api:all']
+ * returns ['read:user:api:access_token',
+ *          'read:user:api:api_token'
+ *          'write:user:api:access_token',
+ *          'write:user:api:api_token']
+ */
+export const extractScopes = (scopes?: Scope[]): Scope[] => {
+  if (scopes == null) {
+    return [];
+  }
+
+  const result = new Set<Scope>(); // remove duplicates
+  const impliedScopes = new Set<Scope>();
+
+  scopes.forEach((scope) => {
+    getImpliedScopes(scope).forEach((impliedScope) => {
+      impliedScopes.add(impliedScope);
+    });
+  });
+  impliedScopes.forEach((scope) => {
+    extractAllScope(scope).forEach((extractedScope) => {
+      result.add(extractedScope);
+    });
+  });
+  return Array.from(result);
+};

+ 2 - 2
packages/remark-attachment-refs/src/server/routes/refs.ts

@@ -83,7 +83,7 @@ export const routesFactory = (crowi): any => {
   /**
    * return an Attachment model
    */
-  router.get('/ref', accessTokenParser, loginRequired, async(req: RequestWithUser, res) => {
+  router.get('/ref', accessTokenParser(), loginRequired, async(req: RequestWithUser, res) => {
     const user = req.user;
     const { pagePath, fileNameOrId } = req.query;
     const filterXSS = new FilterXSS();
@@ -138,7 +138,7 @@ export const routesFactory = (crowi): any => {
   /**
    * return a list of Attachment
    */
-  router.get('/refs', accessTokenParser, loginRequired, async(req: RequestWithUser, res) => {
+  router.get('/refs', accessTokenParser(), loginRequired, async(req: RequestWithUser, res) => {
     const user = req.user;
     const { prefix, pagePath } = req.query;
     const options: Record<string, string | undefined> = JSON.parse(req.query.options?.toString() ?? '');

+ 1 - 1
packages/remark-lsx/src/server/index.ts

@@ -54,7 +54,7 @@ const middleware = (crowi: any, app: any): void => {
   const loginRequired = crowi.require('../middlewares/login-required')(crowi, true, loginRequiredFallback);
   const accessTokenParser = crowi.accessTokenParser;
 
-  app.get('/_api/lsx', accessTokenParser, loginRequired, lsxValidator, paramValidator, listPages);
+  app.get('/_api/lsx', accessTokenParser(), loginRequired, lsxValidator, paramValidator, listPages);
 };
 
 export default middleware;