docs(openapi): document apikey auth in openapi.json (#7534)

* docs(openapi): document apikey auth in openapi.json (#7532)

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: <key> 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) <noreply@anthropic.com>

* 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>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: JohnMcLear <220864+JohnMcLear@users.noreply.github.com>
This commit is contained in:
John McLear 2026-04-18 17:09:18 +01:00 committed by GitHub
parent 0206e0447c
commit 66f49bb808
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 123 additions and 24 deletions

View File

@ -192,6 +192,16 @@ const prepareDefinition = (mapping: Map<string, Record<string, RestAPIMapping>>,
"in": string
},
"apiKeyAlias"?: {
"type": string,
"name": string,
"in": string
},
"apiKeyHeader"?: {
"type": string,
"name": string,
"in": string
},
"sso"?: {
"type": string,
"flows": {
@ -255,10 +265,20 @@ const prepareDefinition = (mapping: Map<string, Record<string, RestAPIMapping>>,
}
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": []
}
{"apiKey": []},
{"apiKeyAlias": []},
{"apiKeyHeader": []},
]
} else if (authenticationMethod === "sso") {
definitions.components.securitySchemes.sso = {

View File

@ -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<string, any>,
},
security: [] as Array<Record<string, string[]>>,
};
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)]});
});
}

View File

@ -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)}`);
}
});
});
});