From d5efa6b9ab9e17825cc17a82309c6f3dea73a4b4 Mon Sep 17 00:00:00 2001 From: Jesse Bickel Date: Mon, 27 Oct 2025 11:54:23 -0500 Subject: [PATCH] Single logout via a small Swagger-UI plugin To allow Keycloak logout from the OpenAPI documentation, this uses an OpenAPI plugin as a separate module. The reason for clean separation is inclusion of the "dom" module, which has no place in the node project except to be added to the `swagger-ui` browser code. Without this change, the logout only applies to the OpenAPI docs page and the PDC Keycloak session would remain. With this change, the logout really logs out of PDC Keycloak entirely. Issue #1915 Single logout from OpenAPI docs --- eslint.config.mjs | 6 +- package.json | 10 ++-- .../components/securitySchemes/auth.json | 1 + src/openapi/plugins/logout.ts | 59 +++++++++++++++++++ src/routers/documentationRouter.ts | 2 + tsconfig.dev.json | 3 +- tsconfig.json | 2 +- tsconfig.openapi.json | 13 ++++ 8 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 src/openapi/plugins/logout.ts create mode 100644 tsconfig.openapi.json diff --git a/eslint.config.mjs b/eslint.config.mjs index d05d65918..0bb9839fc 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -17,7 +17,7 @@ export default defineConfig([ ...love, languageOptions: { parserOptions: { - project: './tsconfig.dev.json', + project: ['./tsconfig.dev.json', './tsconfig.openapi.json'], }, }, }, @@ -42,7 +42,7 @@ export default defineConfig([ }, parserOptions: { - project: './tsconfig.dev.json', + project: ['./tsconfig.dev.json', './tsconfig.openapi.json'], }, }, @@ -87,7 +87,7 @@ export default defineConfig([ 'import/resolver': { typescript: { alwaysTryTypes: true, - project: './tsconfig.dev.json', + project: ['./tsconfig.dev.json', './tsconfig.openapi.json'], }, node: true, }, diff --git a/package.json b/package.json index 1232e88f7..bbfb45ca8 100644 --- a/package.json +++ b/package.json @@ -3,20 +3,20 @@ "description": "The service backend for the Philanthropy Data Commons", "main": "dist/index.js", "scripts": { - "build": "npm run cleanup && npm run build:node && npm run build:assets && npm run build:openapi", + "build": "npm run cleanup && npm run build:openapi && npm run build:node && npm run build:assets", "build:node": "tsc -p tsconfig.json", "build:assets": "copyfiles -u 1 \"src/**/*.sql\" \"src/**/.keep\" \"src/public/**\" dist", - "build:openapi": "redocly bundle \"src/openapi/api.json\" -o \"dist/openapi/api.json\"", + "build:openapi": "tsc -p tsconfig.openapi.json && redocly bundle \"src/openapi/api.json\" -o \"dist/openapi/api.json\"", "cleanup": "rimraf dist", "format": "npm run format:eslint && npm run format:prettier && npm run format:sqlfluff", "format:eslint": "eslint ./src --fix || true", "format:prettier": "prettier . --write", "format:sqlfluff": "./venv/bin/sqlfluff fix src/database/", - "lint": "npm run lint:eslint && npm run lint:prettier && npm run lint:tsc && npm run lint:openapi && npm run lint:sqlfluff && npm run lint:docker-compose", + "lint": "npm run lint:eslint && npm run lint:prettier && npm run lint:openapi && npm run lint:tsc && npm run lint:sqlfluff && npm run lint:docker-compose", "lint:docker-compose": "dclint ./compose-ci.yml", "lint:eslint": "eslint ./src --max-warnings=0", - "lint:openapi": "npx @redocly/cli lint src/openapi/api.json", - "lint:tsc": "tsc --noEmit -p tsconfig.dev.json", + "lint:openapi": "npm run build:openapi && npx @redocly/cli lint src/openapi/api.json", + "lint:tsc": "npm run build:openapi && tsc --noEmit -p tsconfig.dev.json", "lint:prettier": "prettier . --check", "lint:sqlfluff": "./venv/bin/sqlfluff lint src/database/", "migrate": "ts-node src/scripts/migrate.ts | pino-pretty", diff --git a/src/openapi/components/securitySchemes/auth.json b/src/openapi/components/securitySchemes/auth.json index 6fff2ffce..8d3f67c24 100644 --- a/src/openapi/components/securitySchemes/auth.json +++ b/src/openapi/components/securitySchemes/auth.json @@ -1,6 +1,7 @@ { "type": "oauth2", "description": "The OAuth 2.0 (and OpenID Connect) Authorization Code flow.", + "x-logoutUrl": "{{AUTH_ISSUER}}/protocol/openid-connect/logout", "flows": { "authorizationCode": { "authorizationUrl": "{{AUTH_ISSUER}}/protocol/openid-connect/auth", diff --git a/src/openapi/plugins/logout.ts b/src/openapi/plugins/logout.ts new file mode 100644 index 000000000..74e62cc57 --- /dev/null +++ b/src/openapi/plugins/logout.ts @@ -0,0 +1,59 @@ +type NestedMap = Map>>>; +type DeepNestedMap = Map< + string, + Map>>> +>; +interface System { + getState: () => DeepNestedMap; + specSelectors: { + specJson: () => NestedMap; + }; +} + +const logout = ( + system: System, +): { + statePlugins: { + auth: { + wrapActions: { + logout: ( + originalFunction: (keys: string[]) => Map, + ) => (keys: string[]) => Map; + }; + }; + }; +} => ({ + statePlugins: { + auth: { + wrapActions: { + logout: + (originalFunction: (keys: string[]) => Map) => + (keys: string[]) => { + const logoutUrl = system.specSelectors + .specJson() + .get('components') + ?.get('securitySchemes') + ?.get('auth') + ?.get('x-logoutUrl'); + const idToken = system + .getState() + .get('auth') + ?.get('authorized') + ?.get('auth') + ?.get('token') + ?.get('id_token'); + const { + location: { href }, + } = window; + const result = originalFunction(keys); + if (logoutUrl !== undefined) { + location.href = `${logoutUrl}?&id_token_hint=${idToken}&post_logout_redirect_uri=${href}`; + } + return result; + }, + }, + }, + }, +}); + +export { logout }; diff --git a/src/routers/documentationRouter.ts b/src/routers/documentationRouter.ts index 049b08f5b..db12134d3 100644 --- a/src/routers/documentationRouter.ts +++ b/src/routers/documentationRouter.ts @@ -3,6 +3,7 @@ import express from 'express'; import { requireEnv } from 'require-env-variable'; import swaggerUi from 'swagger-ui-express'; import { documentationHandlers } from '../handlers/documentationHandlers'; +import { logout } from '../../dist/openapi/plugins/logout'; import type { SwaggerUiOptions } from 'swagger-ui-express'; const { OPENAPI_DOCS_AUTH_CLIENT_ID } = requireEnv( @@ -20,6 +21,7 @@ const options: SwaggerUiOptions = { scopes: ['openid', 'roles', 'profile'], usePkceWithAuthorizationCodeGrant: true, }, + plugins: [logout], }, swaggerUrl: '/openapi/api.json', }; diff --git a/tsconfig.dev.json b/tsconfig.dev.json index 73db699f4..751446a17 100644 --- a/tsconfig.dev.json +++ b/tsconfig.dev.json @@ -6,5 +6,6 @@ "noUncheckedIndexedAccess": true, "isolatedModules": true }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/openapi/plugins"] } diff --git a/tsconfig.json b/tsconfig.json index f8f61f45a..1737fc8b7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,5 +8,5 @@ "noUncheckedIndexedAccess": true }, "include": ["src"], - "exclude": ["**/*.test.ts", "**/src/test"] + "exclude": ["**/*.test.ts", "**/src/test", "src/openapi/plugins/logout.ts"] } diff --git a/tsconfig.openapi.json b/tsconfig.openapi.json new file mode 100644 index 000000000..15ba625e1 --- /dev/null +++ b/tsconfig.openapi.json @@ -0,0 +1,13 @@ +{ + "extends": "@tsconfig/node24/tsconfig.json", + "compilerOptions": { + "lib": ["dom"], + "outDir": "dist/openapi/plugins", + "declaration": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "noUncheckedIndexedAccess": true + }, + "files": ["src/openapi/plugins/logout.ts"], + "include": ["src/openapi/plugins/logout.ts"] +}