From ad6604aa71dbeaf7e4cf7e56e55ebc1084e5e77f Mon Sep 17 00:00:00 2001 From: John McLear Date: Fri, 17 Apr 2026 16:02:40 +0100 Subject: [PATCH 1/2] docs(openapi): document apikey auth in openapi.json (#7532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API accepts the key via ?apikey=, ?api_key=, or the apikey header, but only ?apikey= was advertised in /api-docs.json. /api/{version}/openapi.json was worse: it hardcoded an OAuth2 scheme even when Etherpad was started in apikey auth mode. Switch both generators on settings.authenticationMethod and publish apiKey schemes for the query (apikey, api_key) and header (apikey) variants. The openapi.ts definition is now regenerated per request so runtime settings are reflected. The raw authorization: header still works in code but is deliberately not documented — pinning it in the spec would ossify a quirk. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/node/handler/RestAPI.ts | 26 ++++++++++-- src/node/hooks/express/openapi.ts | 63 ++++++++++++++++++++---------- src/tests/backend/specs/api/api.ts | 58 +++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 24 deletions(-) diff --git a/src/node/handler/RestAPI.ts b/src/node/handler/RestAPI.ts index 1b0d1d09d1d..1bc4b4825bc 100644 --- a/src/node/handler/RestAPI.ts +++ b/src/node/handler/RestAPI.ts @@ -192,6 +192,16 @@ const prepareDefinition = (mapping: Map>, "in": string }, + "apiKeyAlias": { + "type": string, + "name": string, + "in": string + }, + "apiKeyHeader": { + "type": string, + "name": string, + "in": string + }, "sso"?: { "type": string, "flows": { @@ -243,6 +253,16 @@ const prepareDefinition = (mapping: Map>, "in": "query" }, + "apiKeyAlias": { + "type": "apiKey", + "name": "api_key", + "in": "query" + }, + "apiKeyHeader": { + "type": "apiKey", + "name": "apikey", + "in": "header" + }, }, }, "servers": [ @@ -256,9 +276,9 @@ const prepareDefinition = (mapping: Map>, if (authenticationMethod === "apikey") { definitions.security = [ - { - "apiKey": [] - } + {"apiKey": []}, + {"apiKeyAlias": []}, + {"apiKeyHeader": []}, ] } else if (authenticationMethod === "sso") { definitions.components.securitySchemes.sso = { diff --git a/src/node/hooks/express/openapi.ts b/src/node/hooks/express/openapi.ts index 519b5ec78f2..e07daf6d86b 100644 --- a/src/node/hooks/express/openapi.ts +++ b/src/node/hooks/express/openapi.ts @@ -482,26 +482,44 @@ const generateDefinitionForVersion = (version:string, style = APIPathStyle.FLAT) responses: { ...defaultResponses, }, - securitySchemes: { - openid: { - type: "oauth2", - flows: { - authorizationCode: { - authorizationUrl: settings.sso.issuer+"/oidc/auth", - tokenUrl: settings.sso.issuer+"/oidc/token", - scopes: { - openid: "openid", - profile: "profile", - email: "email", - admin: "admin" - } - } + securitySchemes: {} as Record, + }, + security: [] as Array>, + }; + + if (settings.authenticationMethod === 'apikey') { + definition.components.securitySchemes.apiKey = { + type: 'apiKey', name: 'apikey', in: 'query', + }; + definition.components.securitySchemes.apiKeyAlias = { + type: 'apiKey', name: 'api_key', in: 'query', + }; + definition.components.securitySchemes.apiKeyHeader = { + type: 'apiKey', name: 'apikey', in: 'header', + }; + definition.security = [ + {apiKey: []}, + {apiKeyAlias: []}, + {apiKeyHeader: []}, + ]; + } else { + definition.components.securitySchemes.openid = { + type: 'oauth2', + flows: { + authorizationCode: { + authorizationUrl: settings.sso.issuer + '/oidc/auth', + tokenUrl: settings.sso.issuer + '/oidc/token', + scopes: { + openid: 'openid', + profile: 'profile', + email: 'email', + admin: 'admin', }, }, }, - }, - security: [{openid: []}], - }; + }; + definition.security = [{openid: []}]; + } // build operations for (const funcName of Object.keys(apiHandler.version[version])) { @@ -566,14 +584,16 @@ exports.expressPreSession = async (hookName:string, {app}:any) => { for (const style of [APIPathStyle.FLAT, APIPathStyle.REST]) { const apiRoot = getApiRootForVersion(version, style); - // generate openapi definition for this API version + // generate openapi definition for this API version (used for openapi-backend routing) const definition = generateDefinitionForVersion(version, style); - // serve version specific openapi definition + // serve version specific openapi definition; regenerate per request so runtime + // settings (e.g. authenticationMethod) are reflected app.get(`${apiRoot}/openapi.json`, (req:any, res:any) => { // For openapi definitions, wide CORS is probably fine res.header('Access-Control-Allow-Origin', '*'); - res.json({...definition, servers: [generateServerForApiVersion(apiRoot, req)]}); + const liveDefinition = generateDefinitionForVersion(version, style); + res.json({...liveDefinition, servers: [generateServerForApiVersion(apiRoot, req)]}); }); // serve latest openapi definition file under /api/openapi.json @@ -581,7 +601,8 @@ exports.expressPreSession = async (hookName:string, {app}:any) => { if (isLatestAPIVersion) { app.get(`/${style}/openapi.json`, (req:any, res:any) => { res.header('Access-Control-Allow-Origin', '*'); - res.json({...definition, servers: [generateServerForApiVersion(apiRoot, req)]}); + const liveDefinition = generateDefinitionForVersion(version, style); + res.json({...liveDefinition, servers: [generateServerForApiVersion(apiRoot, req)]}); }); } diff --git a/src/tests/backend/specs/api/api.ts b/src/tests/backend/specs/api/api.ts index bab70b47ca6..53e2e84c976 100644 --- a/src/tests/backend/specs/api/api.ts +++ b/src/tests/backend/specs/api/api.ts @@ -10,6 +10,7 @@ const common = require('../../common'); const validateOpenAPI = require('openapi-schema-validation').validate; +import settings from '../../../../node/utils/Settings'; let agent: any; let apiVersion = 1; @@ -54,4 +55,61 @@ describe(__filename, function () { } }); }); + + describe('security schemes with authenticationMethod=apikey', function () { + let originalAuthMethod: string; + + before(function () { + originalAuthMethod = settings.authenticationMethod; + settings.authenticationMethod = 'apikey'; + }); + + after(function () { + settings.authenticationMethod = originalAuthMethod; + }); + + it('/api-docs.json documents apikey query param (primary name)', async function () { + const res = await agent.get('/api-docs.json').expect(200); + const schemes = res.body.components.securitySchemes; + const apiKeyQuery = Object.values(schemes).find( + (s: any) => s.type === 'apiKey' && s.in === 'query' && s.name === 'apikey'); + if (!apiKeyQuery) { + throw new Error(`Expected apiKey query param 'apikey' in securitySchemes: ` + + `${JSON.stringify(schemes)}`); + } + }); + + it('/api-docs.json documents api_key query param alias', async function () { + const res = await agent.get('/api-docs.json').expect(200); + const schemes = res.body.components.securitySchemes; + const apiKeyQueryAlias = Object.values(schemes).find( + (s: any) => s.type === 'apiKey' && s.in === 'query' && s.name === 'api_key'); + if (!apiKeyQueryAlias) { + throw new Error(`Expected apiKey query param 'api_key' in securitySchemes: ` + + `${JSON.stringify(schemes)}`); + } + }); + + it('/api-docs.json documents apikey header', async function () { + const res = await agent.get('/api-docs.json').expect(200); + const schemes = res.body.components.securitySchemes; + const apiKeyHeader = Object.values(schemes).find( + (s: any) => s.type === 'apiKey' && s.in === 'header' && s.name === 'apikey'); + if (!apiKeyHeader) { + throw new Error(`Expected apiKey header 'apikey' in securitySchemes: ` + + `${JSON.stringify(schemes)}`); + } + }); + + it('/api/openapi.json exposes apiKey security in apikey mode', async function () { + this.timeout(15000); + const res = await agent.get('/api/openapi.json').expect(200); + const schemes = res.body.components.securitySchemes; + const hasApiKey = Object.values(schemes).some((s: any) => s.type === 'apiKey'); + if (!hasApiKey) { + throw new Error(`Expected at least one apiKey securityScheme in ` + + `/api/openapi.json, got: ${JSON.stringify(schemes)}`); + } + }); + }); }); From 71d8fdf55a7a1612c9f1ef54cfc8a459e14ecabd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Apr 2026 09:46:05 +0000 Subject: [PATCH 2/2] refactor(openapi): add apiKeyAlias/apiKeyHeader conditionally in RestAPI.ts In SSO mode, apiKeyAlias and apiKeyHeader were always present in securitySchemes even though they're only relevant when authenticationMethod is 'apikey'. Mirror the pattern used for the sso scheme: add these two schemes dynamically inside the apikey branch, and mark them optional in the TypeScript type annotation. Agent-Logs-Url: https://github.com/ether/etherpad/sessions/1d440432-7389-462e-9aac-9a3c027640e8 Co-authored-by: JohnMcLear <220864+JohnMcLear@users.noreply.github.com> --- src/node/handler/RestAPI.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/node/handler/RestAPI.ts b/src/node/handler/RestAPI.ts index 1bc4b4825bc..1e3427eb75c 100644 --- a/src/node/handler/RestAPI.ts +++ b/src/node/handler/RestAPI.ts @@ -192,12 +192,12 @@ const prepareDefinition = (mapping: Map>, "in": string }, - "apiKeyAlias": { + "apiKeyAlias"?: { "type": string, "name": string, "in": string }, - "apiKeyHeader": { + "apiKeyHeader"?: { "type": string, "name": string, "in": string @@ -253,16 +253,6 @@ const prepareDefinition = (mapping: Map>, "in": "query" }, - "apiKeyAlias": { - "type": "apiKey", - "name": "api_key", - "in": "query" - }, - "apiKeyHeader": { - "type": "apiKey", - "name": "apikey", - "in": "header" - }, }, }, "servers": [ @@ -275,6 +265,16 @@ const prepareDefinition = (mapping: Map>, } if (authenticationMethod === "apikey") { + definitions.components.securitySchemes.apiKeyAlias = { + type: "apiKey", + name: "api_key", + in: "query", + }; + definitions.components.securitySchemes.apiKeyHeader = { + type: "apiKey", + name: "apikey", + in: "header", + }; definitions.security = [ {"apiKey": []}, {"apiKeyAlias": []},