diff --git a/.env.example b/.env.example index 3c5a693fd..0e7e6b712 100644 --- a/.env.example +++ b/.env.example @@ -76,6 +76,13 @@ MAIL_FROM_NAME="${APP_NAME}" # Enable Shibboleth authentication #SHIBBOLETH_ENABLED=false +# OIDC config +#OIDC_ENABLED=false +#OIDC_ISSUER= +#OIDC_CLIENT_ID= +#OIDC_CLIENT_SECRET= +#OIDC_SCOPES=profile,email + # Enabled locales # Comma separated list, e.g. de,en # If unset all available locales are enabled (lang + resources/custom/lang) diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 638274d2d..000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,34 +0,0 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# Please see the documentation for all configuration options: -# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates - -version: 2 -updates: - # Maintain dependencies for npm - - package-ecosystem: "npm" - directory: "/" - open-pull-requests-limit: 20 - schedule: - interval: "weekly" - day: "monday" - time: "06:00" - timezone: "Europe/Berlin" - groups: - tiptap: - patterns: - - "@tiptap*" - primevue: - patterns: - - "primevue*" - - "@primevue*" - - # Maintain dependencies for Composer - - package-ecosystem: "composer" - directory: "/" - open-pull-requests-limit: 20 - schedule: - interval: "weekly" - day: "monday" - time: "06:00" - timezone: "Europe/Berlin" diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 4a9d749fb..30c742562 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -2,12 +2,8 @@ name: CI Docker build on: workflow_dispatch: - release: - types: [published] - push: - branches: - - develop - - "[0-9].x" + pull_request: + jobs: build-and-push-image: runs-on: ubuntu-latest @@ -22,26 +18,12 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Login to Docker Hub + - name: Log in to the Container registry uses: docker/login-action@v3 with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - - - name: Extract metadata (tags, labels) for Docker - id: meta - uses: docker/metadata-action@v5 - with: - images: pilos/pilos - flavor: | - latest=auto - prefix= - suffix= - tags: | - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - type=ref,event=branch,prefix=dev- + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Set version to tag name if: ${{ github.event_name == 'release' }} @@ -57,7 +39,6 @@ jobs: file: docker/app/Dockerfile context: . push: true - tags: ${{ steps.meta.outputs.tags }} + tags: "${{github.action_repository}}:${{github.head_ref}}" labels: ${{ steps.meta.outputs.labels }} - cache-from: type=registry,ref=pilos/pilos:buildcache - cache-to: type=registry,ref=pilos/pilos:buildcache,mode=max + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 156a36f27..000000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,446 +0,0 @@ -name: CI - -on: - push: - branches: - - develop - - "[0-9].x" - pull_request: - -env: - PHP_VERSION: 8.4 - CYPRESS_PROJECT_ID: w8t3fx - -jobs: - backend: - name: Backend - runs-on: ubuntu-latest - - services: - redis: - image: redis - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 6379 - postgres: - image: postgres - env: - POSTGRES_USER: user - POSTGRES_PASSWORD: password - POSTGRES_DB: test - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 5432 - - mariadb: - image: mariadb:11 - ports: - - 3306 - env: - MYSQL_USER: user - MYSQL_PASSWORD: password - MYSQL_DATABASE: test - MYSQL_ROOT_PASSWORD: password - options: --health-cmd="healthcheck.sh --connect --innodb_initialized" --health-interval=5s --health-timeout=2s --health-retries=3 - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 2 - - name: Verify MariaDB connection - env: - PORT: ${{ job.services.mariadb.ports[3306] }} - run: | - while ! mysqladmin ping -h"127.0.0.1" -P"$PORT" --silent; do - sleep 1 - done - - name: Verify Postgres connection - env: - PORT: ${{ job.services.postgres.ports[5432] }} - run: | - while ! pg_isready -h"127.0.0.1" -p"$PORT" > /dev/null 2> /dev/null; do - sleep 1 - done - - name: Install packages - run: | - sudo apt-get update - sudo apt-get install pv mariadb-client - - name: Setup PHP, with composer and extensions - uses: shivammathur/setup-php@v2 #https://github.com/shivammathur/setup-php - with: - php-version: ${{ env.PHP_VERSION }} - extensions: bcmath, ctype, fileinfo, json, mbstring, dom, ldap, pdo, tokenizer, xml, mysql, sqlite, imagick, exif, intl - coverage: pcov - - name: Copy .env - run: php -r "copy('.env.ci', '.env');" - - name: Get Composer Cache Directory - id: composer-cache - run: | - echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - - uses: actions/cache@v4 - with: - path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} - restore-keys: | - ${{ runner.os }}-composer- - - name: Install php dependencies - run: | - composer self-update - composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist - - name: Generate key - run: php artisan key:generate - - name: Directory Permissions - run: chmod -R 777 storage bootstrap/cache - - name: Migrate Database - env: - DB_HOST: 127.0.0.1 - DB_PORT: ${{ job.services.mariadb.ports[3306] }} - DB_DATABASE: test - DB_USERNAME: root - DB_PASSWORD: password - run: php artisan migrate --no-interaction -vvv --force - - name: Execute code style check via Laravel Pint - run: vendor/bin/pint --test -v - - name: Execute tests (Unit and Feature tests) via PHPUnit - if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork }} - env: - DB_HOST: 127.0.0.1 - DB_PORT: ${{ job.services.mariadb.ports[3306] }} - DB_DATABASE: test - DB_USERNAME: root - DB_PASSWORD: password - REDIS_HOST: 127.0.0.1 - REDIS_PORT: ${{ job.services.redis.ports[6379] }} - LOG_CHANNEL: stack - run: php artisan test --parallel --testsuite=Unit,Feature --coverage-clover=coverage.xml - - name: Execute tests (Unit, Feature and Integration tests) via PHPUnit - if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }} - env: - DB_HOST: 127.0.0.1 - DB_PORT: ${{ job.services.mariadb.ports[3306] }} - DB_DATABASE: test - DB_USERNAME: root - DB_PASSWORD: password - REDIS_HOST: 127.0.0.1 - REDIS_PORT: ${{ job.services.redis.ports[6379] }} - LOG_CHANNEL: stack - BBB_TEST_SERVER_HOST: ${{ secrets.BBB_TEST_SERVER_HOST }} - BBB_TEST_SERVER_SECRET: ${{ secrets.BBB_TEST_SERVER_SECRET }} - - run: php artisan test --parallel --testsuite=Unit,Feature --coverage-clover=coverage.xml - - - name: Execute tests (Unit and Feature tests) via PHPUnit using Postgres - env: - DB_CONNECTION: pgsql - DB_HOST: 127.0.0.1 - DB_PORT: ${{ job.services.postgres.ports[5432] }} - DB_DATABASE: test - DB_USERNAME: user - DB_PASSWORD: password - REDIS_HOST: 127.0.0.1 - REDIS_PORT: ${{ job.services.redis.ports[6379] }} - LOG_CHANNEL: stack - run: php artisan test --parallel --testsuite=Unit,Feature - - name: Upload coverage - uses: codecov/codecov-action@v5 - with: - fail_ci_if_error: true - - name: Upload laravel logs - uses: actions/upload-artifact@v4 - if: failure() - with: - name: laravel.log - path: storage/logs/laravel.log - frontend-code-style-check: - name: Frontend Code Style Check - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Copy .env - run: php -r "copy('.env.example', '.env');" - - name: Get npm cache directory - id: npm-cache-dir - shell: bash - run: echo "dir=$(npm config get cache)" >> ${GITHUB_OUTPUT} - - uses: actions/cache@v4 - id: npm-cache # use this to check for `cache-hit` ==> if: steps.npm-cache.outputs.cache-hit != 'true' - with: - path: ${{ steps.npm-cache-dir.outputs.dir }} - key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-node- - - name: Install dependencies - run: npm ci - - name: Check code formatting - run: npm run prettier - - name: Linting - run: npm run lint - docker-build: - name: Docker Build - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build and export - uses: docker/build-push-action@v6 - with: - file: docker/app/Dockerfile - context: . - load: true - tags: pilos:latest - cache-from: type=gha - cache-to: type=gha,mode=max - outputs: type=docker,dest=/tmp/pilos-image.tar - build-args: | - VITE_COVERAGE=true - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: pilos-image - path: /tmp/pilos-image.tar - - generate-frontend-matrix: - name: Generate Frontend Matrix - runs-on: ubuntu-latest - outputs: - record: ${{ steps.generate-matrix.outputs.record }} - tag: ${{ steps.generate-matrix.outputs.tag }} - matrix: ${{ steps.generate-matrix.outputs.matrix }} - frontend_group: ${{ steps.generate-matrix.outputs.frontend_group }} - visual_group: ${{ steps.generate-matrix.outputs.visual_group }} - system_group: ${{ steps.generate-matrix.outputs.system_group }} - steps: - - name: Generate matrix - id: generate-matrix - run: | - if [ ${{ (github.actor == 'dependabot[bot]' || github.event_name == 'push') && runner.debug != '1' }} = true ]; then - record=false - tag='' - frontend_group='' - visual_group='' - system_group='' - matrix='{ "containers": [1] }' - else - record=true - tag=${{ github.event_name }} - frontend_group="Frontend tests" - visual_group="Visual tests" - system_group="System tests" - matrix='{ "containers": [1,2,3,4,5] }' - fi - echo "record=$record" >> "$GITHUB_OUTPUT" - echo "tag=$tag" >> "$GITHUB_OUTPUT" - echo "frontend_group=$frontend_group" >> "$GITHUB_OUTPUT" - echo "visual_group=$visual_group" >> "$GITHUB_OUTPUT" - echo "system_group=$system_group" >> "$GITHUB_OUTPUT" - echo "matrix=$matrix" >> "$GITHUB_OUTPUT" - frontend-tests: - name: Frontend Tests - runs-on: ubuntu-latest - needs: - - docker-build - - generate-frontend-matrix - strategy: - # don't fail the entire matrix on failure - fail-fast: false - matrix: ${{ fromJson(needs.generate-frontend-matrix.outputs.matrix) }} - - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Download artifact - uses: actions/download-artifact@v4 - with: - name: pilos-image - path: /tmp - - name: Load image - run: | - docker load --input /tmp/pilos-image.tar - - - name: Copy .env - run: docker run --rm pilos:latest cat ./.env.ci > .env - - - name: Generate key - run: | - docker run --rm \ - --mount type=bind,source=${{ github.workspace }}/.env,target=/var/www/html/.env \ - --entrypoint /bin/bash \ - pilos:latest \ - -c "chown www-data:www-data .env && pilos-cli key:generate" - - - name: Adjust .env - run: | - sed -i 's/CONTAINER_IMAGE=.*/CONTAINER_IMAGE=pilos:latest/g' .env - sed -i 's|APP_URL=.*|APP_URL=http://localhost:9080|g' .env - sed -i 's|BBB_TEST_SERVER_HOST=.*|BBB_TEST_SERVER_HOST=${{ secrets.BBB_TEST_SERVER_HOST }}|g' .env - sed -i 's|BBB_TEST_SERVER_SECRET=.*|BBB_TEST_SERVER_SECRET=${{ secrets.BBB_TEST_SERVER_SECRET }}|g' .env - - - name: Start app - run: docker compose -f compose.test.yml -f compose.test.ci.yml up -d - - name: Cypress run frontend tests - uses: cypress-io/github-action@v6 - with: - wait-on: "http://localhost:9080" # Waits for above - group: ${{ needs.generate-frontend-matrix.outputs.frontend_group }} - parallel: ${{ needs.generate-frontend-matrix.outputs.record }} - record: ${{ needs.generate-frontend-matrix.outputs.record }} - tag: ${{ needs.generate-frontend-matrix.outputs.tag }} - env: - CYPRESS_PROJECT_ID: ${{ env.CYPRESS_PROJECT_ID }} - CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - COMMIT_INFO_MESSAGE: ${{ github.event.pull_request.title }} - COMMIT_INFO_SHA: ${{ github.event.pull_request.head.sha }} - APP_URL: "http://localhost:9080" - TZ: "America/New_York" - ELECTRON_EXTRA_LAUNCH_ARGS: "--lang=en" - - name: Upload screenshots - uses: actions/upload-artifact@v4 - if: always() - with: - name: cypress-screenshots - path: tests/Frontend/screenshots - - name: Upload coverage - uses: codecov/codecov-action@v5 - with: - fail_ci_if_error: true - directory: coverage - visual-tests: - name: Visual Tests - runs-on: ubuntu-latest - needs: - - docker-build - - generate-frontend-matrix - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Fetch develop branch - if: github.ref != 'refs/heads/develop' - run: git fetch origin develop:develop - - name: Download artifact - uses: actions/download-artifact@v4 - with: - name: pilos-image - path: /tmp - - name: Load image - run: | - docker load --input /tmp/pilos-image.tar - - name: Copy .env - run: docker run --rm pilos:latest cat ./.env.ci > .env - - name: Generate key - run: | - docker run --rm \ - --mount type=bind,source=${{ github.workspace }}/.env,target=/var/www/html/.env \ - --entrypoint /bin/bash \ - pilos:latest \ - -c "chown www-data:www-data .env && pilos-cli key:generate" - - - name: Adjust .env - run: | - sed -i 's/CONTAINER_IMAGE=.*/CONTAINER_IMAGE=pilos:latest/g' .env - sed -i 's|APP_URL=.*|APP_URL=http://localhost:9080|g' .env - sed -i 's|BBB_TEST_SERVER_HOST=.*|BBB_TEST_SERVER_HOST=${{ secrets.BBB_TEST_SERVER_HOST }}|g' .env - sed -i 's|BBB_TEST_SERVER_SECRET=.*|BBB_TEST_SERVER_SECRET=${{ secrets.BBB_TEST_SERVER_SECRET }}|g' .env - - - name: Start app - run: docker compose -f compose.test.yml -f compose.test.ci.yml up -d - - - name: Run cypress - uses: cypress-io/github-action@v6 - with: - command-prefix: happo-e2e -- npx - group: ${{ needs.generate-frontend-matrix.outputs.visual_group }} - record: ${{ needs.generate-frontend-matrix.outputs.record }} - tag: ${{ needs.generate-frontend-matrix.outputs.tag }} - project: ./tests/Visual - env: - NODE_OPTIONS: "--experimental-require-module" - HAPPO_API_KEY: ${{ secrets.HAPPO_API_KEY }} - HAPPO_API_SECRET: ${{ secrets.HAPPO_API_SECRET }} - HAPPO_DELETE_OLD_COMMENTS: true - BASE_BRANCH: origin/develop - CYPRESS_PROJECT_ID: ${{ env.CYPRESS_PROJECT_ID }} - CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - COMMIT_INFO_MESSAGE: ${{ github.event.pull_request.title }} - COMMIT_INFO_SHA: ${{ github.event.pull_request.head.sha }} - APP_URL: "http://localhost:9080" - TZ: "America/New_York" - ELECTRON_EXTRA_LAUNCH_ARGS: "--lang=en" - system-tests: - name: System Tests - runs-on: ubuntu-latest - needs: - - docker-build - - generate-frontend-matrix - if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }} - - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Download artifact - uses: actions/download-artifact@v4 - with: - name: pilos-image - path: /tmp - - name: Load image - run: | - docker load --input /tmp/pilos-image.tar - docker image ls -a - - - name: Copy .env - run: docker run --rm pilos:latest cat ./.env.ci > .env - - - name: Generate key - run: | - docker run --rm \ - --mount type=bind,source=${{ github.workspace }}/.env,target=/var/www/html/.env \ - --entrypoint /bin/bash \ - pilos:latest \ - -c "chown www-data:www-data .env && pilos-cli key:generate" - - - name: Adjust .env - run: | - sed -i 's/CONTAINER_IMAGE=.*/CONTAINER_IMAGE=pilos:latest/g' .env - sed -i 's|APP_URL=.*|APP_URL=http://localhost:9080|g' .env - sed -i 's|BBB_TEST_SERVER_HOST=.*|BBB_TEST_SERVER_HOST=${{ secrets.BBB_TEST_SERVER_HOST }}|g' .env - sed -i 's|BBB_TEST_SERVER_SECRET=.*|BBB_TEST_SERVER_SECRET=${{ secrets.BBB_TEST_SERVER_SECRET }}|g' .env - - - name: Start app - run: docker compose -f compose.test.yml -f compose.test.ci.yml up -d - - - name: Cypress run system tests - uses: cypress-io/github-action@v6 - with: - wait-on: "http://localhost:9080" # Waits for above - group: ${{ needs.generate-frontend-matrix.outputs.system_group }} - record: ${{ needs.generate-frontend-matrix.outputs.record }} - tag: ${{ needs.generate-frontend-matrix.outputs.tag }} - project: ./tests/System - env: - CYPRESS_PROJECT_ID: ${{ env.CYPRESS_PROJECT_ID }} - CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - COMMIT_INFO_MESSAGE: ${{ github.event.pull_request.title }} - COMMIT_INFO_SHA: ${{ github.event.pull_request.head.sha }} - APP_URL: "http://localhost:9080" - TZ: "America/New_York" - ELECTRON_EXTRA_LAUNCH_ARGS: "--lang=en" - - name: Upload screenshots - uses: actions/upload-artifact@v4 - if: always() - with: - name: cypress-screenshots - path: tests/System/screenshots diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml deleted file mode 100644 index 91aef3398..000000000 --- a/.github/workflows/deploy-docs.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Build and Deploy Docs - -on: - workflow_dispatch: - push: - branches: - - "develop" - paths: - - "docs/**" - -# Do not build the docs concurrently -concurrency: - group: docs - cancel-in-progress: true - -jobs: - build: - name: Build docs - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./docs - steps: - # Setup - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - cache-dependency-path: ./docs/package-lock.json - - name: Install dependencies - run: npm ci - - # Build static docs - - name: Build all versions - run: ./build.sh - - name: Build website - run: npm run docusaurus build - - name: upload build artifact - uses: actions/upload-pages-artifact@v3 - with: - path: ./docs/build - - deploy: - name: Deploy docs to gh-pages - needs: build - - permissions: - pages: write - id-token: write - - environment: - name: Documentation - url: ${{ steps.deployment.outputs.page_url }} - - runs-on: ubuntu-latest - steps: - - name: deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.github/workflows/pull-locales.yml b/.github/workflows/pull-locales.yml deleted file mode 100644 index 382e5ada1..000000000 --- a/.github/workflows/pull-locales.yml +++ /dev/null @@ -1,98 +0,0 @@ -name: Pull locales from POEditor - -on: - workflow_dispatch: - inputs: - poeditor_project_id: - required: true - description: "POEditor Project ID" - type: number - schedule: - - cron: "0 * * * *" - -env: - PHP_VERSION: 8.4 - -jobs: - pull-locales: - name: Pull locales from POEditor - runs-on: ubuntu-latest - - services: - mariadb: - image: mariadb:11 - ports: - - 3306 - env: - MYSQL_USER: user - MYSQL_PASSWORD: password - MYSQL_DATABASE: test - MYSQL_ROOT_PASSWORD: password - options: --health-cmd="healthcheck.sh --connect --innodb_initialized" --health-interval=5s --health-timeout=2s --health-retries=3 - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 2 - - name: Verify MariaDB connection - env: - PORT: ${{ job.services.mariadb.ports[3306] }} - run: | - while ! mysqladmin ping -h"127.0.0.1" -P"$PORT" --silent; do - sleep 1 - done - - name: Setup PHP, with composer and extensions - uses: shivammathur/setup-php@v2 #https://github.com/shivammathur/setup-php - with: - php-version: ${{ env.PHP_VERSION }} - extensions: bcmath, ctype, fileinfo, json, mbstring, dom, ldap, pdo, tokenizer, xml, mysql, sqlite - coverage: pcov - - name: Copy .env - run: php -r "copy('.env.ci', '.env');" - - name: Get Composer Cache Directory - id: composer-cache - run: | - echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - - uses: actions/cache@v4 - with: - path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} - restore-keys: | - ${{ runner.os }}-composer- - - name: Install php dependencies - run: | - composer self-update - composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist - - name: Generate key - run: php artisan key:generate - - name: Directory Permissions - run: chmod -R 777 storage bootstrap/cache - - name: Migrate Database - env: - DB_HOST: 127.0.0.1 - DB_PORT: ${{ job.services.mariadb.ports[3306] }} - DB_DATABASE: test - DB_USERNAME: root - DB_PASSWORD: password - run: php artisan migrate --no-interaction -vvv --force - - - name: Execute command to pull locales from POEditor - env: - DB_HOST: 127.0.0.1 - DB_PORT: ${{ job.services.mariadb.ports[3306] }} - DB_DATABASE: test - DB_USERNAME: root - DB_PASSWORD: password - POEDITOR_TOKEN: ${{ secrets.POEDITOR_TOKEN }} - POEDITOR_PROJECT: ${{ github.event.inputs.poeditor_project_id || secrets.POEDITOR_PROJECT}} - run: php artisan locales:import - - - name: Create Pull Request - uses: peter-evans/create-pull-request@v5 - with: - commit-message: Update locales - add-paths: "lang/**/*.php" - branch: update-locales - title: Update locales using POEditor - body: This PR was automatically created using the most recent translations from POEditor. diff --git a/.github/workflows/update-bbb-recording-player.yml b/.github/workflows/update-bbb-recording-player.yml deleted file mode 100644 index 18e67b88e..000000000 --- a/.github/workflows/update-bbb-recording-player.yml +++ /dev/null @@ -1,62 +0,0 @@ -name: BBB Recording Player Updates - -on: - schedule: - - cron: "0 0 * * *" # Run every day at midnight - -jobs: - check-for-updates: - name: Check for BBB Recording Player Updates - runs-on: ubuntu-latest - - steps: - - name: Get the latest BBB Recording Player release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - curl -s --request GET \ - --url "https://api.github.com/repos/bigbluebutton/bbb-playback/releases/latest" \ - --header "Authorization: Bearer $GH_TOKEN" \ - > player-release.json - - name: Extract the latest BBB Recording Player version - run: echo "LATEST_PLAYER_VERSION=$(jq -r '.tag_name' player-release.json | sed 's/^v//')" >> $GITHUB_ENV - - name: Extract release notes - run: | - RELEASE_NOTES=$(jq -r '.body' player-release.json) - echo "RELEASE_NOTES<> $GITHUB_ENV - echo "$RELEASE_NOTES" >> $GITHUB_ENV - echo "EOF" >> $GITHUB_ENV - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 2 - - name: Extract the current BBB Recording Player version from Docker file - run: echo "CURRENT_PLAYER_VERSION=$(sed -n 's/ARG PLAYBACK_PLAYER_VERSION=//p' docker/app/Dockerfile)" >> $GITHUB_ENV - - name: Compare the versions - run: echo "NEEDS_UPDATE=$(dpkg --compare-versions ${{ env.LATEST_PLAYER_VERSION }} gt ${{ env.CURRENT_PLAYER_VERSION }} && echo true || echo false)" >> $GITHUB_ENV - - name: Update the BBB Player version - if: ${{ env.NEEDS_UPDATE == 'true' }} - run: | - sed -i "s/ARG PLAYBACK_PLAYER_VERSION=.*/ARG PLAYBACK_PLAYER_VERSION=${LATEST_PLAYER_VERSION}/" docker/app/Dockerfile - - name: Create PR with the update - if: ${{ env.NEEDS_UPDATE == 'true' }} - uses: peter-evans/create-pull-request@v7 - with: - token: ${{ secrets.GITHUB_TOKEN }} - add-paths: "docker/app/Dockerfile" - commit-message: "Update BBB Recording Player to version ${{ env.LATEST_PLAYER_VERSION }}" - title: "Bump BBB Recording Player from ${{ env.CURRENT_PLAYER_VERSION }} to ${{ env.LATEST_PLAYER_VERSION }}" - labels: "dependencies" - body: | - Bumps BBB Recording Player from ${{ env.CURRENT_PLAYER_VERSION }} to ${{ env.LATEST_PLAYER_VERSION }} - -
- Release notes -

Sourced from bigbluebutton/bbb-playback's releases.

- - ${{ env.RELEASE_NOTES }} -
- - ## Automated Update - This PR was automatically generated by the BBB Recording Player update workflow. - branch: "update-bbb-recording-player-${{ env.LATEST_PLAYER_VERSION }}" diff --git a/.github/workflows/upload-locales.yml b/.github/workflows/upload-locales.yml deleted file mode 100644 index ff15bd4dc..000000000 --- a/.github/workflows/upload-locales.yml +++ /dev/null @@ -1,90 +0,0 @@ -name: Upload locales to POEditor - -on: - workflow_dispatch: - inputs: - poeditor_project_id: - required: true - description: "POEditor Project ID" - type: number - push: - branches: - - "develop" - -env: - PHP_VERSION: 8.4 - -jobs: - pull-locales: - name: Upload locales to POEditor - runs-on: ubuntu-latest - - services: - mariadb: - image: mariadb:11 - ports: - - 3306 - env: - MYSQL_USER: user - MYSQL_PASSWORD: password - MYSQL_DATABASE: test - MYSQL_ROOT_PASSWORD: password - options: --health-cmd="healthcheck.sh --connect --innodb_initialized" --health-interval=5s --health-timeout=2s --health-retries=3 - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 2 - - name: Verify MariaDB connection - env: - PORT: ${{ job.services.mariadb.ports[3306] }} - run: | - while ! mysqladmin ping -h"127.0.0.1" -P"$PORT" --silent; do - sleep 1 - done - - name: Setup PHP, with composer and extensions - uses: shivammathur/setup-php@v2 #https://github.com/shivammathur/setup-php - with: - php-version: ${{ env.PHP_VERSION }} - extensions: bcmath, ctype, fileinfo, json, mbstring, dom, ldap, pdo, tokenizer, xml, mysql, sqlite - coverage: pcov - - name: Copy .env - run: php -r "copy('.env.ci', '.env');" - - name: Get Composer Cache Directory - id: composer-cache - run: | - echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - - uses: actions/cache@v4 - with: - path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} - restore-keys: | - ${{ runner.os }}-composer- - - name: Install php dependencies - run: | - composer self-update - composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist - - name: Generate key - run: php artisan key:generate - - name: Directory Permissions - run: chmod -R 777 storage bootstrap/cache - - name: Migrate Database - env: - DB_HOST: 127.0.0.1 - DB_PORT: ${{ job.services.mariadb.ports[3306] }} - DB_DATABASE: test - DB_USERNAME: root - DB_PASSWORD: password - run: php artisan migrate --no-interaction -vvv --force - - - name: Execute command to push locales from POEditor - env: - DB_HOST: 127.0.0.1 - DB_PORT: ${{ job.services.mariadb.ports[3306] }} - DB_DATABASE: test - DB_USERNAME: root - DB_PASSWORD: password - POEDITOR_TOKEN: ${{ secrets.POEDITOR_TOKEN }} - POEDITOR_PROJECT: ${{ github.event.inputs.poeditor_project_id || secrets.POEDITOR_PROJECT}} - run: php artisan locales:upload diff --git a/CHANGELOG.md b/CHANGELOG.md index 4949fd88a..5dbb4805c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Support for legacy 6-digit access codes imported from Greenlight v2 ([#2433]) +### Fixed + +- Cancel and Continue buttons not immediately visible on small screens in the Start/Join Room dialog ([#2333]) ## [v4.7.0] - 2025-07-21 @@ -33,6 +36,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Show unavailable room types in create room dialog ([#2265], [#2279]) - Show unavailable room types in change room type dialog ([#2265], [#2279]) - Infinite loading when navigating back after logout redirect due to bfcache ([#2282]) +- OpenID Connect authentication ([#300], [#2281]) + +### Fixed + +- Logout session_expired warning message style ([68abce8](https://github.com/THM-Health/PILOS/commit/68abce87bcd241db3261a448cf53e430bd639e28)) ## [v4.6.1] - 2025-06-16 @@ -381,7 +389,11 @@ You can find the changelog for older versions there [here](https://github.com/TH [#31]: https://github.com/THM-Health/PILOS/issues/31 [#75]: https://github.com/THM-Health/PILOS/issues/75 +<<<<<<< HEAD [#77]: https://github.com/THM-Health/PILOS/issues/77 +======= +[#300]: https://github.com/THM-Health/PILOS/issues/300 +>>>>>>> 52b3ec2d (Adjust changelog) [#315]: https://github.com/THM-Health/PILOS/issues/315 [#372]: https://github.com/THM-Health/PILOS/issues/372 [#373]: https://github.com/THM-Health/PILOS/pull/373 @@ -529,11 +541,21 @@ You can find the changelog for older versions there [here](https://github.com/TH [#2165]: https://github.com/THM-Health/PILOS/pull/2165 [#2222]: https://github.com/THM-Health/PILOS/pull/2222 [#2223]: https://github.com/THM-Health/PILOS/pull/2223 +<<<<<<< HEAD [#2265]: https://github.com/THM-Health/PILOS/issues/2265 [#2279]: https://github.com/THM-Health/PILOS/pull/2279 [#2282]: https://github.com/THM-Health/PILOS/pull/2282 +<<<<<<< HEAD [#2433]: https://github.com/THM-Health/PILOS/pull/2433 [unreleased]: https://github.com/THM-Health/PILOS/compare/v4.7.1...develop +======= +[#2281]: https://github.com/THM-Health/PILOS/pull/2281 +[unreleased]: https://github.com/THM-Health/PILOS/compare/v4.6.1...develop +>>>>>>> 52b3ec2d (Adjust changelog) +======= +[#2333]: https://github.com/THM-Health/PILOS/pull/2333 +[unreleased]: https://github.com/THM-Health/PILOS/compare/v4.7.0...develop +>>>>>>> b8e6a263 (Update changelog) [v3.0.0]: https://github.com/THM-Health/PILOS/releases/tag/v3.0.0 [v3.0.1]: https://github.com/THM-Health/PILOS/releases/tag/v3.0.1 [v3.0.2]: https://github.com/THM-Health/PILOS/releases/tag/v3.0.2 diff --git a/app/Auth/ExternalUser.php b/app/Auth/ExternalUser.php index 304ee9358..ac213d839 100644 --- a/app/Auth/ExternalUser.php +++ b/app/Auth/ExternalUser.php @@ -114,6 +114,9 @@ public function validate() } } + /** + * @throws MissingAttributeException + */ public function syncWithEloquentModel(User $eloquentUser, array $roles): User { // Validate attributes diff --git a/app/Auth/OIDC/AccessTokenHashChecker.php b/app/Auth/OIDC/AccessTokenHashChecker.php new file mode 100644 index 000000000..6a46b75b6 --- /dev/null +++ b/app/Auth/OIDC/AccessTokenHashChecker.php @@ -0,0 +1,33 @@ +openIDConnectClient->getIdTokenHeader()['alg']; + + $bit = match ($alg) { + 'EdDSA' => '512', + default => substr($alg, 2, 3), + }; + + $len = ((int) $bit) / 16; + $expected_at_hash = $this->openIDConnectClient->base64url_encode(substr(hash('sha'.$bit, $this->openIDConnectClient->getAccessToken(), true), 0, $len)); + + if ($value !== $expected_at_hash) { + throw new InvalidClaimException('The claim "at_hash" does not match the Access Token hash value.', 'at_hash', $value); + } + } + + public function supportedClaim(): string + { + return 'at_hash'; + } +} diff --git a/app/Auth/OIDC/EventsChecker.php b/app/Auth/OIDC/EventsChecker.php new file mode 100644 index 000000000..130641262 --- /dev/null +++ b/app/Auth/OIDC/EventsChecker.php @@ -0,0 +1,31 @@ +expectedEvent)) { + throw new InvalidClaimException('The claim "events" does not contain the expected event "'.$this->expectedEvent.'".', 'events', $value); + } + + if (! is_object($value->{$this->expectedEvent}) || ! empty((array) $value->{$this->expectedEvent})) { + throw new InvalidClaimException('The claim "events" member "'.$this->expectedEvent.'" is not an empty JSON object.', 'events', $value); + } + } + + public function supportedClaim(): string + { + return 'events'; + } +} diff --git a/app/Auth/OIDC/OIDCController.php b/app/Auth/OIDC/OIDCController.php new file mode 100644 index 000000000..404e9757f --- /dev/null +++ b/app/Auth/OIDC/OIDCController.php @@ -0,0 +1,84 @@ +middleware('guest'); + } + + /** + * Redirect to the OpenID Provider for authentication with an optional redirect back to a specific URL + */ + public function redirect(Request $request) + { + try { + return $this->provider->redirect($request->query('redirect')); + } catch (OpenIDConnectNetworkException $e) { + \Log::error('OIDC login redirection failed: '.$e->getMessage()); + + return redirect('/external_login?error=openid_connect_network_exception'); + } catch (\Throwable $e) { + \Log::error('OIDC login redirection failed: '.$e->getMessage()); + + return redirect('/external_login?error=openid_connect_exception'); + } + } + + /** + * Handle Authorization Code Flow redirect back from the OpenID Provider with an Authorization Code + */ + public function callback(Request $request): RedirectResponse + { + try { + $user = $this->provider->login($request); + } catch (OpenIDConnectCodeMissingException $e) { + \Log::warning('OIDC login failed: '.$e->getMessage()); + + return redirect()->route('auth.oidc.redirect'); + } catch (MissingAttributeException $e) { + return redirect('/external_login?error=missing_attributes'); + } catch (OpenIDConnectNetworkException $e) { + \Log::error('OIDC login failed: '.$e->getMessage()); + + return redirect('/external_login?error=openid_connect_network_exception'); + } catch (\Throwable $e) { + \Log::error('OIDC login failed: '.$e->getMessage()); + + // Any other error that occurs during the login process + return redirect('/external_login?error=openid_connect_exception'); + } + + \Log::info('External user {user} has been successfully authenticated.', ['user' => $user->getLogLabel(), 'type' => 'oidc']); + + // Update the last login timestamp + $user->last_login = now(); + $user->save(); + + $url = '/external_login'; + + if ($request->session()->has('redirect_url')) { + return redirect(\Uri::of($url) + ->withQuery(['redirect' => $request->session()->get('redirect_url')]) + ->value()); + } + + return redirect($url); + } + + /** + * Handle the back-channel logout request from OpenID Provider + */ + public function logout(Request $request): Response + { + return $this->provider->backChannelLogout($request); + } +} diff --git a/app/Auth/OIDC/OIDCProvider.php b/app/Auth/OIDC/OIDCProvider.php new file mode 100644 index 000000000..b50596de6 --- /dev/null +++ b/app/Auth/OIDC/OIDCProvider.php @@ -0,0 +1,156 @@ +openIDConnectClient->getSignOutUrl(session('oidc_id_token'), $redirect); + } catch (OpenIDConnectClientException $e) { + // Expected exception when the logout URL is not available / OP does not support logout + return false; + } catch (\Throwable $e) { + \Log::error('OIDC logout failed: '.$e->getMessage()); + + return false; + } + } + + /** + * @throws OpenIDConnectNetworkException + * @throws OpenIDConnectClientException + */ + public function redirect($redirect = null) + { + if ($redirect) { + \Session::put('redirect_url', $redirect); + } + + return redirect($this->openIDConnectClient->getAuthenticationRequestUrl()); + } + + /** + * @throws RequestException + * @throws OpenIDConnectNetworkException + * @throws JsonException + * @throws OpenIDConnectProviderException + * @throws OpenIDConnectValidationException + * @throws ConnectionException + * @throws OpenIDConnectClientException + * @throws OpenIDConnectCodeMissingException + * @throws MissingAttributeException + * @throws InvalidClaimException + * @throws JsonException + * @throws MissingMandatoryClaimException + * @throws OpenIDConnectClientException + * @throws OpenIDConnectNetworkException + * @throws OpenIDConnectValidationException + */ + public function login(Request $request): User + { + if (! $this->openIDConnectClient->authenticate($request)) { + // Response is missing the code parameters + throw new OpenIDConnectCodeMissingException("Response is missing 'code' parameter."); + } + + $claims = $this->openIDConnectClient->getVerifiedClaims(); + + // Create new open-id connect user + $user_info = get_object_vars($this->openIDConnectClient->requestUserInfo()); + $oidc_user = new OIDCUser($user_info); + + // Get eloquent user (existing or new) + $user = $oidc_user->createOrFindEloquentModel('oidc'); + + // Sync attributes + $oidc_user->syncWithEloquentModel($user, config('services.oidc.mapping')->roles); + + Auth::login($user); + + $sessionData = [ + ['key' => 'oidc_sub', 'value' => $user_info['sub']], + ]; + + if (isset($claims->sid)) { + $sessionData[] = ['key' => 'oidc_sid', 'value' => $claims->sid]; + } + + session(['session_data' => $sessionData]); + + session()->put('oidc_id_token', $this->openIDConnectClient->serializeJWS($this->openIDConnectClient->getIdToken())); + + return $user; + } + + public function backChannelLogout(Request $request): Response + { + try { + $this->openIDConnectClient->verifyLogoutToken($request); + + $claims = $this->openIDConnectClient->getVerifiedClaims(); + $sub = $this->openIDConnectClient->getSubjectFromBackChannel(); + $sid = $this->openIDConnectClient->getSidFromBackChannel(); + $jti = $this->openIDConnectClient->getJtiFromBackChannel(); + + $exp = $claims->exp; + + if (Cache::has('oidc-jti-'.$jti)) { + // Token has already been used + return response('', 400) + ->header('Cache-Control', 'no-store'); + } + + // Store the JTI in cache to prevent replay attacks, until the expiration time of the token + Cache::put('oidc-jti-'.$jti, true, Carbon::createFromTimestamp($exp)); + + } catch (\Throwable $e) { + Log::error('OIDC back-channel logout failed: '.$e->getMessage()); + + return response('', 400) + ->header('Cache-Control', 'no-store'); + } + + if ($sid) { + // If sid is present, delete only the session with that sid + + $lookupSessions = SessionData::where('key', 'oidc_sid')->where('value', $sid)->get(); + } else { + // If sid is not present, delete all sessions with that sub + + $lookupSessions = SessionData::where('key', 'oidc_sub')->where('value', $sub)->get(); + } + + foreach ($lookupSessions as $lookupSession) { + $user = $lookupSession->session->user->getLogLabel(); + Log::info('Deleting session of user {user} via OIDC back-channel logout', ['user' => $user, 'type' => 'oidc']); + $lookupSession->session()->delete(); + } + + return response('', 200) + ->header('Cache-Control', 'no-store'); + } +} diff --git a/app/Auth/OIDC/OIDCServiceProvider.php b/app/Auth/OIDC/OIDCServiceProvider.php new file mode 100644 index 000000000..0503532e8 --- /dev/null +++ b/app/Auth/OIDC/OIDCServiceProvider.php @@ -0,0 +1,48 @@ +app->singleton(OIDCProvider::class, function (Application $app) { + $oidc = new OpenIDConnectClient( + config('services.oidc.issuer'), + config('services.oidc.client_id'), + config('services.oidc.client_secret'), + route('auth.oidc.callback'), + ); + + $oidc->addScope(config('services.oidc.scopes')); + $oidc->setLeeway(config('services.oidc.leeway')); + $oidc->setTimeout(config('services.oidc.timeout')); + $oidc->setCacheConfigMaxAge(config('services.oidc.cache_config_max_age')); + $oidc->setCacheJwksMaxAge(config('services.oidc.cache_jwks_max_age')); + + // Disable peer verification in only allowed in a local environment + if (! config('services.oidc.verify_peer') && $app->isLocal()) { + $oidc->setVerifyPeer(false); + } + + return new OIDCProvider($oidc); + }); + } + + /** + * Get the services provided by the provider. + * + * @return array + */ + public function provides(): array + { + return [OIDCProvider::class]; + } +} diff --git a/app/Auth/OIDC/OIDCUser.php b/app/Auth/OIDC/OIDCUser.php new file mode 100644 index 000000000..0aef54680 --- /dev/null +++ b/app/Auth/OIDC/OIDCUser.php @@ -0,0 +1,33 @@ +attributes; + + // Loop through the attribute mapping + foreach ($attributeMap as $attribute => $oidc_attribute) { + // Loop through the OIDC user attributes + foreach ($oidc_user as $attribute_name => $value) { + // If the current OIDC attribute matches the name of the OIDC attribute in the mapping, + // add values to the attributes of the user + if (strcasecmp($oidc_attribute, $attribute_name) == 0) { + // If the value is an array, add each sub-value + if (is_array($value)) { + foreach ($value as $sub_value) { + $this->addAttributeValue($attribute, $sub_value); + } + } else { + $this->addAttributeValue($attribute, $value); + } + } + } + } + } +} diff --git a/app/Auth/OIDC/OpenIDConnectAlgorithmSubset.php b/app/Auth/OIDC/OpenIDConnectAlgorithmSubset.php new file mode 100644 index 000000000..64b5022ee --- /dev/null +++ b/app/Auth/OIDC/OpenIDConnectAlgorithmSubset.php @@ -0,0 +1,9 @@ + + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +namespace App\Auth\OIDC; + +use Illuminate\Http\Client\ConnectionException; +use Illuminate\Http\Client\PendingRequest; +use Illuminate\Http\Client\RequestException; +use Illuminate\Http\Client\Response; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Http; +use Illuminate\Support\Str; +use Illuminate\Support\Uri; +use InvalidArgumentException; +use Jose\Component\Checker\AlgorithmChecker; +use Jose\Component\Checker\AudienceChecker; +use Jose\Component\Checker\ClaimCheckerManager; +use Jose\Component\Checker\ExpirationTimeChecker; +use Jose\Component\Checker\HeaderCheckerManager; +use Jose\Component\Checker\InvalidClaimException; +use Jose\Component\Checker\IsEqualChecker; +use Jose\Component\Checker\IssuedAtChecker; +use Jose\Component\Checker\MissingMandatoryClaimException; +use Jose\Component\Core\AlgorithmManagerFactory; +use Jose\Component\Core\JWK; +use Jose\Component\Core\JWKSet; +use Jose\Component\KeyManagement\JWKFactory; +use Jose\Component\Signature\Algorithm\EdDSA; +use Jose\Component\Signature\Algorithm\ES256; +use Jose\Component\Signature\Algorithm\ES384; +use Jose\Component\Signature\Algorithm\ES512; +use Jose\Component\Signature\Algorithm\HS256; +use Jose\Component\Signature\Algorithm\HS384; +use Jose\Component\Signature\Algorithm\HS512; +use Jose\Component\Signature\Algorithm\PS256; +use Jose\Component\Signature\Algorithm\PS384; +use Jose\Component\Signature\Algorithm\PS512; +use Jose\Component\Signature\Algorithm\RS256; +use Jose\Component\Signature\Algorithm\RS384; +use Jose\Component\Signature\Algorithm\RS512; +use Jose\Component\Signature\JWS; +use Jose\Component\Signature\JWSTokenSupport; +use Jose\Component\Signature\JWSVerifier; +use Jose\Component\Signature\Serializer\CompactSerializer; +use JsonException; +use Session; +use Symfony\Component\Clock\Clock; + +class OpenIDConnectClient +{ + /** + * @var bool Verify SSL peer on transactions + */ + private bool $verifyPeer = true; + + /** + * @var string if we acquire an access token it will be stored here + */ + protected string $accessToken; + + /** + * @var JWS if we acquire an id token it will be stored here + */ + protected JWS $idToken; + + /** + * @var object stores the token response + */ + private object $tokenResponse; + + /** + * @var array holds scopes + */ + private array $scopes = ['openid']; + + /** + * @var mixed holds well-known openid server properties + */ + private mixed $wellKnown = false; + + /** + * @var int timeout (seconds) + */ + protected int $timeOut = 60; + + /** + * @var int leeway (seconds) + */ + private int $leeway = 300; + + /** + * @var int fallback cache max age (seconds) for openid configuration + */ + private int $cacheConfigMaxAge = 0; + + /** + * @var int fallback cache max age (seconds) for jwks + */ + private int $cacheJwksMaxAge = 0; + + /** + * @var object holds verified jwt claims + */ + protected object $verifiedClaims; + + /** + * @var string if we acquire a sid in back-channel logout it will be stored here + */ + private ?string $backChannelSid = null; + + /** + * @var string if we acquire a sub in back-channel logout it will be stored here + */ + private ?string $backChannelSubject = null; + + /** + * @var string jti (JWT ID) of back-channel logout it will be stored here + */ + private string $backChannelJti; + + private AlgorithmManagerFactory $algorithmManagerFactory; + + private CompactSerializer $compactSerializer; + + /** + * @param string $provider_url + */ + public function __construct(private string $providerUrl, private string $clientID, private string $clientSecret, private string $redirectURL) + { + + $algorithmManagerFactory = new AlgorithmManagerFactory; + $algorithmManagerFactory->add('PS256', new PS256); + $algorithmManagerFactory->add('RS256', new RS256); + $algorithmManagerFactory->add('PS384', new PS384); + $algorithmManagerFactory->add('RS384', new RS384); + $algorithmManagerFactory->add('PS512', new PS512); + $algorithmManagerFactory->add('RS512', new RS512); + $algorithmManagerFactory->add('HS256', new HS256); + $algorithmManagerFactory->add('HS512', new HS512); + $algorithmManagerFactory->add('HS384', new HS384); + $algorithmManagerFactory->add('ES256', new ES256); + $algorithmManagerFactory->add('ES384', new ES384); + $algorithmManagerFactory->add('ES512', new ES512); + $algorithmManagerFactory->add('EdDSA', new EdDSA); + $this->algorithmManagerFactory = $algorithmManagerFactory; + + $this->compactSerializer = new CompactSerializer; + } + + /** + * Authenticate the user with the OpenID Connect provider using the authorization code + * + * @param Request $request The request object containing the authorization code and state + * @return bool Returns true if authentication is successful, false if the code is missing + * + * @throws OpenIDConnectClientException + * @throws OpenIDConnectNetworkException + * @throws ConnectionException + * @throws OpenIDConnectClientException + * @throws OpenIDConnectNetworkException + * @throws RequestException + * @throws OpenIDConnectValidationException + * @throws OpenIDConnectProviderException + * @throws JsonException + * @throws InvalidClaimException + * @throws MissingMandatoryClaimException + */ + public function authenticate(Request $request): bool + { + // Do a preemptive check to see if the provider has thrown an error from a previous redirect + if ($request->has('error')) { + $desc = $request->has('error_description') ? ' Description: '.$request->input('error_description') : ''; + throw new OpenIDConnectProviderException('Authentication Error Response: Error: '.$request->input('error').$desc); + } + + // If the authorization code is missing, the authentication has failed + // User might have called the authentication URL directly + if (! $request->has('code')) { + return false; + } + + // Check OpenID Connect session + if (! $request->has('state') || ($request->input('state') !== $this->getState())) { + throw new OpenIDConnectValidationException('Authentication Response state invalid'); + } + + // Cleanup state + $this->unsetState(); + + // Request token from the server using the code + $token_json = $this->requestTokens($request->input('code')); + + if (! property_exists($token_json, 'id_token')) { + throw new OpenIDConnectValidationException('Token Response is missing id_token'); + } + + if (! property_exists($token_json, 'token_type') || Str::lower($token_json->token_type) !== 'bearer') { + throw new OpenIDConnectValidationException('Token Response token_type is not Bearer'); + } + + if (! property_exists($token_json, 'access_token')) { + throw new OpenIDConnectValidationException('Token Response is missing access_token'); + } + + $id_token = $token_json->id_token; + + $jws = $this->unserializeJWS($id_token); + + // Verify header + $this->verifyJWSHeader($jws, OpenIDConnectAlgorithmSubset::ID_TOKEN); + + // Verify the signature + $this->verifyJWSSignature($jws); + + // Save the id token + $this->idToken = $jws; + + // Save the access token + $this->accessToken = $token_json->access_token; + + // Save the full response + $this->tokenResponse = $token_json; + + // Get claims from JWT + $claims = $this->getJWSClaims($jws); + + // Verify the claims in the id token + $this->verifyIdTokenClaims($claims); + + // Clean up the session a little + $this->unsetNonce(); + + // Save the verified claims + $this->verifiedClaims = $claims; + + // Success! + return true; + } + + /** + * Verify each claim in the id token according to the spec + * + * @throws InvalidClaimException + * @throws MissingMandatoryClaimException + * @throws OpenIDConnectClientException + * @throws OpenIDConnectNetworkException + */ + public function verifyIdTokenClaims(object $claims): void + { + $clock = new Clock; + $claimCheckerManager = new ClaimCheckerManager( + [ + new IssuedAtChecker(clock: $clock, allowedTimeDrift: $this->leeway), + new ExpirationTimeChecker(clock: $clock, allowedTimeDrift: $this->leeway), + new AudienceChecker(audience: $this->clientID), + new IsEqualChecker(key: 'nonce', value: $this->getNonce()), + new AccessTokenHashChecker($this), + new IsEqualChecker(key: 'iss', value: $this->getWellKnownConfigValue('issuer')), + ] + ); + + $claimCheckerManager->check((array) $claims, ['sub', 'aud', 'iss', 'iat', 'exp', 'nonce']); + } + + /** + * It calls the end-session endpoint of the OpenID Connect provider to notify the OpenID + * Connect provider that the end-user has logged out of the relying party site + * (the client application). + * + * @param string $idToken ID token (obtained at login) + * @param string|null $redirect URL to which the RP is requesting that the End-User's User Agent + * be redirected after a logout has been performed. The value MUST have been previously + * registered with the OP. Value can be null. + * + * @throws OpenIDConnectClientException + * @throws OpenIDConnectNetworkException + */ + public function getSignOutUrl(string $idToken, string $redirect): string + { + $sign_out_endpoint = $this->getWellKnownConfigValue('end_session_endpoint'); + + $signout_params = [ + 'id_token_hint' => $idToken, + 'post_logout_redirect_uri' => $redirect, + ]; + + return Uri::of($sign_out_endpoint)->withQuery($signout_params)->value(); + } + + /** + * Decode and then verify a logout token sent as part of + * back-channel logout flows. + * + * This function should be evaluated as a boolean check + * in your route that receives the POST request for back-channel + * logout executed from the OP. + * + * @throws OpenIDConnectClientException + * @throws InvalidArgumentException + * @throws OpenIDConnectValidationException + * @throws OpenIDConnectNetworkException + * @throws JsonException + * @throws InvalidClaimException + * @throws MissingMandatoryClaimException + */ + public function verifyLogoutToken(Request $request): void + { + // Check if the logout token is present in the request + if (! $request->has('logout_token')) { + throw new OpenIDConnectClientException('Back-channel logout: There was no logout_token in the request'); + } + + $logout_token = $request->input('logout_token'); + + $jws = $this->unserializeJWS($logout_token); + + // Verify header + // "Like ID Tokens, selection of the algorithm used is governed by the id_token_signing_alg_values_supported Discovery parameter" + $this->verifyJWSHeader($jws, OpenIDConnectAlgorithmSubset::ID_TOKEN); + + // Verify the signature + $this->verifyJWSSignature($jws); + + // Get claims from JWT + $claims = $this->getJWSClaims($jws); + + // Verify Logout Token Claims + $this->verifyLogoutTokenClaims($claims); + + $this->verifiedClaims = $claims; + + // Set the sid, which could be used to map to a session in + // the RP, and therefore be used to help destroy the RP's + // session. + if (isset($claims->sid)) { + $this->backChannelSid = $claims->sid; + } + + // Set the sub, which could be used to map to a session in + // the RP, and therefore be used to help destroy the RP's + // session. + if (isset($claims->sub)) { + $this->backChannelSubject = $claims->sub; + } + + $this->backChannelJti = $claims->jti; + } + + /** + * Verify each claim in the logout token according to the + * spec for back-channel logout. + * + * @throws InvalidClaimException + * @throws MissingMandatoryClaimException + * @throws OpenIDConnectClientException + * @throws OpenIDConnectNetworkException + */ + public function verifyLogoutTokenClaims(object $claims): void + { + $clock = new Clock; + $claimCheckerManager = new ClaimCheckerManager( + [ + new IssuedAtChecker(clock: $clock, allowedTimeDrift: $this->leeway), + new ExpirationTimeChecker(clock: $clock, allowedTimeDrift: $this->leeway), + new AudienceChecker(audience: $this->clientID), + new IsEqualChecker(key: 'iss', value: $this->getWellKnownConfigValue('issuer')), + new EventsChecker('http://schemas.openid.net/event/backchannel-logout'), + ] + ); + + $claimCheckerManager->check((array) $claims, ['aud', 'iss', 'iat', 'exp', 'events', 'jti']); + + // Verify that the Logout Token doesn't contain a nonce Claim. + if (isset($claims->nonce)) { + throw new InvalidClaimException('"nonce" is not allowed.', 'nonce', $claims->nonce); + } + + // Verify that the logout token contains a sub or sid, or both + if (! isset($claims->sid) && ! isset($claims->sub)) { + throw new MissingMandatoryClaimException('The sid or sub claim is required.', array_keys((array) $claims)); + } + } + + /** + * @param array $scope - example: given_name, etc... + */ + public function addScope(array $scope) + { + $this->scopes = array_unique(array_merge($this->scopes, $scope)); + } + + /** + * Gets anything that we need configuration wise including endpoints, and other values + * + * + * @throws OpenIDConnectClientException + * @throws OpenIDConnectNetworkException + */ + public function getWellKnownConfigValue(string $param): mixed + { + // If the configuration value is not available, attempt to fetch it from a well-known config endpoint + // This is also known as auto "discovery" + if (! $this->wellKnown) { + $well_known_config_url = Str::finish($this->providerUrl, '/').'.well-known/openid-configuration'; + + // If we have the response cached, use it + if (\Cache::has($well_known_config_url)) { + $this->wellKnown = \Cache::get($well_known_config_url); + } else { + // Try to fetch the well known configuration + try { + $response = $this->getHttpClient()->get($well_known_config_url)->throw(); + } catch (\Throwable $e) { + throw new OpenIDConnectNetworkException('Unable to fetch openid configuration: '.$e->getMessage(), $e->getCode()); + } + $maxAge = $this->cacheConfigMaxAge; + if ($response->hasHeader('Cache-Control')) { + if (preg_match('/max-age=(\d+)/i', $response->header('Cache-Control'), $matches)) { + $maxAge = (int) $matches[1] ?: $maxAge; + } + } + + if (is_object($response->object())) { + $this->wellKnown = $response->object(); + + if ($maxAge > 0) { + \Cache::put($well_known_config_url, $this->wellKnown, $maxAge); + } + } else { + throw new OpenIDConnectClientException('The well-known configuration is not a valid JSON object.'); + } + + } + } + + if (property_exists($this->wellKnown, $param)) { + return $this->wellKnown->{$param}; + } + + throw new OpenIDConnectClientException("The provider $param could not be fetched. Make sure your provider has a well known configuration available."); + } + + /** + * @throws OpenIDConnectClientException + * @throws OpenIDConnectNetworkException + * @throws JsonException + */ + public function getJwkSet(): JWKSet + { + $jwksUri = $this->getWellKnownConfigValue('jwks_uri'); + + // If we have the response cached, use it + if (\Cache::has($jwksUri)) { + $jwkSetResponse = \Cache::get($jwksUri); + } else { + // Try to fetch the jwks + try { + $response = $this->getHttpClient()->get($jwksUri)->throw(); + } catch (\Throwable $e) { + throw new OpenIDConnectNetworkException('Unable to fetch jwks: '.$e->getMessage(), $e->getCode()); + } + $maxAge = $this->cacheJwksMaxAge; + if ($response->hasHeader('Cache-Control')) { + if (preg_match('/max-age=(\d+)/i', $response->header('Cache-Control'), $matches)) { + $maxAge = (int) $matches[1] ?: $maxAge; + } + } + if ($maxAge > 0) { + \Cache::put($jwksUri, $response->body(), $maxAge); + } + + $jwkSetResponse = $response->body(); + } + + return JWKSet::createFromJson($jwkSetResponse); + } + + /** + * Create url for authorization request + * + * @throws OpenIDConnectClientException + * @throws OpenIDConnectNetworkException + */ + public function getAuthenticationRequestUrl(): string + { + $auth_endpoint = $this->getWellKnownConfigValue('authorization_endpoint'); + + // Generate and store a nonce in the session + // The nonce is an arbitrary value + $nonce = $this->setNonce(Str::random()); + + // State essentially acts as a session key for OIDC + $state = $this->setState(Str::random()); + + $auth_params = [ + 'response_type' => 'code', + 'redirect_uri' => $this->redirectURL, + 'client_id' => $this->clientID, + 'nonce' => $nonce, + 'state' => $state, + 'scope' => implode(' ', $this->scopes), + ]; + + return Uri::of($auth_endpoint)->withQuery($auth_params)->value(); + } + + /** + * Requests ID and Access tokens + * + * @param string $code authorization code + * + * @throws ConnectionException + * @throws OpenIDConnectClientException + * @throws OpenIDConnectNetworkException + * @throws RequestException + * @throws OpenIDConnectProviderException + */ + protected function requestTokens(string $code): ?object + { + $token_endpoint = $this->getWellKnownConfigValue('token_endpoint'); + + $token_params = [ + 'grant_type' => 'authorization_code', + 'code' => $code, + 'redirect_uri' => $this->redirectURL, + ]; + + // Using client_secret_basic authentication + try { + $response = $this->getHttpClient() + ->withBasicAuth(urlencode($this->clientID), urlencode($this->clientSecret)) + ->asForm() + ->post($token_endpoint, $token_params); + } catch (\Throwable $e) { + throw new OpenIDConnectNetworkException('Unable to fetch tokens '.$e->getMessage(), $e->getCode()); + } + + try { + $this->tokenResponse = $response->throw()->object(); + } catch (\Throwable $e) { + throw new OpenIDConnectProviderException('Token Error Response '.$e->getMessage(), $e->getCode()); + } + + return $this->tokenResponse; + } + + private function getJWK(string $alg, string $key): JWK + { + return JWKFactory::createFromSecret( + $key, + [ + 'alg' => $alg, + 'use' => 'sig', + ] + ); + } + + /** + * Returns the claims from a JWS object + */ + public function getJWSClaims(JWS $jws): object + { + return json_decode($jws->getPayload()); + } + + /** + * Verifies the JWS signature of a JWS object + * + * @param JWS $jws The JWS object to verify + * + * @throws OpenIDConnectClientException + * @throws InvalidArgumentException + * @throws OpenIDConnectValidationException + * @throws OpenIDConnectNetworkException + * @throws JsonException + */ + public function verifyJWSSignature(JWS $jws): void + { + $signature = $jws->getSignature(0); + $alg = $signature->getProtectedHeaderParameter('alg'); + + $algorithmManager = $this->algorithmManagerFactory->create([$alg]); + $jwsVerifier = new JWSVerifier($algorithmManager); + + switch ($alg) { + case 'PS256': + case 'RS256': + case 'PS384': + case 'RS384': + case 'PS512': + case 'RS512': + case 'ES256': + case 'ES384': + case 'ES512': + case 'EdDSA': + + if ($signature->hasProtectedHeaderParameter('jwk')) { + throw new OpenIDConnectClientException('Self signed JWK header is not valid'); + } else { + $jwkSet = $this->getJwkSet(); + + $restrictions = []; + if ($signature->hasProtectedHeaderParameter('kid')) { + $restrictions['kid'] = $signature->getProtectedHeaderParameter('kid'); + } + + $jwk = $jwkSet->selectKey('sig', $algorithmManager->get($algorithmManager->list()[0]), $restrictions); + } + break; + case 'HS256': + case 'HS512': + case 'HS384': + $jwk = $this->getJWK($alg, $this->clientSecret); + break; + default: + throw new OpenIDConnectClientException('Unsupported signature algorithm: '.$alg); + } + + if ($jwk === null) { + throw new OpenIDConnectClientException('Unable to find JWK for algorithm: '.$alg); + } + + if (! $jwsVerifier->verifyWithKey($jws, $jwk, 0)) { + throw new OpenIDConnectValidationException('JWS signature invalid'); + } + } + + /** + * Verifies the JWS header of a JWS object + * + * @throws OpenIDConnectValidationException + * @throws OpenIDConnectNetworkException + */ + public function verifyJWSHeader(JWS $jws, OpenIDConnectAlgorithmSubset $algSubset): void + { + $jwsTokenSupport = new JWSTokenSupport; + $rpSupportedAlgs = $this->algorithmManagerFactory->aliases(); + + $supportedAlgs = []; + + try { + $opSupportedAlgs = $this->getWellKnownConfigValue($algSubset->value); + $supportedAlgs = array_intersect($opSupportedAlgs, $rpSupportedAlgs); + } catch (OpenIDConnectClientException $e) { + // Discovery document doesn't provide a list of supported signing algorithms + // we will use all algorithms that we support + $supportedAlgs = $rpSupportedAlgs; + } + + try { + new HeaderCheckerManager( + [ + new AlgorithmChecker($supportedAlgs), + ], + [ + $jwsTokenSupport, + ] + )->check($jws, 0, ['alg']); + } catch (\Throwable $e) { + throw new OpenIDConnectValidationException('Error verifying JWS header: '.$e->getMessage(), $e->getCode(), $e); + } + } + + /** + * Unserializes a JWS string into a JWS object + * + * @throws InvalidArgumentException + */ + public function unserializeJWS(string $jws): JWS + { + return $this->compactSerializer->unserialize($jws); + } + + /** + * Serializes a JWS object into a JWS string + */ + public function serializeJWS(JWS $jws): string + { + return $this->compactSerializer->serialize($jws); + } + + /** + * Request claims about the End-User from UserInfo Endpoint + * + * + * @throws InvalidClaimException + * @throws JsonException + * @throws MissingMandatoryClaimException + * @throws OpenIDConnectClientException + * @throws OpenIDConnectNetworkException + * @throws OpenIDConnectValidationException + * @throws OpenIDConnectProviderException + */ + public function requestUserInfo(): object + { + $user_info_endpoint = $this->getWellKnownConfigValue('userinfo_endpoint'); + + // The accessToken has to be sent in the Authorization header. + // Accept json to indicate response type + try { + $response = $this->getHttpClient()->acceptJson()->withToken($this->accessToken)->get($user_info_endpoint); + } catch (\Throwable $e) { + throw new OpenIDConnectNetworkException('Unable to retrieve user data: '.$e->getMessage(), $e->getCode()); + } + + try { + $body = $response->throw(); + } catch (\Throwable $e) { + throw new OpenIDConnectProviderException('UserInfo Error Response: '.$e->getMessage(), $e->getCode()); + } + + return $this->getClaimsFromUserInfoResponse($body); + } + + /** + * @throws OpenIDConnectValidationException + * @throws OpenIDConnectClientException + * @throws OpenIDConnectNetworkException + * @throws JsonException + * @throws MissingMandatoryClaimException + * @throws InvalidClaimException + */ + private function getClaimsFromUserInfoResponse(Response $response): object + { + // When we receive application/jwt, the UserInfo Response is signed and/or encrypted. + + /* + * The UserInfo Endpoint MUST return a content-type header to indicate which format is being returned. + * The content-type of the HTTP response MUST be application/json if the response body is a text JSON object; the response body SHOULD be encoded using UTF-8. + * + * If the UserInfo Response is signed and/or encrypted, then the Claims are returned in a JWT and the content-type MUST be application/jwt. + * The response MAY be encrypted without also being signed. + * If both signing and encryption are requested, the response MUST be signed then encrypted, with the result being a Nested JWT, as defined in [JWT]. + */ + + // Extract the content type from the response (remove optional charset) + $contentTypeHeader = $response->getHeader('Content-Type'); + if (empty($contentTypeHeader)) { + throw new OpenIDConnectClientException('User data response is missing Content-Type header'); + } + + $contentType = explode(';', $contentTypeHeader[0])[0]; + + if ($contentType === 'application/jwt') { + return $this->getClaimsFromSignedUserInfoResponse($response->body()); + + } else { + return $this->getClaimsFromUnsignedUserInfoResponse($response->body()); + } + } + + /** + * @throws JsonException + * @throws MissingMandatoryClaimException + * @throws InvalidClaimException + */ + private function getClaimsFromUnsignedUserInfoResponse($content): object + { + $claims = json_decode($content, flags: JSON_THROW_ON_ERROR); + + /* + * The sub (subject) Claim MUST always be returned in the UserInfo Response. + * NOTE: Due to the possibility of token substitution attacks (see Section 16.11), the UserInfo Response is not guaranteed to be about the End-User identified by the sub (subject) element of the ID Token. + * The sub Claim in the UserInfo Response MUST be verified to exactly match the sub Claim in the ID Token; if they do not match, the UserInfo Response values MUST NOT be used. + */ + + new ClaimCheckerManager( + [ + new IsEqualChecker(key: 'sub', value: $this->getIdTokenPayload()->sub), + ] + )->check((array) $claims, ['sub']); + + return $claims; + } + + /** + * @throws OpenIDConnectValidationException + * @throws OpenIDConnectClientException + * @throws OpenIDConnectNetworkException + * @throws JsonException + * @throws InvalidArgumentException + * @throws MissingMandatoryClaimException + * @throws InvalidClaimException + */ + private function getClaimsFromSignedUserInfoResponse(string $jwt): object + { + $jws = $this->unserializeJWS($jwt); + + // Verify header + $this->verifyJWSHeader($jws, OpenIDConnectAlgorithmSubset::USERINFO); + + // Verify the signature + $this->verifyJWSSignature($jws); + + // Get claims from JWT + $claims = $this->getJWSClaims($jws); + + /* + * The sub (subject) Claim MUST always be returned in the UserInfo Response. + * NOTE: Due to the possibility of token substitution attacks (see Section 16.11), the UserInfo Response is not guaranteed to be about the End-User identified by the sub (subject) element of the ID Token. + * The sub Claim in the UserInfo Response MUST be verified to exactly match the sub Claim in the ID Token; if they do not match, the UserInfo Response values MUST NOT be used. + * + * If signed, the UserInfo Response MUST contain the Claims iss (issuer) and aud (audience) as members. + * The iss value MUST be the OP's Issuer Identifier URL. The aud value MUST be or include the RP's Client ID value. + */ + + new ClaimCheckerManager( + [ + new AudienceChecker($this->clientID), + new IsEqualChecker(key: 'iss', value: $this->getWellKnownConfigValue('issuer')), + new IsEqualChecker(key: 'sub', value: $this->getIdTokenPayload()->sub), + ] + )->check((array) $claims, ['sub', 'aud', 'iss']); + + return $claims; + } + + public function getVerifiedClaims(): object + { + return $this->verifiedClaims; + } + + protected function getHttpClient(): PendingRequest + { + $client = Http::timeout($this->timeOut); + + if (! $this->verifyPeer) { + $client = $client->withoutVerifying(); + } + + return $client; + } + + /** + * @return string|null + */ + public function getAccessToken() + { + return $this->accessToken; + } + + /** + * @return JWS|null + */ + public function getIdToken() + { + return $this->idToken; + } + + /** + * @return array + */ + public function getIdTokenHeader() + { + return $this->getIdToken()->getSignature(0)->getProtectedHeader(); + } + + /** + * @return object + */ + public function getIdTokenPayload() + { + return $this->getJWSClaims($this->getIdToken()); + } + + /** + * Stores nonce + */ + protected function setNonce(string $nonce): string + { + Session::put('openid_connect_nonce', $nonce); + + return $nonce; + } + + /** + * Get stored nonce + * + * @return string + */ + protected function getNonce() + { + return Session::get('openid_connect_nonce', false); + } + + /** + * Cleanup nonce + * + * @return void + */ + protected function unsetNonce() + { + Session::remove('openid_connect_nonce'); + } + + /** + * Stores $state + */ + protected function setState(string $state): string + { + Session::put('openid_connect_state', $state); + + return $state; + } + + /** + * Get stored state + * + * @return string + */ + protected function getState() + { + return Session::get('openid_connect_state', false); + } + + /** + * Cleanup state + * + * @return void + */ + protected function unsetState() + { + Session::remove('openid_connect_state'); + } + + /** + * Set timeout (seconds) + */ + public function setTimeout(int $timeout) + { + $this->timeOut = $timeout; + } + + public function setLeeway(int $leeway) + { + $this->leeway = $leeway; + } + + public function setVerifyPeer(bool $verifyPeer): void + { + $this->verifyPeer = $verifyPeer; + } + + public function setCacheJwksMaxAge(int $cacheJwksMaxAge): void + { + $this->cacheJwksMaxAge = $cacheJwksMaxAge; + } + + public function setCacheConfigMaxAge(int $cacheConfigMaxAge): void + { + $this->cacheConfigMaxAge = $cacheConfigMaxAge; + } + + public function getSidFromBackChannel(): ?string + { + return $this->backChannelSid; + } + + public function getSubjectFromBackChannel(): ?string + { + return $this->backChannelSubject; + } + + public function getJtiFromBackChannel(): string + { + return $this->backChannelJti; + } + + public function base64url_encode($data) + { + // Convert Base64 to Base64URL by replacing "+" with "-" and "/" with "_" and remove tailing "=" if any + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } +} diff --git a/app/Auth/OIDC/OpenIDConnectClientException.php b/app/Auth/OIDC/OpenIDConnectClientException.php new file mode 100644 index 000000000..8ba817343 --- /dev/null +++ b/app/Auth/OIDC/OpenIDConnectClientException.php @@ -0,0 +1,5 @@ +table('rooms')->where('deleted', false)->get(['id', 'uid', 'user_id', 'name', 'room_settings', 'access_code']); $sharedAccesses = DB::connection('greenlight')->table('shared_accesses')->get(['room_id', 'user_id']); - $availableAuthenticators = ['shibboleth']; + $availableAuthenticators = ['shibboleth', 'oidc']; $socialProviders = DB::connection('greenlight')->table('users')->select('provider')->whereNotIn('provider', ['greenlight', 'ldap'])->distinct()->get(); $providerAuthenticatorMap = []; diff --git a/app/Http/Controllers/api/v1/auth/LoginController.php b/app/Http/Controllers/api/v1/auth/LoginController.php index 29e3c2308..935791d89 100644 --- a/app/Http/Controllers/api/v1/auth/LoginController.php +++ b/app/Http/Controllers/api/v1/auth/LoginController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\api\v1\auth; +use App\Auth\OIDC\OIDCProvider; use App\Auth\Shibboleth\ShibbolethProvider; use App\Http\Controllers\Controller; use App\Prometheus\Counter; @@ -70,12 +71,19 @@ public function logout(Request $request) { // Redirect url after logout $redirect = false; + // Message to display on logout, e.g. incomplete logout + $message = null; // Logout from external authentication provider switch (\Auth::user()->authenticator) { case 'shibboleth': $redirect = app(ShibbolethProvider::class)->logout(url('/logout')); - + break; + case 'oidc': + $redirect = app(OIDCProvider::class)->logout(url('/logout')); + if (! $redirect) { + $message = 'oidc_incomplete'; + } break; } @@ -84,6 +92,7 @@ public function logout(Request $request) return response()->json([ 'redirect' => $redirect, + 'message' => $message, ]); } } diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php index 08344dda3..f617a8b0b 100644 --- a/app/Http/Middleware/VerifyCsrfToken.php +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -13,5 +13,6 @@ class VerifyCsrfToken extends Middleware */ protected $except = [ 'auth/shibboleth/logout', + 'auth/oidc/logout', ]; } diff --git a/app/Http/Resources/Config.php b/app/Http/Resources/Config.php index 7cd2e6c7e..e539a143e 100644 --- a/app/Http/Resources/Config.php +++ b/app/Http/Resources/Config.php @@ -107,6 +107,7 @@ public function toArray($request) 'local' => config('auth.local.enabled'), 'ldap' => config('ldap.enabled'), 'shibboleth' => config('services.shibboleth.enabled'), + 'oidc' => config('services.oidc.enabled'), ], ]; } diff --git a/app/Models/Room.php b/app/Models/Room.php index 987ef3549..9cb2620c6 100644 --- a/app/Models/Room.php +++ b/app/Models/Room.php @@ -135,6 +135,7 @@ protected function casts() 'lobby' => [ 'cast' => RoomLobby::class, 'expert' => true, + 'only' => [RoomLobby::ENABLED, RoomLobby::DISABLED], ], 'visibility' => [ 'cast' => RoomVisibility::class, @@ -356,7 +357,7 @@ public function getRole(?User $user, ?RoomToken $token): RoomUserRole return $token->role; } - return RoomUserRole::GUEST; + return $this->getRoomSetting('default_role'); } if ($this->owner->is($user) || $user->can('rooms.manage')) { diff --git a/composer.json b/composer.json index 463985865..9ce0cb57b 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,8 @@ "spatie/laravel-ignition": "^2.0", "spatie/laravel-settings": "^3.3", "symfony/var-exporter": "^7.0", - "ext-redis": "*" + "ext-redis": "*", + "web-token/jwt-framework": "^4.0" }, "require-dev": { "barryvdh/laravel-ide-helper": "^3.0", diff --git a/composer.lock b/composer.lock index 41788ed2f..1707e0a74 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "dc99a7e9dd4a98aad3fd7c11e20f2b30", + "content-hash": "0c1389e9346d23e7196d7db4865419ef", "packages": [ { "name": "brick/math", @@ -5940,6 +5940,115 @@ ], "time": "2025-01-13T13:04:43+00:00" }, + { + "name": "spomky-labs/pki-framework", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/pki-framework.git", + "reference": "eced5b5ce70518b983ff2be486e902bbd15135ae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/eced5b5ce70518b983ff2be486e902bbd15135ae", + "reference": "eced5b5ce70518b983ff2be486e902bbd15135ae", + "shasum": "" + }, + "require": { + "brick/math": "^0.10|^0.11|^0.12|^0.13", + "ext-mbstring": "*", + "php": ">=8.1" + }, + "require-dev": { + "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0", + "ext-gmp": "*", + "ext-openssl": "*", + "infection/infection": "^0.28|^0.29", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpstan/extension-installer": "^1.3|^2.0", + "phpstan/phpstan": "^1.8|^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", + "phpstan/phpstan-phpunit": "^1.1|^2.0", + "phpstan/phpstan-strict-rules": "^1.3|^2.0", + "phpunit/phpunit": "^10.1|^11.0|^12.0", + "rector/rector": "^1.0|^2.0", + "roave/security-advisories": "dev-latest", + "symfony/string": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0", + "symplify/easy-coding-standard": "^12.0" + }, + "suggest": { + "ext-bcmath": "For better performance (or GMP)", + "ext-gmp": "For better performance (or BCMath)", + "ext-openssl": "For OpenSSL based cyphering" + }, + "type": "library", + "autoload": { + "psr-4": { + "SpomkyLabs\\Pki\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joni Eskelinen", + "email": "jonieske@gmail.com", + "role": "Original developer" + }, + { + "name": "Florent Morselli", + "email": "florent.morselli@spomky-labs.com", + "role": "Spomky-Labs PKI Framework developer" + } + ], + "description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.", + "homepage": "https://github.com/spomky-labs/pki-framework", + "keywords": [ + "DER", + "Private Key", + "ac", + "algorithm identifier", + "asn.1", + "asn1", + "attribute certificate", + "certificate", + "certification request", + "cryptography", + "csr", + "decrypt", + "ec", + "encrypt", + "pem", + "pkcs", + "public key", + "rsa", + "sign", + "signature", + "verify", + "x.509", + "x.690", + "x509", + "x690" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/pki-framework/issues", + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.3.0" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2025-06-13T08:35:04+00:00" + }, { "name": "symfony/clock", "version": "v7.3.0", @@ -6014,6 +6123,81 @@ ], "time": "2024-09-25T14:21:43+00:00" }, + { + "name": "symfony/config", + "version": "v7.2.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/config.git", + "reference": "e0b050b83ba999aa77a3736cb6d5b206d65b9d0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/config/zipball/e0b050b83ba999aa77a3736cb6d5b206d65b9d0d", + "reference": "e0b050b83ba999aa77a3736cb6d5b206d65b9d0d", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/filesystem": "^7.1", + "symfony/polyfill-ctype": "~1.8" + }, + "conflict": { + "symfony/finder": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "require-dev": { + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/finder": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Config\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/config/tree/v7.2.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-04-03T21:14:15+00:00" + }, { "name": "symfony/console", "version": "v7.3.3", @@ -6177,6 +6361,86 @@ ], "time": "2024-09-25T14:21:43+00:00" }, + { + "name": "symfony/dependency-injection", + "version": "v7.3.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/dependency-injection.git", + "reference": "8656c4848b48784c4bb8c4ae50d2b43f832cead8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/8656c4848b48784c4bb8c4ae50d2b43f832cead8", + "reference": "8656c4848b48784c4bb8c4ae50d2b43f832cead8", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^3.5", + "symfony/var-exporter": "^6.4.20|^7.2.5" + }, + "conflict": { + "ext-psr": "<1.1|>=2", + "symfony/config": "<6.4", + "symfony/finder": "<6.4", + "symfony/yaml": "<6.4" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "symfony/service-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "symfony/config": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/yaml": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DependencyInjection\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows you to standardize and centralize the way objects are constructed in your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dependency-injection/tree/v7.3.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-24T04:04:43+00:00" + }, { "name": "symfony/deprecation-contracts", "version": "v3.6.0", @@ -6467,7 +6731,137 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v7.2.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "b8dce482de9d7c9fe2891155035a7248ab5c7fdb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/b8dce482de9d7c9fe2891155035a7248ab5c7fdb", + "reference": "b8dce482de9d7c9fe2891155035a7248ab5c7fdb", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v7.2.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-10-25T15:15:23+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.3.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/2a6614966ba1074fa93dae0bc804227422df4dfe", + "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.3.2" }, "funding": [ { @@ -6478,40 +6872,50 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2025-07-15T13:41:35+00:00" }, { - "name": "symfony/finder", - "version": "v7.3.2", + "name": "symfony/http-client-contracts", + "version": "v3.6.0", "source": { "type": "git", - "url": "https://github.com/symfony/finder.git", - "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe" + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "75d7043853a42837e68111812f4d964b01e5101c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/2a6614966ba1074fa93dae0bc804227422df4dfe", - "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c", + "reference": "75d7043853a42837e68111812f4d964b01e5101c", "shasum": "" }, "require": { - "php": ">=8.2" - }, - "require-dev": { - "symfony/filesystem": "^6.4|^7.0" + "php": ">=8.1" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, "autoload": { "psr-4": { - "Symfony\\Component\\Finder\\": "" + "Symfony\\Contracts\\HttpClient\\": "" }, "exclude-from-classmap": [ - "/Tests/" + "/Test/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -6520,18 +6924,26 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Finds files and directories via an intuitive fluent interface", + "description": "Generic abstractions related to HTTP clients", "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], "support": { - "source": "https://github.com/symfony/finder/tree/v7.3.2" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0" }, "funding": [ { @@ -6542,16 +6954,12 @@ "url": "https://github.com/fabpot", "type": "github" }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-07-15T13:41:35+00:00" + "time": "2025-04-29T11:18:49+00:00" }, { "name": "symfony/http-foundation", @@ -8712,6 +9120,142 @@ ], "time": "2024-11-21T01:49:47+00:00" }, + { + "name": "web-token/jwt-framework", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/web-token/jwt-framework.git", + "reference": "82cd173980cc98f72e6cdcaf00ebcbf4111f3d84" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-token/jwt-framework/zipball/82cd173980cc98f72e6cdcaf00ebcbf4111f3d84", + "reference": "82cd173980cc98f72e6cdcaf00ebcbf4111f3d84", + "shasum": "" + }, + "require": { + "brick/math": "^0.12 || ^0.13", + "ext-json": "*", + "ext-openssl": "*", + "php": ">=8.2", + "psr/clock": "^1.0", + "psr/event-dispatcher": "^1.0", + "spomky-labs/pki-framework": "^1.2.1", + "symfony/config": "^7.0", + "symfony/console": "^7.0", + "symfony/dependency-injection": "^7.0", + "symfony/event-dispatcher": "^7.0", + "symfony/http-client-contracts": "^3.4", + "symfony/http-kernel": "^7.0" + }, + "conflict": { + "spomky-labs/jose": "*" + }, + "replace": { + "web-token/jwt-bundle": "self.version", + "web-token/jwt-experimental": "self.version", + "web-token/jwt-library": "self.version" + }, + "require-dev": { + "ekino/phpstan-banned-code": "^2.0|^3.0", + "ergebnis/phpunit-slow-test-detector": "^2.14", + "ext-curl": "*", + "ext-gmp": "*", + "ext-sodium": "*", + "infection/infection": "^0.29", + "matthiasnoback/symfony-config-test": "5.1.x-dev", + "paragonie/sodium_compat": "^1.20|^2.0", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.3|^2.0", + "phpstan/phpstan": "^1.8|^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", + "phpstan/phpstan-doctrine": "^1.3|^2.0", + "phpstan/phpstan-phpunit": "^1.1|^2.0", + "phpstan/phpstan-strict-rules": "^1.4|^2.0", + "phpstan/phpstan-symfony": "^1.3|^2.0", + "phpunit/phpunit": "^10.5.10|^11.0", + "qossmic/deptrac": "^2.0", + "rector/rector": "^1.0|^2.0", + "roave/security-advisories": "dev-latest", + "spomky-labs/aes-key-wrap": "^7.0", + "staabm/phpstan-dba": "^0.2.79|^0.3", + "staabm/phpstan-todo-by": "^0.1.25|^0.2", + "struggle-for-php/sfp-phpstan-psr-log": "^0.20|^0.21|^0.22|^0.23", + "symfony/browser-kit": "^7.0", + "symfony/clock": "^7.0", + "symfony/finder": "^7.0", + "symfony/framework-bundle": "^7.0", + "symfony/http-client": "^7.0", + "symfony/serializer": "^7.0", + "symfony/var-dumper": "^7.0", + "symfony/yaml": "^7.0", + "symplify/easy-coding-standard": "^12.0" + }, + "suggest": { + "spomky-labs/aes-key-wrap": "To enable AES Key Wrap algorithm.", + "symfony/serializer": "Use the Symfony serializer to serialize/unserialize JWS and JWE tokens.", + "symfony/var-dumper": "Used to show data on the debug toolbar." + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Jose\\Component\\": "src/Library/", + "Jose\\Experimental\\": "src/Experimental/", + "Jose\\Bundle\\JoseFramework\\": "src/Bundle/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-token/jwt-bundle/contributors" + } + ], + "description": "JSON Object Signing and Encryption library for PHP and Symfony Bundle.", + "homepage": "https://github.com/web-token/jwt-framework", + "keywords": [ + "JOSE", + "JWE", + "JWK", + "JWKSet", + "JWS", + "Jot", + "RFC7515", + "RFC7516", + "RFC7517", + "RFC7518", + "RFC7519", + "RFC7520", + "bundle", + "jwa", + "jwt", + "symfony" + ], + "support": { + "issues": "https://github.com/web-token/jwt-framework/issues", + "source": "https://github.com/web-token/jwt-framework/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2025-03-12T11:25:35+00:00" + }, { "name": "webmozart/assert", "version": "1.11.0", @@ -11147,147 +11691,6 @@ ], "time": "2024-10-20T05:08:20+00:00" }, - { - "name": "symfony/config", - "version": "v7.2.6", - "source": { - "type": "git", - "url": "https://github.com/symfony/config.git", - "reference": "e0b050b83ba999aa77a3736cb6d5b206d65b9d0d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/e0b050b83ba999aa77a3736cb6d5b206d65b9d0d", - "reference": "e0b050b83ba999aa77a3736cb6d5b206d65b9d0d", - "shasum": "" - }, - "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/filesystem": "^7.1", - "symfony/polyfill-ctype": "~1.8" - }, - "conflict": { - "symfony/finder": "<6.4", - "symfony/service-contracts": "<2.5" - }, - "require-dev": { - "symfony/event-dispatcher": "^6.4|^7.0", - "symfony/finder": "^6.4|^7.0", - "symfony/messenger": "^6.4|^7.0", - "symfony/service-contracts": "^2.5|^3", - "symfony/yaml": "^6.4|^7.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Config\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/config/tree/v7.2.6" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2025-04-03T21:14:15+00:00" - }, - { - "name": "symfony/filesystem", - "version": "v7.2.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/filesystem.git", - "reference": "b8dce482de9d7c9fe2891155035a7248ab5c7fdb" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/b8dce482de9d7c9fe2891155035a7248ab5c7fdb", - "reference": "b8dce482de9d7c9fe2891155035a7248ab5c7fdb", - "shasum": "" - }, - "require": { - "php": ">=8.2", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-mbstring": "~1.8" - }, - "require-dev": { - "symfony/process": "^6.4|^7.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Filesystem\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides basic utilities for the filesystem", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.2.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-10-25T15:15:23+00:00" - }, { "name": "symfony/stopwatch", "version": "v7.2.4", diff --git a/config/app.php b/config/app.php index c06eb170e..5f76dcd33 100644 --- a/config/app.php +++ b/config/app.php @@ -240,6 +240,7 @@ App\Auth\LDAP\LDAPServiceProvider::class, App\Auth\Shibboleth\ShibbolethServiceProvider::class, + App\Auth\OIDC\OIDCServiceProvider::class, App\Providers\TranslationServiceProvider::class, diff --git a/config/services.php b/config/services.php index 697364a5a..2b7eb63d6 100644 --- a/config/services.php +++ b/config/services.php @@ -1,6 +1,7 @@ (bool) env('SHIBBOLETH_SESSION_CHECK_ENABLED', true), 'logout' => env('SHIBBOLETH_LOGOUT_URL', '/Shibboleth.sso/Logout'), ], + + 'oidc' => [ + 'enabled' => $oidcEnabled, + 'issuer' => env('OIDC_ISSUER'), + 'client_id' => env('OIDC_CLIENT_ID'), + 'client_secret' => env('OIDC_CLIENT_SECRET'), + 'scopes' => explode(',', env('OIDC_SCOPES', 'profile,email')), + 'leeway' => (int) env('OIDC_LEEWAY', 300), + 'timeout' => (int) env('OIDC_TIMEOUT', 10), + 'verify_peer' => (bool) env('OIDC_VERIFY_PEER', true), + 'cache_config_max_age' => (int) env('OIDC_CACHE_CONFIG_MAX_AGE', 0), + 'cache_jwks_max_age' => (int) env('OIDC_CACHE_JWKS_MAX_AGE', 0), + 'mapping' => $oidcEnabled ? json_decode(file_get_contents(app_path('Auth/config/oidc_mapping.json'))) : null, + ], ]; diff --git a/docs/docs/administration/08-advanced/01-external-authentication.md b/docs/docs/administration/08-advanced/01-external-authentication.md index 1e680536f..90984b10a 100644 --- a/docs/docs/administration/08-advanced/01-external-authentication.md +++ b/docs/docs/administration/08-advanced/01-external-authentication.md @@ -1,6 +1,6 @@ --- title: External Authentication -description: Guide how to connect PILOS to external authentication systems like LDAP and Shibboleth +description: Guide how to connect PILOS to external authentication systems like LDAP, Shibboleth and OpenID Connect. --- ## Introduction @@ -11,7 +11,7 @@ PILOS has two types of users: Local users can be created by administrators. They can log in to the system with the combination of email address and password. Via PILOS, an email can be sent to the user upon creation, also a password reset function can be activated. **External users** -In large environments it is impractical to manage all users in PILOS. Therefore PILOS can be connected to external authentication systems. LDAP and Shibboleth are available as interfaces. All authentication providers can be operated in parallel, but none of them more than once. +In large environments it is impractical to manage all users in PILOS. Therefore PILOS can be connected to external authentication systems. LDAP, Shibboleth and OpenID-Connect are available as interfaces. All three authentication providers can be operated in parallel, but none of them more than once. ## Setup of external authenticators @@ -126,15 +126,82 @@ To disable the shibboleth session check you can set the following `.env` variabl SHIBBOLETH_SESSION_CHECK_ENABLED=false ``` +### OpenID Connect + +This application uses the **Authorization Code Flow** for OpenID Connect authentication. + +#### Key Requirements + +- ##### Client Registration + + Manual registration with your OpenID Connect Provider is required. Dynamic registration is **not supported**. + +- ##### Client Authentication + + The application authenticates with the token endpoint using the `client_secret_basic` method. + +- ##### Supported Signing Algorithms + - RS256, RS384, RS512 + - PS256, PS384, PS512 + - ES256, ES384, ES512 + - EdDSA + - HS256, HS384, HS512 + +- ##### Limitations + - Encrypted ID token responses are **not supported**. + - **Back-channel logout** is **recommended**, especially in shared environments (e.g., computer labs), to prevent immediate re-login after logout. + - **Front-channel logout** and **OpenID Connect session management** are **not supported**. + +#### Configure OpenID Connect Provider + +Register PILOS as a new client with your OpenID Connect Provider using these settings: + +- **Authentication flow**: Authorization Code Flow +- **Client authentication**: `client_secret_basic` +- **Client ID and client secret**: _Generated by the OpenID Connect Provider or set application URL_ +- **Client secret**: _Generated by the OpenID Connect Provider or use a secure random string_ +- **Redirect URI:** `https://your-domain.com/auth/oidc/callback` +- **Post-Logout Redirect URI:** `https://your-domain.com/logout` +- **Back-Channel Logout URI:** `https://your-domain.com/auth/oidc/logout` + +#### Configure PILOS + +To enable OpenID Connect, you need to add/set the following options in the .env file and adjust to your needs. + +```bash +# OpenID Connect Configuration +OIDC_ENABLED=true +OIDC_ISSUER=http://idp.university.org +OIDC_CLIENT_ID=my_client_id +OIDC_CLIENT_SECRET=my_client_secret +OIDC_SCOPES="profile,email" +``` + +#### Configuration Options + +| Option | Default Value | Description | +| --------------------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `OIDC_ENABLED` | `false` | Enable OpenID Connect authentication. | +| `OIDC_ISSUER` | `null` | The issuer URL of the OpenID Connect Provider. | +| `OIDC_CLIENT_ID` | `null` | The client ID for the OpenID Connect application. | +| `OIDC_CLIENT_SECRET` | `null` | The client secret for the OpenID Connect application. | +| `OIDC_SCOPES` | `profile,email` | The scopes (e.g., `profile,email`) to request from the OpenID Connect Provider as a comma-separated list. The `openid` scope is always included automatically. | +| `OIDC_LEEWAY` | `300` | The leeway in seconds for validating the ID token's expiration time. | +| `OIDC_TIMEOUT` | `10` | The timeout in seconds for the OpenID Connect requests. | +| `OIDC_VERIFY_PEER` | `true` | Whether to verify the SSL certificate of the OpenID Connect Provider. This can only be disabled in local env. | +| `OIDC_CACHE_CONFIG_MAX_AGE` | `0` | Overwrite openid-configuration cache duration if OpenID Connect Provider doesn't set a max-age cache duration > 0 | +| `OIDC_CACHE_JWKS_MAX_AGE` | `0` | Overwrite jwks cache duration if OpenID Connect Provider doesn't set a max-age cache duration > 0 | + ## Configure mapping For each external authenticator the attribute and role mapping needs to be configured. The mapping is defined in a JSON file, which is stored in the directory `app/Auth/config` of the pilos installation. -| Authenticator | Filename | -| ------------- | ----------------------- | -| LDAP | ldap_mapping.json | -| Shibboleth | shibboleth_mapping.json | +| Authenticator | Filename | +| -------------- | ----------------------- | +| LDAP | ldap_mapping.json | +| Shibboleth | shibboleth_mapping.json | +| OpenID Connect | oidc_mapping.json | ### Attribute mapping @@ -219,13 +286,13 @@ If the `all` attribute is also `true`: Check that regular expression doesn't mat ### Examples -### LDAP +#### LDAP -#### Attributes +##### Attributes In this example the LDAP schema uses the common name (CN) as username and has the group memberships in the memberof attribute. -#### Roles +##### Roles - The "superuser" role is assigned to any user whose email ends with @its.university.org and who is in the "cn=admin,ou=Groups,dc=uni,dc=org" group. @@ -270,13 +337,13 @@ In this example the LDAP schema uses the common name (CN) as username and has th } ``` -### Shibboleth +#### Shibboleth -#### Attributes +##### Attributes The attribute names are the header names in which the attribute values are send by the apache mod_shib to the application. -#### Roles +##### Roles - The "superuser" role is assigned to any user whose email ends with @its.university.org and who has the "staff" affiliation. @@ -320,3 +387,37 @@ The attribute names are the header names in which the attribute values are send ] } ``` + +#### OpenID Connect + +##### Attributes + +In this example the OpenID Connect provider returns the claim `preferred_username` which contains the username and an additional claim `roles` with an array of roles. + +##### Roles + +- The "superuser" role is assigned to any user who has the "pilos-superuser" role. + +```json +{ + "attributes": { + "external_id": "preferred_username", + "first_name": "given_name", + "last_name": "family_name", + "email": "email", + "roles": "roles" + }, + "roles": [ + { + "name": "superuser", + "disabled": false, + "rules": [ + { + "attribute": "roles", + "regex": "/^pilos-superuser$/im" + } + ] + } + ] +} +``` diff --git a/lang/en/admin.php b/lang/en/admin.php index ee80c91f7..5d4761dc4 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -441,6 +441,7 @@ 'authenticator' => [ 'ldap' => 'LDAP', 'local' => 'Local', + 'oidc' => 'OIDC', 'shibboleth' => 'Shibboleth', 'title' => 'Authentication Type', ], diff --git a/lang/en/auth.php b/lang/en/auth.php index ed420cb8f..db642a8a5 100644 --- a/lang/en/auth.php +++ b/lang/en/auth.php @@ -13,8 +13,11 @@ 'error' => [ 'login_failed' => 'Login failed', 'missing_attributes' => 'Attributes for authentication are missing.', + 'openid_connect_exception' => 'Authentication failed due to an error.', + 'openid_connect_network_exception' => 'Failed to connect to the authentication provider.', 'reason' => 'Error reason', - 'shibboleth_session_duplicate_exception' => 'The Shibboleth session is already in use. Please log in again.', + 'shibboleth_session_duplicate_exception' => 'The Shibboleth session is already in use.', + 'try_again' => 'Please try logging in again or contact support if the problem persists.', ], 'failed' => 'These credentials do not match our records.', 'flash' => [ @@ -37,6 +40,12 @@ 'logout_success' => 'Successfully logged out', 'new_password' => 'New password', 'new_password_confirmation' => 'New password confirmation', + 'oidc' => [ + 'redirect' => 'Log in', + 'tab_title' => 'OpenID Connect', + 'title' => 'Log in with OpenID Connect', + 'logout_incomplete' => 'You are still logged in at the OpenID Connect provider.', + ], 'password' => 'Password', 'reset_password' => 'Reset password', 'send_email_confirm_mail' => 'A verification email has been sent to :email. Please confirm the new email address by clicking on the link in the email.', diff --git a/lang/en/rooms.php b/lang/en/rooms.php index 02bc70d77..22246e077 100644 --- a/lang/en/rooms.php +++ b/lang/en/rooms.php @@ -177,8 +177,12 @@ 'invalid_personal_link' => 'This personalised room link is invalid.', 'invitation' => [ 'code' => 'Access code', - 'copied' => 'Copied access information to clipboard', - 'copy' => 'Copy', + 'copied_message' => 'Copied invitation message to clipboard', + 'copied_url' => 'Copied room link to clipboard', + 'copied_code' => 'Copied access code to clipboard', + 'copy_message' => 'Copy invitation message', + 'copy_url' => 'Copy room link', + 'copy_code' => 'Copy access code', 'link' => 'Link', 'room' => 'Join ":roomname" with :platform', 'share' => 'Share', diff --git a/resources/js/components/LoginTabExternal.vue b/resources/js/components/LoginTabExternal.vue index 8272d6510..b282a4c70 100644 --- a/resources/js/components/LoginTabExternal.vue +++ b/resources/js/components/LoginTabExternal.vue @@ -1,5 +1,5 @@