Browse Source

Merge pull request #11244 from growilabs/imprv/x-access-token-header

support: Enable API token header "X-GROWI-ACCESS-TOKEN"
mergify[bot] 4 days ago
parent
commit
73fd1f4403
23 changed files with 1040 additions and 18 deletions
  1. 118 0
      .kiro/specs/access-token-parser/brief.md
  2. 285 0
      .kiro/specs/access-token-parser/design.md
  3. 117 0
      .kiro/specs/access-token-parser/requirements.md
  4. 163 0
      .kiro/specs/access-token-parser/research.md
  5. 22 0
      .kiro/specs/access-token-parser/spec.json
  6. 57 0
      .kiro/specs/access-token-parser/tasks.md
  7. 7 0
      apps/app/bin/openapi/definition-apiv1.js
  8. 7 0
      apps/app/bin/openapi/definition-apiv3.js
  9. 1 0
      apps/app/src/features/ai-tools/suggest-path/server/routes/apiv3/index.ts
  10. 38 0
      apps/app/src/server/middlewares/access-token-parser/access-token.integ.ts
  11. 3 8
      apps/app/src/server/middlewares/access-token-parser/access-token.ts
  12. 28 0
      apps/app/src/server/middlewares/access-token-parser/api-token.integ.ts
  13. 3 10
      apps/app/src/server/middlewares/access-token-parser/api-token.ts
  14. 135 0
      apps/app/src/server/middlewares/access-token-parser/extract-access-token.spec.ts
  15. 31 0
      apps/app/src/server/middlewares/access-token-parser/extract-access-token.ts
  16. 1 0
      apps/app/src/server/routes/apiv3/activity.ts
  17. 3 0
      apps/app/src/server/routes/apiv3/app-settings/index.ts
  18. 6 0
      apps/app/src/server/routes/apiv3/bookmark-folder.ts
  19. 2 0
      apps/app/src/server/routes/apiv3/g2g-transfer.ts
  20. 4 0
      apps/app/src/server/routes/apiv3/import.ts
  21. 4 0
      apps/app/src/server/routes/apiv3/in-app-notification.ts
  22. 4 0
      apps/app/src/server/routes/apiv3/page-listing.ts
  23. 1 0
      apps/app/src/server/routes/apiv3/user-activities.ts

+ 118 - 0
.kiro/specs/access-token-parser/brief.md

@@ -0,0 +1,118 @@
+# Brief: access-token-parser
+
+## Problem
+
+GROWI's API authentication currently accepts an access token in only two ways: as a
+Bearer token in the `Authorization` header, or as an `access_token` query parameter
+(see https://docs.growi.org/en/api/rest-v3.html).
+
+When the `Authorization` header is already consumed by something else (e.g. Basic
+authentication on a reverse proxy), callers are forced to fall back to the query
+parameter. Putting the token in a GET query string is insecure — it leaks into URLs,
+server logs, browser history, and referrers
+(see https://owasp.org/www-community/vulnerabilities/Information_exposure_through_query_strings_in_url).
+
+There is also no spec governing the `access-token-parser` middleware, so future changes
+have no requirements/design baseline to maintain against (cc-sdd).
+
+## Current State
+
+- The `access-token-parser` middleware is **already implemented** at
+  `apps/app/src/server/middlewares/access-token-parser/`:
+  - `index.ts` — `accessTokenParser(scopes, opts)` orchestrator; runs
+    `parserForAccessToken(scopes)` and, when `opts.acceptLegacy`, `parserForApiToken`.
+  - `access-token.ts` — `parserForAccessToken`: scope-checked AccessToken model lookup.
+  - `api-token.ts` — `parserForApiToken`: legacy `User.apiToken` lookup.
+  - `extract-bearer-token.ts` — pulls the Bearer token from `Authorization`.
+  - Co-located `*.integ.ts` integration tests.
+- Token extraction order today (both parsers):
+  `bearerToken ?? req.query.access_token ?? req.body.access_token`.
+  **There is no header source between Bearer and query.**
+- An **open upstream PR (#10443, branch `support/api-token-header`, base `master`,
+  +114/-6, 13 files)** by ryu-sato adds exactly the missing `X-GROWI-ACCESS-TOKEN`
+  header support. This PR is the salvage source for the current deliverable.
+- **No `access-token-parser` spec exists yet.**
+
+## Desired Outcome
+
+- API callers can authenticate by sending the token in the `x-growi-access-token`
+  request header, with priority directly after the `Authorization` Bearer token and
+  before the query/body sources — for both the scoped AccessToken path and the legacy
+  api-token path.
+- The header auth method is advertised in the OpenAPI definitions (apiv1 + apiv3) as a
+  new `accessTokenHeaderAuth` security scheme and applied to the relevant routes.
+- A spec (this one) exists so all future `access-token-parser` changes can be maintained
+  via cc-sdd (requirements → design → tasks → impl).
+
+## Approach
+
+Salvage PR #10443 onto a fresh branch cut from `master` (NOT from the current
+`imprv/x-access-token-header` branch, which carries unrelated MongoDB-regex work), then
+open a new PR to `master`. The technical change is small and well understood:
+
+1. Insert `?? req.headers['x-growi-access-token']` between the Bearer token and the
+   query/body sources in both `access-token.ts` and `api-token.ts`.
+2. Add the `accessTokenHeaderAuth` (`type: apiKey`, `in: header`,
+   `name: x-growi-access-token`) security scheme to `bin/openapi/definition-apiv1.js`
+   and `definition-apiv3.js`, and to the top-level `security` array.
+3. Add `- accessTokenHeaderAuth: []` to the per-route OpenAPI `security` blocks.
+4. Add integration tests covering the header path for both parsers.
+
+**Salvage hygiene**: copy only the *meaningful* changes. PR #10443 also contains
+incidental `import` reordering (the `SCOPE` import moved) caused by its older base —
+do not carry that noise. Re-verify the route list against current `master`, since routes
+added/changed since the PR was opened may also need the `accessTokenHeaderAuth: []` line
+for consistency.
+
+**Workflow: hybrid.** Lock intent in brief + requirements first; implement the salvage
+and open the PR; then finalize design/tasks so the spec stands as the maintenance
+baseline that matches what shipped.
+
+## Scope
+
+- **In**:
+  - `x-growi-access-token` header as a token source in `parserForAccessToken` and
+    `parserForApiToken`, with correct priority ordering.
+  - `accessTokenHeaderAuth` OpenAPI security scheme (apiv1 + apiv3) and its application
+    to the affected routes.
+  - Integration tests for the header path.
+  - A spec baseline (requirements/design/tasks) for the access-token-parser middleware,
+    centered on the header feature with minimal surrounding context.
+- **Out**:
+  - Redesigning the scope model, the AccessToken model, or the legacy api-token mechanism.
+  - Client/SDK or docs-site changes beyond the in-repo OpenAPI definitions.
+  - Deprecating or removing the existing query/body token sources.
+  - Broad brownfield documentation of the entire middleware (kept minimal by decision).
+
+## Boundary Candidates
+
+- Token-source extraction & priority ordering (the parser logic).
+- OpenAPI security-scheme declaration and per-route application.
+- Test coverage for the new header path.
+
+## Out of Boundary
+
+- Authentication/authorization scope semantics (owned by the AccessToken model + SCOPE
+  definitions in `@growi/core`).
+- Reverse-proxy / Basic-auth configuration (the motivating environment, not this code).
+
+## Upstream / Downstream
+
+- **Upstream**: `@growi/core` interfaces (`Scope`, `AccessTokenParser*`, `IUserHasId`),
+  the `AccessToken` Mongoose model, `serializeUserSecurely`. Salvage source: PR #10443.
+- **Downstream**: every apiv3 route guarded by `accessTokenParser`; OpenAPI consumers
+  reading the generated security schemes.
+
+## Existing Spec Touchpoints
+
+- **Extends**: none (new spec).
+- **Adjacent**: none of the existing specs (auto-scroll, editor-keymaps, oauth2-email-support,
+  suggest-path, …) overlap this middleware.
+
+## Constraints
+
+- Branch from `master`, not the current `imprv/x-access-token-header` branch.
+- TDD per repo policy: header-path integration tests precede/accompany the logic change.
+- All in-repo code comments and spec documents in English (`spec.json.language: en`).
+- Header name is matched case-insensitively by Express via `req.headers['x-growi-access-token']`.
+- Final PR targets `master`; use `gh` CLI for all GitHub operations.

+ 285 - 0
.kiro/specs/access-token-parser/design.md

@@ -0,0 +1,285 @@
+# Design Document
+
+## Overview
+
+**Purpose**: Add `X-GROWI-ACCESS-TOKEN` as a request-header source for API authentication,
+so callers whose `Authorization` header is already consumed (e.g. Basic auth on a reverse
+proxy) can authenticate without exposing the token in a URL query string.
+
+**Users**: GROWI REST API consumers (apiv1 / apiv3), and API-spec readers who rely on the
+generated OpenAPI security schemes.
+
+**Impact**: Extends the existing `access-token-parser` middleware. The header becomes an
+additional token source positioned directly after the `Authorization` Bearer token and
+before the `access_token` query/body parameters, for both the scope-based access-token
+path and the legacy api-token path. Token validation, scope checks, and read-only checks
+are unchanged and remain source-agnostic. This spec also establishes the cc-sdd
+maintenance baseline for the middleware.
+
+### Goals
+- Accept the access token from the `X-GROWI-ACCESS-TOKEN` header on both parser paths.
+- Guarantee a single, consistent token-source precedence across both parsers.
+- Advertise the new method in the apiv1 and apiv3 OpenAPI definitions.
+- Leave all existing token sources and authorization behavior unchanged.
+
+### Non-Goals
+- Changing the scope model, the `AccessToken` storage model, or the legacy api-token
+  mechanism.
+- Removing or deprecating the Bearer, query, or body token sources.
+- Client/SDK changes or docs-site updates beyond the in-repo OpenAPI definitions.
+- Adding configuration or a feature flag to toggle the header (out of requirements scope).
+
+## Boundary Commitments
+
+### This Spec Owns
+- The token-source resolution order used by the access-token-parser middleware, including
+  the new `X-GROWI-ACCESS-TOKEN` header position (`Bearer ?? header ?? query ?? body`).
+- The canonical header-name constant `X_GROWI_ACCESS_TOKEN_HEADER_NAME`.
+- The `accessTokenHeaderAuth` OpenAPI security-scheme declaration and its application to
+  every apiv3 route that already advertises `bearer` + `accessTokenInQuery`.
+
+### Out of Boundary
+- Token validity, scope sufficiency, and read-only enforcement — owned by the `AccessToken`
+  model and `SCOPE` definitions in `@growi/core`; reused unchanged.
+- Downstream per-route authorization (rejecting unauthenticated requests).
+- Bearer extraction semantics (`extract-bearer-token.ts`) — unchanged.
+
+### Allowed Dependencies
+- `@growi/core/dist/interfaces/server` (`AccessTokenParserReq`), `AccessToken` model,
+  `serializeUserSecurely`, the existing `extractBearerToken` helper.
+- Express request typing for header/query/body access.
+
+### Revalidation Triggers
+- A change to the token-source precedence order.
+- A rename or value change of `X_GROWI_ACCESS_TOKEN_HEADER_NAME`.
+- A change to the `extractAccessToken` return contract (`string | null`).
+- Adding/removing an `accessTokenInQuery` route without mirroring `accessTokenHeaderAuth`.
+
+## Architecture
+
+### Existing Architecture Analysis
+
+The middleware (`apps/app/src/server/middlewares/access-token-parser/`) is an Express
+adapter with two parser functions orchestrated by `index.ts`:
+
+- `parserForAccessToken(scopes)` — scope-checked lookup via `AccessToken.findUserIdByToken`.
+- `parserForApiToken` — legacy `User.apiToken` lookup, run only when `opts.acceptLegacy`.
+
+Both currently duplicate the same token-source chain:
+`extractBearerToken(req.headers.authorization) ?? req.query.access_token ?? req.body.access_token`,
+followed by an identical `typeof !== 'string'` guard. This duplication is the seam where
+the two paths could drift — directly relevant to requirement 3 (consistent precedence).
+
+**Precedent**: `apps/app/src/server/service/g2g-transfer.ts` defines
+`export const X_GROWI_TRANSFER_KEY_HEADER_NAME = 'x-growi-transfer-key'` (a TS constant)
+while the OpenAPI `.js` definitions hardcode the literal. This design follows the same
+split.
+
+### Architecture Pattern & Boundary Map
+
+Pure-function extraction (per coding-style): the duplicated token-source chain is lifted
+into a single `extractAccessToken` helper that both parsers call. This makes the precedence
+the single source of truth and removes the drift risk.
+
+```mermaid
+graph TB
+    Req[Incoming request] --> Extract[extractAccessToken]
+    Bearer[extract-bearer-token] --> Extract
+    Extract --> AT[parserForAccessToken]
+    Extract --> APIT[parserForApiToken]
+    AT --> Model[AccessToken model + scope check]
+    APIT --> User[User.findUserByApiToken]
+    Model --> ReqUser[req.user via serializeUserSecurely]
+    User --> ReqUser
+```
+
+**Key decisions**:
+- `extractAccessToken` owns the precedence `Bearer ?? header ?? query ?? body` and the
+  string-type guard; both parsers depend on it (dependency direction:
+  `extract-bearer-token` → `extract-access-token` → parsers → `index`).
+- Header name lives in a TS constant used by the parser side; OpenAPI `.js` files keep the
+  literal `x-growi-access-token`, consistent with the g2g precedent.
+
+### Technology Stack
+
+| Layer | Choice / Version | Role in Feature | Notes |
+|-------|------------------|-----------------|-------|
+| Backend / Services | Express (existing) | Header/query/body access via `AccessTokenParserReq` | Header keys are lowercased by Express → case-insensitive (1.3) |
+| API spec | swagger-jsdoc OpenAPI defs (existing `bin/openapi/*.js`) | Declare + apply `accessTokenHeaderAuth` | Literal header name, no new deps |
+
+No new runtime dependencies.
+
+## File Structure Plan
+
+### Directory Structure
+```
+apps/app/src/server/middlewares/access-token-parser/
+├── extract-access-token.ts        # NEW: X_GROWI_ACCESS_TOKEN_HEADER_NAME + extractAccessToken()
+├── extract-access-token.spec.ts   # NEW: unit tests for precedence + guard
+├── extract-bearer-token.ts        # unchanged
+├── access-token.ts                # MODIFIED: use extractAccessToken()
+├── api-token.ts                   # MODIFIED: use extractAccessToken()
+├── access-token.integ.ts          # MODIFIED: + header-path test (1.x)
+├── api-token.integ.ts             # MODIFIED: + header-path test (2.1)
+└── index.ts                       # unchanged
+```
+
+### Modified Files
+- `apps/app/src/server/middlewares/access-token-parser/access-token.ts` — replace the
+  inline token chain with `extractAccessToken(req)`.
+- `apps/app/src/server/middlewares/access-token-parser/api-token.ts` — same replacement.
+- `apps/app/src/server/middlewares/access-token-parser/access-token.integ.ts` — add a
+  header-path success test (salvaged from PR #10443).
+- `apps/app/src/server/middlewares/access-token-parser/api-token.integ.ts` — add a
+  header-path success test (salvaged from PR #10443).
+- `apps/app/bin/openapi/definition-apiv1.js` — add `accessTokenHeaderAuth` to
+  `components.securitySchemes` and to the top-level `security` array.
+- `apps/app/bin/openapi/definition-apiv3.js` — same as apiv1.
+- The 9 apiv3 route files carrying `accessTokenInQuery` — add `- accessTokenHeaderAuth: []`
+  after every `- accessTokenInQuery: []` block (26 sites total). Authoritative set:
+  `activity.ts` (1), `user-activities.ts` (1), `bookmark-folder.ts` (6), `import.ts` (4),
+  `in-app-notification.ts` (4), `page-listing.ts` (4), `g2g-transfer.ts` (2),
+  `app-settings/index.ts` (3), and
+  `features/ai-tools/suggest-path/server/routes/apiv3/index.ts` (1). **Drift note**: PR
+  #10443 targeted `app-settings.js` (now `app-settings/index.ts`), omitted
+  `user-activities.ts`, and predated the `features/` suggest-path route; do not apply the
+  PR's route hunks verbatim — drive edits off a full-tree sweep of current-master
+  `accessTokenInQuery` sites (`grep -rn accessTokenInQuery apps/app/src`).
+
+## Components and Interfaces
+
+| Component | Layer | Intent | Req Coverage | Key Dependencies | Contracts |
+|-----------|-------|--------|--------------|------------------|-----------|
+| `extractAccessToken` | middleware/util | Resolve token from all sources by precedence | 1.1–1.3, 2.1, 3.1–3.4 | `extractBearerToken` (P0) | Service |
+| `parserForAccessToken` | middleware | Scoped access-token auth | 1.1, 1.2, 4.1–4.3 | `extractAccessToken` (P0), `AccessToken` (P0) | Service |
+| `parserForApiToken` | middleware | Legacy api-token auth | 2.1, 2.2, 4.1 | `extractAccessToken` (P0), `User` (P0) | Service |
+| OpenAPI definitions | api-spec | Advertise header auth method | 5.1–5.3 | swagger-jsdoc (P1) | API |
+
+### Middleware
+
+#### extractAccessToken
+
+| Field | Detail |
+|-------|--------|
+| Intent | Single source of truth for token-source precedence and the string guard |
+| Requirements | 1.1, 1.2, 1.3, 2.1, 3.1, 3.2, 3.3, 3.4 |
+
+**Responsibilities & Constraints**
+- Resolve the token in order: Bearer → `X-GROWI-ACCESS-TOKEN` header → `access_token`
+  query → `access_token` body.
+- A non-string (e.g. array-valued, from a duplicated header) `X-GROWI-ACCESS-TOKEN` value
+  is **skipped** so resolution falls through to the remaining sources, rather than
+  short-circuiting (3.4). Implement by coercing a non-string header to `undefined` before
+  the precedence chain so `??` continues past it.
+- Return `null` when no string-typed source resolves; otherwise return the string token.
+- Does not validate the token; resolution only.
+
+**Dependencies**
+- Outbound: `extractBearerToken` — Bearer parsing (P0).
+
+**Contracts**: Service [x]
+
+##### Service Interface
+```typescript
+export const X_GROWI_ACCESS_TOKEN_HEADER_NAME = 'x-growi-access-token';
+
+export const extractAccessToken = (req: AccessTokenParserReq): string | null;
+```
+- Preconditions: `req` is an Express request (`AccessTokenParserReq`).
+- Postconditions: returns a string token, or `null` when no string-typed source resolves.
+- Invariants: precedence order is `Bearer ?? header ?? query ?? body`; Bearer always wins
+  when present (3.1); a non-string header is skipped so resolution falls through (3.4).
+
+#### parserForAccessToken / parserForApiToken (modified)
+
+| Field | Detail |
+|-------|--------|
+| Intent | Reuse `extractAccessToken`; keep validation/authorization unchanged |
+| Requirements | 1.1, 1.2, 2.1, 2.2, 4.1, 4.2, 4.3 |
+
+**Responsibilities & Constraints**
+- Replace the inline `bearer ?? query ?? body` + `typeof` guard with
+  `const accessToken = extractAccessToken(req); if (accessToken == null) return;`.
+- All downstream behavior (scope check, read-only rejection, `serializeUserSecurely`,
+  legacy `acceptLegacy` gating in `index.ts`) is unchanged → satisfies 4.1–4.3 and 2.2 by
+  reuse, and 3.3 (non-regression) because the resolved value for header-absent requests is
+  identical to today.
+
+**Contracts**: Service [x] (signatures unchanged: `(req, res) => Promise<void>`)
+
+### API Spec
+
+#### OpenAPI security scheme
+
+**Contracts**: API [x]
+
+| Field | Value |
+|-------|-------|
+| Scheme name | `accessTokenHeaderAuth` |
+| `type` | `apiKey` |
+| `in` | `header` |
+| `name` | `x-growi-access-token` |
+
+- Declared in `components.securitySchemes` of both `definition-apiv1.js` and
+  `definition-apiv3.js`, added to the top-level `security` array (5.1).
+- Applied per-route as `- accessTokenHeaderAuth: []` alongside existing `bearer` and
+  `accessTokenInQuery` entries; existing schemes retained (5.2, 5.3).
+
+## Requirements Traceability
+
+| Requirement | Summary | Components | Interfaces | Flows |
+|-------------|---------|------------|------------|-------|
+| 1.1 | Header auth, scoped path, no Bearer | extractAccessToken, parserForAccessToken | `extractAccessToken` | Extract→AT |
+| 1.2 | Header token with sufficient scope authenticates | parserForAccessToken | scope check (reused) | AT→Model |
+| 1.3 | Header name case-insensitive | extractAccessToken | Express lowercased key | — |
+| 2.1 | Header auth, legacy path, no Bearer | extractAccessToken, parserForApiToken | `extractAccessToken` | Extract→APIT |
+| 2.2 | Legacy header ignored when acceptLegacy off | index.ts gating (reused) | `accessTokenParser` opts | — |
+| 3.1 | Bearer wins over header | extractAccessToken | precedence invariant | — |
+| 3.2 | Header wins over query/body | extractAccessToken | precedence invariant | — |
+| 3.3 | No header → unchanged resolution | extractAccessToken, both parsers | precedence invariant | — |
+| 3.4 | Non-string header ignored | extractAccessToken | string guard | — |
+| 4.1 | Invalid header token → unauthenticated | parserForAccessToken, parserForApiToken | validation (reused) | — |
+| 4.2 | Insufficient scope → unauthenticated | parserForAccessToken | scope check (reused) | — |
+| 4.3 | Read-only user → unauthenticated | parserForAccessToken | readOnly check (reused) | — |
+| 5.1 | Declare accessTokenHeaderAuth scheme | OpenAPI definitions | API contract | — |
+| 5.2 | Apply scheme to advertising routes | route security blocks | API contract | — |
+| 5.3 | Retain existing schemes | OpenAPI definitions | API contract | — |
+
+## Error Handling
+
+The middleware does not throw on authentication failure: when no valid token resolves, it
+leaves `req.user` unset and calls `next()`, delegating rejection to downstream route
+authorization (existing behavior, preserved for the header source — 4.1). `extractAccessToken`
+never throws; non-string sources resolve to `null` (3.4). Token-source values continue to
+be logged only as truncated prefixes/suffixes (existing `api-token.ts` behavior) — the raw
+header value must not be logged.
+
+## Testing Strategy
+
+### Unit Tests (`extract-access-token.spec.ts`, new)
+- Returns Bearer token when both Bearer and `x-growi-access-token` header are present (3.1).
+- Returns header token when no Bearer but header + query are present (3.2).
+- Returns query/body token (in order) when no Bearer and no header present (3.3).
+- Returns `null` when `x-growi-access-token` is an array / non-string (3.4).
+- Resolves header regardless of letter casing of the key (1.3).
+
+### Integration Tests (salvaged + extended)
+- `access-token.integ.ts`: valid scoped token in `x-growi-access-token` header with a
+  satisfying scope authenticates the owner (1.1, 1.2).
+- `api-token.integ.ts`: valid legacy api-token in `x-growi-access-token` header
+  authenticates the owner (2.1).
+- (Reused coverage) existing invalid-token / insufficient-scope / read-only tests confirm
+  4.1–4.3 still hold via the shared resolution path.
+
+### OpenAPI Verification
+- Regenerate apiv1/apiv3 specs and confirm `accessTokenHeaderAuth` appears in
+  `securitySchemes` and on each previously `accessTokenInQuery`-advertising route; added
+  `accessTokenHeaderAuth: []` line count equals the `accessTokenInQuery` count (25).
+
+## Security Considerations
+
+- The header source is held to the **same** validation as all other sources (4.1–4.3); it
+  introduces no new bypass — the only change is *where* the token string is read from.
+- Motivation is to avoid tokens in URLs/query strings (information exposure via query
+  strings); the header method does not appear in URLs, logs-by-default, or referrers.
+- The raw token from the header must never be logged in full (mirror existing truncation).

+ 117 - 0
.kiro/specs/access-token-parser/requirements.md

@@ -0,0 +1,117 @@
+# Requirements Document
+
+## Introduction
+
+GROWI's API authentication accepts an access token only as a Bearer token in the
+`Authorization` header or as an `access_token` query/body parameter. When the
+`Authorization` header is already consumed (for example, Basic authentication on a
+reverse proxy), callers must fall back to the query parameter, which leaks the token into
+URLs, server logs, browser history, and referrers.
+
+This feature adds a dedicated request header, `X-GROWI-ACCESS-TOKEN`, as an additional
+token source for the Access Token Parser, covering both the scope-based access-token path
+and the legacy api-token path, and advertises it in the OpenAPI definitions. It also
+establishes this spec as the cc-sdd maintenance baseline for the Access Token Parser
+middleware. The salvage source is upstream PR #10443.
+
+## Boundary Context
+
+- **In scope**:
+  - Accepting the access token from the `X-GROWI-ACCESS-TOKEN` request header for both
+    the scope-based access-token path and the legacy api-token path.
+  - The priority of the header source relative to the existing Bearer, query, and body
+    sources.
+  - Declaring an `accessTokenHeaderAuth` security scheme in the apiv1 and apiv3 OpenAPI
+    definitions and applying it to the routes that already advertise the Bearer and
+    query token methods.
+- **Out of scope**:
+  - Changing the scope-evaluation model, the access-token storage model, or the legacy
+    api-token mechanism.
+  - Removing or deprecating the existing Bearer, query, or body token sources.
+  - Client/SDK changes or documentation-site updates beyond the in-repo OpenAPI
+    definitions.
+- **Adjacent expectations**:
+  - Authorization decisions (scope sufficiency, read-only restriction, token validity)
+    remain owned by the existing access-token validation; this feature only adds a new
+    place to read the token from and does not relax those checks.
+  - Downstream route authorization continues to reject unauthenticated requests; the
+    parser only attaches the authenticated user when a valid token is found.
+
+## Requirements
+
+### Requirement 1: Header token acceptance on the scope-based access-token path
+
+**Objective:** As an API caller whose `Authorization` header is already in use, I want to
+supply my scoped access token in the `X-GROWI-ACCESS-TOKEN` header, so that I can
+authenticate without exposing the token in the URL.
+
+#### Acceptance Criteria
+1. When a request carries a valid scoped access token in the `X-GROWI-ACCESS-TOKEN`
+   header and no Bearer token is present, the Access Token Parser shall authenticate the
+   request as the token's owner.
+2. When the access token supplied in the `X-GROWI-ACCESS-TOKEN` header grants a scope that
+   satisfies the route's required scope, the Access Token Parser shall attach the
+   authenticated user to the request.
+3. The Access Token Parser shall treat the `X-GROWI-ACCESS-TOKEN` header name
+   case-insensitively, accepting it regardless of the letter casing used by the client.
+
+### Requirement 2: Header token acceptance on the legacy api-token path
+
+**Objective:** As an API caller using a legacy api-token on a route that still accepts it,
+I want to supply that token in the `X-GROWI-ACCESS-TOKEN` header, so that I get the same
+header-based option as scoped tokens.
+
+#### Acceptance Criteria
+1. Where a route enables legacy api-token acceptance, when a request carries a valid
+   legacy api-token in the `X-GROWI-ACCESS-TOKEN` header and no Bearer token is present,
+   the Access Token Parser shall authenticate the request as the token's owner.
+2. Where a route does not enable legacy api-token acceptance, the Access Token Parser
+   shall not authenticate a request solely on the basis of a legacy api-token presented
+   in the `X-GROWI-ACCESS-TOKEN` header.
+
+### Requirement 3: Token source priority and non-regression of existing sources
+
+**Objective:** As an API caller, I want predictable precedence among the token sources, so
+that adding the header does not change the behavior of requests that already work.
+
+#### Acceptance Criteria
+1. When a request carries both a Bearer token in the `Authorization` header and a token in
+   the `X-GROWI-ACCESS-TOKEN` header, the Access Token Parser shall use the Bearer token.
+2. When a request carries a token in the `X-GROWI-ACCESS-TOKEN` header and also in the
+   `access_token` query or body parameter, and no Bearer token is present, the Access
+   Token Parser shall use the `X-GROWI-ACCESS-TOKEN` header value.
+3. When a request carries no `X-GROWI-ACCESS-TOKEN` header, the Access Token Parser shall
+   continue to resolve the token from the Bearer, query, and body sources exactly as
+   before this feature.
+4. If the `X-GROWI-ACCESS-TOKEN` header is present but is not a single string value, the
+   Access Token Parser shall ignore it and fall back to the remaining token sources.
+
+### Requirement 4: Invalid or insufficient header token handling
+
+**Objective:** As a security-conscious operator, I want header-supplied tokens to be held
+to the same validation as other sources, so that the new header cannot bypass any check.
+
+#### Acceptance Criteria
+1. If the token supplied in the `X-GROWI-ACCESS-TOKEN` header is invalid, expired, or
+   unknown, the Access Token Parser shall leave the request unauthenticated and allow
+   downstream authorization to reject it.
+2. If the scoped access token supplied in the `X-GROWI-ACCESS-TOKEN` header lacks a scope
+   sufficient for the route, the Access Token Parser shall leave the request
+   unauthenticated.
+3. If the access token supplied in the `X-GROWI-ACCESS-TOKEN` header belongs to a
+   read-only user on a path that rejects read-only users, the Access Token Parser shall
+   leave the request unauthenticated.
+
+### Requirement 5: OpenAPI advertisement of the header authentication method
+
+**Objective:** As an API consumer reading the OpenAPI specification, I want the header
+authentication method to be documented, so that I know `X-GROWI-ACCESS-TOKEN` is a
+supported way to authenticate.
+
+#### Acceptance Criteria
+1. The apiv1 and apiv3 OpenAPI definitions shall declare an `accessTokenHeaderAuth`
+   security scheme that authenticates via the `x-growi-access-token` request header.
+2. Where a route already advertises the Bearer and query token methods, the OpenAPI
+   definition shall also advertise the `accessTokenHeaderAuth` method for that route.
+3. The OpenAPI definitions shall retain the existing `bearer` and `accessTokenInQuery`
+   security methods alongside the new `accessTokenHeaderAuth` method.

+ 163 - 0
.kiro/specs/access-token-parser/research.md

@@ -0,0 +1,163 @@
+# Gap Analysis: access-token-parser (X-GROWI-ACCESS-TOKEN header)
+
+_Date: 2026-05-29 · Salvage source: PR #10443 (`support/api-token-header` → `master`)_
+
+## 1. Current State
+
+The middleware already exists at
+`apps/app/src/server/middlewares/access-token-parser/`:
+
+| File | Role | Token extraction today |
+|------|------|------------------------|
+| `index.ts` | `accessTokenParser(scopes, opts)` orchestrator; runs `parserForAccessToken`, then `parserForApiToken` when `opts.acceptLegacy` | — |
+| `access-token.ts` | scope-checked `AccessToken` lookup | `bearer ?? query ?? body` |
+| `api-token.ts` | legacy `User.apiToken` lookup | `bearer ?? query ?? body` |
+| `extract-bearer-token.ts` | pulls Bearer from `Authorization` | (unchanged by this work) |
+| `*.integ.ts` | co-located integration tests | — |
+
+- `AccessTokenParserReq` (`packages/core/src/interfaces/server/access-token-parser.ts`)
+  extends Express `Request`, so `req.headers['x-growi-access-token']` is typed as
+  `string | string[] | undefined` — the existing `typeof accessToken !== 'string'` guard
+  already covers the array case.
+- **Header-name convention exists**: g2g-transfer declares
+  `export const X_GROWI_TRANSFER_KEY_HEADER_NAME = 'x-growi-transfer-key'`
+  (`apps/app/src/server/service/g2g-transfer.ts:40`) and references it in the OpenAPI
+  definition. This is the precedent for a shared header-name constant.
+- No reference to `x-growi-access-token` exists anywhere yet.
+- OpenAPI security schemes (`bearer`, `accessTokenInQuery`) are declared in
+  `apps/app/bin/openapi/definition-apiv1.js` and `definition-apiv3.js`.
+
+## 2. Requirement-to-Asset Map
+
+| Requirement | Existing asset | Gap | Tag |
+|-------------|----------------|-----|-----|
+| R1 header on scoped path | `access-token.ts` token resolution | insert header source between Bearer and query | Missing |
+| R2 header on legacy path | `api-token.ts` token resolution | insert header source between Bearer and query | Missing |
+| R3 priority / non-regression | both parsers' `??` chain | order header after Bearer, before query/body; array guard already present | Missing (partial reuse) |
+| R4 invalid/insufficient handling | `AccessToken.findUserIdByToken`, scope check, `readOnly` check | none — validation is source-agnostic; reused as-is | Reuse |
+| R5 OpenAPI advertisement | apiv1/apiv3 definitions + per-route `security` blocks | add `accessTokenHeaderAuth` scheme + add `- accessTokenHeaderAuth: []` to every route block | Missing |
+
+## 3. Salvage Drift vs current master (CRITICAL)
+
+PR #10443 was authored on an older tree. The route-file portion has drifted; **do not
+apply the patch verbatim**:
+
+- **`app-settings.js` → `app-settings/index.ts`**: the PR edits `apps/app/src/server/routes/apiv3/app-settings.js`,
+  but master has refactored it to `app-settings/index.ts` (3 `accessTokenInQuery` blocks).
+  The PR hunk will not apply.
+- **`user-activities.ts` missed**: master's `user-activities.ts` (1 block) carries
+  `accessTokenInQuery` but is **not** in PR #10443. Verbatim salvage would leave it
+  inconsistent.
+- The PR also carries incidental `import` reordering noise (the `SCOPE` import moved) from
+  its older base — exclude it.
+
+**Authoritative route set in current master** — 8 files, 25 `accessTokenInQuery` blocks,
+each needs an `accessTokenHeaderAuth: []` sibling:
+
+| File | blocks |
+|------|--------|
+| `activity.ts` | 1 |
+| `user-activities.ts` | 1 |
+| `bookmark-folder.ts` | 6 |
+| `import.ts` | 4 |
+| `in-app-notification.ts` | 4 |
+| `page-listing.ts` | 4 |
+| `g2g-transfer.ts` | 2 |
+| `app-settings/index.ts` | 3 |
+
+**Robust salvage method**: instead of applying the PR patch, add
+`- accessTokenHeaderAuth: []` immediately after **every** `- accessTokenInQuery: []`
+occurrence in current master. This is drift-proof and self-verifying (count of added
+lines must equal 25). Carry the logic + test changes from the PR (they apply cleanly),
+discard its route-file hunks.
+
+## 4. Implementation Approach Options
+
+### Option A — Verbatim PR salvage (apply #10443 patch)
+- ✅ Fastest mechanically.
+- ❌ Breaks on `app-settings` path drift; ❌ misses `user-activities.ts`; ❌ imports noise.
+- **Rejected** — produces an inconsistent, non-applying result.
+
+### Option B — Extend in place, drift-corrected (recommended)
+- Logic: add `?? req.headers['x-growi-access-token']` between Bearer and query in both
+  `access-token.ts` and `api-token.ts`.
+- OpenAPI: declare `accessTokenHeaderAuth` in both definition files; add
+  `- accessTokenHeaderAuth: []` after every `accessTokenInQuery` block across the 8
+  current-master route files (25 sites).
+- Tests: port the two header-path integration tests from the PR.
+- ✅ Drift-proof, consistent, minimal new surface; reuses all validation.
+- ❌ Manual care across 25 sites (mitigated by the "after every accessTokenInQuery" rule).
+
+### Option C — Extract a shared header-name constant + Option B
+- In addition to Option B, define `X_GROWI_ACCESS_TOKEN_HEADER_NAME = 'x-growi-access-token'`
+  (mirroring `X_GROWI_TRANSFER_KEY_HEADER_NAME`) and reference it from both parsers and the
+  OpenAPI `name` fields, removing the magic string.
+- ✅ Single source of truth, aligns with coding-style (no magic strings) and the existing
+  g2g convention; reduces drift risk for future changes.
+- ❌ Slightly larger diff than the raw PR; OpenAPI `.js` files use literal strings today, so
+  the constant may only be cleanly shared on the parser side unless the definition files
+  import it. **Decision for design phase**: whether to thread the constant into the
+  OpenAPI definitions or keep the literal there.
+
+## 5. Effort & Risk
+
+- **Effort: S (1–3 days)** — established pattern, one-line logic change ×2, mechanical
+  OpenAPI edits ×25, two integration tests.
+- **Risk: Low** — validation/authorization reused unchanged; header is purely additive and
+  guarded; existing sources untouched (R3 non-regression). Main risk is **coverage
+  completeness** of the 25 OpenAPI sites, mitigated by the count check.
+
+## 6. Recommendations for Design Phase
+
+- **Preferred approach**: Option B, with Option C's constant as a recommended refinement.
+- **Key decisions to settle in design**:
+  1. Whether to introduce `X_GROWI_ACCESS_TOKEN_HEADER_NAME` and whether the OpenAPI
+     definition `.js` files reference it or keep the literal.
+  2. Confirm the priority order `Bearer ?? header ?? query ?? body` for both parsers.
+  3. Confirm the OpenAPI route set = the 8 current-master files (not the PR's 7), explicitly
+     including `user-activities.ts` and `app-settings/index.ts`.
+- **Research items**: none outstanding — the change is well understood and self-contained.
+- **Branch reminder**: cut from `master`, not the current `imprv/x-access-token-header`
+  branch (which carries unrelated MongoDB-regex work).
+
+---
+
+## Design Synthesis Outcomes (design phase)
+
+**Generalization** — R1 (scoped path) and R2 (legacy path) are the same problem: read the
+token from one more source at the same precedence position. Both parsers duplicate the
+`bearer ?? query ?? body` chain + `typeof` guard. Decision: extract a pure
+`extractAccessToken(req): string | null` helper (new `extract-access-token.ts`) owning the
+precedence `Bearer ?? header ?? query ?? body`. This makes precedence the single source of
+truth (directly serves R3's cross-parser consistency) and removes the drift seam. Aligns
+with coding-style "Pure Function Extraction" and the recorded feedback on single source of
+truth / drift prevention.
+
+**Build vs Adopt** — Header name: adopt the existing `X_GROWI_TRANSFER_KEY_HEADER_NAME`
+precedent (g2g-transfer.ts) → define `X_GROWI_ACCESS_TOKEN_HEADER_NAME = 'x-growi-access-token'`
+in the parser TS module. Express natively lowercases header keys → case-insensitive (R1.3),
+no library needed. OpenAPI `.js` definitions keep the literal string (CommonJS build
+scripts), mirroring how g2g keeps the literal in OpenAPI while the constant lives in TS.
+
+**Simplification** — No config/feature-flag for the header (out of requirements). Do not
+modify `extract-bearer-token.ts`. Centralize the `typeof !== 'string'` guard inside
+`extractAccessToken` so both parsers collapse to
+`const accessToken = extractAccessToken(req); if (accessToken == null) return;`. Parser
+signatures and all validation/authorization remain unchanged (reuse → R4, R2.2, R3.3).
+
+**Route-edit method (drift-proof)** — Drive OpenAPI route edits off current-master
+`accessTokenInQuery` sites (8 files / 25 blocks), NOT the PR #10443 file list. Add
+`- accessTokenHeaderAuth: []` after each. Self-check: added line count == 25.
+
+---
+
+## Coverage Correction (task 3.2 implementation)
+
+The gap-analysis route sweep grepped only `apps/app/src/server/routes` and undercounted.
+A full-tree sweep (`grep -rn accessTokenInQuery apps/app/src`) finds **26** sites across
+**9** files — the 8 originally listed plus
+`apps/app/src/features/ai-tools/suggest-path/server/routes/apiv3/index.ts` (1) in the
+`features/` tree. Requirement 5.2 ("every route advertising the query method also
+advertises the header method") requires this 9th file, so task 3.2's scope was extended
+to 26 sites. Lesson: sweep `apps/app/src` (including `features/`), not just
+`server/routes/apiv3`, when enumerating OpenAPI security blocks.

+ 22 - 0
.kiro/specs/access-token-parser/spec.json

@@ -0,0 +1,22 @@
+{
+  "feature_name": "access-token-parser",
+  "created_at": "2026-05-29T13:26:57Z",
+  "updated_at": "2026-05-29T14:45:00Z",
+  "language": "en",
+  "phase": "implementation-complete",
+  "approvals": {
+    "requirements": {
+      "generated": true,
+      "approved": true
+    },
+    "design": {
+      "generated": true,
+      "approved": true
+    },
+    "tasks": {
+      "generated": true,
+      "approved": true
+    }
+  },
+  "ready_for_implementation": true
+}

+ 57 - 0
.kiro/specs/access-token-parser/tasks.md

@@ -0,0 +1,57 @@
+# Implementation Plan
+
+> Salvage source: PR #10443. Branch from `master` (NOT the current
+> `imprv/x-access-token-header` branch, which carries unrelated MongoDB-regex work).
+> Test-first per repo TDD policy.
+
+- [x] 1. Foundation: shared token-source extraction utility
+- [x] 1.1 Create the shared token-source extractor with unit tests (test-first)
+  - Write failing unit tests first, covering: precedence Bearer > `X-GROWI-ACCESS-TOKEN` header > query > body; non-string / array-valued header is ignored; header key resolves case-insensitively
+  - Define the canonical header-name constant and implement the pure extractor that returns the resolved token string or null
+  - Observable: a new unit test file passes, exercising every precedence, guard, and casing case; the no-header case resolves exactly to the prior Bearer/query/body result
+  - _Requirements: 1.3, 3.1, 3.2, 3.3, 3.4_
+  - _Boundary: extractAccessToken_
+
+- [x] 2. Core: parser integration with header support
+- [x] 2.1 (P) Route the scoped access-token parser through the shared extractor
+  - Replace the inline token chain and type guard with the shared extractor; leave scope check, read-only rejection, and user serialization unchanged
+  - Add an integration test: a valid scoped token supplied in the `X-GROWI-ACCESS-TOKEN` header with a satisfying scope authenticates the token owner
+  - Observable: the access-token integration suite passes including the new header test, and the existing invalid-token / insufficient-scope / read-only tests remain green
+  - _Requirements: 1.1, 1.2, 4.1, 4.2, 4.3_
+  - _Boundary: parserForAccessToken_
+  - _Depends: 1.1_
+- [x] 2.2 (P) Route the legacy api-token parser through the shared extractor
+  - Replace the inline token chain and type guard with the shared extractor
+  - Add an integration test: a valid legacy api-token supplied in the `X-GROWI-ACCESS-TOKEN` header authenticates the owner; confirm the `acceptLegacy` gating is unchanged (legacy token ignored when the route does not opt in)
+  - Observable: the api-token integration suite passes including the new header test
+  - _Requirements: 2.1, 2.2, 4.1_
+  - _Boundary: parserForApiToken_
+  - _Depends: 1.1_
+
+- [x] 3. Integration: OpenAPI advertisement of the header method
+- [x] 3.1 (P) Declare the `accessTokenHeaderAuth` security scheme in the apiv1 and apiv3 definitions
+  - Add an `apiKey` / `in: header` / `name: x-growi-access-token` scheme to the security schemes and to the top-level security array in both definition files
+  - Independent of tasks 2.1/2.2 (separate boundary, no shared files), so it may run concurrently with the parser work
+  - Observable: both definition files contain the new scheme while retaining the existing `bearer` and `accessTokenInQuery` schemes
+  - _Requirements: 5.1, 5.3_
+  - _Boundary: OpenAPI definitions_
+- [x] 3.2 Apply the header auth method to every advertising route
+  - Add an `accessTokenHeaderAuth` entry after every `accessTokenInQuery` block across the 9 current-master route files (26 sites): activity, user-activities, bookmark-folder, import, in-app-notification, page-listing, g2g-transfer, app-settings index, and features/ai-tools/suggest-path. Do not apply PR #10443's route hunks verbatim — drive edits off a full-tree sweep (`grep -rn accessTokenInQuery apps/app/src`) to absorb the `app-settings` path drift, the missing `user-activities`, and the `features/` suggest-path route
+  - Observable: the number of added `accessTokenHeaderAuth` lines equals 26, and every route that advertises `accessTokenInQuery` also advertises `accessTokenHeaderAuth`
+  - _Requirements: 5.2_
+  - _Boundary: apiv3 route security blocks_
+  - _Depends: 3.1_
+
+- [x] 4. Validation: regression and spec verification
+- [x] 4.1 Verify OpenAPI regeneration and run end-to-end quality gates
+  - Regenerate the apiv1/apiv3 specs and confirm `accessTokenHeaderAuth` appears in the schemes and on each route that previously advertised `accessTokenInQuery`
+  - Run lint, the full access-token-parser test suite, and the build for the app package
+  - Confirm non-regression: requests with no `X-GROWI-ACCESS-TOKEN` header resolve identically to pre-change behavior
+  - Observable: lint/typecheck/tests green and regenerated specs consistent (0 query-ops missing the header method; added-line count check = 26). NOTE: the full app build is blocked by a pre-existing, unrelated devcontainer dependency-hoisting issue (`@lezer/*`, `styled-jsx` in the client bundle) — see Implementation Notes; verify the production build in CI.
+  - _Requirements: 3.3, 5.1, 5.2, 5.3_
+  - _Depends: 2.1, 2.2, 3.2_
+
+## Implementation Notes
+- Req 3.4 semantics: a non-string `X-GROWI-ACCESS-TOKEN` value (duplicated header → array) is coerced to `undefined` before the `??` chain so resolution falls through to query/body, per requirements.md 3.4. design.md was corrected to match (the initial "centralized guard at end" wording implied short-circuit-to-null).
+- OpenAPI route coverage: enumerate `accessTokenInQuery` with a FULL-tree sweep (`grep -rn accessTokenInQuery apps/app/src`), not just `server/routes/apiv3` — the `features/` tree holds the suggest-path route (26 sites / 9 files, not 25 / 8).
+- Task 4.1 build gate: `turbo run build --filter @growi/app` FAILS in this devcontainer on a PRE-EXISTING, unrelated client-bundle dependency-hoisting issue — Turbopack cannot resolve `@lezer/common`, `@lezer/lr` (transitive deps of `@codemirror/lang-python`/`lang-yaml`) and `styled-jsx` (import trace: ConflictDiffModal → editor → codemirror; none touched by this server-only change). `pnpm install --frozen-lockfile` reports "Already up to date", so the state is lockfile-defined and independent of this feature. Verified green for this change: 23/23 access-token-parser tests, `lint:typecheck` (exit 0), `lint:openapi:apiv1`+`apiv3` (1 passing/0 failing, 0 query-ops missing the header method), biome on changed files (only the pre-existing `res`-unused warning). The production build should be confirmed in CI (`reusable-app-prod.yml`), where the dependency environment is correct.

+ 7 - 0
apps/app/bin/openapi/definition-apiv1.js

@@ -24,6 +24,7 @@ module.exports = {
   security: [
     {
       bearer: [],
+      accessTokenHeaderAuth: [],
       accessTokenInQuery: [],
     },
   ],
@@ -34,6 +35,12 @@ module.exports = {
         scheme: 'bearer',
         description: 'Access token generated by each GROWI users',
       },
+      accessTokenHeaderAuth: {
+        type: 'apiKey',
+        in: 'header',
+        name: 'x-growi-access-token',
+        description: 'Access token generated by each GROWI users',
+      },
       accessTokenInQuery: {
         type: 'apiKey',
         name: 'access_token',

+ 7 - 0
apps/app/bin/openapi/definition-apiv3.js

@@ -24,6 +24,7 @@ module.exports = {
   security: [
     {
       bearer: [],
+      accessTokenHeaderAuth: [],
       accessTokenInQuery: [],
     },
   ],
@@ -34,6 +35,12 @@ module.exports = {
         scheme: 'bearer',
         description: 'Access token generated by each GROWI users',
       },
+      accessTokenHeaderAuth: {
+        type: 'apiKey',
+        in: 'header',
+        name: 'x-growi-access-token',
+        description: 'Access token generated by each GROWI users',
+      },
       accessTokenInQuery: {
         type: 'apiKey',
         name: 'access_token',

+ 1 - 0
apps/app/src/features/ai-tools/suggest-path/server/routes/apiv3/index.ts

@@ -99,6 +99,7 @@ const validator = [
  *     security:
  *       - bearer: []
  *       - accessTokenInQuery: []
+ *       - accessTokenHeaderAuth: []
  *     requestBody:
  *       required: true
  *       content:

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

@@ -183,6 +183,44 @@ describe('access-token-parser middleware for access token with scopes', () => {
     expect(serializeUserSecurely).toHaveBeenCalledOnce();
   });
 
+  it('should authenticate with token supplied in X-GROWI-ACCESS-TOKEN header', async () => {
+    // arrange
+    const reqMock = mock<AccessTokenParserReq>({
+      user: undefined,
+    });
+    const resMock = mock<Response>();
+
+    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 a wildcard (parent) scope
+    const { token } = await AccessToken.generateToken(
+      targetUser._id,
+      new Date(Date.now() + 1000 * 60 * 60 * 24),
+      [SCOPE.READ.USER_SETTINGS.ALL],
+    );
+
+    // act - supply the token via the X-GROWI-ACCESS-TOKEN header (Express lowercases keys),
+    // and require a narrower scope to also exercise scope satisfaction
+    reqMock.headers['x-growi-access-token'] = token;
+    await parserForAccessToken([SCOPE.READ.USER_SETTINGS.INFO])(
+      reqMock,
+      resMock,
+    );
+
+    // assert
+    expect(reqMock.user).toBeDefined();
+    expect(reqMock.user?._id).toStrictEqual(targetUser._id);
+    expect(serializeUserSecurely).toHaveBeenCalledOnce();
+  });
+
   it('should authenticate with wildcard scope', async () => {
     // arrange
     const reqMock = mock<AccessTokenParserReq>({

+ 3 - 8
apps/app/src/server/middlewares/access-token-parser/access-token.ts

@@ -6,7 +6,7 @@ import type { Response } from 'express';
 import { AccessToken } from '~/server/models/access-token';
 import loggerFactory from '~/utils/logger';
 
-import { extractBearerToken } from './extract-bearer-token';
+import { extractAccessToken } from './extract-access-token';
 
 const logger = loggerFactory(
   'growi:middleware:access-token-parser:access-token',
@@ -14,13 +14,8 @@ const logger = loggerFactory(
 
 export const parserForAccessToken = (scopes: Scope[]) => {
   return async (req: AccessTokenParserReq, res: Response): Promise<void> => {
-    // Extract token from Authorization header first
-    // It is more efficient to call it only once in "AccessTokenParser," which is the caller of the method
-    const bearerToken = extractBearerToken(req.headers.authorization);
-
-    const accessToken =
-      bearerToken ?? req.query.access_token ?? req.body.access_token;
-    if (accessToken == null || typeof accessToken !== 'string') {
+    const accessToken = extractAccessToken(req);
+    if (accessToken == null) {
       return;
     }
     if (scopes == null || scopes.length === 0) {

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

@@ -150,6 +150,34 @@ describe('access-token-parser middleware', () => {
     expect(serializeUserSecurely).toHaveBeenCalledOnce();
   });
 
+  it('should set req.user with a valid api token in the X-GROWI-ACCESS-TOKEN header', async () => {
+    // arrange
+    const reqMock = mock<AccessTokenParserReq>({
+      user: undefined,
+    });
+    const resMock = mock<Response>();
+
+    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.headers['x-growi-access-token'] = targetUser.apiToken;
+    await parserForApiToken(reqMock, resMock);
+
+    // assert
+    expect(reqMock.user).toBeDefined();
+    expect(reqMock.user?._id).toStrictEqual(targetUser._id);
+    expect(serializeUserSecurely).toHaveBeenCalledOnce();
+  });
+
   it('should ignore non-Bearer Authorization header', async () => {
     // arrange
     const reqMock = mock<AccessTokenParserReq>({

+ 3 - 10
apps/app/src/server/middlewares/access-token-parser/api-token.ts

@@ -7,7 +7,7 @@ import mongoose from 'mongoose';
 
 import loggerFactory from '~/utils/logger';
 
-import { extractBearerToken } from './extract-bearer-token';
+import { extractAccessToken } from './extract-access-token';
 
 const logger = loggerFactory('growi:middleware:access-token-parser:api-token');
 
@@ -15,15 +15,8 @@ export const parserForApiToken = async (
   req: AccessTokenParserReq,
   res: Response,
 ): Promise<void> => {
-  // Extract token from Authorization header first
-  // It is more efficient to call it only once in "AccessTokenParser," which is the caller of the method
-  const bearerToken = extractBearerToken(req.headers.authorization);
-
-  // Try all possible token sources in order of priority
-  const accessToken =
-    bearerToken ?? req.query.access_token ?? req.body.access_token;
-
-  if (accessToken == null || typeof accessToken !== 'string') {
+  const accessToken = extractAccessToken(req);
+  if (accessToken == null) {
     return;
   }
 

+ 135 - 0
apps/app/src/server/middlewares/access-token-parser/extract-access-token.spec.ts

@@ -0,0 +1,135 @@
+import type { IncomingHttpHeaders } from 'node:http';
+import type { AccessTokenParserReq } from '@growi/core/dist/interfaces/server';
+
+import {
+  extractAccessToken,
+  X_GROWI_ACCESS_TOKEN_HEADER_NAME,
+} from './extract-access-token';
+
+// Build a minimal request shaped like the real Express request: only explicitly-set
+// properties exist, so unset sources are genuinely `undefined` (unlike a deep auto-mock,
+// which would stub every accessed path and break the `??` precedence chain).
+const buildReq = (parts: {
+  headers?: IncomingHttpHeaders;
+  query?: { access_token?: string };
+  body?: { access_token?: string };
+}): AccessTokenParserReq =>
+  ({
+    headers: parts.headers ?? {},
+    query: parts.query ?? {},
+    body: parts.body ?? {},
+  }) as AccessTokenParserReq;
+
+describe('extractAccessToken', () => {
+  it('returns the Bearer token when present, even if other sources exist (3.1)', () => {
+    // arrange
+    const req = buildReq({
+      headers: {
+        authorization: 'Bearer bearer-token',
+        [X_GROWI_ACCESS_TOKEN_HEADER_NAME]: 'header-token',
+      },
+      query: { access_token: 'query-token' },
+      body: { access_token: 'body-token' },
+    });
+
+    // act / assert
+    expect(extractAccessToken(req)).toBe('bearer-token');
+  });
+
+  it('returns the header token when no Bearer is present (3.2)', () => {
+    // arrange
+    const req = buildReq({
+      headers: { [X_GROWI_ACCESS_TOKEN_HEADER_NAME]: 'header-token' },
+      query: { access_token: 'query-token' },
+      body: { access_token: 'body-token' },
+    });
+
+    // act / assert
+    expect(extractAccessToken(req)).toBe('header-token');
+  });
+
+  it('returns the query token when no Bearer and no header (3.3)', () => {
+    // arrange
+    const req = buildReq({
+      headers: {},
+      query: { access_token: 'query-token' },
+      body: { access_token: 'body-token' },
+    });
+
+    // act / assert
+    expect(extractAccessToken(req)).toBe('query-token');
+  });
+
+  it('returns the body token when only the body has it (3.3)', () => {
+    // arrange
+    const req = buildReq({
+      headers: {},
+      query: {},
+      body: { access_token: 'body-token' },
+    });
+
+    // act / assert
+    expect(extractAccessToken(req)).toBe('body-token');
+  });
+
+  it('ignores an array-valued (non-string) header and falls through to query (3.4)', () => {
+    // arrange
+    // Express represents repeated headers as string[]; a non-string header value must be
+    // skipped so resolution continues to the remaining sources rather than failing.
+    const req = buildReq({
+      headers: { [X_GROWI_ACCESS_TOKEN_HEADER_NAME]: ['a', 'b'] },
+      query: { access_token: 'query-token' },
+      body: {},
+    });
+
+    // act / assert
+    expect(extractAccessToken(req)).toBe('query-token');
+  });
+
+  it('returns null when the only source is an array-valued header (3.4)', () => {
+    // arrange
+    const req = buildReq({
+      headers: { [X_GROWI_ACCESS_TOKEN_HEADER_NAME]: ['a', 'b'] },
+      query: {},
+      body: {},
+    });
+
+    // act / assert
+    expect(extractAccessToken(req)).toBeNull();
+  });
+
+  it('returns null when no string-typed source is present (3.4)', () => {
+    // arrange
+    const req = buildReq({ headers: {}, query: {}, body: {} });
+
+    // act / assert
+    expect(extractAccessToken(req)).toBeNull();
+  });
+
+  it('resolves the header case-insensitively via the lowercase constant (1.3)', () => {
+    // arrange
+    // Express lowercases incoming header keys, so the canonical constant is lowercase;
+    // indexing by it resolves a header regardless of the sender's casing.
+    const req = buildReq({
+      headers: { 'x-growi-access-token': 'header-token' },
+      query: {},
+      body: {},
+    });
+
+    // act / assert
+    expect(X_GROWI_ACCESS_TOKEN_HEADER_NAME).toBe('x-growi-access-token');
+    expect(extractAccessToken(req)).toBe('header-token');
+  });
+
+  it('matches the prior Bearer/query/body precedence when no header is present', () => {
+    // arrange
+    const req = buildReq({
+      headers: { authorization: 'Bearer bearer-token' },
+      query: { access_token: 'query-token' },
+      body: { access_token: 'body-token' },
+    });
+
+    // act / assert
+    expect(extractAccessToken(req)).toBe('bearer-token');
+  });
+});

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

@@ -0,0 +1,31 @@
+import type { AccessTokenParserReq } from '@growi/core/dist/interfaces/server';
+
+import { extractBearerToken } from './extract-bearer-token';
+
+// Canonical header name for passing an access token outside the Authorization header.
+// Express lowercases incoming header keys, so indexing by this lowercase constant
+// resolves the header case-insensitively. Mirrors X_GROWI_TRANSFER_KEY_HEADER_NAME.
+export const X_GROWI_ACCESS_TOKEN_HEADER_NAME = 'x-growi-access-token';
+
+/**
+ * Resolve the access token from a request using the single source-of-truth precedence:
+ * Bearer > X-GROWI-ACCESS-TOKEN header > access_token query > access_token body.
+ *
+ * A non-string X-GROWI-ACCESS-TOKEN value (e.g. an array from a duplicated header) is
+ * coerced to `undefined` before the precedence chain, so resolution falls through to the
+ * remaining sources instead of short-circuiting (3.4). `null` is returned only when no
+ * string-typed source resolves.
+ */
+export const extractAccessToken = (
+  req: AccessTokenParserReq,
+): string | null => {
+  const headerToken = req.headers[X_GROWI_ACCESS_TOKEN_HEADER_NAME];
+
+  const token =
+    extractBearerToken(req.headers.authorization) ??
+    (typeof headerToken === 'string' ? headerToken : undefined) ??
+    req.query.access_token ??
+    req.body.access_token;
+
+  return typeof token === 'string' ? token : null;
+};

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

@@ -192,6 +192,7 @@ module.exports = (crowi: Crowi): Router => {
    *     security:
    *       - bearer: []
    *       - accessTokenInQuery: []
+   *       - accessTokenHeaderAuth: []
    *     parameters:
    *       - name: limit
    *         in: query

+ 3 - 0
apps/app/src/server/routes/apiv3/app-settings/index.ts

@@ -392,6 +392,7 @@ module.exports = (crowi: Crowi) => {
    *        security:
    *          - bearer: []
    *          - accessTokenInQuery: []
+   *          - accessTokenHeaderAuth: []
    *        summary: /app-settings
    *        description: get app setting params
    *        responses:
@@ -1087,6 +1088,7 @@ module.exports = (crowi: Crowi) => {
    *        security:
    *          - bearer: []
    *          - accessTokenInQuery: []
+   *          - accessTokenHeaderAuth: []
    *        summary: AccessToken supported.
    *        description: Update V5SchemaMigration
    *        responses:
@@ -1145,6 +1147,7 @@ module.exports = (crowi: Crowi) => {
    *        security:
    *          - bearer: []
    *          - accessTokenInQuery: []
+   *          - accessTokenHeaderAuth: []
    *        summary: AccessToken supported.
    *        description: Update MaintenanceMode
    *        requestBody:

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

@@ -150,6 +150,7 @@ module.exports = (crowi: Crowi) => {
    *        security:
    *          - bearer: []
    *          - accessTokenInQuery: []
+   *          - accessTokenHeaderAuth: []
    *        summary: Create bookmark folder
    *        description: Create a new bookmark folder
    *        requestBody:
@@ -215,6 +216,7 @@ module.exports = (crowi: Crowi) => {
    *        security:
    *          - bearer: []
    *          - accessTokenInQuery: []
+   *          - accessTokenHeaderAuth: []
    *        summary: List bookmark folders of a user
    *        description: List bookmark folders of a user
    *        parameters:
@@ -316,6 +318,7 @@ module.exports = (crowi: Crowi) => {
    *        security:
    *          - bearer: []
    *          - accessTokenInQuery: []
+   *          - accessTokenHeaderAuth: []
    *        summary: Delete bookmark folder
    *        description: Delete a bookmark folder and its children
    *        parameters:
@@ -372,6 +375,7 @@ module.exports = (crowi: Crowi) => {
    *        security:
    *          - bearer: []
    *          - accessTokenInQuery: []
+   *          - accessTokenHeaderAuth: []
    *        summary: Update bookmark folder
    *        description: Update a bookmark folder
    *        requestBody:
@@ -437,6 +441,7 @@ module.exports = (crowi: Crowi) => {
    *        security:
    *          - bearer: []
    *          - accessTokenInQuery: []
+   *          - accessTokenHeaderAuth: []
    *        summary: Update bookmark folder
    *        description: Update a bookmark folder
    *        requestBody:
@@ -498,6 +503,7 @@ module.exports = (crowi: Crowi) => {
    *        security:
    *          - bearer: []
    *          - accessTokenInQuery: []
+   *          - accessTokenHeaderAuth: []
    *        summary: Update bookmark in folder
    *        description: Update a bookmark in a folder
    *        requestBody:

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

@@ -615,6 +615,7 @@ module.exports = (crowi: Crowi): Router => {
    *      security:
    *        - bearer: []
    *        - accessTokenInQuery: []
+   *        - accessTokenHeaderAuth: []
    *      requestBody:
    *        required: true
    *        content:
@@ -688,6 +689,7 @@ module.exports = (crowi: Crowi): Router => {
    *      security:
    *        - bearer: []
    *        - accessTokenInQuery: []
+   *        - accessTokenHeaderAuth: []
    *      requestBody:
    *        required: true
    *        content:

+ 4 - 0
apps/app/src/server/routes/apiv3/import.ts

@@ -177,6 +177,7 @@ export default function route(crowi: Crowi): Router {
    *      security:
    *        - bearer: []
    *        - accessTokenInQuery: []
+   *        - accessTokenHeaderAuth: []
    *      summary: /import/status
    *      description: Get properties of stored zip files for import
    *      responses:
@@ -213,6 +214,7 @@ export default function route(crowi: Crowi): Router {
    *      security:
    *        - bearer: []
    *        - accessTokenInQuery: []
+   *        - accessTokenHeaderAuth: []
    *      summary: /import
    *      description: import a collection from a zipped json
    *      requestBody:
@@ -375,6 +377,7 @@ export default function route(crowi: Crowi): Router {
    *      security:
    *        - bearer: []
    *        - accessTokenInQuery: []
+   *        - accessTokenHeaderAuth: []
    *      summary: /import/upload
    *      description: upload a zip file
    *      requestBody:
@@ -441,6 +444,7 @@ export default function route(crowi: Crowi): Router {
    *      security:
    *        - bearer: []
    *        - accessTokenInQuery: []
+   *        - accessTokenHeaderAuth: []
    *      summary: /import/all
    *      description: Delete all zip files
    *      responses:

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

@@ -105,6 +105,7 @@ module.exports = (crowi: Crowi) => {
    *      security:
    *        - bearer: []
    *        - accessTokenInQuery: []
+   *        - accessTokenHeaderAuth: []
    *      summary: /in-app-notification/list
    *      description: Get the list of in-app notifications
    *      parameters:
@@ -207,6 +208,7 @@ module.exports = (crowi: Crowi) => {
    *      security:
    *        - bearer: []
    *        - accessTokenInQuery: []
+   *        - accessTokenHeaderAuth: []
    *      summary: /in-app-notification/status
    *      description: Get the status of in-app notifications
    *      responses:
@@ -250,6 +252,7 @@ module.exports = (crowi: Crowi) => {
    *      security:
    *        - bearer: []
    *        - accessTokenInQuery: []
+   *        - accessTokenHeaderAuth: []
    *      summary: /in-app-notification/open
    *      description: Open the in-app notification
    *      requestBody:
@@ -301,6 +304,7 @@ module.exports = (crowi: Crowi) => {
    *      security:
    *        - bearer: []
    *        - accessTokenInQuery: []
+   *        - accessTokenHeaderAuth: []
    *      summary: /in-app-notification/all-statuses-open
    *      description: Open all in-app notifications
    *      responses:

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

@@ -76,6 +76,7 @@ const routerFactory = (crowi: Crowi): Router => {
    *     security:
    *       - bearer: []
    *       - accessTokenInQuery: []
+   *       - accessTokenHeaderAuth: []
    *     summary: /page-listing/root
    *     description: Get the root page
    *     responses:
@@ -113,6 +114,7 @@ const routerFactory = (crowi: Crowi): Router => {
    *     security:
    *       - bearer: []
    *       - accessTokenInQuery: []
+   *       - accessTokenHeaderAuth: []
    *     summary: /page-listing/children
    *     description: Get the children of a page
    *     parameters:
@@ -193,6 +195,7 @@ const routerFactory = (crowi: Crowi): Router => {
    *     security:
    *       - bearer: []
    *       - accessTokenInQuery: []
+   *       - accessTokenHeaderAuth: []
    *     summary: /page-listing/info
    *     description: Get summary information of pages
    *     parameters:
@@ -354,6 +357,7 @@ const routerFactory = (crowi: Crowi): Router => {
    *     security:
    *       - bearer: []
    *       - accessTokenInQuery: []
+   *       - accessTokenHeaderAuth: []
    *     summary: /page-listing/item
    *     description: Get a single page item for tree display
    *     parameters:

+ 1 - 0
apps/app/src/server/routes/apiv3/user-activities.ts

@@ -159,6 +159,7 @@ module.exports = (crowi: Crowi): Router => {
    *       - cookieAuth: []
    *       - bearer: []
    *       - accessTokenInQuery: []
+   *       - accessTokenHeaderAuth: []
    *     parameters:
    *       - name: limit
    *         in: query