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.
X-GROWI-ACCESS-TOKEN header on both parser paths.AccessToken storage model, or the legacy api-token
mechanism.X-GROWI-ACCESS-TOKEN header position (Bearer ?? header ?? query ?? body).X_GROWI_ACCESS_TOKEN_HEADER_NAME.accessTokenHeaderAuth OpenAPI security-scheme declaration and its application to
every apiv3 route that already advertises bearer + accessTokenInQuery.AccessToken
model and SCOPE definitions in @growi/core; reused unchanged.extract-bearer-token.ts) — unchanged.@growi/core/dist/interfaces/server (AccessTokenParserReq), AccessToken model,
serializeUserSecurely, the existing extractBearerToken helper.X_GROWI_ACCESS_TOKEN_HEADER_NAME.extractAccessToken return contract (string | null).accessTokenInQuery route without mirroring accessTokenHeaderAuth.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.
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.
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)..js files keep the
literal x-growi-access-token, consistent with the g2g precedent.| 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.
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
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.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).| 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 |
| 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
X-GROWI-ACCESS-TOKEN header → access_token
query → access_token body.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.null when no string-typed source resolves; otherwise return the string token.Dependencies
extractBearerToken — Bearer parsing (P0).Contracts: Service [x]
export const X_GROWI_ACCESS_TOKEN_HEADER_NAME = 'x-growi-access-token';
export const extractAccessToken = (req: AccessTokenParserReq): string | null;
req is an Express request (AccessTokenParserReq).null when no string-typed source resolves.Bearer ?? header ?? query ?? body; Bearer always wins
when present (3.1); a non-string header is skipped so resolution falls through (3.4).| 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
bearer ?? query ?? body + typeof guard with
const accessToken = extractAccessToken(req); if (accessToken == null) return;.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
Contracts: API [x]
| Field | Value |
|---|---|
| Scheme name | accessTokenHeaderAuth |
type |
apiKey |
in |
header |
name |
x-growi-access-token |
components.securitySchemes of both definition-apiv1.js and
definition-apiv3.js, added to the top-level security array (5.1).- accessTokenHeaderAuth: [] alongside existing bearer and
accessTokenInQuery entries; existing schemes retained (5.2, 5.3).| 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 | — |
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.
extract-access-token.spec.ts, new)x-growi-access-token header are present (3.1).null when x-growi-access-token is an array / non-string (3.4).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).accessTokenHeaderAuth appears in
securitySchemes and on each previously accessTokenInQuery-advertising route; added
accessTokenHeaderAuth: [] line count equals the accessTokenInQuery count (25).