From ce74c67b7ff86b6099f86605f4672767837ae607 Mon Sep 17 00:00:00 2001 From: sambles Date: Tue, 16 Dec 2025 14:53:27 +0000 Subject: [PATCH 1/5] Updated Package Requirements: urllib3==2.6.0 (#1307) Co-authored-by: awsbuild --- kubernetes/worker-controller/requirements.txt | 2 +- requirements-server.txt | 2 +- requirements-worker.txt | 2 +- requirements.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/kubernetes/worker-controller/requirements.txt b/kubernetes/worker-controller/requirements.txt index ae92248af..e48dc4c99 100644 --- a/kubernetes/worker-controller/requirements.txt +++ b/kubernetes/worker-controller/requirements.txt @@ -46,7 +46,7 @@ six==1.17.0 # python-dateutil typing-extensions==4.15.0 # via aiosignal -urllib3==2.5.0 +urllib3==2.6.0 # via kubernetes-asyncio websockets==13.1 # via -r kubernetes/worker-controller/requirements.in diff --git a/requirements-server.txt b/requirements-server.txt index e04213720..7e0ef59ca 100644 --- a/requirements-server.txt +++ b/requirements-server.txt @@ -341,7 +341,7 @@ uritemplate==4.2.0 # via # coreapi # drf-yasg -urllib3==2.5.0 +urllib3==2.6.0 # via # botocore # requests diff --git a/requirements-worker.txt b/requirements-worker.txt index ea538acf9..8cc940cbd 100644 --- a/requirements-worker.txt +++ b/requirements-worker.txt @@ -335,7 +335,7 @@ tzdata==2025.2 # via # kombu # pandas -urllib3==2.5.0 +urllib3==2.6.0 # via # botocore # requests diff --git a/requirements.txt b/requirements.txt index 95f9c1884..a57356da0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -634,7 +634,7 @@ uritemplate==4.2.0 # via # coreapi # drf-yasg -urllib3==2.5.0 +urllib3==2.6.0 # via # botocore # requests From 4c4e8271f3498eb3c0338b5a78f77900a96a1785 Mon Sep 17 00:00:00 2001 From: sambles Date: Wed, 17 Dec 2025 10:29:49 +0000 Subject: [PATCH 2/5] Update test for analyses_settings computation_settings merge (#1308) * Update testing for analyses_settings computation_settings * trim down PR template * pep * Set version 2.4.11 * retest --------- Co-authored-by: awsbuild --- .github/PULL_REQUEST_TEMPLATE.md | 2 -- VERSION | 2 +- requirements-worker.txt | 2 +- requirements.txt | 2 +- .../tests/test_tasks.py | 19 ++++++++++++++++++- 5 files changed, 21 insertions(+), 6 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 2d819eab2..bc16ffcec 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,5 +1,3 @@ -**IMPORTANT: Please attach or create an issue after submitting a Pull Request. - ### Release notes feature title ... Release notes description / summary diff --git a/VERSION b/VERSION index b0f6bf0cd..11e321269 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.4.10 +2.4.11 diff --git a/requirements-worker.txt b/requirements-worker.txt index 8cc940cbd..4cafbc5c7 100644 --- a/requirements-worker.txt +++ b/requirements-worker.txt @@ -190,7 +190,7 @@ oasis-data-manager==0.1.6 # -r requirements-worker.in # oasislmf # ods-tools -oasislmf[extra]==2.4.10 +oasislmf[extra]==2.4.11 # via -r requirements-worker.in ods-tools==4.0.5 # via oasislmf diff --git a/requirements.txt b/requirements.txt index a57356da0..5a391d73a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -375,7 +375,7 @@ oasis-data-manager==0.1.6 # -r requirements-worker.in # oasislmf # ods-tools -oasislmf[extra]==2.4.10 +oasislmf[extra]==2.4.11 # via -r requirements-worker.in ods-tools==4.0.5 # via diff --git a/src/model_execution_worker/tests/test_tasks.py b/src/model_execution_worker/tests/test_tasks.py index fb465f225..977efdb0a 100644 --- a/src/model_execution_worker/tests/test_tasks.py +++ b/src/model_execution_worker/tests/test_tasks.py @@ -89,11 +89,26 @@ def test_custom_model_runner_does_not_exist___generate_losses_is_called_output_f MODEL_DATA_DIRECTORY=model_data_dir, WORKING_DIRECTORY=work_dir,): self.create_tar(str(Path(media_root, 'location.tar'))) - Path(media_root, 'analysis_settings.json').touch() Path(run_dir, 'output').mkdir(parents=True) Path(model_data_dir, 'supplier', 'model', 'version').mkdir(parents=True) log_file = Path(log_dir, 'log-file.log').touch() + settings_file_path = Path(media_root, 'analysis_settings.json') + analysis_settings = { + "computation_settings": { + "ktools_num_processes": 42 + }, + "model_settings": { + }, + "model_name_id": "PiWind", + "model_supplier_id": "OasisLMF", + "gul_output": False, + "gul_summaries": [] + } + + with open(settings_file_path, 'w') as f: + json.dump(analysis_settings, f, indent=4) + params = { "oasis_files_dir": os.path.join(run_dir, 'input'), "model_run_dir": run_dir, @@ -122,6 +137,8 @@ def fake_run_dir(*args, **kwargs): cmd_mock.assert_called_once() called_args = cmd_mock.call_args.kwargs + self.assertEqual(called_args.get('ktools_num_processes', None), + analysis_settings.get("computation_settings").get("ktools_num_processes")) self.assertEqual(called_args.get('oasis_files_dir', None), params.get('oasis_files_dir')) self.assertEqual(called_args.get('model_run_dir', None), params.get('model_run_dir')) self.assertEqual(called_args.get('ktools_fifo_relative', None), params.get('ktools_fifo_relative')) From 26e28fb5dd12a06283413ff8a76d3cc79b98e447 Mon Sep 17 00:00:00 2001 From: Ha-Ree <48283886+Ha-Ree@users.noreply.github.com> Date: Fri, 19 Dec 2025 15:37:36 +0000 Subject: [PATCH 3/5] fix (#1310) --- src/model_execution_worker/distributed_tasks.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/model_execution_worker/distributed_tasks.py b/src/model_execution_worker/distributed_tasks.py index d470d113d..8c2e9e99f 100644 --- a/src/model_execution_worker/distributed_tasks.py +++ b/src/model_execution_worker/distributed_tasks.py @@ -431,6 +431,7 @@ def prepare_input_generation_params( from oasislmf.manager import OasisManager gen_files_params = OasisManager()._params_generate_oasis_files(**lookup_params) params = paths_to_absolute_paths({**gen_files_params}, config_path) + params['oed_schema_info'] = gen_files_params.get('oed_schema_info', None) params['log_storage'] = dict() params['log_location'] = filestore.put(kwargs.get('log_filename')) @@ -853,6 +854,7 @@ def prepare_losses_generation_params( from oasislmf.manager import OasisManager gen_losses_params = OasisManager()._params_generate_oasis_losses(**run_params) params = paths_to_absolute_paths({**gen_losses_params}, config_path) + params['oed_schema_info'] = gen_losses_params.get('oed_schema_info', None) params['log_location'] = filestore.put(kwargs.get('log_filename')) params['verbose'] = debug_worker From 8385d7c3e65284260b6de5e67597f9c51fc224ee Mon Sep 17 00:00:00 2001 From: Anish Kothikar <42143884+SkylordA@users.noreply.github.com> Date: Wed, 7 Jan 2026 11:56:37 +0000 Subject: [PATCH 4/5] Feature/generic OIDC (#1248) * saving work * saving work * fix authentik chart bugs * adds worker deployment for authentik * adds blueprint template code with example blueprint * adds custom blueprints for oasis/swagger providers/apps and custom users from values.yaml * saving work, adds authentik code to python/django, adds custom flow for oasis-server and swagger providers * saving work: simplifying authentik blueprint * saving work: changes flow type to accessCode, this allows partial functionality with authentik, ui login still broken * fixing accessCode for authentik, was getting cors error for some reason all of a sudden * adds new endpoint for service authorization via client_credentials. refactor all service authorization requests to use client_credentials endpoint instead of password grants. Creates new client specifically for service authorization with custom JWT claim. Adds same service client_credentials auth as keycloak but with authentik. * adds servicetokenserializer for simplejwt, works for service authentication, but some reason ui login with simple is broken * fix default django admin user env setting * modify auth templates so pods only start if correct authType selected in values.yaml * fix cicd workflow file * saving work: combines keycloak/authentik oidc backend classes into 1 generic class. Adds endpoints for oidc authorization and callback. Adds new vars to send to the UI for auth type and external hostname. Refactor settings/base.py * adds tokens to url in callback to be read by R UI * merges service/access_token/ with access_token/ endpoint, so this endpoint hadnles simpleJWT user/service authentication, and OIDC service authentication only. * minikube-cicd * minikube-cicd * minikube-cicd * stupid error * more stupid errors * test cicd with keycloak * test cicd with authentik + added delay for server to start * update django 5.2.7 CVE-2025-59681 * adds logout endpoint for oidc, constructs external url from server env vars * updates callback view to send a coded session token, which will expire after 1 minute. This session token can be used to call the new oidc/session_token/ endpoint to get access/refresh/id tokens * adds group named admin to authentik so GenericOIDCBackendAuthentication class can check for it correctly. Consolidates user and provider blueprints for authentik into one blueprint. Cleans up blueprints. * updates condition checks/errors for TokenObtainPairView * updates minikube cicd workflow file to install branch of OasisLMF * mistake * fix cicd workflow env vars * tries to change oasislmf branch in test-images.yml workflow * tries no-cache setting for docker build * adds oasislmf_branch to all tests in test-images * adds option to change piwind branch, makes it easier to change oasislmf branch * updates auth tests * sets up env vars in seperate job * just hardcodes branch references * push for workflow trigger/adds comment * push for workflow * test workflows with keycloak * test with simple jwt auth * fix bug with simple jwt auth not working * updates README and comment documentation everywhere to generic OIDC information and new login flows * adds oasisplatform_branch arg * update comment * test push * test push * test push * test push * test push * test push * test server images without correct platform branch set * try setting worker img and tag to build for some image version tests, removes oasisplatform_branch arg * revert previous workflow changes * Cant be package pin?? worth testing * f * role back docker builds in each img test * docker builds not needed in piwnd * tweaks for CI * f * fix * pep * adds access_token parameters for swagger ui * fix * pep * f * revert branches for image tests * adds comment to amend workflow file after merge, renames service user env vars for docker-compose files * adds TODO to remove temporary build line after UI release * pep8 * changes to generic_oidc before release (#1299) * removes piwind-path-cfg file, pushed by accident * revert changes to build scripts to use latest versions of other oasis packages * ci-retest * Update Kube CI test (#1304) * test using main branches * test with keycloak * revert back to authentik --------- Co-authored-by: SkylordA * Fix OIDC minikube test (#1305) * test * f * show mem usage * f * forgot the always * mem metrics not that useful * minor things I noticed were different, probably inconsequential * unique default service account names --------- Co-authored-by: Sam Gamble Co-authored-by: sambles --- .github/workflows/minikube-cicd.yml | 113 +++++++- .gitignore | 2 + compose/debug.docker-compose.yml | 5 +- compose/mysql.docker-compose.yml | 5 +- compose/s3.docker-compose.yml | 5 +- docker-compose.yml | 5 +- kubernetes/charts/README.md | 71 ++++- .../resources/model_registration.sh | 16 +- .../oasis-models/templates/_helpers.tpl | 17 +- .../charts/oasis-platform/resources/README.md | 9 +- .../resources/blueprints/oasis-blueprint.yaml | 155 +++++++++++ .../oasis-users-blueprint-template.yaml | 12 + .../oasis-platform/resources/oasis-realm.json | 80 +++++- .../oasis-platform/templates/authentik.yaml | 207 ++++++++++++++ .../oasis-platform/templates/ingress.yaml | 7 + .../oasis-platform/templates/keycloak.yaml | 9 +- .../oasis-platform/templates/oasis.yaml | 20 +- .../templates/oasis_server.yaml | 118 ++++---- .../templates/oasis_worker_controller.yaml | 17 +- kubernetes/charts/oasis-platform/values.yaml | 77 +++++- kubernetes/scripts/README.md | 13 +- kubernetes/scripts/api/common.sh | 20 +- kubernetes/scripts/k8s/port-forward.sh | 3 + kubernetes/worker-controller/README.md | 17 +- .../worker-controller/debug_local_env.sh | 11 +- .../worker-controller/src/oasis_client.py | 18 +- .../src/worker_controller.py | 13 +- .../src/worker_deployments.py | 2 - src/server/oasisapi/auth/serializers.py | 100 +++++-- src/server/oasisapi/auth/tests/test_jwt.py | 6 + src/server/oasisapi/auth/tests/test_oidc.py | 3 + src/server/oasisapi/auth/urls.py | 6 +- src/server/oasisapi/auth/views.py | 254 ++++++++++++++++- src/server/oasisapi/info/views.py | 2 +- src/server/oasisapi/oidc/common.py | 28 ++ src/server/oasisapi/oidc/generic_auth.py | 223 +++++++++++++++ src/server/oasisapi/oidc/keycloak_auth.py | 257 ------------------ .../oasisapi/oidc/migrations/0001_initial.py | 6 +- src/server/oasisapi/oidc/models.py | 15 +- src/server/oasisapi/routing.py | 14 +- src/server/oasisapi/settings/base.py | 36 ++- src/server/oasisapi/urls.py | 74 ++++- src/startup_server.sh | 4 +- src/utils/set_default_user.py | 33 ++- 44 files changed, 1605 insertions(+), 503 deletions(-) create mode 100644 kubernetes/charts/oasis-platform/resources/blueprints/oasis-blueprint.yaml create mode 100644 kubernetes/charts/oasis-platform/resources/blueprints/oasis-users-blueprint-template.yaml create mode 100644 kubernetes/charts/oasis-platform/templates/authentik.yaml create mode 100644 src/server/oasisapi/oidc/common.py create mode 100644 src/server/oasisapi/oidc/generic_auth.py delete mode 100644 src/server/oasisapi/oidc/keycloak_auth.py diff --git a/.github/workflows/minikube-cicd.yml b/.github/workflows/minikube-cicd.yml index 369bbb635..d388712a9 100644 --- a/.github/workflows/minikube-cicd.yml +++ b/.github/workflows/minikube-cicd.yml @@ -11,6 +11,10 @@ on: description: 'If set, pip install ods-tools branch [git ref]' required: false type: string + piwind_branch: + description: 'PiWind branch [git ref] (default=main)' + required: false + type: string workflow_dispatch: inputs: oasislmf_branch: @@ -21,6 +25,10 @@ on: description: 'If set, pip install ods-tools branch [git ref]' required: false type: string + piwind_branch: + description: 'PiWind branch [git ref] (default=main)' + required: false + type: string jobs: minikube: @@ -28,10 +36,18 @@ jobs: env: OASIS_MODEL_DATA_DIR: /shared-fs/PIWIND ACTIONS_STEP_DEBUG: true + oasislmf_branch: ${{ github.event_name != 'workflow_dispatch' && 'main' || inputs.oasislmf_branch }} + ods_branch: ${{ github.event_name != 'workflow_dispatch' && 'main' || inputs.ods_branch }} + piwind_branch: ${{ github.event_name != 'workflow_dispatch' && 'main' || inputs.piwind_branch }} + steps: - name: Clone OasisPiWind model data run: | - git clone https://github.com/OasisLMF/OasisPiWind.git /tmp/piwind + if [[ -z "${{ env.piwind_branch }}" ]]; then + git clone https://github.com/OasisLMF/OasisPiWind.git /tmp/piwind + else + git clone -b ${{ env.piwind_branch }} https://github.com/OasisLMF/OasisPiWind.git /tmp/piwind + fi - name: Set OASIS_MODEL_DATA_DIR env run: echo "OASIS_MODEL_DATA_DIR=/tmp/piwind" >> $GITHUB_ENV @@ -42,12 +58,15 @@ jobs: - name: Start Minikube uses: medyagh/setup-minikube@latest +# - name: Enable metrics +# run: minikube addons enable metrics-server + - name: Build Docker images run: | eval $(minikube docker-env) - docker build -f Dockerfile.api_server --build-arg ods_tools_branch=${{ inputs.ods_branch }} --build-arg oasislmf_branch=${{ inputs.oasislmf_branch }} -t coreoasis/api_server:dev . - docker build -f Dockerfile.model_worker --build-arg ods_tools_branch=${{ inputs.ods_branch }} --build-arg oasislmf_branch=${{ inputs.oasislmf_branch }} -t coreoasis/model_worker:dev . + docker build -f Dockerfile.api_server --build-arg ods_tools_branch=${{ env.ods_branch }} --build-arg oasislmf_branch=${{ env.oasislmf_branch }} -t coreoasis/api_server:dev . + docker build -f Dockerfile.model_worker --build-arg ods_tools_branch=${{ env.ods_branch }} --build-arg oasislmf_branch=${{ env.oasislmf_branch }} -t coreoasis/model_worker:dev . pushd kubernetes/worker-controller docker build -t coreoasis/worker_controller:dev . @@ -79,7 +98,7 @@ jobs: uses: actions/checkout@v3 with: repository: OasisLMF/OasisPiWind - ref: main + ref: ${{ env.piwind_branch }} - name: Start Minikube Tunnel run: | nohup minikube tunnel > /dev/null 2>&1 & @@ -103,12 +122,26 @@ jobs: - name: Authenticate run: | + USERNAME_OR_ID=$(kubectl get secret oasis-service-account-credentials -o jsonpath="{.data.username_or_id}" | base64 -d) + PASSWORD_OR_SECRET=$(kubectl get secret oasis-service-account-credentials -o jsonpath="{.data.password_or_secret}" | base64 -d) + USE_OIDC=$(kubectl get secret oasis-service-account-credentials -o jsonpath="{.data.use_oidc}" | base64 -d) + + echo "Delay to wait for authentication server to start" + sleep 60 delay=1 for i in {1..8}; do delay=$((delay * 2)) - RESPONSE=$(curl -s -k -X POST https://ui.oasis.local/api/access_token/ \ - -H "Content-Type: application/json" \ - -d '{"username": "admin", "password": "password"}') + if [ "$USE_OIDC" = "true" ]; then + echo "Using OIDC authentication" + RESPONSE=$(curl -s -k -X POST https://ui.oasis.local/api/access_token/ \ + -H "Content-Type: application/json" \ + -d "{\"client_id\": \"$USERNAME_OR_ID\", \"client_secret\": \"$PASSWORD_OR_SECRET\"}") + else + echo "Using Simple JWT authentication" + RESPONSE=$(curl -s -k -X POST https://ui.oasis.local/api/access_token/ \ + -H "Content-Type: application/json" \ + -d "{\"username\": \"$USERNAME_OR_ID\", \"password\": \"$PASSWORD_OR_SECRET\"}") + fi if echo "$RESPONSE" | jq -e . > /dev/null 2>&1; then TOKEN=$(echo "$RESPONSE" | jq -r '.access_token') @@ -141,12 +174,17 @@ jobs: kubectl scale --replicas=1 deployment/worker-oasislmf-piwind-1-v1 - name: Install OasisLMF - run: pip install oasislmf + run: | + pip install --upgrade pip + pip install -v "git+https://git@github.com/OasisLMF/OasisLMF.git@${{ env.oasislmf_branch }}#egg=oasislmf" - name: Create test settings run: | cat < test_settings.json { + "computation_settings": { + "ktools_num_processes": 1 + }, "version": "3", "analysis_tag": "base_example", "source_tag": "MDK", @@ -172,9 +210,21 @@ jobs: - name: Run analysis run: | set -e -o pipefail + USERNAME_OR_ID=$(kubectl get secret oasis-service-account-credentials -o jsonpath="{.data.username_or_id}" | base64 -d) + PASSWORD_OR_SECRET=$(kubectl get secret oasis-service-account-credentials -o jsonpath="{.data.password_or_secret}" | base64 -d) + USE_OIDC=$(kubectl get secret oasis-service-account-credentials -o jsonpath="{.data.use_oidc}" | base64 -d) + + if [ "$USE_OIDC" = "true" ]; then + echo "Using OIDC authentication" + CREDENTIALS=$(printf '{"client_id": "%s", "client_secret": "%s"}' "$USERNAME_OR_ID" "$PASSWORD_OR_SECRET") + else + echo "Using Simple JWT authentication" + CREDENTIALS=$(printf '{"username": "%s", "password": "%s"}' "$USERNAME_OR_ID" "$PASSWORD_OR_SECRET") + fi + { oasislmf api run \ - --server-login-json '{"username": "admin", "password": "password"}' \ + --server-login-json "$CREDENTIALS" \ --server-version 'v1' \ --server-url 'http://ui.oasis.local/api' \ --model-id 1 \ @@ -184,6 +234,51 @@ jobs: } 2>&1 | tee logs.txt echo "exit_code=$?" >> "$GITHUB_ENV" +# - name: Minikube memory usage +# if: always() +# run: | +# # per pod memory data +# kubectl top pods --all-namespaces --no-headers 2>/dev/null || { +# echo "Failed to get metrics. Check metrics server."; exit 1; +# } +# echo +# echo "Memory Usage Summary:" +# echo "====================" +# +# # total memory +# PODS=$(kubectl top pods --all-namespaces --no-headers | wc -l) +# TOTAL=$(kubectl top pods --all-namespaces --no-headers | awk '{ +# mem=$4; +# gsub(/[^0-9.]/, "", mem); +# if($4 ~ /Gi/) mem*=1024; +# total+=mem +# } END {printf "%.0f", total}') +# +# echo "Total pods: $PODS" +# echo "Total memory: ${TOTAL}Mi" +# echo "Average: $(echo "scale=0; $TOTAL / $PODS" | bc)Mi per pod" +# +# echo +# echo "By Namespace:" +# echo "-------------" +# kubectl top pods --all-namespaces --no-headers | awk '{ +# ns=$1; mem=$4; +# gsub(/[^0-9.]/, "", mem); +# if($4 ~ /Gi/) mem*=1024; +# ns_mem[ns]+=mem; ns_count[ns]++ +# } END { +# for(ns in ns_mem) +# printf "%-20s %6.0fMi (%d pods)\n", ns, ns_mem[ns], ns_count[ns] +# }' | sort -k2 -nr +# +# echo +# echo "Top 5 Memory Users:" +# echo "-------------------" +# kubectl top pods --all-namespaces --no-headers | sort -k4 -hr | head -5 | \ +# awk '{printf "%-30s %s\n", $1"/"$2, $4}' + + + - name: Upload Artifact if: always() uses: actions/upload-artifact@v4 diff --git a/.gitignore b/.gitignore index 4a06ac149..046a64152 100644 --- a/.gitignore +++ b/.gitignore @@ -112,3 +112,5 @@ out/ # Static web src/server/static/ +# PiWind config +scripts/piwind-path-cfg diff --git a/compose/debug.docker-compose.yml b/compose/debug.docker-compose.yml index 16e2f0feb..ce20aaec3 100755 --- a/compose/debug.docker-compose.yml +++ b/compose/debug.docker-compose.yml @@ -62,8 +62,9 @@ services: environment: <<: *shared-env STARTUP_RUN_MIGRATIONS: "true" - OASIS_ADMIN_USER: admin - OASIS_ADMIN_PASS: password + OASIS_USE_OIDC: "" + OASIS_SERVICE_USERNAME_OR_ID: admin + OASIS_SERVICE_PASSWORD_OR_SECRET: password volumes: - filestore-OasisData:/shared-fs:rw - ../src/server/oasisapi:/var/www/oasis/src/server/oasisapi diff --git a/compose/mysql.docker-compose.yml b/compose/mysql.docker-compose.yml index 96fab84a2..9f51392e9 100755 --- a/compose/mysql.docker-compose.yml +++ b/compose/mysql.docker-compose.yml @@ -62,8 +62,9 @@ services: environment: <<: *shared-env STARTUP_RUN_MIGRATIONS: "true" - OASIS_ADMIN_USER: admin - OASIS_ADMIN_PASS: password + OASIS_USE_OIDC: "" + OASIS_SERVICE_USERNAME_OR_ID: admin + OASIS_SERVICE_PASSWORD_OR_SECRET: password volumes: - filestore-OasisData:/shared-fs:rw - ../src/server/oasisapi:/var/www/oasis/src/server/oasisapi diff --git a/compose/s3.docker-compose.yml b/compose/s3.docker-compose.yml index 7dce632dd..6f5a19c08 100755 --- a/compose/s3.docker-compose.yml +++ b/compose/s3.docker-compose.yml @@ -90,8 +90,9 @@ services: environment: <<: *shared-env STARTUP_RUN_MIGRATIONS: "true" - OASIS_ADMIN_USER: admin - OASIS_ADMIN_PASS: password + OASIS_USE_OIDC: "" + OASIS_SERVICE_USERNAME_OR_ID: admin + OASIS_SERVICE_PASSWORD_OR_SECRET: password volumes: - ../src/server/oasisapi:/var/www/oasis/src/server/oasisapi - ../src:/var/www/oasis/src:rw diff --git a/docker-compose.yml b/docker-compose.yml index bb2a3eb61..ae73ca04b 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -62,8 +62,9 @@ services: environment: <<: *shared-env STARTUP_RUN_MIGRATIONS: "true" - OASIS_ADMIN_USER: admin - OASIS_ADMIN_PASS: password + OASIS_USE_OIDC: "" + OASIS_SERVICE_USERNAME_OR_ID: admin + OASIS_SERVICE_PASSWORD_OR_SECRET: password volumes: - filestore-OasisData:/shared-fs:rw server-websocket: diff --git a/kubernetes/charts/README.md b/kubernetes/charts/README.md index 4fe689ff4..3747109b3 100644 --- a/kubernetes/charts/README.md +++ b/kubernetes/charts/README.md @@ -190,7 +190,8 @@ Now you should be able to access the following pages: - Oasis API - [https://ui.oasis.local/api/](https://api.oasis.local/api/) - Oasis UI - [https://ui.oasis.local](https://ui.oasis.local) -- Keycloak - [https://ui.oasis.local/auth/admin](https://ui.oasis.local/admin/auth/) +- Keycloak - [https://ui.oasis.local/auth/admin](https://ui.oasis.local/admin/auth/) (If "keycloak" is set as auth_type in oasis-platform/values.yaml) +- Authentik - [https://ui.oasis.local/authentik/](https://ui.oasis.local/authentik/) (If "authentik" is set as auth_type in oasis-platform/values.yaml) - Prometheus - [https://ui.oasis.local/prometheus/](https://ui.oasis.local/prometheus/) - Alert manager - [https://ui.oasis.local/alert-manager/](https://ui.oasis.local/alert-manager/) - Grafana - [https://ui.oasis.local/grafana/](https://ui.oasis.local/grafana/) @@ -213,6 +214,9 @@ kubectl port-forward deployment/oasis-ui 8080:3838 # Access Keycloak UI on http://localhost:8081 kubectl port-forward deployment/keycloak 8081:8080 +# Access Authentik UI on http://localhost:9001 +kubectl port-forward deployment/authentik 9001:9000 + # Access Prometheus on http://localhost:9090 kubectl port-forward statefulset/prometheus-monitoring-kube-prometheus-prometheus 9090 @@ -283,12 +287,16 @@ only. cp oasis-montoring/values.yaml monitoring-values.yaml ``` 3. Then we need to edit our value files to change the credentials: - 1. Edit `platform-values.yaml` and set the `oasisServer.user` and `oasisServer.password` to your new credentials: + 1. Edit `platform-values.yaml` and set the `oasisService.user` and `oasisService.password` to your new credentials or `oasisService.serviceClientName` and `oasisService.serviceClientSecret` to your new credentials. These set the credentials for all service authentication (oasis-server, worker-controller, model-registration etc.): ``` - oasisServer: + oasisService: + # Modify this if using Simple JWT user: oasis password: password123 + # Modifying this if using OIDC client_credentials (keycloak, authentik, etc.) + serviceClientName: oasis-service # or whatever your service authentication client_id is called + serviceClientSecret: verySecureSecret # Set secret to whatever the service client_secret is. ``` 2. Edit `monitoring-values.yaml` and set your new password for Grafana (we can't change the username): @@ -529,7 +537,8 @@ Default credentials are admin/password. !!! Please note that upgrading this chart might reset changes in Prometheus, Alert manager and Grafana. Always make a backup of your changes before your upgrade. -# Keycloak +# OIDC +## Keycloak [Keycloak](https://keycloak.org) is now the default user manager and authentication service (can be disabled by chart settings). Read more about Keycloak [here](https://www.keycloak.org/docs/latest/getting_started/index.html). @@ -546,11 +555,40 @@ oasisServer: clientSecret: ``` +## Authentik + +[Authentik](https://goauthentik.io/) is an alternative user manager and authentication service (can be disabled by chart +settings). Read more about Authentik [here](https://docs.goauthentik.io/). + +You can pick your preferred authentication by changing `oasisServer.apiAuthType` to either `authentik` or `simple` for +the standard simple jwt authentication. + +``` +oasisServer: + apiAuthType: authentik + oidc: + endpoint: + clientName: + clientSecret: +``` + +## Service Authentication + +Services must use a different client to authenticate, and this client must be configured to use client_credentials authentication, and must be configured to return `is_service_account=True` in the response. + +You must also set the `oasisService.serviceClientName` and `oasisService.serviceClientSecret` to ensure services have the correct credentials to authenticate. + +``` +oasisService: + serviceClientName: + serviceClientSecret: +``` + ## Administration console -Read [Accessing user interfaces](#accessing-user-interfaces) on how to access keycloak the administration console or use -the default ingress link: +Read [Accessing user interfaces](#accessing-user-interfaces) on how to access the administration console for your OIDC provideror use the default ingress link: +### Keycloak [https://ui.oasis.local/auth/admin/](https://ui.oasis.local/auth/admin/) The keycloak administrator user is defined in your oasis-platform chart values: @@ -564,6 +602,20 @@ keycloak: Full admin console documentation is found on [keycloak.org](https://www.keycloak.org/docs/latest/server_admin/#admin-console). +### Authentik +[https://ui.oasis.local/authentik/](https://ui.oasis.local/authentik/) + +The authentik administrator user is defined in your oasis-platform chart values: + +``` +authentik: + bootstrapUser: akadmin # Do not change this, this is the default username set by authentik. + bootstrapPassword: password +``` + +Full admin console documentation is found +on [authentik.org](https://docs.goauthentik.io/). + ### Realm Settings Contains generic settings for the realm. The most interesting one is probably the `Token` tab to set different timeouts. @@ -587,13 +639,12 @@ Manage groups from here and then add them to each user in the `Manage / Users` p ## Default settings -The oasis-platform chart creates a default realm (keycloak security context) on the first deployment to manage all -users, credentials, roles etc. for the oasis REST API. +The oasis-platform chart creates a default realm on the first deployment to manage all users, credentials, roles etc. for the oasis REST API. This is the same structure for both keycloak and authentik. A default REST API user is created on `helm install` and to change the username and password edit the values: ``` -keycloak: +: oasisRestApi: users: # A default user is created at first deployment. @@ -606,7 +657,7 @@ Default username is `oasis` with password `password`. ## Groups -Groups are now supported by creating them in Keycloak and assign them to users. A user can then set groups on objects +Groups are now supported by creating them in Keycloak and assign them to users. Authentik has groups by default. A user can then set groups on objects like portfolio, model and data files. A user can set all or a subset of the groups the user belongs to and if no group is set it will automatically get all the users groups. diff --git a/kubernetes/charts/oasis-models/resources/model_registration.sh b/kubernetes/charts/oasis-models/resources/model_registration.sh index c3fbbf325..04f04e101 100644 --- a/kubernetes/charts/oasis-models/resources/model_registration.sh +++ b/kubernetes/charts/oasis-models/resources/model_registration.sh @@ -39,21 +39,27 @@ if [ -z "$OASIS_SERVER_HOST" ] || [ -z "$OASIS_SERVER_PORT" ]; then exit 1 fi -if [ -z "$OASIS_ADMIN_USER" ] || [ -z "$OASIS_ADMIN_PASS" ]; then +if [ -z "$OASIS_SERVICE_USERNAME_OR_ID" ] || [ -z "$OASIS_SERVICE_PASSWORD_OR_SECRET" ]; then echo "Missing required API credentials env var(s)" exit 1 fi +if [ "$OASIS_USE_OIDC" == "true" ]; then + echo "Using OIDC Authentication" + DATA="{\"client_id\": \"${OASIS_SERVICE_USERNAME_OR_ID}\", \"client_secret\": \"${OASIS_SERVICE_PASSWORD_OR_SECRET}\"}" +else + echo "Using Simple JWT Authentication" + DATA="{\"username\": \"${OASIS_SERVICE_USERNAME_OR_ID}\", \"password\": \"${OASIS_SERVICE_PASSWORD_OR_SECRET}\"}" +fi + -ACCESS_TOKEN=$(curl -s -X POST "${BASE_URL}/access_token/" -H "accept: application/json" -H "Content-Type: application/json" \ - -d "{ \"username\": \"${OASIS_ADMIN_USER}\", \"password\": \"${OASIS_ADMIN_PASS}\"}" | jq -r '.access_token // empty') +ACCESS_TOKEN=$(curl -s -X POST "${BASE_URL}/access_token/" -H "accept: application/json" -H "Content-Type: application/json" -d "$DATA" | jq -r '.access_token // empty') if [ -n "$ACCESS_TOKEN" ]; then echo "Authenticated" else echo "Failed to authenticate:" - curl -X POST "${BASE_URL}/access_token/" -H "accept: application/json" -H "Content-Type: application/json" \ - -d "{ \"username\": \"${OASIS_ADMIN_USER}\", \"password\": \"${OASIS_ADMIN_PASS}\"}" + curl -X POST "${BASE_URL}/access_token/" -H "accept: application/json" -H "Content-Type: application/json" -d "$DATA" exit 1 fi diff --git a/kubernetes/charts/oasis-models/templates/_helpers.tpl b/kubernetes/charts/oasis-models/templates/_helpers.tpl index 44cd543e8..9b2739570 100644 --- a/kubernetes/charts/oasis-models/templates/_helpers.tpl +++ b/kubernetes/charts/oasis-models/templates/_helpers.tpl @@ -214,16 +214,21 @@ Oasis server client variables configMapKeyRef: name: {{ .Values.oasisServer.name }} key: port -- name: OASIS_ADMIN_USER +- name: OASIS_SERVICE_USERNAME_OR_ID valueFrom: secretKeyRef: - name: oasis-rest-service-account - key: username -- name: OASIS_ADMIN_PASS + name: oasis-service-account-credentials + key: username_or_id +- name: OASIS_SERVICE_PASSWORD_OR_SECRET valueFrom: secretKeyRef: - name: oasis-rest-service-account - key: password + name: oasis-service-account-credentials + key: password_or_secret +- name: OASIS_USE_OIDC + valueFrom: + secretKeyRef: + name: oasis-service-account-credentials + key: use_oidc {{- end }} {{/* diff --git a/kubernetes/charts/oasis-platform/resources/README.md b/kubernetes/charts/oasis-platform/resources/README.md index e8b48d2c4..f9ed232a5 100644 --- a/kubernetes/charts/oasis-platform/resources/README.md +++ b/kubernetes/charts/oasis-platform/resources/README.md @@ -1,5 +1,5 @@ -# Update default oasis realm - +# Update default oasis OIDC configuration +## Keycloak Realms 1. Open a shell on the keycloak pod: ``` @@ -27,3 +27,8 @@ # Download the export: kubectl cp $PN:/tmp/oasis-realm.json oasis-realm.json ``` + +# Authentik Blueprints +Authentik does have the functionality to export blueprints, however these are often exported into a large, unordered yaml file containing all default authentik configuration data, and everything is linked together by random primary keys, making this file essentially not human readable and difficult to cut down and edit. + +The best way to modify Authentiks configuration is to directly edit the `blueprints/oasis-blueprint.yaml` file, using the [default github blueprints](https://github.com/goauthentik/authentik/tree/main/blueprints) as an example. \ No newline at end of file diff --git a/kubernetes/charts/oasis-platform/resources/blueprints/oasis-blueprint.yaml b/kubernetes/charts/oasis-platform/resources/blueprints/oasis-blueprint.yaml new file mode 100644 index 000000000..2fa90b076 --- /dev/null +++ b/kubernetes/charts/oasis-platform/resources/blueprints/oasis-blueprint.yaml @@ -0,0 +1,155 @@ +version: 1 +metadata: + name: custom-oasis-providers + labels: + system: "false" + +# See here for examples: https://github.com/goauthentik/authentik/tree/main/blueprints + +entries: +- model: authentik_core.group + state: created + identifiers: + name: admin + attrs: + is_superuser: true + id: oasis_admin_group +- model: authentik_providers_oauth2.oauth2provider + state: present + identifiers: + name: provider-for-swagger + attrs: + name: "Provider for swagger" + client_id: swagger + client_secret: ZEbHaO5irVER9MJRu48PSglwHTbk4fHTkRSrdABYGpvkWlIgj1uReEXXkhBOnLHV5TwzuM5ASqFH4fHv6c9bDNYNnQqp3a5QT7niJSs5ulfu1ASFdYZb5s16m4UlHcPE + client_type: confidential + include_claims_in_id_token: true + access_code_validity: minutes=1 + access_token_validity: minutes=5 + refresh_token_validity: days=30 + authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]] + invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]] + issuer_mode: global + signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]] + sub_mode: hashed_user_id + redirect_uris: + - matching_mode: regex + url: ".*" + - matching_mode: strict + url: "http://ui.oasis.local" + property_mappings: + - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]] + - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]] + - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]] + - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-offline_access]] + +- model: authentik_core.application + state: present + identifiers: + slug: swagger + attrs: + name: swagger + slug: swagger + policy_engine_mode: any + provider: !Find [authentik_providers_oauth2.oauth2provider, [name, provider-for-swagger]] + +- model: authentik_providers_oauth2.oauth2provider + state: present + identifiers: + name: provider-for-oasis-server + attrs: + name: "Provider for oasis-server" + client_id: oasis-server + client_secret: EfNMUM3GG1bd1CYUvNfiBGWKfvbGFiNAdutEqHSarZ9H7oL0sZfKLvPT1ujaqVm2839Vka8Ky0elliMQ6yWKN8Jv8dzh3BeVFn0F7LPquGkIus6JJ9nGH1vtfCt7AhtO + client_type: confidential + include_claims_in_id_token: true + access_code_validity: minutes=1 + access_token_validity: minutes=5 + refresh_token_validity: days=30 + authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]] + invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]] + issuer_mode: global + signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]] + sub_mode: hashed_user_id + redirect_uris: + - matching_mode: regex + url: ".*" + - matching_mode: strict + url: "http://ui.oasis.local" + property_mappings: + - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]] + - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]] + - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]] + - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-offline_access]] + +- model: authentik_core.application + state: present + identifiers: + slug: oasis-server + attrs: + name: oasis-server + slug: oasis-server + policy_engine_mode: any + provider: !Find [authentik_providers_oauth2.oauth2provider, [name, provider-for-oasis-server]] + + +- model: authentik_providers_oauth2.scopemapping + state: present + identifiers: + managed: goauthentik.io/providers/oauth2/scope-profile-service-account + attrs: + name: "Scope: profile with is_service_account" + scope_name: profile + description: "General profile information with is_service_account" + expression: | + return { + # This is the exact same as profile but has an extra field is_service_account + "name": request.user.name, + "given_name": request.user.name, + "preferred_username": request.user.username, + "nickname": request.user.username, + "groups": [group.name for group in request.user.ak_groups.all()], + "is_service_account": True, + } + +- model: authentik_providers_oauth2.oauth2provider + state: present + identifiers: + name: provider-for-oasis-service + attrs: + name: "Provider for oasis-service" + client_id: oasis-service + client_secret: serviceNotSoSecret + client_type: confidential + include_claims_in_id_token: true + access_code_validity: minutes=1 + access_token_validity: minutes=5 + refresh_token_validity: days=30 + authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]] + invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]] + issuer_mode: global + signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]] + sub_mode: hashed_user_id + redirect_uris: + - matching_mode: regex + url: ".*" + - matching_mode: strict + url: "http://ui.oasis.local" + property_mappings: + - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]] + - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]] + - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile-service-account]] + - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-offline_access]] + +- model: authentik_core.application + state: present + identifiers: + slug: oasis-service + attrs: + name: oasis-service + slug: oasis-service + policy_engine_mode: any + provider: !Find [authentik_providers_oauth2.oauth2provider, [name, provider-for-oasis-service]] + +# Users are added here +___USERS___ diff --git a/kubernetes/charts/oasis-platform/resources/blueprints/oasis-users-blueprint-template.yaml b/kubernetes/charts/oasis-platform/resources/blueprints/oasis-users-blueprint-template.yaml new file mode 100644 index 000000000..97303b5c9 --- /dev/null +++ b/kubernetes/charts/oasis-platform/resources/blueprints/oasis-users-blueprint-template.yaml @@ -0,0 +1,12 @@ +- model: authentik_core.user + state: present + identifiers: + username: ___USERNAME___ + attrs: + username: ___USERNAME___ + name: ___USERNAME___ + email: "___USERNAME___@example.com" + is_active: true + password: ___PASSWORD___ + groups: + ___GROUPS___ diff --git a/kubernetes/charts/oasis-platform/resources/oasis-realm.json b/kubernetes/charts/oasis-platform/resources/oasis-realm.json index 58aa62b34..706bb6c53 100644 --- a/kubernetes/charts/oasis-platform/resources/oasis-realm.json +++ b/kubernetes/charts/oasis-platform/resources/oasis-realm.json @@ -539,15 +539,60 @@ "clientAuthenticatorType" : "client-secret", "secret" : "e4f4fb25-2250-4210-a7d6-9b16c3d2ab77", "redirectUris" : [ "*" ], - "webOrigins" : [ ], + "webOrigins" : [ "*" ], "notBefore" : 0, "bearerOnly" : false, "consentRequired" : false, "standardFlowEnabled" : true, - "implicitFlowEnabled" : true, - "directAccessGrantsEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : false, "serviceAccountsEnabled" : false, - "publicClient" : true, + "publicClient" : false, + "frontchannelLogout" : false, + "protocol" : "openid-connect", + "attributes" : { + "client.secret.creation.time" : "1677167475", + "post.logout.redirect.uris" : "+", + "oauth2.device.authorization.grant.enabled" : "false", + "backchannel.logout.revoke.offline.tokens" : "false", + "use.refresh.tokens" : "true", + "tls-client-certificate-bound-access-tokens" : "false", + "oidc.ciba.grant.enabled" : "false", + "backchannel.logout.session.required" : "true", + "client_credentials.use_refresh_token" : "false", + "acr.loa.map" : "{}", + "require.pushed.authorization.requests" : "false", + "display.on.consent.screen" : "false", + "token.response.type.bearer.lower-case" : "false" + }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : true, + "nodeReRegistrationTimeout" : -1, + "defaultClientScopes" : [ "web-origins", "acr", "openid", "roles", "profile", "email", "microprofile-jwt" ], + "optionalClientScopes" : [ "address", "phone", "offline_access" ] + }, { + "id" : "5be255ea-e55a-40dc-a6dc-fe286c55979c", + "clientId" : "oasis-service", + "name" : "", + "description" : "", + "rootUrl" : "", + "adminUrl" : "", + "baseUrl" : "", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "secret" : "serviceNotSoSecret", + "redirectUris" : [ "*" ], + "webOrigins" : [ "*" ], + "notBefore" : 0, + "bearerOnly" : false, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : false, + "serviceAccountsEnabled" : true, + "publicClient" : false, "frontchannelLogout" : false, "protocol" : "openid-connect", "attributes" : { @@ -565,6 +610,22 @@ "display.on.consent.screen" : "false", "token.response.type.bearer.lower-case" : "false" }, + "protocolMappers": [ + { + "name": "Is Service Account", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-claim-mapper", + "consentRequired": false, + "config": { + "claim.name": "is_service_account", + "claim.value": "true", + "jsonType.label": "boolean", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + } + ], "authenticationFlowBindingOverrides" : { }, "fullScopeAllowed" : true, "nodeReRegistrationTimeout" : -1, @@ -651,16 +712,17 @@ "enabled" : true, "alwaysDisplayInConsole" : false, "clientAuthenticatorType" : "client-secret", + "secret" : "e4f4fb25-2250-4210-a7d6-9b16c3d2ab77", "redirectUris" : [ "*" ], - "webOrigins" : [ ], + "webOrigins" : [ "*" ], "notBefore" : 0, "bearerOnly" : false, "consentRequired" : false, "standardFlowEnabled" : true, - "implicitFlowEnabled" : true, - "directAccessGrantsEnabled" : true, - "serviceAccountsEnabled" : false, - "publicClient" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : false, + "serviceAccountsEnabled" : true, + "publicClient" : false, "frontchannelLogout" : false, "protocol" : "openid-connect", "attributes" : { diff --git a/kubernetes/charts/oasis-platform/templates/authentik.yaml b/kubernetes/charts/oasis-platform/templates/authentik.yaml new file mode 100644 index 000000000..f16f0cd21 --- /dev/null +++ b/kubernetes/charts/oasis-platform/templates/authentik.yaml @@ -0,0 +1,207 @@ +{{- if and (eq .Values.oasisServer.apiAuthType "authentik") .Values.authentik }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Values.authentik.name }} + labels: + {{- include "h.labels" . | nindent 4 }} +data: + host: {{ .Values.authentik.name }} + port: {{ .Values.authentik.port | quote }} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Values.authentik.name }}-blueprint + labels: + {{- include "h.labels" . | nindent 4 }} +data: + oasis-blueprint.yaml: |- + {{- $userTemplate := .Files.Get "resources/blueprints/oasis-users-blueprint-template.yaml" }} + {{- $userEntries := list -}} + + {{- range $i, $user := .Values.authentik.oasisRestApi.users }} + {{- $groups := list " - !Find [authentik_core.group, [name, authentik Read-only]]" }} + {{- if $user.admin }} + {{- $groups = append $groups " - !Find [authentik_core.group, [name, admin]]" }} + {{- end }} + {{- $entry := $userTemplate | + replace "___USERNAME___" $user.username | + replace "___PASSWORD___" $user.password | + replace "___GROUPS___" (join "\n " $groups) -}} + {{- $userEntries = append $userEntries $entry }} + {{- end }} + + {{- .Files.Get "resources/blueprints/oasis-blueprint.yaml" | replace "___USERS___" (join "\n" $userEntries) | nindent 4 }} +--- +apiVersion: v1 +kind: Secret +metadata: + name: {{ .Values.authentik.name }}-secret + namespace: {{ .Release.Namespace }} + labels: + {{- include "h.labels" . | nindent 4}} +type: Opaque +stringData: + # Authentik automated-install envs (read only on first startup) + AUTHENTIK_BOOTSTRAP_PASSWORD: {{ .Values.authentik.bootstrapPassword | quote }} + AUTHENTIK_BOOTSTRAP_EMAIL: {{ .Values.authentik.bootstrapEmail | quote }} + AUTHENTIK_BOOTSTRAP_TOKEN: {{ .Values.authentik.bootstrapToken | quote }} + AUTHENTIK_SECRET_KEY: {{ .Values.authentik.secretKey | quote }} + + # Database connection (mapped from your values) + AUTHENTIK_POSTGRESQL__HOST: {{ .Values.databases.authentik_db.name | quote }} + AUTHENTIK_POSTGRESQL__PORT: {{ .Values.databases.authentik_db.port | quote }} + AUTHENTIK_POSTGRESQL__NAME: {{ .Values.databases.authentik_db.dbName | quote }} + AUTHENTIK_POSTGRESQL__USER: {{ .Values.databases.authentik_db.user | quote }} + AUTHENTIK_POSTGRESQL__PASSWORD: {{ .Values.databases.authentik_db.password | quote }} + + # Redis + AUTHENTIK_REDIS__HOST: {{ .Values.authentik.redis.host | quote }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ .Values.authentik.name }} + labels: + {{- include "h.labels" . | nindent 4 }} +spec: + type: ClusterIP + ports: + - port: {{ .Values.authentik.port }} + targetPort: {{ .Values.authentik.port }} + protocol: TCP + name: authentik-http + selector: + {{- include "h.selectorLabels" . | nindent 4 }} + app: {{ .Values.authentik.name }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.authentik.name }} + labels: + {{- include "h.labels" . | nindent 4}} +spec: + replicas: 1 + selector: + matchLabels: + app: {{ .Values.authentik.name }} + template: + metadata: + labels: + {{- include "h.labels" . | nindent 8 }} + app: {{ .Values.authentik.name }} + spec: + initContainers: + {{- include "h.initTcpAvailabilityCheckBySecret" (list . .Values.databases.authentik_db.name) | nindent 8}} + containers: + - name: {{ .Values.authentik.name }} + image: {{ .Values.images.authentik.image }}:{{ .Values.images.authentik.version }} + args: + - server + ports: + - containerPort: {{ .Values.authentik.port }} + env: + - name: AUTHENTIK_LOG_LEVEL + value: DEBUG + - name: AUTHENTIK_WEB__PATH + value: /authentik/ + - name: AUTHENTIK_DISABLE_UPDATE_CHECK + value: "true" + envFrom: + - secretRef: + name: {{ .Values.authentik.name }}-secret + startupProbe: + httpGet: + path: "/authentik/-/health/live/" + port: {{ .Values.authentik.port }} + initialDelaySeconds: 60 + periodSeconds: 10 + timeoutSeconds: 30 + failureThreshold: 60 + livenessProbe: + httpGet: + path: "/authentik/-/health/live/" + port: {{ .Values.authentik.port }} + initialDelaySeconds: 60 + periodSeconds: 15 + timeoutSeconds: 10 + failureThreshold: 60 + readinessProbe: + httpGet: + path: "/authentik/-/health/ready/" + port: {{ .Values.authentik.port }} + initialDelaySeconds: 90 + periodSeconds: 15 + timeoutSeconds: 10 + failureThreshold: 60 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.authentik.name }}-worker + labels: + {{- include "h.labels" . | nindent 4}} +spec: + replicas: 1 + selector: + matchLabels: + app: {{ .Values.authentik.name }}-worker + template: + metadata: + labels: + {{- include "h.labels" . | nindent 8 }} + app: {{ .Values.authentik.name }}-worker + spec: + initContainers: + {{- include "h.initTcpAvailabilityCheckBySecret" (list . .Values.databases.authentik_db.name) | nindent 8}} + containers: + - name: {{ .Values.authentik.name }}-worker + image: {{ .Values.images.authentik.image }}:{{ .Values.images.authentik.version }} + args: + - worker + env: + - name: AUTHENTIK_LOG_LEVEL + value: DEBUG + - name: AUTHENTIK_DISABLE_UPDATE_CHECK + value: "true" + envFrom: + - secretRef: + name: {{ .Values.authentik.name }}-secret + volumeMounts: + - name: blueprint-config + mountPath: /blueprints/oasis-blueprint.yaml + subPath: oasis-blueprint.yaml + startupProbe: + exec: + command: + - ak + - healthcheck + initialDelaySeconds: 60 + periodSeconds: 10 + timeoutSeconds: 30 + failureThreshold: 60 + livenessProbe: + exec: + command: + - ak + - healthcheck + initialDelaySeconds: 60 + periodSeconds: 15 + timeoutSeconds: 10 + failureThreshold: 60 + readinessProbe: + exec: + command: + - ak + - healthcheck + initialDelaySeconds: 90 + periodSeconds: 15 + timeoutSeconds: 10 + failureThreshold: 60 + volumes: + - name: blueprint-config + configMap: + name: {{ .Values.authentik.name }}-blueprint +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/oasis-platform/templates/ingress.yaml b/kubernetes/charts/oasis-platform/templates/ingress.yaml index 18d573964..110becaf0 100644 --- a/kubernetes/charts/oasis-platform/templates/ingress.yaml +++ b/kubernetes/charts/oasis-platform/templates/ingress.yaml @@ -36,6 +36,13 @@ spec: name: keycloak port: number: 8080 + - path: /authentik + pathType: Prefix + backend: + service: + name: authentik + port: + number: 9000 - path: /api pathType: Prefix backend: diff --git a/kubernetes/charts/oasis-platform/templates/keycloak.yaml b/kubernetes/charts/oasis-platform/templates/keycloak.yaml index c5a94a1c7..21d47a67f 100644 --- a/kubernetes/charts/oasis-platform/templates/keycloak.yaml +++ b/kubernetes/charts/oasis-platform/templates/keycloak.yaml @@ -1,3 +1,4 @@ +{{- if and (eq .Values.oasisServer.apiAuthType "keycloak") .Values.keycloak }} {{ $realmSecretName := printf "%s-realm" .Values.keycloak.name }} apiVersion: v1 kind: ConfigMap @@ -29,13 +30,6 @@ data: oasis: |- {{- $userTemplate := .Files.Get "resources/oasis-realm-user.json" }} {{- $userJson := list -}} - {{- $userJson = $userTemplate | - replace "___UUID___" uuidv4 | - replace "___USERNAME___" .Values.keycloak.oasisRestApi.platformServiceAccount.username | - replace "___PASSWORD___" .Values.keycloak.oasisRestApi.platformServiceAccount.password | - replace "___ROLES___" (join ", " (list "\"default-roles-oasis\"" "\"admin\"")) | - replace "___GROUPS___" "" | - append $userJson -}} {{- range $i, $user := .Values.keycloak.oasisRestApi.users }} {{- $groups := list }} {{- if $user.admin }} @@ -209,3 +203,4 @@ spec: volumeAttributes: secretProviderClass: "azure-secret-provider" {{- end }} +{{- end }} diff --git a/kubernetes/charts/oasis-platform/templates/oasis.yaml b/kubernetes/charts/oasis-platform/templates/oasis.yaml index b19cf28e8..eee897544 100644 --- a/kubernetes/charts/oasis-platform/templates/oasis.yaml +++ b/kubernetes/charts/oasis-platform/templates/oasis.yaml @@ -1,19 +1,21 @@ ---- apiVersion: v1 kind: Secret metadata: - name: oasis-rest-service-account + name: oasis-service-account-credentials namespace: {{ .Release.Namespace }} labels: {{- include "h.labels" . | nindent 4 }} type: Opaque data: - {{- if eq .Values.oasisServer.apiAuthType "keycloak" }} - username: {{ .Values.keycloak.oasisRestApi.platformServiceAccount.username | b64enc }} - password: {{ .Values.keycloak.oasisRestApi.platformServiceAccount.password | b64enc }} + {{- $allowedOIDCAuthProviders := .Values.oasisServer.allowedOIDCAuthProviders}} + {{- if has .Values.oasisServer.apiAuthType $allowedOIDCAuthProviders }} + username_or_id: {{ .Values.oasisService.serviceClientName | b64enc }} + password_or_secret: {{ .Values.oasisService.serviceClientSecret | b64enc }} + use_oidc: {{ tpl "true" . | b64enc }} {{- else }} - username: {{ .Values.oasisServer.user | b64enc }} - password: {{ .Values.oasisServer.password | b64enc }} + username_or_id: {{ .Values.oasisService.user | b64enc }} + password_or_secret: {{ .Values.oasisService.password | b64enc }} + use_oidc: {{ "" | b64enc }} {{- end }} --- apiVersion: v1 @@ -207,6 +209,8 @@ spec: - image: {{ .Values.images.oasis.ui.image }}:{{ .Values.images.oasis.ui.version }} name: {{ .Values.oasisUI.name }} env: + - name: INGRESS_EXTERNAL_HOST + value: {{ .Values.ingress.uiHostname }} - name: API_IP value: oasis-server:8000/api/ - name: API_VERSION @@ -215,6 +219,8 @@ spec: value: oasis_localhost - name: API_SHARE_FILEPATH value: ./downloads + - name: API_AUTH_TYPE + value: {{ .Values.oasisServer.apiAuthType }} ports: - containerPort: 3838 name: {{ .Values.oasisUI.name }} diff --git a/kubernetes/charts/oasis-platform/templates/oasis_server.yaml b/kubernetes/charts/oasis-platform/templates/oasis_server.yaml index ce073ba50..1deb236b1 100644 --- a/kubernetes/charts/oasis-platform/templates/oasis_server.yaml +++ b/kubernetes/charts/oasis-platform/templates/oasis_server.yaml @@ -1,28 +1,4 @@ apiVersion: v1 -kind: Secret -metadata: - name: {{ .Values.oasisServer.name }} - namespace: {{ .Release.Namespace }} - labels: - {{- include "h.labels" . | nindent 4}} -type: Opaque -data: - user: {{ required (printf "A valid user for oasis api is required") .Values.oasisServer.user | b64enc }} - password: {{ include "h.password" (list .Values.oasisServer.name "password" . .Values.oasisServer.password (printf "A valid password for %s is required" .Values.oasisServer.name)) }} ---- -apiVersion: v1 -kind: Secret -metadata: - name: {{ .Values.oasisWebsocket.name }} - namespace: {{ .Release.Namespace }} - labels: - {{- include "h.labels" . | nindent 4}} -type: Opaque -data: - user: {{ required (printf "A valid user for oasis api is required") .Values.oasisWebsocket.user | b64enc }} - password: {{ include "h.password" (list .Values.oasisWebsocket.name "password" . .Values.oasisWebsocket.password (printf "A valid password for %s is required" .Values.oasisWebsocket.name)) }} ---- -apiVersion: v1 kind: ConfigMap metadata: name: {{ .Values.oasisServer.name }} @@ -79,26 +55,41 @@ spec: spec: {{- include "h.affinity" . | nindent 6 }} initContainers: - {{- include "h.initTcpAvailabilityCheckBySecret" (list . .Values.databases.oasis_db.name .Values.databases.celery_db.name .Values.keycloak.name .Values.databases.broker.name) | nindent 8}} + {{- $checks := list . .Values.databases.oasis_db.name .Values.databases.celery_db.name .Values.databases.broker.name }} + + {{- if and (eq .Values.oasisServer.apiAuthType "keycloak") .Values.keycloak.name }} + {{- $checks = append $checks .Values.keycloak.name }} + {{- end }} + + {{- if and (eq .Values.oasisServer.apiAuthType "authentik") .Values.authentik.name }} + {{- $checks = append $checks .Values.authentik.name }} + {{- end }} + + {{- include "h.initTcpAvailabilityCheckBySecret" $checks | nindent 8 }} containers: - image: {{ .Values.images.oasis.platform.image }}:{{ .Values.images.oasis.platform.version }} name: {{ .Values.oasisWebsocket.name }} imagePullPolicy: {{ .Values.images.oasis.platform.imagePullPolicy }} env: - - name: OASIS_ADMIN_USER - valueFrom: - secretKeyRef: - name: {{ .Values.oasisWebsocket.name }} - key: user - - name: OASIS_ADMIN_PASS - valueFrom: - secretKeyRef: - name: {{ .Values.oasisWebsocket.name }} - key: password {{- include "h.serverDbVars" . | indent 12}} {{- include "h.celeryDbVars" . | indent 12}} {{- include "h.brokerVars" . | indent 12 }} {{- include "h.channelLayerVars" . | indent 12 }} + - name: OASIS_SERVICE_USERNAME_OR_ID + valueFrom: + secretKeyRef: + name: oasis-service-account-credentials + key: username_or_id + - name: OASIS_SERVICE_PASSWORD_OR_SECRET + valueFrom: + secretKeyRef: + name: oasis-service-account-credentials + key: password_or_secret + - name: OASIS_USE_OIDC + valueFrom: + secretKeyRef: + name: oasis-service-account-credentials + key: use_oidc - name: OASIS_DEBUG value: "1" - name: OASIS_URL_SUB_PATH @@ -107,15 +98,21 @@ spec: value: {{ .Values.ingress.uiHostname }} - name: INGRESS_INTERNAL_HOST value: {{ .Release.Name | lower }}-traefik - {{- if eq .Values.oasisServer.apiAuthType "keycloak" }} + - name: OASIS_SERVER_ALLOWED_OIDC_AUTH_PROVIDERS + value: {{ join "," .Values.oasisServer.allowedOIDCAuthProviders }} - name: OASIS_SERVER_API_AUTH_TYPE - value: keycloak + value: {{ .Values.oasisServer.apiAuthType }} + {{- if has .Values.oasisServer.apiAuthType .Values.oasisServer.allowedOIDCAuthProviders }} - name: OASIS_SERVER_OIDC_CLIENT_NAME value: {{ .Values.oasisServer.oidc.clientName }} - name: OASIS_SERVER_OIDC_CLIENT_SECRET value: {{ .Values.oasisServer.oidc.clientSecret }} - name: OASIS_SERVER_OIDC_ENDPOINT value: {{ .Values.oasisServer.oidc.endpoint }} + - name: OASIS_SERVER_OIDC_SERVICE_CLIENT_NAME + value: {{ .Values.oasisServer.oidc.serviceClientName }} + - name: OASIS_SERVER_OIDC_SERVICE_CLIENT_SECRET + value: {{ .Values.oasisServer.oidc.serviceClientSecret }} {{- end }} ports: - containerPort: {{ .Values.oasisWebsocket.port }} @@ -197,7 +194,17 @@ spec: spec: {{- include "h.affinity" . | nindent 6 }} initContainers: - {{- include "h.initTcpAvailabilityCheckBySecret" (list . .Values.databases.oasis_db.name .Values.databases.celery_db.name .Values.keycloak.name .Values.databases.broker.name) | nindent 8}} + {{- $checks := list . .Values.databases.oasis_db.name .Values.databases.celery_db.name .Values.databases.broker.name }} + + {{- if and (eq .Values.oasisServer.apiAuthType "keycloak") .Values.keycloak.name }} + {{- $checks = append $checks .Values.keycloak.name }} + {{- end }} + + {{- if and (eq .Values.oasisServer.apiAuthType "authentik") .Values.authentik.name }} + {{- $checks = append $checks .Values.authentik.name }} + {{- end }} + + {{- include "h.initTcpAvailabilityCheckBySecret" $checks | nindent 8 }} - name: set-mount-permissions image: busybox command: ["sh", "-c", "find /shared-fs -user root -exec chown 1000:1000 {} \\;"] @@ -209,35 +216,46 @@ spec: name: {{ .Values.oasisServer.name }} imagePullPolicy: {{ .Values.images.oasis.platform.imagePullPolicy }} env: - - name: OASIS_ADMIN_USER - valueFrom: - secretKeyRef: - name: {{ .Values.oasisServer.name }} - key: user - - name: OASIS_ADMIN_PASS - valueFrom: - secretKeyRef: - name: {{ .Values.oasisServer.name }} - key: password {{- include "h.serverDbVars" . | indent 12}} {{- include "h.celeryDbVars" . | indent 12}} {{- include "h.brokerVars" . | indent 12 }} {{- include "h.channelLayerVars" . | indent 12 }} + - name: OASIS_SERVICE_USERNAME_OR_ID + valueFrom: + secretKeyRef: + name: oasis-service-account-credentials + key: username_or_id + - name: OASIS_SERVICE_PASSWORD_OR_SECRET + valueFrom: + secretKeyRef: + name: oasis-service-account-credentials + key: password_or_secret + - name: OASIS_USE_OIDC + valueFrom: + secretKeyRef: + name: oasis-service-account-credentials + key: use_oidc - name: STARTUP_RUN_MIGRATIONS value: "true" - name: INGRESS_EXTERNAL_HOST value: {{ .Values.ingress.uiHostname }} - name: INGRESS_INTERNAL_HOST value: {{ .Release.Name | lower }}-traefik - {{- if eq .Values.oasisServer.apiAuthType "keycloak" }} + - name: OASIS_SERVER_ALLOWED_OIDC_AUTH_PROVIDERS + value: {{ join "," .Values.oasisServer.allowedOIDCAuthProviders }} - name: OASIS_SERVER_API_AUTH_TYPE - value: keycloak + value: {{ .Values.oasisServer.apiAuthType }} + {{- if has .Values.oasisServer.apiAuthType .Values.oasisServer.allowedOIDCAuthProviders }} - name: OASIS_SERVER_OIDC_CLIENT_NAME value: {{ .Values.oasisServer.oidc.clientName }} - name: OASIS_SERVER_OIDC_CLIENT_SECRET value: {{ .Values.oasisServer.oidc.clientSecret }} - name: OASIS_SERVER_OIDC_ENDPOINT value: {{ .Values.oasisServer.oidc.endpoint }} + - name: OASIS_SERVER_OIDC_SERVICE_CLIENT_NAME + value: {{ .Values.oasisServer.oidc.serviceClientName }} + - name: OASIS_SERVER_OIDC_SERVICE_CLIENT_SECRET + value: {{ .Values.oasisServer.oidc.serviceClientSecret }} {{- end }} {{- toYaml .Values.oasisServer.env | nindent 12 }} ports: diff --git a/kubernetes/charts/oasis-platform/templates/oasis_worker_controller.yaml b/kubernetes/charts/oasis-platform/templates/oasis_worker_controller.yaml index 5dba31de2..6452ff392 100644 --- a/kubernetes/charts/oasis-platform/templates/oasis_worker_controller.yaml +++ b/kubernetes/charts/oasis-platform/templates/oasis_worker_controller.yaml @@ -60,16 +60,21 @@ spec: name: main env: {{- include "h.serverApiVars" . | nindent 12}} - - name: OASIS_USERNAME + - name: OASIS_SERVICE_USERNAME_OR_ID valueFrom: secretKeyRef: - name: oasis-rest-service-account - key: username - - name: OASIS_PASSWORD + name: oasis-service-account-credentials + key: username_or_id + - name: OASIS_SERVICE_PASSWORD_OR_SECRET valueFrom: secretKeyRef: - name: oasis-rest-service-account - key: password + name: oasis-service-account-credentials + key: password_or_secret + - name: OASIS_USE_OIDC + valueFrom: + secretKeyRef: + name: oasis-service-account-credentials + key: use_oidc - name: OASIS_CONTINUE_UPDATE_SCALING value: "{{ .Values.workerController.continueUpdateScaling }}" - name: OASIS_NEVER_SHUTDOWN_FIXED_WORKERS diff --git a/kubernetes/charts/oasis-platform/values.yaml b/kubernetes/charts/oasis-platform/values.yaml index 8b11f155d..c87c4ae33 100644 --- a/kubernetes/charts/oasis-platform/values.yaml +++ b/kubernetes/charts/oasis-platform/values.yaml @@ -9,7 +9,7 @@ images: imagePullPolicy: Never ui: image: coreoasis/oasisui_app - version: 1.11.7 + version: latest worker_controller: image: coreoasis/worker_controller version: dev @@ -28,6 +28,9 @@ images: keycloak: image: quay.io/keycloak/keycloak version: 23.0.6-0 + authentik: + image: ghcr.io/goauthentik/server + version: 2025.6.4 init: image: busybox version: 1.28 @@ -162,6 +165,14 @@ databases: volumeSize: 2Gi user: keycloak password: password + authentik_db: + type: postgres + name: authentik-db + dbName: authentik + port: 5432 + volumeSize: 2Gi + user: authentik + password: password celery_db: type: postgres name: celery-db @@ -190,30 +201,72 @@ keycloak: # These users will be created on the first deployment/installation but never updated. Use the keycloak admin # console to manage users. - # The worker controller needs an account to listen to the web socket (queue) - platformServiceAccount: - username: platform-service-controller - password: x7e+GK6Y8ac#grb&9vT4 users: # A default user is created at first deployment. - username: admin password: password admin: true + +# Authentik host, port and console admin credentials +authentik: + name: authentik + port: 9000 + # This is the authentik admin account to manage all users + bootstrapUser: akadmin # This is the default authentik admin name which is unchangeable + bootstrapEmail: akadmin@example.com + bootstrapPassword: password + bootstrapToken: my-demo-token-abc123 + secretKey: notsosecretkey + redis: + host: valkey + oasisRestApi: + # These users will be created on the first deployment/installation but never updated. Use the authentik admin + # console to manage users. + + users: + # A default user is created at first deployment. + - username: admin + password: password + admin: true + - username: user + password: password + admin: false + +# Credential information for different service authentication +oasisService: + # Default django admin user - please note this is not a valid REST API user when OIDC is enabled + # Calls access_token/ endpoint with --username and --password + user: admin + password: password + # OIDC client_credentials id and secret used for service authentication. + # Calls access_token/ endpoint with --client_id and --client_secret + serviceClientName: oasis-service + serviceClientSecret: serviceNotSoSecret + + # Oasis API host, port and credentials (for HTTP connections) oasisServer: name: oasis-server port: 8000 - # Default django admin user - please note this is not a valid REST API user when Keyclock is enabled - user: admin - password: password + + allowedOIDCAuthProviders: + - keycloak + - authentik # Enable OIDC Keycloak authentication with 'keycloak or set to 'simple' for simple jwt. - apiAuthType: keycloak + # apiAuthType: keycloak + # oidc: + # endpoint: https://ui.oasis.local/auth/realms/oasis/protocol/openid-connect/ + # clientName: oasis-server + # clientSecret: e4f4fb25-2250-4210-a7d6-9b16c3d2ab77 + + # Enable OIDC Authentik authentication with 'authentik or set to 'simple' for simple jwt. + apiAuthType: authentik oidc: - endpoint: https://ui.oasis.local/auth/realms/oasis/protocol/openid-connect/ + endpoint: https://ui.oasis.local/authentik/application/o/ clientName: oasis-server - clientSecret: e4f4fb25-2250-4210-a7d6-9b16c3d2ab77 + clientSecret: EfNMUM3GG1bd1CYUvNfiBGWKfvbGFiNAdutEqHSarZ9H7oL0sZfKLvPT1ujaqVm2839Vka8Ky0elliMQ6yWKN8Jv8dzh3BeVFn0F7LPquGkIus6JJ9nGH1vtfCt7AhtO # Set environment variables for the Oasis API server. env: @@ -226,8 +279,6 @@ oasisServer: oasisWebsocket: name: oasis-websocket port: 8001 - user: admin - password: password # Use same settings as client (uncomment and update charts if needed) # Enable OIDC Keycloak authentication with 'keycloak or set to 'simple' for simple jwt. diff --git a/kubernetes/scripts/README.md b/kubernetes/scripts/README.md index bf8debe1d..8d9d660cb 100644 --- a/kubernetes/scripts/README.md +++ b/kubernetes/scripts/README.md @@ -24,9 +24,10 @@ A few helpful scripts for development Supported environment variables: -| Name | Default | Description | -|-------------------------|----------|-----------------------------------------------------------------------------------------| -| OASIS_USERNAME | admin | Oasis username | -| OASIS_PASSWORD | password | Oasis password | -| OASIS_AUTH_API | 1 | How to authenticate. 1=directly against keycloak on ui.oasis.local, 0=through oasis API | -| OASIS_CLUSTER_NAMESPACE | default | Namespace to use for kubernetes operations | +| Name | Default | Description | +|-------------------------------------|--------------------|-----------------------------------------------------------------------------------------| +| OASIS_SERVICE_USERNAME_OR_ID | oasis-service | Oasis username or client_id for OIDC | +| OASIS_SERVICE_PASSWORD_OR_SECRET | serviceNotSoSecret | Oasis password or client_secret for OIDC | +| OASIS_USE_OIDC | true | What method to authenticate services with, OIDC client_credentials or simple JWT | +| OASIS_AUTH_API | 1 | How to authenticate. 1=directly against keycloak on ui.oasis.local, 0=through oasis API | +| OASIS_CLUSTER_NAMESPACE | default | Namespace to use for kubernetes operations | diff --git a/kubernetes/scripts/api/common.sh b/kubernetes/scripts/api/common.sh index d810e1df1..770490c50 100755 --- a/kubernetes/scripts/api/common.sh +++ b/kubernetes/scripts/api/common.sh @@ -10,20 +10,30 @@ TMP_PATH="/tmp/" # TMP_PATH="/mnt/c/tmp/" #fi +OASIS_SERVICE_USERNAME_OR_ID="${OASIS_SERVICE_USERNAME_OR_ID:-oasis-service}" +OASIS_SERVICE_PASSWORD_OR_SECRET="${OASIS_SERVICE_PASSWORD_OR_SECRET:-serviceNotSoSecret}" +OASIS_USE_OIDC="${OASIS_USE_OIDC:-1}" API_URL="${OASIS_API_URL:-https://ui.oasis.local/api}" -OASIS_USERNAME="${OASIS_USERNAME:-admin}" -OASIS_PASSWORD="${OASIS_PASSWORD:-password}" OASIS_AUTH_API="${OASIS_AUTH_API:-1}" KEYCLOAK_TOKEN_URL="${KEYCLOAK_TOKEN_URL:-https://ui.oasis.local/auth/realms/oasis/protocol/openid-connect/token}" CLIENT_ID=oasis-server CLIENT_SECRET=e4f4fb25-2250-4210-a7d6-9b16c3d2ab77 +if [ "$OASIS_USE_OIDC" == "true" ]; then + DATA="{\"client_id\": \"${OASIS_SERVICE_USERNAME_OR_ID}\", \"client_secret\": \"${OASIS_SERVICE_PASSWORD_OR_SECRET}\"}" +else + DATA="{\"username\": \"${OASIS_SERVICE_USERNAME_OR_ID}\", \"password\": \"${OASIS_SERVICE_PASSWORD_OR_SECRET}\"}" +fi + if [ "$OASIS_AUTH_API" == "1" ]; then - R="$($CURL -X POST "${API_URL}/access_token/" -H "accept: application/json" -H "Content-Type: application/json" \ - -d "{ \"username\": \"${OASIS_USERNAME}\", \"password\": \"${OASIS_PASSWORD}\"}")" + R="$($CURL -X POST "${API_URL}/access_token/" -H "accept: application/json" -H "Content-Type: application/json" -d "$DATA")" ACCESS_TOKEN=$(echo "$R" | jq -r '.access_token // empty') else - R="$($CURL --data-urlencode "grant_type=password" --data-urlencode "client_id=$CLIENT_ID" --data-urlencode "client_secret=$CLIENT_SECRET" --data-urlencode "username=$OASIS_USERNAME" --data-urlencode "password=$OASIS_PASSWORD" "$KEYCLOAK_TOKEN_URL")" + if [ "$OASIS_USE_OIDC" == "true" ]; then + R="$($CURL --data-urlencode "grant_type=client_credentials" --data-urlencode "client_id=$CLIENT_ID" --data-urlencode "client_secret=$CLIENT_SECRET" "$KEYCLOAK_TOKEN_URL")" + else + R="$($CURL --data-urlencode "grant_type=password" --data-urlencode --data-urlencode "username=$OASIS_SERVICE_USERNAME_OR_ID" --data-urlencode "password=$OASIS_SERVICE_PASSWORD_OR_SECRET" "$KEYCLOAK_TOKEN_URL")" + fi ACCESS_TOKEN=$(echo "$R" | jq -r '.access_token // empty') fi diff --git a/kubernetes/scripts/k8s/port-forward.sh b/kubernetes/scripts/k8s/port-forward.sh index 5358d0bfc..aa0307bd5 100755 --- a/kubernetes/scripts/k8s/port-forward.sh +++ b/kubernetes/scripts/k8s/port-forward.sh @@ -36,6 +36,9 @@ for arg in "${@}"; do "keycloak") forwards+=("deployment/keycloak 8081:8080") ;; + "authentik") + forwards+=("deployment/authentik 9001:9000") + ;; "monitoring") forwards+=("deployment/monitoring-grafana 3000:3000") forwards+=("statefulset/prometheus-monitoring-kube-prometheus-prometheus 9090") diff --git a/kubernetes/worker-controller/README.md b/kubernetes/worker-controller/README.md index ce1061e5b..792a5dd04 100644 --- a/kubernetes/worker-controller/README.md +++ b/kubernetes/worker-controller/README.md @@ -17,14 +17,15 @@ Read the oasis-models chart documentation for more details on how to configure e Settings can be passed to the application by either using the command line or environment variables. -Argument | Env. var. name | Default | Description --------------|--------------------|-------------|------------ -`--api-host` | `OASIS_API_HOST` | `localhost` | The hostname of the oasis API -`--api-port` | `OASIS_API_PORT` | `8000` | The port of the oasis API -`--secure` | `OASIS_API_SECURE` | `false` | Use TLS in web socket communication -`--username` | `OASIS_ADMIN_USER` | `admin` | The username of the user to use for authentication against the API -`--password` | `OASIS_ADMIN_PASS` | `password` | The password of the user to use for authentication against the API -`--cluster` | `CLUSTER` | `in` | How to connect to the kubernetes cluster. Either `local` to connect to a cluster on the same machine (development), or `in` to connect to a cluster hosting this container. +Argument | Env. var. name | Default | Description +-----------------------|------------------------------------|------------------------|------------ +`--api-host` | `OASIS_API_HOST` | `localhost` | The hostname of the oasis API +`--api-port` | `OASIS_API_PORT` | `8000` | The port of the oasis API +`--secure` | `OASIS_API_SECURE` | `false` | Use TLS in web socket communication +`--oidc` | `OASIS_USE_OIDC` | `true` | Use OIDC authentication for services via the same endpoint access_token/, but internally using client_credentials +`--username_or_id` | `OASIS_SERVICE_USERNAME_OR_ID` | `oasis-service` | The username of the user to use for authentication against the API, or client_id for OIDC client_credentials +`--password_or_secret` | `OASIS_SERVICE_PASSWORD_OR_SECRET` | `serviceNotSoSecret` | The password of the user to use for authentication against the API, or client_secret for OIDC client_credentials +`--cluster` | `CLUSTER` | `in` | How to connect to the kubernetes cluster. Either `local` to connect to a cluster on the same machine (development), or `in` to connect to a cluster hosting this container. ## Development diff --git a/kubernetes/worker-controller/debug_local_env.sh b/kubernetes/worker-controller/debug_local_env.sh index a46a55202..151ae5dd0 100644 --- a/kubernetes/worker-controller/debug_local_env.sh +++ b/kubernetes/worker-controller/debug_local_env.sh @@ -6,8 +6,15 @@ # 3. install requirememts, 'pip install -r requirements.txt' # 4. Run controller, './src/worker_controller.py' -export OASIS_USERNAME=admin -export OASIS_PASSWORD=password +# For Simple JWT +# export OASIS_SERVICE_USERNAME_OR_ID=admin +# export OASIS_SERVICE_PASSWORD_OR_SECRET=password +# export OASIS_USE_OIDC="" +# For OIDC +export OASIS_SERVICE_USERNAME_OR_ID=oasis-service +export OASIS_SERVICE_PASSWORD_OR_SECRET=serviceNotSoSecret +export OASIS_USE_OIDC="true" + export OASIS_CONTINUE_UPDATE_SCALING=0 export OASIS_NEVER_SHUTDOWN_FIXED_WORKERS=0 export OASIS_API_HOST=ui.oasis.local/api diff --git a/kubernetes/worker-controller/src/oasis_client.py b/kubernetes/worker-controller/src/oasis_client.py index aef0829fe..047374367 100644 --- a/kubernetes/worker-controller/src/oasis_client.py +++ b/kubernetes/worker-controller/src/oasis_client.py @@ -15,15 +15,15 @@ class OasisClient: A simple client for the Oasis API. Takes care of the access token and supports searching for models. """ - def __init__(self, http_host, http_port, http_subpath, ws_host, ws_port, secure, username, password): + def __init__(self, http_host, http_port, http_subpath, ws_host, ws_port, secure, oidc, username_or_id, password_or_secret): """ :param http_host: Oasis API hostname. :param http_port: Oasis API port. :param ws_host: Oasis Websocket hostname. :param ws_port: Oasis Websocket port. :param secure: Use secure connection. - :param username: Username for API authentication. - :param password: Password for API authentication. + :param username_or_id: Username for API authentication, or client_id with OIDC client_credentials. + :param password_or_secret: Password for API authentication, or client_secret with OIDC client_credentials. """ self.ws_host = ws_host self.ws_port = ws_port @@ -35,8 +35,9 @@ def __init__(self, http_host, http_port, http_subpath, ws_host, ws_port, secure, api_path = f'/{http_subpath}' if http_subpath else '' self.http_host = f'{api_proto}{api_host}{api_port}{api_path}' - self.username = username - self.password = password + self.oidc = oidc + self.username_or_id = username_or_id + self.password_or_secret = password_or_secret self.access_token = None self.token_expire_time = None print('Connecting to: ' + self.http_host) @@ -51,13 +52,14 @@ def is_authenticated(self) -> bool: async def authenticate(self): """ - Authenticates with username and password and on success stores the access token + Authenticates with username/password password grant for Simple JWT, or client_credentials grant type for OIDC authentication, on success stores the access token and expire time. """ async with aiohttp.ClientSession(loop=asyncio.get_event_loop()) as session: - params = {'username': self.username, 'password': self.password} - + params = {'username': self.username_or_id, 'password': self.password_or_secret} + if self.oidc: + params = {'client_id': self.username_or_id, 'client_secret': self.password_or_secret} async with session.post(urljoin(self.http_host, '/access_token/'), data=params) as response: data = await self.parse_answer(response) self.access_token = data['access_token'] diff --git a/kubernetes/worker-controller/src/worker_controller.py b/kubernetes/worker-controller/src/worker_controller.py index 3ea53feed..c23df9996 100755 --- a/kubernetes/worker-controller/src/worker_controller.py +++ b/kubernetes/worker-controller/src/worker_controller.py @@ -60,8 +60,12 @@ def parse_args(): parser.add_argument('--websocket-host', help='The websocket hostname', default=getenv('OASIS_WEBSOCKET_HOST', default='localhost')) parser.add_argument('--websocket-port', help='The websocket portnumber', default=getenv('OASIS_WEBSOCKET_PORT', default=8001)) parser.add_argument('--secure', help='Flag if https and wss should be used', default=bool(getenv('OASIS_API_SECURE')), action='store_true') - parser.add_argument('--username', help='The username of the worker controller user', default=getenv('OASIS_USERNAME', default='admin')) - parser.add_argument('--password', help='The password of the worker controller user', default=getenv('OASIS_PASSWORD', default='password')) + parser.add_argument('--oidc', help='Flag if OIDC authentication is enabled, switches access_token/ requests to use client_credentials', + default=bool(getenv('OASIS_USE_OIDC')), action='store_true') + parser.add_argument('--username_or_id', help='The username of the worker controller user, or the client_id of of the client', + default=getenv('OASIS_SERVICE_USERNAME_OR_ID')) + parser.add_argument('--password_or_secret', help='The password of the worker controller user, or the client_secret of of the client', + default=getenv('OASIS_SERVICE_PASSWORD_OR_SECRET')) parser.add_argument('--namespace', help='Namespace of cluster where oasis is deployed to', default=getenv('OASIS_CLUSTER_NAMESPACE', default='default')) parser.add_argument('--limit', help='Hard limit for the total number of workers created', default=getenv('OASIS_TOTAL_WORKER_LIMIT')) @@ -101,8 +105,9 @@ def main(): cli_args.websocket_host, cli_args.websocket_port, cli_args.secure, - cli_args.username, - cli_args.password + cli_args.oidc, + cli_args.username_or_id, + cli_args.password_or_secret ) # Set worker-controller logger diff --git a/kubernetes/worker-controller/src/worker_deployments.py b/kubernetes/worker-controller/src/worker_deployments.py index 5979fc0f1..b3ac2c5c6 100644 --- a/kubernetes/worker-controller/src/worker_deployments.py +++ b/kubernetes/worker-controller/src/worker_deployments.py @@ -40,8 +40,6 @@ def __init__(self, args): self.worker_deployments: [WorkerDeployment] = [] self.api_host = args.api_host self.api_port = args.api_port - self.username = args.username - self.password = args.password async def update_worker(self, name, supplier_id, model_id, model_version_id, api_version, replicas: int): """ diff --git a/src/server/oasisapi/auth/serializers.py b/src/server/oasisapi/auth/serializers.py index 768c242f1..60292f4da 100644 --- a/src/server/oasisapi/auth/serializers.py +++ b/src/server/oasisapi/auth/serializers.py @@ -4,12 +4,11 @@ from rest_framework.exceptions import ValidationError, AuthenticationFailed from rest_framework_simplejwt import settings as jwt_settings from rest_framework_simplejwt.serializers import TokenObtainPairSerializer as BaseTokenObtainPairSerializer -from rest_framework_simplejwt.serializers import TokenObtainSerializer from rest_framework_simplejwt.serializers import TokenRefreshSerializer as BaseTokenRefreshSerializer from .. import settings -from ..oidc.keycloak_auth import keycloak_create_connection +from ..oidc.common import auth_server_create_connection from urllib3.util import connection @@ -60,29 +59,24 @@ def validate(self, attrs): return data -class OIDCTokenObtainPairSerializer(TokenObtainSerializer): +class OIDCClientCredentialsSerializer(serializers.Serializer): """ - Token serializer to authenticate and obtain a access token from Keyloak + Serializer to handle the OIDC client credentials grant flow. """ - connection.create_connection = keycloak_create_connection - - def __init__(self, *args, **kwargs): - super(OIDCTokenObtainPairSerializer, self).__init__(*args, **kwargs) - - self.fields[self.username_field].help_text = _('Your username') - self.fields['password'].help_text = _('your password') + client_id = serializers.CharField(required=True) + client_secret = serializers.CharField(required=True) def validate(self, attrs): + client_id = attrs.get('client_id') + client_secret = attrs.get('client_secret') response = requests.post( settings.OIDC_OP_TOKEN_ENDPOINT, data={ - 'grant_type': 'password', - 'client_id': settings.OIDC_RP_CLIENT_ID, - 'client_secret': settings.OIDC_RP_CLIENT_SECRET, - 'scope': 'openid', - 'username': attrs.get('username', None), - 'password': attrs.get('password', None) + 'grant_type': 'client_credentials', + 'client_id': client_id, + 'client_secret': client_secret, + 'scope': 'openid profile', }, verify=False, ) @@ -92,17 +86,22 @@ def validate(self, attrs): if response.status_code != 200 or 'access_token' not in json: raise AuthenticationFailed({'Detail': 'invalid credentials'}) - cleaned = {key: json[key] for key in ['access_token', 'refresh_token', 'token_type', 'expires_in']} + allowed_keys = ["access_token", "token_type", "expires_in"] + + # Only include refresh_token if it exists in the response + if "refresh_token" in json: + allowed_keys.append("refresh_token") + + cleaned = {key: json[key] for key in allowed_keys if key in json} return cleaned class OIDCTokenRefreshSerializer(serializers.Serializer): - """ - Token serializer to obtain a new access token from Keycloak + Token serializer to refresh tokens using the configured OIDC provider. """ - connection.create_connection = keycloak_create_connection + connection.create_connection = auth_server_create_connection def validate(self, attrs): if 'HTTP_AUTHORIZATION' not in self.context['request'].META.keys(): @@ -130,3 +129,62 @@ def validate(self, attrs): cleaned = {key: json[key] for key in ['access_token', 'refresh_token', 'token_type', 'expires_in']} return cleaned + + +class OIDCBaseSerializer(serializers.Serializer): + """ + Base to ensure the urllib3 connection hack for OIDC Provider is applied if needed. + """ + + def __init__(self, *args, **kwargs): + connection.create_connection = auth_server_create_connection + super().__init__(*args, **kwargs) + + +class OIDCAuthorizationCodeExchangeSerializer(OIDCBaseSerializer): + """ + Exchanges an authorization code for tokens. + Expected input: + - code (from OIDC redirect) + """ + code = serializers.CharField(required=True) + redirect_uri = serializers.CharField(required=False, allow_null=True) + + def validate(self, attrs): + code = attrs.get('code') + redirect_uri = attrs.get('redirect_uri') or settings.OIDC_AUTH_CODE_REDIRECT_URI + + client_id = settings.OIDC_RP_CLIENT_ID + client_secret = settings.OIDC_RP_CLIENT_SECRET + + data = { + 'grant_type': 'authorization_code', + 'code': code, + 'redirect_uri': redirect_uri, + 'client_id': client_id, + 'client_secret': client_secret + } + + response = requests.post( + settings.OIDC_OP_TOKEN_ENDPOINT, + data=data, + verify=False + ) + + try: + json_data = response.json() + except Exception: + raise AuthenticationFailed({'Detail': 'Invalid response from OIDC provider'}) + + if response.status_code != 200 or 'access_token' not in json_data: + raise AuthenticationFailed({'Detail': 'invalid authorization code'}) + + cleaned = { + 'access_token': json_data.get('access_token'), + 'token_type': json_data.get('token_type'), + 'expires_in': json_data.get('expires_in'), + 'id_token': json_data.get('id_token'), + 'refresh_token': json_data.get('refresh_token') if 'refresh_token' in json_data else None, + } + attrs['_tokens'] = cleaned + return attrs diff --git a/src/server/oasisapi/auth/tests/test_jwt.py b/src/server/oasisapi/auth/tests/test_jwt.py index a4feac672..83c51ac0f 100644 --- a/src/server/oasisapi/auth/tests/test_jwt.py +++ b/src/server/oasisapi/auth/tests/test_jwt.py @@ -3,11 +3,14 @@ from django.urls import reverse from django_webtest import WebTest from rest_framework_simplejwt.state import token_backend +from unittest import skipIf from .fakes import fake_user +from src.server.oasisapi import settings class AccessToken(WebTest): + @skipIf(settings.API_AUTH_TYPE != "simple", "Username/password grant disabled for OIDC mode") def test_username_is_not_correct___response_is_401(self): username = 'dirk' password = 'supersecret' @@ -23,6 +26,7 @@ def test_username_is_not_correct___response_is_401(self): self.assertEqual(401, response.status_code) + @skipIf(settings.API_AUTH_TYPE != "simple", "Username/password grant disabled for OIDC mode") def test_password_is_not_correct___response_is_401(self): username = 'dirk' password = 'supersecret' @@ -38,6 +42,7 @@ def test_password_is_not_correct___response_is_401(self): self.assertEqual(401, response.status_code) + @skipIf(settings.API_AUTH_TYPE != "simple", "Username/password grant disabled for OIDC mode") def test_username_and_password_are_correct___returned_token_represents_the_user(self): username = 'dirk' password = 'supersecret' @@ -61,6 +66,7 @@ def test_username_and_password_are_correct___returned_token_represents_the_user( class RefreshToken(WebTest): + @skipIf(settings.API_AUTH_TYPE != "simple", "Username/password grant disabled for OIDC mode") def test_username_and_password_are_correct___refresh_token_can_be_used_to_get_access_token(self): username = 'dirk' password = 'supersecret' diff --git a/src/server/oasisapi/auth/tests/test_oidc.py b/src/server/oasisapi/auth/tests/test_oidc.py index 628c51144..465c4e026 100644 --- a/src/server/oasisapi/auth/tests/test_oidc.py +++ b/src/server/oasisapi/auth/tests/test_oidc.py @@ -3,6 +3,7 @@ import mock from django_webtest import WebTest from rest_framework.reverse import reverse +from unittest import skipIf from src.server.oasisapi import settings @@ -76,6 +77,7 @@ def setUp(self): settings.OIDC_RP_CLIENT_ID = '' settings.OIDC_RP_CLIENT_SECRET = '' + @skipIf(settings.API_AUTH_TYPE == "simple", "client_id/client_secret grant disabled for simple JWT mode") @mock.patch('requests.post', side_effect=mocked_requests_get) def test_access_token_valid(self, mock_post): """ @@ -104,6 +106,7 @@ def test_access_token_valid(self, mock_post): "expires_in": "3600" }) + @skipIf(settings.API_AUTH_TYPE == "simple", "client_id/client_secret grant disabled for simple JWT mode") @mock.patch('requests.post', side_effect=mocked_requests_get) def test_access_token_invalid(self, mock_post): """ diff --git a/src/server/oasisapi/auth/urls.py b/src/server/oasisapi/auth/urls.py index 283c36986..927b04566 100644 --- a/src/server/oasisapi/auth/urls.py +++ b/src/server/oasisapi/auth/urls.py @@ -1,8 +1,12 @@ from django.urls import re_path -from .views import TokenObtainPairView, TokenRefreshView +from .views import OIDCAuthorizeView, OIDCCallbackView, OIDCLogoutView, OIDCSessionTokenView, TokenObtainPairView, TokenRefreshView app_name = 'auth' urlpatterns = [ re_path(r'^access_token/$', TokenObtainPairView.as_view(), name='access_token'), re_path(r'^refresh_token/$', TokenRefreshView.as_view(), name='refresh_token'), + re_path(r'^oidc/authorize/$', OIDCAuthorizeView.as_view(), name='oidc_authorize'), + re_path(r'^oidc/callback/$', OIDCCallbackView.as_view(), name='oidc_callback'), + re_path(r'^oidc/session_token/$', OIDCSessionTokenView.as_view(), name='oidc_session_token'), + re_path(r'^oidc/logout/$', OIDCLogoutView.as_view(), name='oidc_logout'), ] diff --git a/src/server/oasisapi/auth/views.py b/src/server/oasisapi/auth/views.py index c2c07cb03..b0029c8b4 100644 --- a/src/server/oasisapi/auth/views.py +++ b/src/server/oasisapi/auth/views.py @@ -1,11 +1,19 @@ +import datetime +import jwt +from django.http import HttpResponseBadRequest, HttpResponseRedirect +from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema -from rest_framework import status +from rest_framework import status, serializers from rest_framework.parsers import FormParser +from rest_framework.permissions import AllowAny from rest_framework_simplejwt.views import TokenRefreshView as BaseTokenRefreshView, \ TokenObtainPairView as BaseTokenObtainPairView +from rest_framework.response import Response +from rest_framework.views import APIView +from urllib.parse import urlencode -from .serializers import OIDCTokenRefreshSerializer, OIDCTokenObtainPairSerializer, SimpleTokenObtainPairSerializer, \ - SimpleTokenRefreshSerializer +from .serializers import OIDCAuthorizationCodeExchangeSerializer, OIDCClientCredentialsSerializer, OIDCTokenRefreshSerializer, \ + SimpleTokenObtainPairSerializer, SimpleTokenRefreshSerializer from .. import settings from ..schemas.custom_swagger import TOKEN_REFRESH_HEADER from ..schemas.serializers import TokenObtainPairResponseSerializer, TokenRefreshResponseSerializer @@ -20,7 +28,7 @@ class TokenRefreshView(BaseTokenRefreshView): Authorization: Bearer """ - serializer_class = OIDCTokenRefreshSerializer if settings.API_AUTH_TYPE == 'keycloak' else SimpleTokenRefreshSerializer + serializer_class = OIDCTokenRefreshSerializer if settings.API_AUTH_TYPE in settings.ALLOWED_OIDC_AUTH_PROVIDERS else SimpleTokenRefreshSerializer parser_classes = [FormParser] @swagger_auto_schema( @@ -34,13 +42,245 @@ def post(self, request, *args, **kwargs): class TokenObtainPairView(BaseTokenObtainPairView): """ - Fetches a new refresh token from your username and password. + Authenticates services via simple JWT or clients via OIDC based on request data. """ - serializer_class = OIDCTokenObtainPairSerializer if settings.API_AUTH_TYPE == 'keycloak' else SimpleTokenObtainPairSerializer - @swagger_auto_schema( + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + required=[], + properties={ + 'username': openapi.Schema( + type=openapi.TYPE_STRING, + description="Username for Simple JWT Service Authentication", + ), + 'password': openapi.Schema( + type=openapi.TYPE_STRING, + description="Password for Simple JWT Service Authentication", + ), + 'client_id': openapi.Schema( + type=openapi.TYPE_STRING, + escription="Client ID for OIDC Service Authentication", + ), + 'client_secret': openapi.Schema( + type=openapi.TYPE_STRING, + description="Client Secret for OIDC Service Authentication", + ), + }, + ), responses={status.HTTP_200_OK: TokenObtainPairResponseSerializer}, security=[], tags=['authentication']) def post(self, request, *args, **kwargs): return super().post(request, *args, **kwargs) + + def get_serializer_class(self): + request_data = self.request.data + + # If `client_id` and `client_secret` are present, use the OIDC flow + if 'client_id' in request_data and 'client_secret' in request_data: + if settings.API_AUTH_TYPE not in settings.ALLOWED_OIDC_AUTH_PROVIDERS: + raise serializers.ValidationError( + f"OIDC client credentials flow is disabled on this server for api auth_type {settings.API_AUTH_TYPE}.") + return OIDCClientCredentialsSerializer + # If `username` and `password` are present, use the Simple JWT flow + if 'username' in request_data and 'password' in request_data: + if settings.API_AUTH_TYPE != "simple": + raise serializers.ValidationError( + f"Simple JWT username/password flow is disabled on this server for api auth_type {settings.API_AUTH_TYPE}.") + return SimpleTokenObtainPairSerializer + raise serializers.ValidationError("ERROR: Can only call access_token with \"username AND password\" or \"client_id AND client_secret\"") + + +class OIDCAuthorizeView(APIView): + """ + Initiate the authorization code flow by redirecting the browser to OIDC auth endpoint. + Query args: + - next (optional): path to redirect back to after successful authentication + """ + permission_classes = [AllowAny] + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + 'next', + openapi.IN_QUERY, + description="Optional path to redirect back to after successful authentication", + type=openapi.TYPE_STRING, + required=False, + ) + ], + responses={302: 'Redirect to OIDC authorization endpoint'}, + security=[], + tags=['authentication'] + ) + def get(self, request, *args, **kwargs): + if settings.API_AUTH_TYPE not in settings.ALLOWED_OIDC_AUTH_PROVIDERS: + return HttpResponseBadRequest("OIDC authorization flow not enabled on this platform.") + + client_id = settings.OIDC_RP_CLIENT_ID + redirect_uri = settings.OIDC_AUTH_CODE_REDIRECT_URI + + next_url = request.GET.get('next', '/') + state = next_url + + params = { + "response_type": "code", + "client_id": client_id, + "redirect_uri": redirect_uri, + "scope": "openid profile email", + "state": state + } + auth_url = f"{settings.OIDC_OP_AUTHORIZATION_ENDPOINT}?{urlencode(params)}" + return HttpResponseRedirect(auth_url) + + +class OIDCCallbackView(APIView): + """ + Endpoint that OIDC Provider redirects back to after the user logs in (authorization code). + This view accepts 'code' and exchanges it for tokens using client_id/secret of the auth-code client. + POSTs or GETs are accepted (GET for redirect callback). + """ + permission_classes = [AllowAny] + parser_classes = [FormParser] + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + 'code', + openapi.IN_QUERY, + description="Authorization code from OIDC Provider", + type=openapi.TYPE_STRING, + required=True + ), + openapi.Parameter( + 'state', + openapi.IN_QUERY, + description="State parameter (redirect destination)", + type=openapi.TYPE_STRING, + required=False + ) + ], + responses={status.HTTP_200_OK: TokenObtainPairResponseSerializer}, + security=[], + tags=['authentication'] + ) + def get(self, request, *args, **kwargs): + data = { + 'code': request.GET.get('code'), + 'state': request.GET.get('state'), + } + serializer = OIDCAuthorizationCodeExchangeSerializer( + data=data, context={'request': request} + ) + serializer.is_valid(raise_exception=True) + tokens = serializer.validated_data.get('_tokens') + + session_payload = { + "tokens": tokens, + "exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=1) + } + session_token = jwt.encode(session_payload, settings.SECRET_KEY, algorithm="HS256") + + next_url = request.GET.get('state', '/') + redirect_url = f"{next_url}?session_token={session_token}" + return HttpResponseRedirect(redirect_url) + + @swagger_auto_schema( + request_body=OIDCAuthorizationCodeExchangeSerializer, + responses={status.HTTP_200_OK: TokenObtainPairResponseSerializer}, + security=[], + tags=['authentication']) + def post(self, request, *args, **kwargs): + serializer = OIDCAuthorizationCodeExchangeSerializer(data=request.data, context={'request': request}) + serializer.is_valid(raise_exception=True) + tokens = serializer.validated_data.get('_tokens') + return Response(tokens, status=status.HTTP_200_OK) + + +class OIDCSessionTokenView(APIView): + """ + Exchange a temporary session_token (received after OIDC login redirect) for an access and refresh token pair. + Expected input: + - session_token: The one-time identifier returned in the query string from /oidc/callback/. + Example flow: + 1. User is redirected back to frontend with ?session_token=abc123 + 2. Frontend POSTs { \"session_token\": \"abc123\" } to this endpoint + 3. Backend returns `access_token`, `refresh_token`, etc. + """ + permission_classes = [AllowAny] + + @swagger_auto_schema( + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'session_token': openapi.Schema( + type=openapi.TYPE_STRING, + description="Temporary session token obtained from OIDC callback redirect" + ) + }, + required=['session_token'] + ), + responses={ + status.HTTP_200_OK: openapi.Response( + description="Access and refresh tokens successfully returned", + schema=TokenObtainPairResponseSerializer + ), + status.HTTP_400_BAD_REQUEST: openapi.Response( + description="Missing or invalid session_token" + ), + }, + security=[], + tags=['authentication'] + ) + def post(self, request): + session_token = request.data.get("session_token") + if not session_token: + return Response({"detail": "Missing session_token"}, status=status.HTTP_400_BAD_REQUEST) + try: + payload = jwt.decode(session_token, settings.SECRET_KEY, algorithms=["HS256"]) + except jwt.ExpiredSignatureError: + return Response({"detail": "Expired session_token"}, status=status.HTTP_400_BAD_REQUEST) + except Exception: + return Response({"detail": "Invalid session_token"}, status=status.HTTP_400_BAD_REQUEST) + + return Response(payload.get("tokens")) + + +class OIDCLogoutView(APIView): + """ + Logs out the user from the OIDC provider and redirects back to the UI. + """ + permission_classes = [AllowAny] + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + 'id_token_hint', + openapi.IN_QUERY, + description="ID token hint for OIDC logout", + type=openapi.TYPE_STRING, + required=True + ) + ], + responses={302: 'Redirect to OIDC logout endpoint'}, + security=[], + tags=['authentication'] + ) + def get(self, request, *args, **kwargs): + if settings.API_AUTH_TYPE not in settings.ALLOWED_OIDC_AUTH_PROVIDERS: + return HttpResponseBadRequest("OIDC logout flow not enabled on this platform.") + + logout_endpoint = settings.OIDC_OP_ENDSESSION_ENDPOINT + if not logout_endpoint: + return HttpResponseBadRequest("Logout endpoint not configured for this OIDC provider.") + + id_token_hint = request.GET.get('id_token_hint') + post_logout_redirect_uri = settings.EXTERNAL_URI # Home page + + params = { + 'post_logout_redirect_uri': post_logout_redirect_uri, + 'id_token_hint': id_token_hint, + } + + logout_url = f"{logout_endpoint}?{urlencode(params)}" + return HttpResponseRedirect(logout_url) diff --git a/src/server/oasisapi/info/views.py b/src/server/oasisapi/info/views.py index 4cea9ac54..768ea8d41 100644 --- a/src/server/oasisapi/info/views.py +++ b/src/server/oasisapi/info/views.py @@ -65,7 +65,7 @@ def get(self, request): server_config['AWS_QUERYSTRING_AUTH'] = settings.AWS_QUERYSTRING_AUTH # Auth Conf - if settings.API_AUTH_TYPE == 'keycloak': + if settings.API_AUTH_TYPE in settings.ALLOWED_OIDC_AUTH_PROVIDERS: server_config['API_AUTH_TYPE'] = settings.API_AUTH_TYPE server_config['OIDC_OP_AUTHORIZATION_ENDPOINT'] = settings.OIDC_OP_AUTHORIZATION_ENDPOINT server_config['OIDC_OP_TOKEN_ENDPOINT'] = settings.OIDC_OP_TOKEN_ENDPOINT diff --git a/src/server/oasisapi/oidc/common.py b/src/server/oasisapi/oidc/common.py new file mode 100644 index 000000000..17ef8d137 --- /dev/null +++ b/src/server/oasisapi/oidc/common.py @@ -0,0 +1,28 @@ +import os +from urllib3.util.connection import create_connection as urllib3_create_connection + + +def auth_server_create_connection(address, *args, **kwargs): + """ + Wrap urllib3's create_connection to replace authentication server (e.g. keycloak/authentik) external host + with its internal IP (Ingress address) + + The authentication server is bound to a specific hostname which requires us to use the same url to query authentication server in backend that is + used in the UI for authentication (the JWT will contain the authenticaiton URL). We can't access the external ingress + hostname but we can remap map it in this function. + + The kubernetes chart will export these two ENV vars + ❯ export INGRESS_EXTERNAL_HOST='ui.oasis.local' + ❯ export INGRESS_INTERNAL_HOST='10.107.69.196' + + replace 'INGRESS_EXTERNAL_HOST' with 'INGRESS_INTERNAL_HOST' + and then call urllib3s connection + """ + host, port = address + + external_host = os.getenv('INGRESS_EXTERNAL_HOST') + internal_host = os.getenv('INGRESS_INTERNAL_HOST') + + if host == external_host: + host = internal_host + return urllib3_create_connection((host, port), *args, **kwargs) diff --git a/src/server/oasisapi/oidc/generic_auth.py b/src/server/oasisapi/oidc/generic_auth.py new file mode 100644 index 000000000..32985ce37 --- /dev/null +++ b/src/server/oasisapi/oidc/generic_auth.py @@ -0,0 +1,223 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import SuspiciousOperation +from django.db import transaction +from mozilla_django_oidc import auth +from src.server.oasisapi.oidc.common import auth_server_create_connection +from src.server.oasisapi.oidc.models import OIDCUserId + +from urllib3.util import connection + + +class GenericOIDCAuthenticationBackend(auth.OIDCAuthenticationBackend): + """ + Extends Mozilla Django OIDC backend. + + - Verifies JWT from OIDC Provider + - Creates or updates a corresponding Django user + - Syncs groups and roles from claims + + If a user is deleted in the OIDC provider, the local django user will continue to exist to keep analyses, portfolios, + files etc from being deleted. If a new user would be created in the OIDC provider with the same username the old django + user will be renamed to -. + """ + + connection.create_connection = auth_server_create_connection + + def get_or_create_user(self, access_token, id_token, payload): + """ + Returns a User instance if 1 user is found. Creates a user if not found. Returns nothing if multiple users + are matched. + + Create a service account user if preferred_username happens to be missing for client_credentials requests. These + usually are fulfilled by an internally created service account, so should have a preferred_username. + """ + user_info = self.get_userinfo(access_token, id_token, payload) + is_service_account = user_info.get("is_service_account", False) + + sub = self.get_userinfo_attribute(user_info, 'sub') + username = user_info.get("preferred_username", None) + if not username and is_service_account: + username = f"svc-{sub}" + elif not username: + raise SuspiciousOperation('Required key not found in claim: preferred_username') + + user = self.get_user_by_oidc_id(sub) + + if user: + return self.update_user(user, username, user_info) + else: + self.archive_old_user(username, sub) + return self.create_user(username, user_info) + + def create_user(self, username, claims): + """ + Create a user, store the oidc user id and groups. + + :param username: Username of the user + :param claims: User information from the JWT + :return: A User object + """ + user, _ = self.UserModel.objects.get_or_create(username=username) + self.update_roles(user, claims) + self.update_groups(user, claims) + self.create_oidc_user_id(user, claims.get('sub')) + return user + + def update_user(self, user, username, claims): + """ + Update a user including username and groups. + + :param user: The user object + :param username: Username + :param claims: User information from the JWT + :return: + """ + if user.username != username: + user.username = username + user.save() + + self.update_roles(user, claims) + self.update_groups(user, claims) + return user + + def filter_users_by_username(self, username): + """ + Find user by username + + :param username: Username + :return: User object or none() + """ + if not username: + return self.UserModel.objects.none() + return self.UserModel.objects.filter(username__iexact=username) + + def get_user_by_oidc_id(self, oidc_id): + """ + Find a django user by given oidc user id. + + :param oidc_id: oidc user id. + :return: A user object or None. + """ + if oidc_id: + ids = OIDCUserId.objects.filter(oidc_sub__iexact=oidc_id) + if len(ids) == 1: + return ids[0].user + + def verify_claims(self, claims) -> bool: + """ + Verify JWT claim by extracting the username. + + :param claims: JWT claims + """ + self.get_username(claims) + return True + + def get_username(self, claim) -> str: + """ + Extract OIDC username from the JWT claim. + + :param claim: JWT claim. + :return: Username + """ + username = claim.get('preferred_username') + if not username: + raise SuspiciousOperation('No username found in claim / user_info') + return username + + def update_groups(self, user, claims): + """ + Persist OIDC Provider groups as local Django groups. + """ + is_service_account = claims.get("is_service_account", False) + oidc_groups = claims.get('groups', None) + + if oidc_groups is None: + if (user.is_superuser or user.is_staff or is_service_account): + oidc_groups = [] + else: + raise SuspiciousOperation('No group found in claim / user_info') + + # strip leading slashes if any + oidc_groups = [g.lstrip('/') for g in oidc_groups] + + user_groups = [g.name for g in user.groups.all()] + oidc_groups.sort() + user_groups.sort() + + if oidc_groups != user_groups: + with transaction.atomic(): + user.groups.clear() + for group in oidc_groups: + group, _ = Group.objects.get_or_create(name=group) + group.user_set.add(user) + + def update_roles(self, user, claims): + """ + If user belongs to the "admin" group → superuser/staff. + """ + is_service_account = claims.get("is_service_account", False) + oidc_groups = claims.get('groups', []) + + # strip leading slashes if any + oidc_groups = [g.lstrip('/') for g in oidc_groups] + + is_admin = 'admin' in oidc_groups or is_service_account + + if is_admin != user.is_superuser or is_admin != user.is_staff: + user.is_superuser = is_admin + user.is_staff = is_admin + user.save() + + def create_oidc_user_id(self, user, user_id): + """ + Create a oidc user id model and bind it to the user. This is used to verify that the oidc user haven't + changed. + + :param user: Django user object + :param user_id: OIDC user id + """ + OIDCUserId.objects.create(user=user, oidc_sub=user_id) + + def is_oidc_user_id_same(self, user, sub) -> bool: + """ + Verify the user and oidc user id is identical. + + :param user: Django user + :param sub: OIDC user id from the JWT + :return: True if they are identical, False otherwise. + """ + ids = OIDCUserId.objects.filter(pk=user) + return len(ids) == 1 and ids[0].oidc_sub == sub + + def get_userinfo_attribute(self, user_info, key): + """ + Get key from user info (JWT claim) or raise an exception is missing. + + :param user_info: User info (JWT claim) + :param key: Name of attribute/key + :return: Value of key or exception it not existing + """ + value = user_info.get(key) + if not key: + raise SuspiciousOperation(f'Required key not found in claim: {key}') + return value + + def archive_old_user(self, username, sub): + """ + Check if the username exists and if that is the case rename the user. This is used to free the username + for a new oidc user (created with a previously existing username) but still keep all resources bound + to user. + + :param username: Username + :param sub: OIDC user id. Used for renaming the user to a unique name. + """ + user_filter = self.UserModel.objects.filter(username__iexact=username) + + for user in user_filter: + # check for and remove exisiting users before rename + new_username = f'{user.username}-{sub}' + self.UserModel.objects.filter(username=new_username).delete() + + user.username = new_username + user.active = False + user.save() diff --git a/src/server/oasisapi/oidc/keycloak_auth.py b/src/server/oasisapi/oidc/keycloak_auth.py deleted file mode 100644 index 0029e1e53..000000000 --- a/src/server/oasisapi/oidc/keycloak_auth.py +++ /dev/null @@ -1,257 +0,0 @@ -import os -from django.contrib.auth.models import Group -from django.core.exceptions import SuspiciousOperation -from django.db import transaction -from mozilla_django_oidc import auth - -from urllib3.util import connection -from urllib3.util.connection import create_connection as urllib3_create_connection -from src.server.oasisapi.oidc.models import KeycloakUserId - - -def keycloak_create_connection(address, *args, **kwargs): - """ - Wrap urllib3's create_connection to replace keycloak external host - with its internal IP (Ingress address) - - Keycloak is bound to a specific hostname which requires us to use the same url to query keycloak in backend that is - used in the UI for authentication (the JWT will contain the authenticaiton URL). We can't access the external ingress - hostname but we can remap map it in this function. - - The kubernetes chart will export these two ENV vars - ❯ export INGRESS_EXTERNAL_HOST='ui.oasis.local' - ❯ export INGRESS_INTERNAL_HOST='10.107.69.196' - - replace 'INGRESS_EXTERNAL_HOST' with 'INGRESS_INTERNAL_HOST' - and then call urllib3s connection - """ - host, port = address - - external_host = os.getenv('INGRESS_EXTERNAL_HOST') - internal_host = os.getenv('INGRESS_INTERNAL_HOST') - - if host == external_host: - host = internal_host - return urllib3_create_connection((host, port), *args, **kwargs) - - -class KeycloakOIDCAuthenticationBackend(auth.OIDCAuthenticationBackend): - """ - Extends Mozilla Django OIDC backend to support a better Keycloak implementation. - - Each authentication will verify the JWT from keycloak and: - - Make sure there is a django user representation. - - Update username, groups etc from keycloak. - - If a user is deleted in keycloak the local django user will continue to exist to keep analyses, portfolios, - files etc from being deleted. If a new user would be created in keycloak with the same username the old django - user will be renamed to -. - - """ - connection.create_connection = keycloak_create_connection - - def get_or_create_user(self, access_token, id_token, payload): - """ - Returns a User instance if 1 user is found. Creates a user if not found. Returns nothing if multiple users - are matched. - """ - - user_info = self.get_userinfo(access_token, id_token, payload) - sub = self.get_userinfo_attribute(user_info, 'sub') - username = self.get_userinfo_attribute(user_info, 'preferred_username') - - user = self.get_user_by_keycloak_id(sub) - - if user: - - return self.update_user(user, username, user_info) - else: - - self.archive_old_user(username, sub) - return self.create_user(username, user_info) - - def create_user(self, username, claims): - """ - Create a user, store the keycloak user id and groups. - - :param username: Username of the user - :param claims: User information from the JWT - :return: A User object - """ - - user, _ = self.UserModel.objects.get_or_create(username=username) - self.update_roles(user, claims) - self.update_groups(user, claims) - self.create_keycloak_user_id(user, claims.get('sub')) - return user - - def update_user(self, user, username, claims): - """ - Update a user including username and groups. - - :param user: The user object - :param username: Username - :param claims: User information from the JWT - :return: - """ - if user.username != username: - user.username = username - user.save() - - self.update_roles(user, claims) - self.update_groups(user, claims) - return user - - def filter_users_by_username(self, username): - """ - Find user by username - - :param username: Username - :return: User object or none() - """ - - if not username: - return self.UserModel.objects.none() - return self.UserModel.objects.filter(username__iexact=username) - - def get_user_by_keycloak_id(self, keycloak_id): - """ - Find a django user by given keycloak user id. - - :param keycloak_id: Keycloak user id. - :return: A user object or None. - """ - - if keycloak_id: - keycloak_ids = KeycloakUserId.objects.filter(keycloak_user_id__iexact=keycloak_id) - - if len(keycloak_ids) == 1: - return keycloak_ids[0].user - - def verify_claims(self, claims) -> bool: - """ - Verify JWT claim by extracting the username. - - :param claims: JWT claims - """ - self.get_username(claims) - return True - - def get_username(self, claim) -> str: - """ - Extract Keycloaks username from the JWT claim. - - :param claim: JWT claim. - :return: Username - """ - - username = claim.get('preferred_username') - - if not username: - msg = 'No username found in claim / user_info' - raise SuspiciousOperation(msg) - - return username - - def update_groups(self, user, claims): - """ - Persist Keycloak groups as local Django groups. - """ - keycloak_groups = claims.get('groups', None) - - if keycloak_groups is None: - if (user.is_superuser or user.is_staff): - keycloak_groups = [] - else: - msg = 'No group found in claim / user_info' - raise SuspiciousOperation(msg) - - for i, keycloak_group in enumerate(keycloak_groups): - if keycloak_group.startswith('/'): - keycloak_groups[i] = keycloak_group[1:] - - user_groups = list() - for user_group in user.groups.all(): - user_groups.append(user_group.name) - - keycloak_groups.sort() - user_groups.sort() - - if keycloak_groups != user_groups: - with transaction.atomic(): - user.groups.clear() - for group in keycloak_groups: - group, _ = Group.objects.get_or_create(name=group) - group.user_set.add(user) - - def update_roles(self, user, claims): - """ - If a user belongs to the group admin we will enable django attributes is_superuser and is_admin. - """ - keycloak_roles = claims.get('realm_access', dict()).get('roles', []) - - is_admin = 'admin' in keycloak_roles - if is_admin != user.is_superuser or is_admin != user.is_staff: - user.is_superuser = is_admin - user.is_staff = is_admin - user.save() - - def create_keycloak_user_id(self, user, user_id): - """ - Create a keycloak user id model and bind it to the user. This is used to verify that the keycloak user haven't - changed. - - :param user: Django user object - :param user_id: Keycloak user id - """ - - KeycloakUserId.objects.create(user=user, keycloak_user_id=user_id) - - def is_keycloak_user_id_same(self, user, sub) -> bool: - """ - Verify the user and keycloak user id is identical. - - :param user: Django user - :param sub: Keycloak user id from the JWT - :return: True if they are identical, False otherwise. - """ - - filter = KeycloakUserId.objects.filter(pk=user) - - return len(filter) == 1 and filter[0].keycloak_user_id == sub - - def get_userinfo_attribute(self, user_info, key): - """ - Get key from user info (JWT claim) or raise an exception is missing. - - :param user_info: User info (JWT claim) - :param key: Name of attribute/key - :return: Value of key or exception it not existing - """ - - value = user_info.get(key) - - if not key: - raise SuspiciousOperation(f'Required key not found in claim: {key}') - - return value - - def archive_old_user(self, username, sub): - """ - Check if the username exists and if that is the case rename the user. This is used to free the username - for a new keycloak user (created with a previously existing username) but still keep all resources bound - to user. - - :param username: Username - :param sub: Keycloak user id. Used for renaming the user to a unique name. - """ - user_filter = self.UserModel.objects.filter(username__iexact=username) - - for user in user_filter: - # check for and remove exisiting users before rename - new_username = f'{user.username}-{sub}' - self.UserModel.objects.filter(username=new_username).delete() - - user.username = new_username - user.active = False - user.save() diff --git a/src/server/oasisapi/oidc/migrations/0001_initial.py b/src/server/oasisapi/oidc/migrations/0001_initial.py index f1eaacd8a..3bcbf97b2 100644 --- a/src/server/oasisapi/oidc/migrations/0001_initial.py +++ b/src/server/oasisapi/oidc/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.20 on 2023-07-24 11:34 +# Generated by Django 3.2.20 from django.db import migrations, models import django.db.models.deletion @@ -14,10 +14,10 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='KeycloakUserId', + name='OIDCUserId', fields=[ ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='auth.user')), - ('keycloak_user_id', models.CharField(default='', max_length=100)), + ('oidc_sub', models.CharField(default='', max_length=100)), ], ), ] diff --git a/src/server/oasisapi/oidc/models.py b/src/server/oasisapi/oidc/models.py index 059834797..f19d5e3a7 100644 --- a/src/server/oasisapi/oidc/models.py +++ b/src/server/oasisapi/oidc/models.py @@ -2,14 +2,17 @@ from django.db import models -class KeycloakUserId(models.Model): +class OIDCUserId(models.Model): """ - This model is used to persist a Keycloak user id (uuid) and helps us verify our django user still represents - the user in Keycloak it once was created for. I changed Keycloak user id would tell us that the user in - Keycloak with this username has been replaced by a new account. + Persist the OIDC subject claim (`sub`) for a Django user. + + Works with both Keycloak and Authentik (or any OIDC provider). """ # Django user user = models.OneToOneField(settings.AUTH_USER_MODEL, primary_key=True, on_delete=models.CASCADE) - # Keycloak user ID (uuid) - keycloak_user_id = models.CharField(max_length=100, default='') + # The "sub" (subject) claim from the OIDC provider (Keycloak, Authentik, etc.) + oidc_sub = models.CharField(max_length=100, default='') + + def __str__(self): + return f"{self.user.username} -> {self.oidc_sub}" diff --git a/src/server/oasisapi/routing.py b/src/server/oasisapi/routing.py index 015913c5a..671ffe4d3 100644 --- a/src/server/oasisapi/routing.py +++ b/src/server/oasisapi/routing.py @@ -8,7 +8,7 @@ from rest_framework_simplejwt.authentication import JWTAuthentication from src.server.oasisapi import settings -from src.server.oasisapi.oidc.keycloak_auth import KeycloakOIDCAuthenticationBackend +from src.server.oasisapi.oidc.generic_auth import GenericOIDCAuthenticationBackend from src.server.oasisapi.queues.routing import websocket_urlpatterns url_patterns = [ @@ -26,13 +26,17 @@ async def get_user(token_key): logger.warning('No token provided, returning AnonymousUser') return AnonymousUser() - if settings.API_AUTH_TYPE == 'keycloak': - logger.info('Using Keycloak authentication') - backend = KeycloakOIDCAuthenticationBackend() + if settings.API_AUTH_TYPE in settings.ALLOWED_OIDC_AUTH_PROVIDERS: + logger.info(f'Using {settings.API_AUTH_TYPE} authentication') + + backend = GenericOIDCAuthenticationBackend() authentication = OIDCAuthentication(backend) + + async_authentication = sync_to_async(authentication.authenticate, thread_sensitive=True) request = type('', (), {'META': {'HTTP_AUTHORIZATION': header_value}})() - user, access_token = await sync_to_async(authentication.authenticate, thread_sensitive=True)(request) + user, access_token = await async_authentication(request) logger.info(f'Successfully authenticated user: {user}') + return user else: logger.info('Using JWT authentication') diff --git a/src/server/oasisapi/settings/base.py b/src/server/oasisapi/settings/base.py index eab276822..5dbe97722 100644 --- a/src/server/oasisapi/settings/base.py +++ b/src/server/oasisapi/settings/base.py @@ -278,24 +278,36 @@ AUTHENTICATION_BACKENDS = iniconf.settings.get('server', 'auth_backends', fallback='django.contrib.auth.backends.ModelBackend').split(',') AUTH_PASSWORD_VALIDATORS = [] -API_AUTH_TYPE = iniconf.settings.get('server', 'API_AUTH_TYPE', fallback='') +API_AUTH_TYPE = iniconf.settings.get('server', 'API_AUTH_TYPE', fallback='simple') +ALLOWED_OIDC_AUTH_PROVIDERS = iniconf.settings.get('server', 'ALLOWED_OIDC_AUTH_PROVIDERS', fallback='').split(",") -if API_AUTH_TYPE == 'keycloak': +INGRESS_EXTERNAL_HOST = iniconf.settings.get('server', 'INGRESS_EXTERNAL_HOST', fallback='ui.oasis.local') +EXTERNAL_URI = "https://" + INGRESS_EXTERNAL_HOST + "/" +OIDC_AUTH_CODE_REDIRECT_URI = EXTERNAL_URI + "api/oidc/callback/" + +if API_AUTH_TYPE in ALLOWED_OIDC_AUTH_PROVIDERS: INSTALLED_APPS += ( 'mozilla_django_oidc', ) - AUTHENTICATION_BACKENDS = ('src.server.oasisapi.oidc.keycloak_auth.KeycloakOIDCAuthenticationBackend',) + AUTHENTICATION_BACKENDS = ('src.server.oasisapi.oidc.generic_auth.GenericOIDCAuthenticationBackend',) REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'] += ('mozilla_django_oidc.contrib.drf.OIDCAuthentication',) OIDC_RP_CLIENT_ID = iniconf.settings.get('server', 'OIDC_CLIENT_NAME', fallback='') OIDC_RP_CLIENT_SECRET = iniconf.settings.get('server', 'OIDC_CLIENT_SECRET', fallback='') - KEYCLOAK_OIDC_BASE_URL = iniconf.settings.get('server', 'OIDC_ENDPOINT', fallback='') - - OIDC_OP_AUTHORIZATION_ENDPOINT = KEYCLOAK_OIDC_BASE_URL + 'auth' - OIDC_OP_TOKEN_ENDPOINT = KEYCLOAK_OIDC_BASE_URL + 'token' - OIDC_OP_USER_ENDPOINT = KEYCLOAK_OIDC_BASE_URL + 'userinfo' + OIDC_BASE_URL = iniconf.settings.get('server', 'OIDC_ENDPOINT', fallback='') + + if API_AUTH_TYPE == "keycloak": + OIDC_OP_AUTHORIZATION_ENDPOINT = OIDC_BASE_URL + 'auth' + OIDC_OP_TOKEN_ENDPOINT = OIDC_BASE_URL + 'token' + OIDC_OP_USER_ENDPOINT = OIDC_BASE_URL + 'userinfo' + OIDC_OP_ENDSESSION_ENDPOINT = OIDC_BASE_URL + 'logout' + elif API_AUTH_TYPE == "authentik": + OIDC_OP_AUTHORIZATION_ENDPOINT = OIDC_BASE_URL + 'authorize/' + OIDC_OP_TOKEN_ENDPOINT = OIDC_BASE_URL + 'token/' + OIDC_OP_USER_ENDPOINT = OIDC_BASE_URL + 'userinfo/' + OIDC_OP_ENDSESSION_ENDPOINT = OIDC_BASE_URL + OIDC_RP_CLIENT_ID + '/end-session/' # No need to verify our internal self signed keycloak certificate OIDC_VERIFY_SSL = False @@ -304,11 +316,11 @@ 'DEFAULT_GENERATOR_CLASS': DEFAULT_GENERATOR_CLASS, 'USE_SESSION_AUTH': False, 'SECURITY_DEFINITIONS': { - "keycloak": { + "authentik": { "type": "oauth2", - "authorizationUrl": KEYCLOAK_OIDC_BASE_URL + 'auth', - "refreshUrl": OIDC_OP_TOKEN_ENDPOINT + 'auth', - "flow": "implicit", + "authorizationUrl": OIDC_OP_AUTHORIZATION_ENDPOINT, + "tokenUrl": OIDC_OP_TOKEN_ENDPOINT, + "flow": "accessCode", "scopes": {} } }, diff --git a/src/server/oasisapi/urls.py b/src/server/oasisapi/urls.py index 64a2f0a9f..b1ac9484b 100644 --- a/src/server/oasisapi/urls.py +++ b/src/server/oasisapi/urls.py @@ -22,18 +22,70 @@ The general workflow is as follows """ -if settings.API_AUTH_TYPE == 'keycloak': +if settings.API_AUTH_TYPE in settings.ALLOWED_OIDC_AUTH_PROVIDERS: api_info_description += """ -1. Authenticate your client: - 1. Post to the keycloak endpoint: - `grant_type=password&client_id=&client_secret=&username=&password=` - Check your chart values to find the endpoint (`OIDC_ENDPOINT`), (`OIDC_CLIENT_NAME`) and - (`OIDC_CLIENT_SECRET`). - 2. Either supply your username and password to the `/access_token/` endpoint or make a `post` request - to `/refresh_token/` with the `HTTP_AUTHORIZATION` header set as `Bearer `. - 3. Here in swagger - click the `Authorize` button, enter 'swagger' as client_id and click Authorize. This will open - a new window with the keycloak login, enter your credentials and click Login. This will close the window and get - you back to the authorize dialog which you now can close.""" +### Authentication Overview + +Your application can authenticate either as a **service** or as a **user**. +Two different flows are supported: +--- +## 1. Service Authentication (Client Credentials Flow) + +This flow is intended for service-to-service communication where no user is involved. +1. **Request an access token** + - Make a POST request to the `/access_token/` endpoint with: + ``` + grant_type=client_credentials + client_id= + client_secret= + ``` + - The response will include an `access_token` and an optional `refresh_token` (depending on configuration). +2. **Use the token** + - Include the access token in the `Authorization` header when calling protected endpoints: + ``` + Authorization: Bearer + ``` +3. **Refresh the token** + - When the access token expires, request a new one using the same `client_credentials` flow, you cannot call `/refresh_token/` for OIDC client_credentials +--- +## 2. User Authentication (Authorization Code Flow with Session Token) + +This flow is intended for authenticating end-users through OpenID Connect. +1. **Authorize the user** + - Redirect the user’s browser to the `oidc/authorize/` endpoint, including the client and redirect parameters. + - Example: + ``` + GET /oidc/authorize/?client_id=&redirect_uri=&response_type=code&scope=openid&next= + ``` + - The user will log in via the identity provider (e.g., Keycloak, Authentik) and be redirected back to your configured callback URL by the OIDC provider automatically. +2. **Handle the authorization callback** + - Your callback endpoint (e.g., `/oidc/callback/`) will receive a short-lived `session_token` (valid for about one minute). + - This session token contains encoded information allowing you to retrieve the user’s access and refresh tokens. +3. **Exchange the session token** + - Make a POST request to the `/oidc/session_token/` endpoint with: + ``` + session_token= + ``` + - The response will contain the user’s `access_token` and `refresh_token`. +4. **Use the tokens** + - Include the user’s access token in the `Authorization` header when making API calls: + ``` + Authorization: Bearer + ``` +5. **Refreshing tokens** + - When the access token expires, use the `/refresh_token/` endpoint with: + ``` + grant_type=refresh_token + refresh_token= + ``` + - to obtain a new access token. +--- +## 3. Swagger Authentication +In Swagger UI: +1. Click the **Authorize** button. +2. Enter the client_id and client_secret and click **Authorize**. This will open a new window with your OIDC login page where you enter credentials and click login. +3. You should get redirected back to the api page with the authorize dialog. +""" else: api_info_description += """ 1. Authenticate your client, either supply your username and password to the `/access_token/` diff --git a/src/startup_server.sh b/src/startup_server.sh index 0c992e9d7..bd0808456 100755 --- a/src/startup_server.sh +++ b/src/startup_server.sh @@ -29,7 +29,9 @@ if [ "${STARTUP_RUN_MIGRATIONS}" = true ]; then fi set -e -# Create default admin if `$OASIS_ADMIN_USER` && `$OASIS_ADMIN_PASS` are set +# Create default admin if `$OASIS_SERVICE_USERNAME_OR_ID` && `$OASIS_SERVICE_PASSWORD_OR_SECRET` are set +# These will only be set by the script if "simple" apiAuthType is set. +# For OIDC, the users are created by the OIDC provider and backend classes. python3 set_default_user.py exec "$@" diff --git a/src/utils/set_default_user.py b/src/utils/set_default_user.py index f0d2b1266..e9bf18643 100644 --- a/src/utils/set_default_user.py +++ b/src/utils/set_default_user.py @@ -7,17 +7,28 @@ from django.contrib.auth.models import User try: - env_username = os.environ['OASIS_ADMIN_USER'] - env_password = os.environ['OASIS_ADMIN_PASS'] - try: - print('Creating user: "{}"'.format(env_username)) - u = User.objects.get(username=env_username) - u.set_password(env_password) - u.save() - print('User creation complete') - except Exception as e: - User.objects.create_superuser(env_username, 'admin@example.com', env_password) - print(str(e)) + # The default django admin user is created here only when "simple" apiAuthType is used. + # For OIDC, the users are created by the OIDC provider and backend classes. + if not bool(os.environ.get('OASIS_USE_OIDC', False)): + env_username = os.environ.get('OASIS_SERVICE_USERNAME_OR_ID', '') + env_password = os.environ.get('OASIS_SERVICE_PASSWORD_OR_SECRET', '') + + # backwards compatibly + if not env_username: + env_username = os.environ.get('OASIS_ADMIN_USER', '') + if not env_password: + env_password = os.environ.get('OASIS_ADMIN_PASS', '') + + if env_username and env_password: + try: + print('Creating user: "{}"'.format(env_username)) + u = User.objects.get(username=env_username) + u.set_password(env_password) + u.save() + print('User creation complete') + except Exception as e: + User.objects.create_superuser(env_username, 'admin@example.com', env_password) + print(str(e)) except KeyError: print('User Enviroment vars not set') From c215eee5e2ed6b0e46aa6e617d0405d65a898b0b Mon Sep 17 00:00:00 2001 From: Ha-Ree <48283886+Ha-Ree@users.noreply.github.com> Date: Fri, 9 Jan 2026 11:44:10 +0200 Subject: [PATCH 5/5] Fix/current platform errors (#1316) * recompile .txts * pinning version without bug * another instance * path in test * fix versions * correct one for work cont * 40373 * eof --- .github/workflows/minikube-cicd.yml | 9 ++++++--- kubernetes/worker-controller/requirements.in | 2 ++ kubernetes/worker-controller/requirements.txt | 14 ++++++++------ requirements-server.in | 1 + requirements-server.txt | 5 +++-- requirements-worker.in | 2 ++ requirements-worker.txt | 8 +++++--- requirements.txt | 9 ++++++--- 8 files changed, 33 insertions(+), 17 deletions(-) diff --git a/.github/workflows/minikube-cicd.yml b/.github/workflows/minikube-cicd.yml index d388712a9..a66dfc12d 100644 --- a/.github/workflows/minikube-cicd.yml +++ b/.github/workflows/minikube-cicd.yml @@ -214,17 +214,20 @@ jobs: PASSWORD_OR_SECRET=$(kubectl get secret oasis-service-account-credentials -o jsonpath="{.data.password_or_secret}" | base64 -d) USE_OIDC=$(kubectl get secret oasis-service-account-credentials -o jsonpath="{.data.use_oidc}" | base64 -d) + CREDENTIALS_FILE="$(mktemp)" + trap 'rm -f "$CREDENTIALS_FILE"' EXIT + if [ "$USE_OIDC" = "true" ]; then echo "Using OIDC authentication" - CREDENTIALS=$(printf '{"client_id": "%s", "client_secret": "%s"}' "$USERNAME_OR_ID" "$PASSWORD_OR_SECRET") + printf '{"client_id": "%s", "client_secret": "%s"}' "$USERNAME_OR_ID" "$PASSWORD_OR_SECRET" > "$CREDENTIALS_FILE" else echo "Using Simple JWT authentication" - CREDENTIALS=$(printf '{"username": "%s", "password": "%s"}' "$USERNAME_OR_ID" "$PASSWORD_OR_SECRET") + printf '{"username": "%s", "password": "%s"}' "$USERNAME_OR_ID" "$PASSWORD_OR_SECRET" > "$CREDENTIALS_FILE" fi { oasislmf api run \ - --server-login-json "$CREDENTIALS" \ + --server-login-json "$CREDENTIALS_FILE" \ --server-version 'v1' \ --server-url 'http://ui.oasis.local/api' \ --model-id 1 \ diff --git a/kubernetes/worker-controller/requirements.in b/kubernetes/worker-controller/requirements.in index 8212de140..30740e7a6 100644 --- a/kubernetes/worker-controller/requirements.in +++ b/kubernetes/worker-controller/requirements.in @@ -3,3 +3,5 @@ aiohttp>=3.7.4 kubernetes_asyncio==18.20.0 joblib>=1.2.0 packaging>=22.0 +aiohttp>=3.13.3,<4 +urllib3 >=2.6.3,<3.0 diff --git a/kubernetes/worker-controller/requirements.txt b/kubernetes/worker-controller/requirements.txt index e48dc4c99..e08013bc5 100644 --- a/kubernetes/worker-controller/requirements.txt +++ b/kubernetes/worker-controller/requirements.txt @@ -2,11 +2,11 @@ # This file is autogenerated by pip-compile with Python 3.12 # by the following command: # -# pip-compile kubernetes/worker-controller/requirements.in +# pip-compile ./kubernetes/worker-controller/requirements.in # aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.12.15 +aiohttp==3.13.3 # via # -r kubernetes/worker-controller/requirements.in # kubernetes-asyncio @@ -22,7 +22,7 @@ frozenlist==1.7.0 # aiosignal idna==3.10 # via yarl -joblib==1.5.2 +joblib==1.5.1 # via -r kubernetes/worker-controller/requirements.in kubernetes-asyncio==18.20.0 # via -r kubernetes/worker-controller/requirements.in @@ -44,10 +44,12 @@ six==1.17.0 # via # kubernetes-asyncio # python-dateutil -typing-extensions==4.15.0 +typing-extensions==4.14.1 # via aiosignal -urllib3==2.6.0 - # via kubernetes-asyncio +urllib3==2.6.3 + # via + # -r kubernetes/worker-controller/requirements.in + # kubernetes-asyncio websockets==13.1 # via -r kubernetes/worker-controller/requirements.in yarl==1.20.1 diff --git a/requirements-server.in b/requirements-server.in index 1fe66bef3..f560f69e5 100644 --- a/requirements-server.in +++ b/requirements-server.in @@ -38,3 +38,4 @@ setuptools mysqlclient>=2.0,<3.0 whitenoise>=6.0,<7.0 azure-identity>=1.0,<2.0 +urllib3 >=2.6.3,<3.0 diff --git a/requirements-server.txt b/requirements-server.txt index 7e0ef59ca..78e99db37 100644 --- a/requirements-server.txt +++ b/requirements-server.txt @@ -2,7 +2,7 @@ # This file is autogenerated by pip-compile with Python 3.12 # by the following command: # -# pip-compile requirements-server.in +# pip-compile ./requirements-server.in # amqp==5.3.1 # via kombu @@ -341,8 +341,9 @@ uritemplate==4.2.0 # via # coreapi # drf-yasg -urllib3==2.6.0 +urllib3==2.6.3 # via + # -r requirements-server.in # botocore # requests vine==5.1.0 diff --git a/requirements-worker.in b/requirements-worker.in index b9462598e..a1250ab66 100644 --- a/requirements-worker.in +++ b/requirements-worker.in @@ -25,3 +25,5 @@ networkx>=3.0,<4.0 pyodbc>=5.0,<6.0 sqlparams>=6.0,<7.0 sqlparse>=0.0,<1.0 +aiohttp>=3.13.3,<4 +urllib3 >=2.6.3,<3.0 diff --git a/requirements-worker.txt b/requirements-worker.txt index 4cafbc5c7..e55c05620 100644 --- a/requirements-worker.txt +++ b/requirements-worker.txt @@ -2,7 +2,7 @@ # This file is autogenerated by pip-compile with Python 3.12 # by the following command: # -# pip-compile requirements-worker.in +# pip-compile ./requirements-worker.in # adlfs==2024.12.0 # via -r requirements-worker.in @@ -10,8 +10,9 @@ aiobotocore==2.24.1 # via s3fs aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.12.15 +aiohttp==3.13.3 # via + # -r requirements-worker.in # adlfs # aiobotocore # s3fs @@ -335,8 +336,9 @@ tzdata==2025.2 # via # kombu # pandas -urllib3==2.6.0 +urllib3==2.6.3 # via + # -r requirements-worker.in # botocore # requests vine==5.1.0 diff --git a/requirements.txt b/requirements.txt index 5a391d73a..bb1a98a75 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ # This file is autogenerated by pip-compile with Python 3.12 # by the following command: # -# pip-compile requirements.in +# pip-compile ./requirements.in # adlfs==2024.12.0 # via -r requirements-worker.in @@ -10,8 +10,9 @@ aiobotocore==2.24.1 # via s3fs aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.12.15 +aiohttp==3.13.3 # via + # -r requirements-worker.in # adlfs # aiobotocore # s3fs @@ -634,8 +635,10 @@ uritemplate==4.2.0 # via # coreapi # drf-yasg -urllib3==2.6.0 +urllib3==2.6.3 # via + # -r requirements-server.in + # -r requirements-worker.in # botocore # requests vine==5.1.0