diff --git a/medcat-trainer/.env-example b/medcat-trainer/.env-example index 40b942556..6f2d8034f 100644 --- a/medcat-trainer/.env-example +++ b/medcat-trainer/.env-example @@ -3,4 +3,4 @@ SPACY_MODELS="en_core_web_sm en_core_web_md en_core_web_lg" # Ports MCTRAINER_PORT=8001 -SOLR_PORT=8983 +SOLR_PORT=8983 \ No newline at end of file diff --git a/medcat-trainer/README.md b/medcat-trainer/README.md index d44acc59b..bbc9d00c7 100644 --- a/medcat-trainer/README.md +++ b/medcat-trainer/README.md @@ -15,3 +15,5 @@ MedCATTrainer was presented at EMNLP/IJCNLP 2019 :tada: Official docs available [here](https://docs.cogstack.org/projects/medcat-trainer) If you have any questions why not reach out to the community [discourse forum here](https://discourse.cogstack.org/) + + diff --git a/medcat-trainer/docker-compose-dev.yml b/medcat-trainer/docker-compose-dev.yml index 7117ab2ce..3beaa9c49 100644 --- a/medcat-trainer/docker-compose-dev.yml +++ b/medcat-trainer/docker-compose-dev.yml @@ -8,6 +8,8 @@ services: args: SPACY_MODELS: ${SPACY_MODELS:-en_core_web_md} restart: always + networks: + - gateway-auth_gateway-net volumes: - api-media:/home/api/media - api-static:/home/api/static @@ -32,6 +34,8 @@ services: - api-static:/home/api/static - ./nginx/nginx.conf:/etc/nginx/nginx.conf - ./nginx/sites-enabled/:/etc/nginx/sites-enabled + networks: + - gateway-auth_gateway-net env_file: - ./envs/env ports: @@ -39,6 +43,11 @@ services: depends_on: - medcattrainer - solr + labels: + - "traefik.enable=true" + - "traefik.http.routers.nginx.rule=Host(`${NGINX_HOST:-medcattrainer.cogstack.localhost}`)" + - "traefik.http.routers.nginx.entrypoints=web" + - "traefik.http.services.nginx.loadbalancer.server.port=8000" solr: container_name: mct_solr @@ -50,6 +59,8 @@ services: - ${SOLR_PORT:-8983}:8983 volumes: - solr-data:/var/solr + networks: + - gateway-auth_gateway-net command: - -cloud @@ -59,3 +70,8 @@ volumes: api-db: api-db-backup: solr-data: + +networks: + gateway-auth_gateway-net: + external: true + diff --git a/medcat-trainer/docs/installation.md b/medcat-trainer/docs/installation.md index 22fe03240..12a37b1e3 100644 --- a/medcat-trainer/docs/installation.md +++ b/medcat-trainer/docs/installation.md @@ -52,7 +52,7 @@ For password resets and other emailing services email environment variables are Personal email accounts can be set up by users to do this, or you can contact someone in CogStack for a cogstack no email credentials. -The environment variables required are listed in [Environment Variables.](#(optional)-environment-variables) +The environment variables required are listed in [Environment Variables.](#optional-environment-variables) Environment Variables are located in envs/env or envs/env-prod, when those are set webapp/frontend/.env must change "VITE_APP_EMAIL" to 1. @@ -73,6 +73,30 @@ Set these and re-run the docker-compose file. You'll need to `docker stop` the running containers if you have already run the install. +## OIDC Authentication + +You can enable OIDC (OpenID Connect) authentication for the MedCAT Trainer. To do so, you must configure the following environment variables: + +| Variable | Used by | Description | +|-----------------------------------------|-------------------------|----------------------------------------------------------------| +| USE_OIDC | Backend | Enable OIDC login flow (1 (true) / 0 (false)). | +| VITE_USE_OIDC | Frontend | Exposed version of USE_OIDC for Vue. | +| VITE_API_URL | Frontend | Base API URL for frontend calls. | +| VITE_KEYCLOAK_URL | Frontend | Keycloak base URL (e.g. http://keycloak.cogstack.localhost/). | +| VITE_KEYCLOAK_REALM | Frontend | Keycloak realm name. | +| VITE_KEYCLOAK_CLIENT_ID | Frontend | Keycloak client ID for this app. | +| VITE_KEYCLOAK_TOKEN_REFRESH_INTERVAL_MS | Frontend | Token refresh frequency in ms. | +| VITE_KEYCLOAK_TOKEN_MIN_VALIDITY_SECS | Frontend | Minimum token validity before refresh. | +| VITE_LOGOUT_REDIRECT_URI | Frontend | Where to send user after logout. | +| NGINX_HOST | Backend | Host alias used by reverse proxy (Traefik ) | + +You can either use the Gateway Auth stack available in cogstack-ops or deploy your own Keycloak instance. +If you deploy your own Keycloak instance, make sure to configure the network accordingly. + +Currently, there are two roles that can be assigned to users: +- medcattrainer_superuser: grants superuser privileges in the application. +- medcattrainer_staff: grants staff-level privileges without full superuser access. + ### (Optional) Postgres Database Support MedCAT trainer defaults to a local SQLite database, which is suitable for single-user or small-scale setups. diff --git a/medcat-trainer/docs/project_admin.md b/medcat-trainer/docs/project_admin.md index 19f1e30ab..ecf0e994d 100644 --- a/medcat-trainer/docs/project_admin.md +++ b/medcat-trainer/docs/project_admin.md @@ -2,7 +2,7 @@ ## Creating an Annotation Project Annotation projects are used to inspect, validate and improve concepts recognised & linked by MedCAT. -They can also be used collect annotations for defined MetaCAT models tasks, and coming soon RelCAT, or relation annotation models. +They can also be used to collect annotations for defined MetaCAT models tasks, and coming soon RelCAT, or relation annotation models. Using the admin page, a configured admin or superuser can create, edit and delete annotation projects. diff --git a/medcat-trainer/envs/env b/medcat-trainer/envs/env index b083d1e2f..a13fae09f 100644 --- a/medcat-trainer/envs/env +++ b/medcat-trainer/envs/env @@ -44,3 +44,14 @@ EMAIL_USER=example@cogstack.org EMAIL_PASS="to be changed" EMAIL_HOST=mail.cogstack.org EMAIL_PORT=465 + +# Backend auth +USE_OIDC= + +# Traefik +NGINX_HOST=medcattrainer.cogstack.localhost + +# OIDC settings +OIDC_HOST=http://keycloak:8080 +OIDC_REALM=cogstack-realm +OIDC_FRONTEND_CLIENT_ID=cogstack-medcattrainer-frontend \ No newline at end of file diff --git a/medcat-trainer/webapp/api/api/oidc_utils.py b/medcat-trainer/webapp/api/api/oidc_utils.py new file mode 100644 index 000000000..93bb6d4eb --- /dev/null +++ b/medcat-trainer/webapp/api/api/oidc_utils.py @@ -0,0 +1,37 @@ +from django.contrib.auth import get_user_model +import secrets + +def get_user_by_email(request, id_token): + """ + Resolve or create a Django user using the email claim from OIDC. + """ + User = get_user_model() + email = id_token.get('email') + username = id_token.get('preferred_username') + print(id_token) + roles = [] + if 'realm_access' in id_token: + roles = id_token['realm_access'].get('roles', []) + + is_superuser = 'medcattrainer_superuser' in roles + is_staff = 'medcattrainer_staff' in roles + + user, created = User.objects.get_or_create( + email=email, + defaults={ + "username": username, + "first_name": id_token.get("given_name", ""), + "last_name": id_token.get("family_name", ""), + "is_active": True, + "password": secrets.token_urlsafe(32), + }, + ) + + user.username = username + user.first_name = id_token.get("given_name", "") + user.last_name = id_token.get("family_name", "") + user.is_superuser = is_superuser + user.is_staff = is_staff + + user.save() + return user diff --git a/medcat-trainer/webapp/api/api/views.py b/medcat-trainer/webapp/api/api/views.py index 1fe2cb99f..c65c324e9 100644 --- a/medcat-trainer/webapp/api/api/views.py +++ b/medcat-trainer/webapp/api/api/views.py @@ -1,3 +1,4 @@ +import os import traceback from smtplib import SMTPException from tempfile import NamedTemporaryFile @@ -223,7 +224,6 @@ def post(self, request, *args, **kwargs): Please visit https://medcattrainer.readthedocs.io for more information to resolve this.
You can also ask a question at: https://discourse.cogstack.org/c/medcat/5''') - @api_view(http_method_names=['GET']) def get_anno_tool_conf(_): return Response({k: v for k, v in os.environ.items()}) diff --git a/medcat-trainer/webapp/api/core/settings.py b/medcat-trainer/webapp/api/core/settings.py index 079017eb8..465f72a35 100644 --- a/medcat-trainer/webapp/api/core/settings.py +++ b/medcat-trainer/webapp/api/core/settings.py @@ -51,6 +51,8 @@ APPEND_SLASH = True +USE_OIDC = os.getenv('USE_OIDC', '').lower() == '1' + # Application definition INSTALLED_APPS = [ @@ -197,6 +199,38 @@ ] } +if USE_OIDC: + log.info("Using OIDC authentication") + REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"] = [ + 'oidc_auth.authentication.JSONWebTokenAuthentication', + 'oidc_auth.authentication.BearerTokenAuthentication', + ] + +OIDC_HOST = os.environ.get("OIDC_HOST", "") +OIDC_REALM = os.environ.get("OIDC_REALM", "") +OIDC_FRONTEND_CLIENT_ID = os.environ.get("OIDC_FRONTEND_CLIENT_ID", "") + +OIDC_AUTH = { + 'OIDC_ENDPOINT': f"{OIDC_HOST}/realms/{OIDC_REALM}", + 'OIDC_CLAIMS_OPTIONS': { + 'aud': { + 'values': [ + 'account', + OIDC_FRONTEND_CLIENT_ID + ], + 'essential': True, + }, + 'iss': { + 'values': [ + f"{OIDC_HOST}/realms/{OIDC_REALM}" + ], + 'essential': True, + }, + }, + 'USERINFO_ENDPOINT': f"{OIDC_HOST}/realms/{OIDC_REALM}/protocol/openid-connect/userinfo", + 'OIDC_CREATE_USER': True, + 'OIDC_RESOLVE_USER_FUNCTION': 'api.oidc_utils.get_user_by_email', +} # Internationalization # https://docs.djangoproject.com/en/2.2/topics/i18n/ diff --git a/medcat-trainer/webapp/frontend/.env b/medcat-trainer/webapp/frontend/.env new file mode 100644 index 000000000..52b8dc85a --- /dev/null +++ b/medcat-trainer/webapp/frontend/.env @@ -0,0 +1,9 @@ +# Override this with your .env.production +# Frontend auth settings +VITE_USE_OIDC=0 +VITE_KEYCLOAK_URL=http://keycloak.cogstack.localhost/ +VITE_KEYCLOAK_REALM=cogstack-realm +VITE_KEYCLOAK_CLIENT_ID=cogstack-medcattrainer-frontend +VITE_KEYCLOAK_TOKEN_REFRESH_INTERVAL_MS=10000 +VITE_KEYCLOAK_TOKEN_MIN_VALIDITY_SECS=30 +VITE_LOGOUT_REDIRECT_URI=http://home.cogstack.localhost/ \ No newline at end of file diff --git a/medcat-trainer/webapp/frontend/package-lock.json b/medcat-trainer/webapp/frontend/package-lock.json index 9b1724de6..36f6550a1 100644 --- a/medcat-trainer/webapp/frontend/package-lock.json +++ b/medcat-trainer/webapp/frontend/package-lock.json @@ -16,6 +16,7 @@ "@ssthouse/vue3-tree-chart": "^0.2.6", "axios": "^1.8.2", "bootstrap": "^5.3.3", + "keycloak-js": "^26.2.0", "plotly.js-dist": "^3.0.0", "splitpanes": "^3.1.5", "tiny-emitter": "^2.1.0", @@ -32,7 +33,7 @@ "devDependencies": { "@tsconfig/node20": "^20.1.4", "@types/jsdom": "^21.1.7", - "@types/node": "^20.17.0", + "@types/node": "^20.19.17", "@vitejs/plugin-vue": "^5.1.4", "@vitest/coverage-v8": "^3.2.4", "@vitest/eslint-plugin": "1.1.7", @@ -1008,10 +1009,11 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", - "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", "dev": true, + "license": "MIT", "dependencies": { "eslint-visitor-keys": "^3.4.3" }, @@ -1035,12 +1037,13 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.18.0.tgz", - "integrity": "sha512-fTxvnS1sRMu3+JjXwJG0j/i4RT9u4qJ+lqS/yCGap4lH4zZGzQ7tu+xZqQmcMZq5OBZDL4QRxQzRjkWcGt8IVw==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.4", + "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" }, @@ -1049,10 +1052,11 @@ } }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -1063,6 +1067,7 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -1070,20 +1075,35 @@ "node": "*" } }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@eslint/core": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.7.0.tgz", - "integrity": "sha512-xp5Jirz5DyPYlPiKat8jaq0EmYvDXKKpzTbxXMpT9eqlRJkRKIz9AGMdlvYjih+im+QlhWrpvVjl8IPC/lHlUw==", + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/eslintrc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz", - "integrity": "sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", "dev": true, + "license": "MIT", "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", @@ -1103,10 +1123,11 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -1117,6 +1138,7 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -1125,48 +1147,42 @@ } }, "node_modules/@eslint/js": { - "version": "9.13.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.13.0.tgz", - "integrity": "sha512-IFLyoY4d72Z5y/6o/BazFBezupzI/taV8sGumxTAVw3lXG9A6md1Dc34T9s1FoD/an9pJH8RHbAxsaEbBed9lA==", + "version": "9.36.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.36.0.tgz", + "integrity": "sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw==", "dev": true, + "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, "node_modules/@eslint/object-schema": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.4.tgz", - "integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.5.tgz", - "integrity": "sha512-lB05FkqEdUg2AA0xEbUz0SnkXT1LcCTa438W4IWTUh4hdOnVbQyOJ81OrDXsJk/LSiJHubgGEFoR5EHq1NsH1A==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.10.0", + "@eslint/core": "^0.15.2", "levn": "^0.4.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.10.0.tgz", - "integrity": "sha512-gFHJ+xBOo4G3WRlR1e/3G8A6/KZAH6zcE/hkLRCZTi/B9avAG365QhFA8uOGzTMqgTghpn7/fSnscW++dpMSAw==", - "dev": true, - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, "node_modules/@fortawesome/fontawesome-common-types": { "version": "6.6.0", "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.6.0.tgz", @@ -1837,15 +1853,17 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/node": { - "version": "20.17.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.3.tgz", - "integrity": "sha512-tSQrmKKatLDGnG92h40GD7FzUt0MjahaHwOME4VAFeeA/Xopayq5qLyQRy7Jg/pjgKIFBXuKcGhJo+UdYG55jQ==", + "version": "20.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz", + "integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==", "dev": true, + "license": "MIT", "dependencies": { - "undici-types": "~6.19.2" + "undici-types": "~6.21.0" } }, "node_modules/@types/tough-cookie": { @@ -2619,10 +2637,11 @@ } }, "node_modules/acorn": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", - "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -2656,6 +2675,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -2717,7 +2737,8 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "dev": true, + "license": "Python-2.0" }, "node_modules/assertion-error": { "version": "2.0.1", @@ -2764,13 +2785,13 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/axios": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz", - "integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", + "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, @@ -2827,10 +2848,11 @@ } }, "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -2958,6 +2980,7 @@ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -3967,31 +3990,33 @@ } }, "node_modules/eslint": { - "version": "9.13.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.13.0.tgz", - "integrity": "sha512-EYZK6SX6zjFHST/HRytOdA/zE72Cq/bfw45LSyuwrdvcclb/gqV8RRQxywOBEWO2+WDpva6UZa4CcDeJKzUCFA==", - "dev": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.11.0", - "@eslint/config-array": "^0.18.0", - "@eslint/core": "^0.7.0", - "@eslint/eslintrc": "^3.1.0", - "@eslint/js": "9.13.0", - "@eslint/plugin-kit": "^0.2.0", - "@humanfs/node": "^0.16.5", + "version": "9.36.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.36.0.tgz", + "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.36.0", + "@eslint/plugin-kit": "^0.3.5", + "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.3.1", + "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", + "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.1.0", - "eslint-visitor-keys": "^4.1.0", - "espree": "^10.2.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -4005,8 +4030,7 @@ "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "text-table": "^0.2.0" + "optionator": "^0.9.3" }, "bin": { "eslint": "bin/eslint.js" @@ -4106,10 +4130,11 @@ } }, "node_modules/eslint-scope": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.2.0.tgz", - "integrity": "sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -4133,21 +4158,37 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -4168,14 +4209,15 @@ } }, "node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.14.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4185,10 +4227,11 @@ } }, "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -4284,7 +4327,8 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/fast-diff": { "version": "1.3.0", @@ -4324,7 +4368,8 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", @@ -4624,6 +4669,7 @@ "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -4802,10 +4848,11 @@ "dev": true }, "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, + "license": "MIT", "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -5117,6 +5164,7 @@ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -5205,7 +5253,8 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -5239,6 +5288,12 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/keycloak-js": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-26.2.0.tgz", + "integrity": "sha512-CrFcXTN+d6J0V/1v3Zpioys6qHNWE6yUzVVIsCUAmFn9H14GZ0vuYod+lt+SSpMgWGPuneDZBSGBAeLBFuqjsw==", + "license": "Apache-2.0" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5706,6 +5761,7 @@ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, + "license": "MIT", "dependencies": { "callsites": "^3.0.0" }, @@ -6009,6 +6065,7 @@ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } @@ -6384,6 +6441,7 @@ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" }, @@ -6717,10 +6775,11 @@ } }, "node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "dev": true + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" }, "node_modules/unicorn-magic": { "version": "0.3.0", @@ -6781,6 +6840,7 @@ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" } @@ -6812,9 +6872,9 @@ } }, "node_modules/vite": { - "version": "6.3.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.4.tgz", - "integrity": "sha512-BiReIiMS2fyFqbqNT/Qqt4CVITDU9M9vE+DKcVAsB+ZV0wvTKd+3hMbkpxz1b+NmEDMegpVbisKiAZOnvO92Sw==", + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz", + "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/medcat-trainer/webapp/frontend/package.json b/medcat-trainer/webapp/frontend/package.json index d0fd37a28..33f948d02 100644 --- a/medcat-trainer/webapp/frontend/package.json +++ b/medcat-trainer/webapp/frontend/package.json @@ -24,6 +24,7 @@ "@ssthouse/vue3-tree-chart": "^0.2.6", "axios": "^1.8.2", "bootstrap": "^5.3.3", + "keycloak-js": "^26.2.0", "plotly.js-dist": "^3.0.0", "splitpanes": "^3.1.5", "tiny-emitter": "^2.1.0", @@ -40,7 +41,7 @@ "devDependencies": { "@tsconfig/node20": "^20.1.4", "@types/jsdom": "^21.1.7", - "@types/node": "^20.17.0", + "@types/node": "^20.19.17", "@vitejs/plugin-vue": "^5.1.4", "@vitest/coverage-v8": "^3.2.4", "@vitest/eslint-plugin": "1.1.7", diff --git a/medcat-trainer/webapp/frontend/src/App.vue b/medcat-trainer/webapp/frontend/src/App.vue index 53df57b25..3b5932090 100644 --- a/medcat-trainer/webapp/frontend/src/App.vue +++ b/medcat-trainer/webapp/frontend/src/App.vue @@ -1,26 +1,27 @@ @@ -28,39 +29,79 @@ import Login from '@/components/common/Login.vue' import EventBus from '@/event-bus' +const USE_OIDC = import.meta.env.VITE_USE_OIDC === '1' + export default { name: 'App', - components: { - Login - }, + components: { Login }, data () { return { loginModal: false, - uname: this.$cookies.get('username') || null, - version: '' + uname: null, + version: '', + useOidc: USE_OIDC, } }, methods: { + openLogin () { + if (!this.useOidc) { + this.loginModal = true + } else { + // Kick off OIDC login if needed + if (this.$keycloak && !this.$keycloak.authenticated) { + this.$keycloak.login() + } + } + }, loginSuccessful () { - this.loginModal = false - this.uname = this.$cookies.get('username') + if (!this.useOidc) { + this.loginModal = false + this.uname = this.$cookies.get('username') + } else { + this.updateOidcUser() + } if (this.$route.name !== 'home') { this.$router.push({ name: 'home' }) } }, + updateOidcUser () { + if (this.$keycloak && this.$keycloak.tokenParsed) { + this.uname = this.$keycloak.tokenParsed.preferred_username || null + this.$http.defaults.headers.common['Authorization'] = `Bearer ${this.$keycloak.token}` + } + }, logout () { + this.uname = null this.$cookies.remove('username') this.$cookies.remove('api-token') this.$cookies.remove('admin') - if (this.$route.name !== 'home') { - this.$router.push({ name: 'home' }) + + if (this.useOidc && this.$keycloak && this.$keycloak.authenticated) { + this.$keycloak.logout({ + redirectUri: import.meta.env.VITE_LOGOUT_REDIRECT_URI || 'http://home.cogstack.localhost/' + }) } else { - this.$router.go() + if (this.$route.name !== 'home') { + this.$router.push({name: 'home'}) + } else { + this.$router.go() + } } } }, mounted () { EventBus.$on('login:success', this.loginSuccessful) + + if (!this.useOidc) { + this.uname = this.$cookies.get('username') || null + } else { + if (this.$keycloak && this.$keycloak.authenticated) { + this.updateOidcUser() + // Watch for token refresh events + this.$keycloak.onAuthRefreshSuccess = () => this.updateOidcUser() + this.$keycloak.onAuthSuccess = () => this.updateOidcUser() + } + } }, beforeDestroy () { EventBus.$off('login:success', this.loginSuccessful) diff --git a/medcat-trainer/webapp/frontend/src/auth.ts b/medcat-trainer/webapp/frontend/src/auth.ts new file mode 100644 index 000000000..84b2daecf --- /dev/null +++ b/medcat-trainer/webapp/frontend/src/auth.ts @@ -0,0 +1,50 @@ +import type { App } from 'vue' +import Keycloak, { KeycloakConfig } from 'keycloak-js' +import axios from 'axios' + +let keycloak: Keycloak + +export const authPlugin = { + install: async (app: App) => { + const config: KeycloakConfig = { + url: import.meta.env.VITE_KEYCLOAK_URL, + realm: import.meta.env.VITE_KEYCLOAK_REALM, + clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID, + } + + keycloak = new Keycloak(config) + + const authenticated = await keycloak.init({ + onLoad: 'login-required', + pkceMethod: 'S256', + checkLoginIframe: false, + }) + + if (!authenticated) { + console.warn('User is not authenticated') + window.location.reload() + } + + // configure axios + axios.defaults.headers.common['Authorization'] = `Bearer ${keycloak.token}` + + const refreshInterval = Number(import.meta.env.VITE_KEYCLOAK_TOKEN_REFRESH_INTERVAL) || 10000 + const minValidity = Number(import.meta.env.VITE_KEYCLOAK_TOKEN_MIN_VALIDITY) || 30 + + setInterval(() => { + keycloak.updateToken(minValidity) + .then(refreshed => { + if (refreshed) { + axios.defaults.headers.common['Authorization'] = `Bearer ${keycloak.token}` + } + }) + .catch(err => { + console.error('Failed to refresh token', err) + }) + }, refreshInterval) + + + app.config.globalProperties.$keycloak = keycloak + app.config.globalProperties.$http = axios + }, +} diff --git a/medcat-trainer/webapp/frontend/src/components/common/Login.vue b/medcat-trainer/webapp/frontend/src/components/common/Login.vue index eac0887eb..087741d29 100644 --- a/medcat-trainer/webapp/frontend/src/components/common/Login.vue +++ b/medcat-trainer/webapp/frontend/src/components/common/Login.vue @@ -1,23 +1,25 @@ diff --git a/medcat-trainer/webapp/frontend/src/main.ts b/medcat-trainer/webapp/frontend/src/main.ts index bb37fc2e3..c38454084 100644 --- a/medcat-trainer/webapp/frontend/src/main.ts +++ b/medcat-trainer/webapp/frontend/src/main.ts @@ -22,6 +22,7 @@ import 'vuetify/styles' import { createVuetify } from 'vuetify' import * as components from 'vuetify/components' import * as directives from 'vuetify/directives' +import {authPlugin} from "./auth"; const theme ={ dark: false, @@ -45,25 +46,38 @@ const vuetify = createVuetify({ } }) -const app = createApp(App) -app.config.globalProperties.$http = axios -app.component("v-select", vSelect) -app.component('vue-simple-context-menu', VueSimpleContextMenu) -app.component('font-awesome-icon', FontAwesomeIcon) -app.use(router) -app.use(VueCookies, { expires: '7d'}) -app.use(vuetify); +const USE_OIDC = import.meta.env.VITE_USE_OIDC === '1' -(function () { - const apiToken = document.cookie - .split(';') - .map(s => s.trim().split('=')) - .filter(s => s[0] === 'api-token') - if (apiToken.length) { - axios.defaults.headers.common['Authorization'] = `Token ${apiToken[0][1]}` - axios.defaults.timeout = 6000000000 +async function bootstrap() { + const app = createApp(App) + app.config.globalProperties.$http = axios + app.component("v-select", vSelect) + app.component('vue-simple-context-menu', VueSimpleContextMenu) + app.component('font-awesome-icon', FontAwesomeIcon) + app.use(router) + app.use(VueCookies, { expires: '7d'}) + app.use(vuetify); + + console.log("Running in " + import.meta.env.MODE) + + if (USE_OIDC) { + await authPlugin.install(app) + } else { + const apiToken = document.cookie + .split(';') + .map(s => s.trim().split('=')) + .filter(s => s[0] === 'api-token') + + if (apiToken.length) { + axios.defaults.headers.common['Authorization'] = `Token ${apiToken[0][1]}` + axios.defaults.timeout = 6000000000 + } + + app.config.globalProperties.$http = axios } -})() -app.config.compilerOptions.whitespace = 'preserve'; -app.mount('#app') + app.config.compilerOptions.whitespace = 'preserve' + app.mount('#app') +} + +bootstrap() diff --git a/medcat-trainer/webapp/frontend/src/tests/App.spec.ts b/medcat-trainer/webapp/frontend/src/tests/App.spec.ts index 0d26ddee3..f14156b62 100644 --- a/medcat-trainer/webapp/frontend/src/tests/App.spec.ts +++ b/medcat-trainer/webapp/frontend/src/tests/App.spec.ts @@ -57,7 +57,7 @@ describe('App.vue', () => { expect(links[4].props('to')).toBe('/demo') }) - + it('calls /api/version/ when created', async () => { const mockGet = vi.fn().mockResolvedValue({ data: 'v1.2.3' }); mount(App, { @@ -92,7 +92,7 @@ describe('App.vue', () => { }, data() { return { - uname: 'testuser', + uname: 'testUser', loginModal: false, version: 'v1.2.3' } @@ -101,7 +101,7 @@ describe('App.vue', () => { await router.isReady() await flushPromises() - expect(wrapper.text()).toContain('testuser'); - expect(wrapper.find('.logout').exists()).toBe(true); + expect(wrapper.text()).toContain('testUser'); + expect(wrapper.find('.logout').exists()).toBe(true); }); - }); \ No newline at end of file + }); diff --git a/medcat-trainer/webapp/frontend/src/views/Home.vue b/medcat-trainer/webapp/frontend/src/views/Home.vue index 9d5a720cb..e187acacd 100644 --- a/medcat-trainer/webapp/frontend/src/views/Home.vue +++ b/medcat-trainer/webapp/frontend/src/views/Home.vue @@ -46,7 +46,6 @@ import Login from '@/components/common/Login.vue' import EventBus from '@/event-bus' import ProjectList from "@/components/common/ProjectList.vue" - export default { name: 'Home', components: { @@ -73,7 +72,8 @@ export default { loadingProjects: false, isAdmin: false, selectedProjectGroup: null, - cdbSearchIndexStatus: {} + cdbSearchIndexStatus: {}, + useOidc: import.meta.env.VITE_USE_OIDC === '1' } }, created () { @@ -101,11 +101,15 @@ export default { }) // assume if there's an api-token we've logged in before and will try get projects // fallback to logging in otherwise - if (this.$cookies.get('api-token')) { - this.loginSuccessful = true - this.isAdmin = this.$cookies.get('admin') === 'true' - this.fetchProjects() - } + if (!this.useOidc && this.$cookies.get('api-token')) { + this.loginSuccessful = true + this.isAdmin = this.$cookies.get('admin') === 'true' + this.fetchProjects() + } else if (this.useOidc && this.$keycloak && this.$keycloak.authenticated) { + this.loginSuccessful = true + this.isAdmin = this.$keycloak.tokenParsed?.realm_access?.roles.includes('admin') ?? false; + this.fetchProjects() + } }, fetchProjectGroups () { const projectGroupIds = new Set(this.projects.items.filter(p => p.group !== null).map(p => p.group)) diff --git a/medcat-trainer/webapp/frontend/tsconfig.json b/medcat-trainer/webapp/frontend/tsconfig.json index 100cf6a8f..2d3638f3b 100644 --- a/medcat-trainer/webapp/frontend/tsconfig.json +++ b/medcat-trainer/webapp/frontend/tsconfig.json @@ -10,5 +10,9 @@ { "path": "./tsconfig.vitest.json" } - ] + ], + "compilerOptions": { + "target": "esnext", + "module": "esnext" + } } diff --git a/medcat-trainer/webapp/frontend/vite.config.ts b/medcat-trainer/webapp/frontend/vite.config.ts index 77eb8ea7c..fba0599d6 100644 --- a/medcat-trainer/webapp/frontend/vite.config.ts +++ b/medcat-trainer/webapp/frontend/vite.config.ts @@ -31,7 +31,7 @@ export default defineConfig({ }, '^/api/*': { target: 'http://127.0.0.1:8001' - } + } } }, css: { diff --git a/medcat-trainer/webapp/requirements.txt b/medcat-trainer/webapp/requirements.txt index 46b494f77..e972bcef1 100644 --- a/medcat-trainer/webapp/requirements.txt +++ b/medcat-trainer/webapp/requirements.txt @@ -8,4 +8,6 @@ django-background-tasks-updated==1.2.* openpyxl==3.1.2 medcat[meta-cat,spacy,rel-cat,deid]==2.1.* psycopg[binary,pool]==3.2.9 +cryptography==45.0.* +drf-oidc-auth==3.0.0 django-health-check==3.20.0 diff --git a/medcat-trainer/webapp/scripts/load_examples.py b/medcat-trainer/webapp/scripts/load_examples.py index fd011093d..2968039e8 100644 --- a/medcat-trainer/webapp/scripts/load_examples.py +++ b/medcat-trainer/webapp/scripts/load_examples.py @@ -5,12 +5,34 @@ from time import sleep import json +def get_keycloak_access_token(): + print('Getting Keycloak access token...') + keycloak_url = os.environ.get("KEYCLOAK_URL", "http://keycloak.cogstack.localhost") + realm = os.environ.get("KEYCLOAK_REALM", "cogstack-realm") + client_id = os.environ.get("KEYCLOAK_CLIENT_ID", "cogstack-medcattrainer-frontend") + username = os.environ.get("KEYCLOAK_USERNAME", "admin") + password = os.environ.get("KEYCLOAK_PASSWORD", "admin") + + token_url = f"{keycloak_url}/realms/{realm}/protocol/openid-connect/token" + + data = { + "grant_type": "password", + "client_id": client_id, + "username": username, + "password": password, + "scope": "openid profile email" + } + + resp = requests.post(token_url, data=data) + resp.raise_for_status() + return resp.json()["access_token"] def main(port=8000, model_pack_tmp_file='/home/model_pack.zip', dataset_tmp_file='/home/ds.csv', initial_wait=15): + print('Checking for environment variable LOAD_EXAMPLES...') val = os.environ.get('LOAD_EXAMPLES') if val is not None and val not in ('1', 'true', 't', 'y'): print('Found Env Var LOAD_EXAMPLES is False, not loading example data, cdb, vocab and project') @@ -25,15 +47,26 @@ def main(port=8000, try: # check API is available if requests.get(URL).status_code == 200: - # check API default username and pass are available. - payload = {"username": "admin", "password": "admin"} - resp = requests.post(f"{URL}api-token-auth/", json=payload) - if resp.status_code != 200: - break - headers = { - 'Authorization': f'Token {json.loads(resp.text)["token"]}', - } + use_oidc = os.environ.get('USE_OIDC') + print('Checking for environment variable USE_OIDC...') + if use_oidc is not None and use_oidc in ('1', 'true', 't', 'y'): + print('Found environment variable USE_OIDC is set to truthy value. Will load data using JWT') + token = get_keycloak_access_token() + headers = { + 'Authorization': f'Bearer {token}', + } + else: + # check API default username and pass are available. + print('Getting DRF auth token ...') + payload = {"username": "admin", "password": "admin"} + resp = requests.post(f"{URL}api-token-auth/", json=payload) + if resp.status_code != 200: + break + + headers = { + 'Authorization': f'Token {json.loads(resp.text)["token"]}', + } # check concepts DB, vocab, datasets and projects are empty resp_model_packs = requests.get(f'{URL}modelpacks/', headers=headers) diff --git a/medcat-trainer/webapp/scripts/run.sh b/medcat-trainer/webapp/scripts/run.sh index 7e94b228f..7197df010 100755 --- a/medcat-trainer/webapp/scripts/run.sh +++ b/medcat-trainer/webapp/scripts/run.sh @@ -18,7 +18,7 @@ python /home/api/manage.py migrate api --noinput # also create a user group `user_group` that prevents users from deleting models echo "from django.contrib.auth import get_user_model User = get_user_model() -if User.objects.count() == 0: +if not User.objects.filter(username='admin').exists(): User.objects.create_superuser('admin', 'admin@example.com', 'admin') " | python manage.py shell