Sfoglia il codice sorgente

add access-token-parser spec

Yuki Takei 1 settimana fa
parent
commit
77c8230754

+ 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.

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

@@ -0,0 +1,280 @@
+# 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 8 apiv3 route files carrying `accessTokenInQuery` — add `- accessTokenHeaderAuth: []`
+  after every `- accessTokenInQuery: []` block (25 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). **Drift note**: PR #10443 targeted `app-settings.js`
+  (now `app-settings/index.ts`) and omitted `user-activities.ts`; do not apply the PR's
+  route hunks verbatim — drive edits off the current-master `accessTokenInQuery` sites.
+
+## 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.
+- Return the resolved token only when it is a single string; otherwise return `null`
+  (covers array-valued or absent header — 3.4).
+- 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 non-empty-or-empty string token, or `null` when no
+  string-typed source is present.
+- Invariants: precedence order is `Bearer ?? header ?? query ?? body`; Bearer always wins
+  when present (3.1).
+
+#### 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.

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

@@ -0,0 +1,150 @@
+# 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.

+ 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-29T13:40:00Z",
+  "language": "en",
+  "phase": "tasks-approved",
+  "approvals": {
+    "requirements": {
+      "generated": true,
+      "approved": true
+    },
+    "design": {
+      "generated": true,
+      "approved": true
+    },
+    "tasks": {
+      "generated": true,
+      "approved": true
+    }
+  },
+  "ready_for_implementation": true
+}

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

@@ -0,0 +1,52 @@
+# 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.
+
+- [ ] 1. Foundation: shared token-source extraction utility
+- [ ] 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_
+
+- [ ] 2. Core: parser integration with header support
+- [ ] 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_
+- [ ] 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_
+
+- [ ] 3. Integration: OpenAPI advertisement of the header method
+- [ ] 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_
+- [ ] 3.2 Apply the header auth method to every advertising route
+  - Add an `accessTokenHeaderAuth` entry after every `accessTokenInQuery` block across the 8 current-master route files (25 sites): activity, user-activities, bookmark-folder, import, in-app-notification, page-listing, g2g-transfer, app-settings index. Do not apply PR #10443's route hunks verbatim — drive edits off the current-master `accessTokenInQuery` sites to absorb the `app-settings` path drift and the missing `user-activities`
+  - Observable: the number of added `accessTokenHeaderAuth` lines equals 25, and every route that advertises `accessTokenInQuery` also advertises `accessTokenHeaderAuth`
+  - _Requirements: 5.2_
+  - _Boundary: apiv3 route security blocks_
+  - _Depends: 3.1_
+
+- [ ] 4. Validation: regression and spec verification
+- [ ] 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, tests, and build are green; the regenerated specs include the new scheme; the added-line count check (25) holds
+  - _Requirements: 3.3, 5.1, 5.2, 5.3_
+  - _Depends: 2.1, 2.2, 3.2_