diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 0b981122b6..53af08c54b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -26,7 +26,7 @@ body: **Troubleshooting** - Please read our [Troubleshooting wiki page](https://github.com/ubuntu/authd/wiki/06--Troubleshooting) + Please read our [Troubleshooting wiki page](https://documentation.ubuntu.com/authd/stable-docs/reference/troubleshooting/#common-issues-and-limitations) and see if you can find a solution to your problem there. - type: checkboxes attributes: @@ -92,12 +92,17 @@ body: #### Logs \`\`\` - $(sudo journalctl -o short-monotonic --lines 500 _SYSTEMD_UNIT=authd.service \+ UNIT=authd.service \+ \ + $(sudo journalctl -o short-monotonic --lines 500 \ + _SYSTEMD_UNIT=authd.service \+ UNIT=authd.service \+ \ _SYSTEMD_UNIT=snap.authd-msentraid.authd-msentraid.service \+ UNIT=snap.authd-msentraid.authd-msentraid.service \+ SYSLOG_IDENTIFIER=authd-msentraid \+ \ _SYSTEMD_UNIT=snap.authd-google.authd-google.service \+ UNIT=snap.authd-google.authd-google.service \+ SYSLOG_IDENTIFIER=authd-google \+ \ - '_CMDLINE="gdm-session-worker [pam/gdm-authd]"' | sed -E -e 's/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}//g' \ + _SYSTEMD_UNIT=snap.authd-oidc.authd-oidc.service \+ UNIT=snap.authd-oidc.authd-oidc.service \+ SYSLOG_IDENTIFIER=authd-oidc \+ \ + _COMM=authd-pam \+ \ + '_CMDLINE="gdm-session-worker [pam/gdm-authd]"' \ + | sed -E -e 's/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}//g' \ -e 's/GOCSPX-[0-9a-zA-Z_-]+//g' \ - -e 's/[0-9a-zA-Z_-]+\.apps\.googleusercontent\.com//g') + -e 's/[0-9a-zA-Z_-]+\.apps\.googleusercontent\.com//g' \ + -e 's/(client_secret = )\S+.*/\1/g') \`\`\` #### authd apt history @@ -106,7 +111,7 @@ body: \`\`\` #### authd broker configuration - $(sudo sh -c 'if ! find /etc/authd/brokers.d -name \*.conf | grep -q .; then echo ":warning: No config files in /etc/authd/brokers.d/"; else for f in /etc/authd/brokers.d/*.conf; do echo "#### $f"; echo "\`\`\`"; cat $f; echo "\`\`\`"; done; fi') + $(sudo sh -c 'if ! find /etc/authd/brokers.d -name \*.conf | grep -q .; then echo ":warning: No config files in /etc/authd/brokers.d/"; else for f in /etc/authd/brokers.d/*.conf; do echo "#### $f"; echo "\`\`\`"; cat $f; echo "\`\`\`"; done; fi' | sed -E 's/client_secret = .*/client_secret = /g') #### authd-msentraid configuration \`\`\` diff --git a/.github/actions/e2e-tests/set-up-ssh-keys/action.yml b/.github/actions/e2e-tests/set-up-ssh-keys/action.yml new file mode 100644 index 0000000000..830a3fce6d --- /dev/null +++ b/.github/actions/e2e-tests/set-up-ssh-keys/action.yml @@ -0,0 +1,42 @@ +name: Set up SSH keys for VM provisioning +description: "Sets up SSH keys for VM provisioning" +inputs: + E2E_VM_SSH_PRIV_KEY: + description: "Private SSH key for VM provisioning" + required: true + E2E_VM_SSH_PUB_KEY: + description: "Public SSH key for VM provisioning" + required: true +outputs: + keys-path: + description: "Path to the SSH keys" + value: "${{ steps.set-ssh-keys.outputs.keys-path }}" +runs: + using: "composite" + steps: + - name: Set GitHub Path + run: echo "$GITHUB_ACTION_PATH" >> $GITHUB_PATH + shell: bash + env: + GITHUB_ACTION_PATH: ${{ github.action_path }} + + - name: Set up SSH keys for the VM provisioning + id: set-ssh-keys + shell: bash + run: | + set -eu + + mkdir -p "${HOME}/.ssh" + chmod 700 "${HOME}/.ssh" + + echo "${{ inputs.E2E_VM_SSH_PRIV_KEY }}" > "${HOME}/.ssh/id_rsa" + chmod 600 "${HOME}/.ssh/id_rsa" + + echo "${{ inputs.E2E_VM_SSH_PUB_KEY }}" > "${HOME}/.ssh/id_rsa.pub" + chmod 644 "${HOME}/.ssh/id_rsa.pub" + + eval "$(ssh-agent -s)" + + ssh-add "${HOME}/.ssh/id_rsa" + + echo "keys-path=${HOME}/.ssh" >> "$GITHUB_OUTPUT" diff --git a/.github/actions/generate-coverage-report/action.yaml b/.github/actions/generate-coverage-report/action.yaml new file mode 100644 index 0000000000..5536b4caae --- /dev/null +++ b/.github/actions/generate-coverage-report/action.yaml @@ -0,0 +1,49 @@ +name: 'Generate and upload coverage report' +description: 'Generates and uploads the coverage report for Go tests' +inputs: + codecov-token: + description: 'Codecov token for uploading coverage' + required: true +runs: + using: 'composite' + steps: + - name: Generate coverage report + shell: bash + run: | + # Generate coverage report + set -eu + + tmp_dir=$(mktemp -d) + + # Convert the raw coverage data into textfmt so we can merge the Rust one into it + go tool covdata textfmt -i="${RAW_COVERAGE_DIR}" -o="${tmp_dir}/coverage.out" + + # Append the Rust coverage data to the Go one + cat "${RAW_COVERAGE_DIR}/rust-cov/rust2go_coverage" >>"${tmp_dir}/coverage.out" + + # Filter out the testutils package and the pb.go file + grep -v -e "testutils" -e "pb.go" -e "testsdetection" "${tmp_dir}/coverage.out" >"${tmp_dir}/coverage.out.filtered" + + # Generate the Cobertura report for Go and Rust + gocov convert "${tmp_dir}/coverage.out.filtered" | gocov-xml > "${tmp_dir}/coverage.xml" + reportgenerator -reports:"${tmp_dir}/coverage.xml" -targetdir:"${tmp_dir}" -reporttypes:Cobertura + + # Generate the Cobertura report for C + gcovr --cobertura "${tmp_dir}/Cobertura_C.xml" "${RAW_COVERAGE_DIR}" + + # Merge Cobertura reports into a single one + reportgenerator -reports:"${tmp_dir}/Cobertura.xml;${tmp_dir}/Cobertura_C.xml" \ + -targetdir:"${COVERAGE_DIR}" -reporttypes:Cobertura + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + directory: ${{ env.COVERAGE_DIR }} + files: ${{ env.COVERAGE_DIR }}/Cobertura.xml + token: ${{ inputs.codecov-token }} + + - name: Upload coverage artifacts + uses: actions/upload-artifact@v5 + with: + name: coverage + path: ${{ env.COVERAGE_DIR }} diff --git a/.github/actions/install-debug-symbols/action.yaml b/.github/actions/install-debug-symbols/action.yaml new file mode 100644 index 0000000000..e441266304 --- /dev/null +++ b/.github/actions/install-debug-symbols/action.yaml @@ -0,0 +1,30 @@ +name: 'Install debug symbols for glibc, PAM and GLib' +description: 'Installs debug symbols for glibc, PAM and GLib for better stack traces' +runs: + using: 'composite' + steps: + - uses: canonical/desktop-engineering/gh-actions/common/dpkg-install-speedup@main + + - name: Install glibc, PAM and GLib debug symbols + shell: bash + run: | + # Install glibc, PAM and GLib debug symbols + set -eu + + sudo apt-get install -y ubuntu-dbgsym-keyring libc6-dbg + echo "deb http://ddebs.ubuntu.com $(lsb_release -cs) main restricted universe multiverse + deb http://ddebs.ubuntu.com $(lsb_release -cs)-updates main restricted universe multiverse + deb http://ddebs.ubuntu.com $(lsb_release -cs)-proposed main restricted universe multiverse" | \ + sudo tee -a /etc/apt/sources.list.d/ddebs.list + # Sometimes ddebs archive is stuck, so in case of failure we need to go manual + sudo apt-get update -y || true + if ! sudo apt-get install -y libpam-modules-dbgsym libpam0*-dbgsym libglib2.0-0*-dbgsym; then + sudo apt-get install -y ubuntu-dev-tools + for pkg in pam glib2.0; do + pull-lp-debs "${pkg}" $(lsb_release -cs) + pull-lp-ddebs "${pkg}" $(lsb_release -cs) + done + sudo apt-get install -y ./libpam0*.*deb ./libpam-modules*.*deb ./libglib2.0-0*-dbgsym*.ddeb + sudo apt-get remove -y ubuntu-dev-tools + sudo apt-get autoremove -y + fi diff --git a/.github/actions/setup-go-coverage-tests/action.yaml b/.github/actions/setup-go-coverage-tests/action.yaml new file mode 100644 index 0000000000..4f18fc0036 --- /dev/null +++ b/.github/actions/setup-go-coverage-tests/action.yaml @@ -0,0 +1,26 @@ +name: 'Setup Go Tests with Coverage Analysis' +description: 'Additional setup steps for Go tests with coverage analysis' +runs: + using: 'composite' + steps: + - name: Install grcov + uses: baptiste0928/cargo-install@v3 + with: + crate: grcov + + - name: Install coverage collection dependencies + shell: bash + run: | + # Install coverage collection dependencies + set -eu + + echo "::group::Install coverage collection dependencies" + + # Dependendencies for C coverage collection + sudo apt-get install -y gcovr + + # Dependendencies for Go coverage collection + go install github.com/AlekSi/gocov-xml@latest + go install github.com/adombeck/gocov/gocov@latest + dotnet tool install -g dotnet-reportgenerator-globaltool + echo "::endgroup::" diff --git a/.github/actions/setup-go-tests/action.yaml b/.github/actions/setup-go-tests/action.yaml new file mode 100644 index 0000000000..30703cf781 --- /dev/null +++ b/.github/actions/setup-go-tests/action.yaml @@ -0,0 +1,66 @@ +name: 'Setup Go Tests' +description: 'Common setup steps for Go tests' +runs: + using: 'composite' + steps: + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - uses: canonical/desktop-engineering/gh-actions/common/dpkg-install-speedup@main + + - name: Install dependencies + shell: bash + run: | + # Install dependencies + set -eu + echo "::group::Install dependencies" + + sudo apt-get update + + # Disable the php8.3-fpm.service which is enabled by default on ubuntu-latest-runner + # and is restarted when installing the dependencies but times out after 90s. + sudo systemctl mask --now php8.3-fpm.service || true + + # The integration tests build the NSS crate, so we need the cargo build dependencies in order to run them. + sudo apt-get install -y protobuf-compiler + + sudo apt-get install -y ${{ env.go_build_dependencies }} ${{ env.go_test_dependencies}} + + echo "::endgroup::" + + - name: Install gotestfmt and our wrapper script + uses: canonical/desktop-engineering/gh-actions/go/gotestfmt@main + + - name: Install VHS and ttyd for integration tests + shell: bash + run: | + # Install VHS and ttyd for integration tests + set -eu + echo "::group::Install VHS and ttyd" + go install github.com/charmbracelet/vhs@latest + + # VHS requires ttyd >= 1.7.2 to work properly. + wget https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.x86_64 + chmod +x ttyd.x86_64 + sudo mv ttyd.x86_64 /usr/bin/ttyd + + # VHS doesn't really use ffmpeg anymore now, but it still checks for it. + # Drop this when https://github.com/charmbracelet/vhs/pull/591 is released. + sudo ln -s /usr/bin/true /usr/local/bin/ffmpeg + echo "::endgroup::" + + - name: Install latest Rust version + shell: bash + run: rustup update stable + + - name: Prepare tests artifacts path + shell: bash + run: | + # Prepare tests artifacts path + set -eu + + artifacts_dir=$(mktemp -d --tmpdir authd-test-artifacts-XXXXXX) + echo AUTHD_TESTS_ARTIFACTS_PATH="${artifacts_dir}" >> $GITHUB_ENV + + echo ASAN_OPTIONS="log_path=${artifacts_dir}/asan.log:print_stats=true" >> $GITHUB_ENV diff --git a/.github/actions/setup-oras/action.yaml b/.github/actions/setup-oras/action.yaml new file mode 100644 index 0000000000..486736486b --- /dev/null +++ b/.github/actions/setup-oras/action.yaml @@ -0,0 +1,26 @@ +name: 'Setup oras' +description: 'Setup oras CLI tool for pushing and pulling OCI artifacts.' +inputs: + version: + description: 'Version of oras to install' + required: false + default: '1.3.0' +runs: + using: 'composite' + steps: + - name: Install oras + shell: bash + run: | + # Install oras CLI + set -eu + + VERSION="${{ inputs.version }}" + FILENAME="oras_${VERSION}_linux_$(dpkg --print-architecture).tar.gz" + curl -fsSL \ + -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ + -o "${FILENAME}" \ + "https://github.com/oras-project/oras/releases/download/v${VERSION}/${FILENAME}" + mkdir -p oras-install/ + tar -zxf "${FILENAME}" -C oras-install/ + sudo mv oras-install/oras /usr/local/bin/ + rm -rf "${FILENAME}" oras-install/ diff --git a/.github/actions/upload-test-artifacts/action.yaml b/.github/actions/upload-test-artifacts/action.yaml new file mode 100644 index 0000000000..323da5150e --- /dev/null +++ b/.github/actions/upload-test-artifacts/action.yaml @@ -0,0 +1,10 @@ +name: 'Upload test artifacts' +description: 'Uploads test artifacts' +runs: + using: 'composite' + steps: + - name: Upload test artifacts + uses: actions/upload-artifact@v5 + with: + name: authd-${{ github.job }}-artifacts-${{ github.run_attempt }} + path: ${{ env.AUTHD_TESTS_ARTIFACTS_PATH }} diff --git a/.github/dependabot.yml b/.github/dependabot.yml index cdee4301af..add6e487b1 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -20,7 +20,7 @@ updates: commit-message: prefix: "deps(ci)" - # Codebase + # authd codebase ## Go dependencies - package-ecosystem: "gomod" directory: "/" # Location of package manifests @@ -61,3 +61,40 @@ updates: update-types: ["minor", "patch"] commit-message: prefix: "deps(rust)" + + ## git submodules + - package-ecosystem: "gitsubmodule" + directory: "/" # Directory containing .gitmodules + schedule: + interval: "weekly" + commit-message: + prefix: "deps(submodule)" + + # authd-oidc-brokers codebase + ## Go dependencies + - package-ecosystem: "gomod" + directory: "/authd-oidc-brokers" # Location of package manifests + schedule: + interval: "weekly" + groups: + minor-updates: + update-types: [ "minor", "patch" ] + commit-message: + prefix: "deps(brokers/go)" + + - package-ecosystem: "gomod" + directory: "/authd-oidc-brokers/tools" + schedule: + interval: "weekly" + groups: + minor-updates: + update-types: [ "minor", "patch" ] + commit-message: + prefix: "deps(brokers/go-tools)" + ## rust-toolchain.toml + - package-ecosystem: "cargo" + directory: "/authd-oidc-brokers" + schedule: + interval: "weekly" + commit-message: + prefix: "deps(brokers/rust)" diff --git a/.github/scripts/compute-deb-build-hash.sh b/.github/scripts/compute-deb-build-hash.sh new file mode 100755 index 0000000000..06bea16e00 --- /dev/null +++ b/.github/scripts/compute-deb-build-hash.sh @@ -0,0 +1,41 @@ +#!/bin/bash + +set -euo pipefail + +# Add all files in the repo, except for those that we know are not relevant for +# the build of the Debian package. +relevant_files=$(git ls-files | grep -v \ + -e '^\.github/' \ + -e '^\.gitignore$' \ + -e '^\.gitmodules$' \ + -e '^\.golangci\.yaml$' \ + -e '\.md$' \ + -e '^COPYING' \ + -e '^authd-oidc-brokers/' \ + -e '^docs/' \ + -e '^e2e-tests/' \ + -e '^examplebroker/' \ + -e '^gotestcov$' \ + -e '^snap/' \ + -e '_test\.go$' \ + -e '/testdata/' \ + -e '/testutils/') + +# We excluded the .github directory, so we need to add the workflow file back, +# since it is relevant for the build of the Debian package. +relevant_files="${relevant_files}"$'\n'".github/workflows/debian-build.yaml" + +# Sort the files +relevant_files=$(echo "${relevant_files}" | sort -u) + +# Print the files for debugging purposes. +echo >&2 "Build-relevant files: ${relevant_files}" + +hash=$(echo "${relevant_files}" | + grep -e '.' | + xargs git hash-object | sha256sum | cut -d' ' -f1) + +# Print the hash for debugging purposes. +echo >&2 "Hash: ${hash}" + +echo "${hash}" diff --git a/.github/workflows/auto-updates.yaml b/.github/workflows/auto-updates.yaml index f7fd9ff2a7..9884b71e71 100644 --- a/.github/workflows/auto-updates.yaml +++ b/.github/workflows/auto-updates.yaml @@ -1,9 +1,10 @@ -name: Update translations and Rust packaging related files in main +name: Update Rust packaging related files in main on: push: branches: - main - paths-ignore: + paths: + - 'Cargo.lock' - 'debian/control' concurrency: auto-update @@ -40,20 +41,20 @@ jobs: run: | set -euo pipefail - apt update -y - apt install -y dh-cargo git + apt-get update -y + apt-get install -y dh-cargo git if [ "${{ matrix.ubuntu-version }}" = "noble" ]; then # Special behavior on noble as dh-cargo is not new enough there - apt install -y libssl-dev pkg-config + apt-get install -y libssl-dev pkg-config cargo install --locked --root=/usr \ cargo-vendor-filterer@${{ env.CARGO_VENDOR_FILTERER_NOBLE_VERSION }} else - apt install -y cargo-vendor-filterer + apt-get install -y cargo-vendor-filterer fi - name: Checkout the code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ matrix.branch }} @@ -71,14 +72,16 @@ jobs: run: | set -euo pipefail + echo "Running dh-cargo-vendored-sources" VENDORED_SOURCES=$(/usr/share/cargo/bin/dh-cargo-vendored-sources 2>&1) \ || cmd_status=$? + echo "${VENDORED_SOURCES}" + OUTPUT=$(echo "$VENDORED_SOURCES" | grep ^XS-Vendored-Sources-Rust: || true) if [ -z "${OUTPUT}" ]; then if [ "${cmd_status:-0}" -ne 0 ]; then # dh-cargo-vendored-sources failed because of other reason, so let's fail with it! - echo "dh-cargo-vendored-sources failed:" - echo "${VENDORED_SOURCES}" + echo "dh-cargo-vendored-sources failed unexpectedly (exit code ${cmd_status})" exit "${cmd_status}" fi @@ -92,7 +95,7 @@ jobs: - name: Create Pull Request if: ${{ env.modified == 'true' }} - uses: peter-evans/create-pull-request@v7 + uses: peter-evans/create-pull-request@v8 with: commit-message: Auto update packaging related Rust files title: | diff --git a/.github/workflows/automatic-doc-checks.yml b/.github/workflows/automatic-doc-checks.yaml similarity index 74% rename from .github/workflows/automatic-doc-checks.yml rename to .github/workflows/automatic-doc-checks.yaml index 9f44eead32..766c005cfc 100644 --- a/.github/workflows/automatic-doc-checks.yml +++ b/.github/workflows/automatic-doc-checks.yaml @@ -4,13 +4,11 @@ on: push: branches: [main] paths: - - '.github/workflows/automatic-doc-checks.yml' - - '.readthedocs.yaml' + - '.github/workflows/automatic-doc-checks.yaml' - 'docs/**' pull_request: paths: - - '.github/workflows/automatic-doc-checks.yml' - - '.readthedocs.yaml' + - '.github/workflows/automatic-doc-checks.yaml' - 'docs/**' schedule: - cron: '0 12 * * MON' diff --git a/.github/workflows/brokers-qa.yaml b/.github/workflows/brokers-qa.yaml new file mode 100644 index 0000000000..63e49ea5a1 --- /dev/null +++ b/.github/workflows/brokers-qa.yaml @@ -0,0 +1,206 @@ +name: Brokers QA & sanity checks +on: + push: + branches: + - main + tags: + - "*" + paths: + - ".github/workflows/brokers-qa.yaml" + - "authd-oidc-brokers/**" + - "!authd-oidc-brokers/e2e-tests/**" + - "!authd-oidc-brokers/po/**" + - "!authd-oidc-brokers/.gitignore" + + pull_request: + paths: + - ".github/workflows/brokers-qa.yaml" + - "authd-oidc-brokers/**" + - "!authd-oidc-brokers/e2e-tests/**" + - "!authd-oidc-brokers/po/**" + - "!authd-oidc-brokers/.gitignore" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + working-directory: ./authd-oidc-brokers + +jobs: + go-sanity: + name: "Go: Code sanity" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Build libhimmelblau + # The code sanity check fails if himmelblau.h does not exist, so we generate it first. + run: go generate --tags withmsentraid ./internal/providers/msentraid/... + + - name: Go code sanity check + uses: canonical/desktop-engineering/gh-actions/go/code-sanity@v2 + with: + golangci-lint-configfile: ".golangci.yaml" + working-directory: "./authd-oidc-brokers" + tools-directory: "./authd-oidc-brokers/tools" + go-tags: "withmsentraid" + + shell-sanity: + name: "Shell: Code sanity" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Run ShellCheck + uses: ludeeus/action-shellcheck@master + with: + scandir: "./authd-oidc-brokers" + + go-tests: + name: "Go: Tests" + runs-on: ubuntu-24.04 # ubuntu-latest-runner + strategy: + fail-fast: false + matrix: + test: [ "coverage", "asan" ] + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - uses: canonical/desktop-engineering/gh-actions/common/dpkg-install-speedup@main + - name: Install dependencies + run: | + set -eu + sudo apt-get update + sudo apt-get install -y git-delta + + - name: Install coverage collection dependencies + if: matrix.test == 'coverage' + run: | + set -eu + + go install github.com/AlekSi/gocov-xml@latest + go install github.com/adombeck/gocov/gocov@latest + dotnet tool install -g dotnet-reportgenerator-globaltool + + - name: Build libhimmelblau + run: go generate --tags withmsentraid ./internal/providers/msentraid/... + + - name: Prepare tests artifacts path + run: | + set -eu + + artifacts_dir=$(mktemp -d --tmpdir authd-test-artifacts-XXXXXX) + echo AUTHD_TEST_ARTIFACTS_DIR="${artifacts_dir}" >> $GITHUB_ENV + + - name: Install gotestfmt and our wrapper script + uses: canonical/desktop-engineering/gh-actions/go/gotestfmt@main + + - name: Run tests (with coverage collection) + if: matrix.test == 'coverage' + run: | + set -euo pipefail + + # The coverage is not written if the output directory does not exist, so we need to create it. + cov_dir=${PWD}/coverage + raw_cov_dir=${cov_dir}/raw_files + codecov_dir=${cov_dir}/codecov + + mkdir -p "${raw_cov_dir}" "${codecov_dir}" + + # Print executed commands to ease debugging + set -x + + # Overriding the default coverage directory is not an exported flag of go test (yet), so + # we need to override it using the test.gocoverdir flag instead. + #TODO: Update when https://go-review.googlesource.com/c/go/+/456595 is merged. + go test -tags withmsentraid -json -cover -covermode=set ./... -shuffle=on -args -test.gocoverdir="${raw_cov_dir}" 2>&1 | \ + gotestfmt --logfile "${AUTHD_TEST_ARTIFACTS_DIR}/gotestfmt.cover.log" + + # Convert the raw coverage data into textfmt so we can merge the Rust one into it + go tool covdata textfmt -i="${raw_cov_dir}" -o="${cov_dir}/coverage.out" + + # Filter out the testutils package + grep -v -e "testutils" "${cov_dir}/coverage.out" >"${cov_dir}/coverage.out.filtered" + + # Generate the Cobertura report for Go + gocov convert "${cov_dir}/coverage.out.filtered" | gocov-xml > "${cov_dir}/coverage.xml" + reportgenerator -reports:"${cov_dir}/coverage.xml" -targetdir:"${codecov_dir}" -reporttypes:Cobertura + + # Store the coverage directory for the next steps + echo COVERAGE_DIR="${codecov_dir}" >> ${GITHUB_ENV} + + - name: Run msentraid tests (with Address Sanitizer) + if: matrix.test == 'asan' + env: + # Do not optimize, keep debug symbols and frame pointer for better + # stack trace information in case of ASAN errors. + CGO_CFLAGS: "-O0 -g3 -fno-omit-frame-pointer" + GO_TESTS_TIMEOUT: 30m + # Use these flags to give ASAN a better time to unwind the stack trace + GO_GC_FLAGS: -N -l + run: | + # Print executed commands to ease debugging + set -x + + # For llvm-symbolizer + sudo apt-get install -y llvm + + # We only run the msentraid tests with ASAN because only these use cgo. + pushd ./internal/providers/msentraid + go test -asan -gcflags=all="${GO_GC_FLAGS}" -c + go tool test2json -p internal/providers/msentraid ./msentraid.test \ + -test.v=test2json \ + -test.failfast \ + -test.timeout ${GO_TESTS_TIMEOUT} | \ + gotestfmt --logfile "${AUTHD_TEST_ARTIFACTS_DIR}/gotestfmt.asan.log" || \ + exit_code=$? + popd + + # We don't need the xtrace output after this point + set +x + + # We're logging to a file, and this is useful for having artifacts, but we still may want to see it in logs: + for f in "${AUTHD_TEST_ARTIFACTS_DIR}"/*asan.log*; do + if ! [ -e "${f}" ]; then + continue + fi + if [ -s "${f}" ]; then + echo "::group::${f} ($(wc -l < "${f}") lines)" + cat "${f}" + echo "::endgroup::" + else + echo "${f}: empty" + fi + done + + exit ${exit_code} + + - name: Upload coverage to Codecov + if: matrix.test == 'coverage' + uses: codecov/codecov-action@v5 + with: + directory: ${{ env.COVERAGE_DIR }} + files: ${{ env.COVERAGE_DIR }}/Cobertura.xml + token: ${{ secrets.CODECOV_TOKEN }} + + - name: Upload coverage report as artifact + if: matrix.test == 'coverage' && github.ref == 'refs/heads/main' + uses: actions/upload-artifact@v7 + with: + name: coverage + path: ${{ env.COVERAGE_DIR }} + + - name: Upload test artifacts + if: failure() + uses: actions/upload-artifact@v7 + with: + name: authd-${{ github.job }}-artifacts-${{ github.run_attempt }} + path: ${{ env.AUTHD_TEST_ARTIFACTS_DIR }} diff --git a/.github/workflows/build-broker-snap.yaml b/.github/workflows/build-broker-snap.yaml new file mode 100644 index 0000000000..646ed33cf2 --- /dev/null +++ b/.github/workflows/build-broker-snap.yaml @@ -0,0 +1,92 @@ +name: build-broker-snap (reusable) + +on: + workflow_call: + inputs: + broker: + description: 'Name of the broker snap to build' + required: true + type: string + +jobs: + build-broker-snap: + name: Build broker snap (${{ inputs.broker }}) + runs-on: ubuntu-latest + permissions: + # Needed by actions/checkout + contents: read + # Needed to push/pull OCI artifact to/from ghcr.io + packages: write + steps: + - uses: actions/checkout@v6 + with: + # The authd-msentraid broker requires the libhimmelblau submodule. + submodules: recursive + + - name: Compute hash of build-relevant files + id: files-hash + run: | + set -euo pipefail + + hash=${{ hashFiles('authd-oidc-brokers/**', 'snap/**') }} + + # Print the hash for debugging purposes. + echo "Hash: ${hash}" + + echo "hash=${hash}" >> "${GITHUB_OUTPUT}" + + - uses: ./.github/actions/setup-oras + + - name: Try to restore broker snap from OCI registry cache + id: restore-cache + run: | + set -euo pipefail + + echo "${{ secrets.GITHUB_TOKEN }}" \ + | oras login ghcr.io -u "${{ github.actor }}" --password-stdin + + OCI_REPO=ghcr.io/${{ github.repository }}/${{ inputs.broker }}-snap + OCI_TAG=${{ steps.files-hash.outputs.hash }} + if oras pull "${OCI_REPO}:${OCI_TAG}"; then + echo "cache-hit=true" >> "${GITHUB_OUTPUT}" + else + echo "cache-hit=false" >> "${GITHUB_OUTPUT}" + fi + + - name: Prepare build of the broker snap + if: steps.restore-cache.outputs.cache-hit != 'true' + run: | + set -euo pipefail + set -x + + # Tags and main branch are needed by the snap/scripts/version script + # which is used during the build of the broker snap. + git fetch --tags --unshallow + git fetch origin main:main + + ./snap/scripts/prepare-variant --broker "${{ inputs.broker }}" + + - name: Build broker snap + if: steps.restore-cache.outputs.cache-hit != 'true' + id: build-broker-snap + uses: snapcore/action-build@v1 + + - name: Rename snap to canonical name + if: steps.restore-cache.outputs.cache-hit != 'true' + run: mv "${{ steps.build-broker-snap.outputs.snap }}" "${{ inputs.broker }}.snap" + + - name: Upload snap as OCI artifact + run: | + set -euo pipefail + + # Upload the snap to the OCI registry with the following tags: + # - The current commit hash. This is used by subsequent workflows to + # download the snap for this run. + # - The hash of the files that affect the build. This allows us to + # use it as a cache key, avoiding unnecessary rebuilds. + OCI_REPO=ghcr.io/${{ github.repository }}/${{ inputs.broker }}-snap + OCI_TAG=${{ steps.files-hash.outputs.hash }} + oras push "${OCI_REPO}:${OCI_TAG}" \ + "${{ inputs.broker }}.snap" \ + --artifact-type application/vnd.snap + oras tag "${OCI_REPO}:${OCI_TAG}" "${{ github.sha }}" diff --git a/.github/workflows/build-deb.yaml b/.github/workflows/build-deb.yaml deleted file mode 100644 index 6bc07eb0c2..0000000000 --- a/.github/workflows/build-deb.yaml +++ /dev/null @@ -1,269 +0,0 @@ -name: Build debian packages - -on: - push: - branches: - - main - paths-ignore: - - .github/workflows/automatic-doc-checks.yml - - .readthedocs.yaml - - docs/** - tags: - - "*" - pull_request: - paths-ignore: - - .github/workflows/automatic-doc-checks.yml - - .readthedocs.yaml - - docs/** - -env: - UBUNTU_VERSIONS: | - ["noble", "plucky", "devel"] - CARGO_VENDOR_FILTERER_VERSION: 0.5.16 - -jobs: - define-versions: - name: Define build versions - runs-on: ubuntu-latest - outputs: - ubuntu-versions: ${{ env.UBUNTU_VERSIONS }} - steps: - - run: 'true' - - build-deb-package: - name: Build ubuntu package - runs-on: ubuntu-latest - needs: define-versions - strategy: - fail-fast: false - matrix: - ubuntu-version: ${{ fromJSON(needs.define-versions.outputs.ubuntu-versions) }} - outputs: - run-id: ${{ github.run_id }} - # FIXME: Use dynamic outputs when possible: https://github.com/actions/runner/pull/2477 - pkg-dsc-devel: ${{ steps.outputs.outputs.pkg-dsc-devel }} - pkg-dsc-plucky: ${{ steps.outputs.outputs.pkg-dsc-plucky }} - pkg-dsc-noble: ${{ steps.outputs.outputs.pkg-dsc-noble }} - pkg-src-changes-devel: ${{ steps.outputs.outputs.pkg-src-changes-devel }} - pkg-src-changes-plucky: ${{ steps.outputs.outputs.pkg-src-changes-plucky }} - pkg-src-changes-noble: ${{ steps.outputs.outputs.pkg-src-changes-noble }} - - steps: - - name: Checkout authd code - uses: actions/checkout@v4 - - - name: Build debian packages and sources - uses: canonical/desktop-engineering/gh-actions/common/build-debian@main - with: - docker-image: ubuntu:${{ matrix.ubuntu-version }} - extra-source-build-deps: | - ca-certificates - git - libssl-dev - extra-source-build-script: | - if [ "${{ matrix.ubuntu-version }}" == noble ]; then - cargo install --locked --root=/usr \ - cargo-vendor-filterer@${{ env.CARGO_VENDOR_FILTERER_VERSION }} - command -v cargo-vendor-filterer - fi - - # FIXME: Use dynamic outputs when possible: https://github.com/actions/runner/pull/2477 - - name: Generate outputs - id: outputs - run: | - ( - echo "pkg-dsc-${{ matrix.ubuntu-version }}=${{ env.PKG_DSC }}" - echo "pkg-src-changes-${{ matrix.ubuntu-version }}=${{ env.PKG_SOURCE_CHANGES }}" - ) >> "${GITHUB_OUTPUT}" - - check-modified-files: - name: Check modified files - runs-on: ubuntu-latest - needs: - - build-deb-package - outputs: - list: ${{ fromJSON(steps.git-diff.outputs.modified_files) }} - - steps: - - name: Checkout authd code - uses: actions/checkout@v4 - with: - fetch-depth: 100 - - - id: git-diff - name: Check modified files - run: | - set -ue - - base_ref=${{ github.event.pull_request.base.sha }} - if [ -z "${base_ref}" ]; then - base_ref=${{ github.event.before }} - fi - if [ -z "${base_ref}" ]; then - base_ref=$(git log --root --reverse -n1 --format=%H) - fi - - # Build a JSON array of modified paths. - modified_files=$(git diff --name-only "${base_ref}" HEAD | \ - while read line; do - jq -n --arg path "$line" '$path' - done | jq -n '. |= [inputs]') - echo "${modified_files}" - - escaped_json=$(echo "${modified_files}" | jq '.| tostring') - echo "modified_files=${escaped_json}" >> "${GITHUB_OUTPUT}" - - synchronize-packaging-branches: - name: Update packaging branch - runs-on: ubuntu-latest - needs: - - define-versions - - build-deb-package - permissions: - contents: write - strategy: - fail-fast: false - matrix: - ubuntu-version: ${{ fromJSON(needs.define-versions.outputs.ubuntu-versions) }} - env: - PACKAGING_BRANCH: ubuntu-packaging-${{ matrix.ubuntu-version }} - - # Run only on: - # - Push events to main - # - On github release - if: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/main') || - github.event_name == 'release' }} - - steps: - # FIXME: Use dynamic outputs when possible: https://github.com/actions/runner/pull/2477 - - name: Setup job variables - run: | - set -exuo pipefail - - json_output='${{ toJSON(needs.build-deb-package.outputs) }}' - for var in $(echo "${json_output}" | jq -r 'keys | .[]'); do - if [[ "${var}" != *"-${{ matrix.ubuntu-version }}" ]]; then - continue; - fi - - v=$(echo "${json_output}" | jq -r ".\"${var}\"") - var="${var%-${{ matrix.ubuntu-version }}}" - echo "${var//-/_}=${v}" >> "${GITHUB_ENV}" - done - - - name: Download artifacts - uses: actions/download-artifact@v4 - with: - run-id: ${{ needs.build-deb-package.outputs.run-id }} - merge-multiple: true - - - name: Install dependencies - run: | - set -euo pipefail - - sudo apt update -y - sudo apt install -y --no-install-suggests --no-install-recommends \ - dpkg-dev devscripts - - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 100 - path: repo - - - name: Extract the debian sources - run: | - set -euo pipefail - - dpkg-source -x ${{ env.pkg_dsc }} sources - - - name: Commit packaging sources - run: | - set -exuo pipefail - - # Create or switch to the packaging branch - if git -C repo fetch --depth=1 origin "${{ env.PACKAGING_BRANCH }}:${{ env.PACKAGING_BRANCH }}"; then - git -C repo checkout "${{ env.PACKAGING_BRANCH }}" - else - git -C repo checkout -b "${{ env.PACKAGING_BRANCH }}" - fi - - # Replace the repository content with the package sources - mv repo/.git sources/ - cd sources - - # Drop the ubuntu version, as the PPA recipe will add it anyways - version=$(dpkg-parsechangelog -SVersion) - sanitized_version=$(echo "${version}" | sed "s,~[0-9.]\+\$,,") - perl -pe "s|\Q${version}\E|${sanitized_version}|" debian/changelog > \ - debian/changelog.sanitized - mv debian/changelog.sanitized debian/changelog - dpkg-parsechangelog - - git config --global user.name "Ubuntu Enterprise Desktop" - git config --global user.email "ubuntu-devel-discuss@lists.ubuntu.com" - - git add --all - git commit \ - --allow-empty \ - -m "Update ubuntu ${{ matrix.ubuntu-version }} package sources" \ - -m "Use upstream commit ${GITHUB_SHA}" - - - name: Push to packaging branch - run: | - set -exuo pipefail - - git -C sources push origin "${{ env.PACKAGING_BRANCH }}:${{ env.PACKAGING_BRANCH }}" - - run-autopkgtests: - name: Run autopkgtests - runs-on: ubuntu-latest - needs: - - define-versions - - build-deb-package - - check-modified-files - strategy: - fail-fast: false - matrix: - ubuntu-version: ${{ fromJSON(needs.define-versions.outputs.ubuntu-versions) }} - - # Run autopkgtests only on: - # - Push events to main - # - When a file in the debian subdir is modified - # - When this file is modified - # - On new tags - # - On github release - if: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/main') || - contains(needs.check-modified-files.outputs.list, 'debian/') || - contains(needs.check-modified-files.outputs.list, '.github/workflows/build-deb.yaml') || - startsWith(github.ref, 'refs/tags/') || - github.event_name == 'release' }} - - steps: - # FIXME: Use dynamic outputs when possible: https://github.com/actions/runner/pull/2477 - - name: Setup job variables - run: | - set -exuo pipefail - - json_output='${{ toJSON(needs.build-deb-package.outputs) }}' - for var in $(echo "${json_output}" | jq -r 'keys | .[]'); do - if [[ "${var}" != *"-${{ matrix.ubuntu-version }}" ]]; then - continue; - fi - - v=$(echo "${json_output}" | jq -r ".\"${var}\"") - var="${var%-${{ matrix.ubuntu-version }}}" - echo "${var//-/_}=${v}" >> "${GITHUB_ENV}" - done - - - name: Download artifacts - uses: actions/download-artifact@v4 - with: - run-id: ${{ needs.build-deb-package.outputs.run-id }} - merge-multiple: true - - - name: Run autopkgtests - uses: canonical/desktop-engineering/gh-actions/common/run-autopkgtest@main - with: - lxd-image: ubuntu:${{ matrix.ubuntu-version }} - source-changes: ${{ env.pkg_src_changes }} diff --git a/.github/workflows/cla-check.yaml b/.github/workflows/cla-check.yaml index c9fc001af5..566269953a 100644 --- a/.github/workflows/cla-check.yaml +++ b/.github/workflows/cla-check.yaml @@ -10,3 +10,7 @@ jobs: uses: canonical/has-signed-canonical-cla@v2 with: accept-existing-contributors: true + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true \ No newline at end of file diff --git a/.github/workflows/debian-build-test-and-sync.yaml b/.github/workflows/debian-build-test-and-sync.yaml new file mode 100644 index 0000000000..673f01dbda --- /dev/null +++ b/.github/workflows/debian-build-test-and-sync.yaml @@ -0,0 +1,161 @@ +name: build-deb-and-test (reusable) + +on: + workflow_call: + inputs: + files-hash: + description: 'Hash of build-relevant files, used as cache key and concurrency group' + type: string + required: true + ubuntu-version: + description: 'Ubuntu version to build and test the Debian package for' + type: string + required: true + modified-files: + description: 'JSON array of files modified since the last commit, used to decide whether to run autopkgtests' + type: string + required: true + +jobs: + build-deb: + uses: ./.github/workflows/debian-build.yaml + with: + files-hash: ${{ inputs.files-hash }} + ubuntu-version: ${{ inputs.ubuntu-version }} + secrets: inherit + + run-autopkgtests: + name: Run autopkgtests + runs-on: ubuntu-latest + needs: build-deb + + # Run autopkgtests only on: + # - Push events to main + # - When a file in the debian subdir is modified + # - When this file is modified + # - On new tags + # - On github release + if: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/main') || + contains(inputs.modified-files, 'debian/') || + contains(inputs.modified-files, '.github/workflows/debian.yaml') || + startsWith(github.ref, 'refs/tags/') || + github.event_name == 'release' }} + + steps: + - uses: actions/checkout@v6 + + - uses: ./.github/actions/setup-oras + + - name: Download the Debian sources + env: + OCI_REPO: ghcr.io/${{ github.repository }}/authd-deb-sources-${{ inputs.ubuntu-version }} + OCI_TAG: ${{ github.sha }} + run: | + set -euo pipefail + + echo "${{ secrets.GITHUB_TOKEN }}" \ + | oras login ghcr.io -u "${{ github.actor }}" --password-stdin + + oras pull "${OCI_REPO}:${OCI_TAG}" + + - name: Run autopkgtests + uses: canonical/desktop-engineering/gh-actions/common/run-autopkgtest@main + with: + lxd-image: ubuntu:${{ inputs.ubuntu-version }} + source-changes: ${{ needs.build-deb.outputs.pkg-src-changes }} + autopkgtest-args: --add-apt-source=ppa:ubuntu-enterprise-desktop/golang + + synchronize-packaging-branches: + name: Update packaging branch + runs-on: ubuntu-latest + needs: build-deb + permissions: + contents: write + env: + PACKAGING_BRANCH: ubuntu-packaging-${{ inputs.ubuntu-version }} + + # Run only on: + # - Push events to main + # - On github release + if: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/main') || + github.event_name == 'release' }} + + steps: + - uses: actions/checkout@v6 + + - uses: ./.github/actions/setup-oras + + - name: Download the Debian sources + env: + OCI_REPO: ghcr.io/${{ github.repository }}/authd-deb-sources-${{ inputs.ubuntu-version }} + OCI_TAG: ${{ github.sha }} + run: | + set -euo pipefail + + echo "${{ secrets.GITHUB_TOKEN }}" \ + | oras login ghcr.io -u "${{ github.actor }}" --password-stdin + + oras pull "${OCI_REPO}:${OCI_TAG}" + + - name: Install dependencies + run: | + set -euo pipefail + + sudo apt-get update -y + sudo apt-get install -y --no-install-suggests --no-install-recommends \ + dpkg-dev devscripts + + - name: Checkout packaging branch + uses: actions/checkout@v6 + with: + path: packaging-branch + + - name: Prepare packaging branch + run: | + set -euo pipefail + + cd packaging-branch + + # Checkout to the packaging branch if it exists, otherwise create a new orphan branch with a clean state to make + # sure we don't accidentally include files from the repository that are not part of the package sources. + if git ls-remote --exit-code --heads origin "${{ env.PACKAGING_BRANCH }}" >/dev/null 2>&1; then + git fetch origin "${{ env.PACKAGING_BRANCH }}" + git checkout -B "${{ env.PACKAGING_BRANCH }}" "origin/${{ env.PACKAGING_BRANCH }}" + else + git checkout --orphan "${{ env.PACKAGING_BRANCH }}" + git rm -rf . >/dev/null 2>&1 || true + git clean -fdx + fi + + - name: Extract the debian sources + run: dpkg-source -x ${{ env.pkg_dsc }} sources + + - name: Commit packaging sources + run: | + set -exuo pipefail + + # Replace the repository content with the package sources + find packaging-branch -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} + + cp --force --recursive sources/. packaging-branch/ + + cd packaging-branch + + # Drop the ubuntu version, as the PPA recipe will add it anyways + version=$(dpkg-parsechangelog -SVersion) + sanitized_version=$(echo "${version}" | sed "s,~[0-9.]\+\$,,") + perl -pe "s|\Q${version}\E|${sanitized_version}|" debian/changelog > \ + debian/changelog.sanitized + mv debian/changelog.sanitized debian/changelog + dpkg-parsechangelog + + git config user.name "Ubuntu Enterprise Desktop" + git config user.email "ubuntu-devel-discuss@lists.ubuntu.com" + + git add --all + git commit \ + --allow-empty \ + -m "Update ubuntu ${{ matrix.ubuntu-version }} package sources" \ + -m "Use upstream commit ${{ github.sha }}" + + - name: Push to packaging branch + run: git -C sources push origin "${{ env.PACKAGING_BRANCH }}:${{ env.PACKAGING_BRANCH }}" diff --git a/.github/workflows/debian-build.yaml b/.github/workflows/debian-build.yaml new file mode 100644 index 0000000000..aaddbad41b --- /dev/null +++ b/.github/workflows/debian-build.yaml @@ -0,0 +1,256 @@ +name: build-deb (reusable) + +on: + workflow_call: + inputs: + files-hash: + description: 'Hash of build-relevant files, used as cache key' + type: string + required: true + ubuntu-version: + description: 'Ubuntu version to build the Debian package for' + type: string + required: true + outputs: + pkg-name: + value: ${{ jobs.build-deb.outputs.pkg-name }} + pkg-version: + value: ${{ jobs.build-deb.outputs.pkg-version }} + pkg-dsc: + value: ${{ jobs.build-deb.outputs.pkg-dsc }} + pkg-src-changes: + value: ${{ jobs.build-deb.outputs.pkg-src-changes }} + + +env: + CARGO_VENDOR_FILTERER_VERSION: 0.5.16 + +jobs: + build-deb: + name: Build Debian package (${{ inputs.files-hash }}) + runs-on: ubuntu-latest + outputs: + pkg-name: ${{ steps.outputs.outputs.pkg-name }} + pkg-version: ${{ steps.outputs.outputs.pkg-version }} + pkg-dsc: ${{ steps.outputs.outputs.pkg-dsc }} + pkg-src-changes: ${{ steps.outputs.outputs.pkg-src-changes }} + + steps: + - uses: actions/checkout@v6 + + - uses: ./.github/actions/setup-oras + + - name: Try to restore deb and sources from OCI registry + id: restore-cache + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + set -x + + echo "${{ secrets.GITHUB_TOKEN }}" \ + | oras login ghcr.io -u "${{ github.actor }}" --password-stdin + + OCI_DEB=ghcr.io/${{ github.repository }}/authd-deb-${{ inputs.ubuntu-version }} + OCI_SOURCES=ghcr.io/${{ github.repository }}/authd-deb-sources-${{ inputs.ubuntu-version }} + OCI_TAG=${{ inputs.files-hash }} + + try_restore() { + # Try to restore the deb + if ! oras pull "${OCI_DEB}:${OCI_TAG}" 2>/dev/null; then + return 1 + fi + PKG_DEB=$(find . -maxdepth 1 -name "authd_*.deb") + [ -n "${PKG_DEB}" ] || return 1 + + # Try to restore the sources + if ! oras pull "${OCI_SOURCES}:${OCI_TAG}" 2>/dev/null; then + echo "cache-hit=false" >> "${GITHUB_OUTPUT}" + return 0 + fi + PKG_DSC=$(find . -maxdepth 1 -name "authd_*.dsc") + PKG_SOURCE_CHANGES=$(find . -maxdepth 1 -name "authd_*.changes") + PKG_TARBALL=$(find . -maxdepth 1 -name "authd_*.tar*") + [ -n "${PKG_DSC}" ] || return 1 + [ -n "${PKG_SOURCE_CHANGES}" ] || return 1 + [ -n "${PKG_TARBALL}" ] || return 1 + + echo "PKG_NAME=$(dpkg-parsechangelog --show-field source)" >> "${GITHUB_ENV}" + echo "PKG_VERSION=$(dpkg-parsechangelog --show-field version)" >> "${GITHUB_ENV}" + echo "PKG_DEB=${PKG_DEB}" >> "${GITHUB_ENV}" + echo "PKG_DSC=${PKG_DSC}" >> "${GITHUB_ENV}" + echo "PKG_SOURCE_CHANGES=${PKG_SOURCE_CHANGES}" >> "${GITHUB_ENV}" + echo "PKG_TARBALL=${PKG_TARBALL}" >> "${GITHUB_ENV}" + echo "cache-hit=true" >> "${GITHUB_OUTPUT}" + return 0 + } + + # Fast path: artifact already in OCI registry + if try_restore; then + exit 0 + fi + + # Check if another run is already building the same package. + # We identify a builder run as any other non-completed workflow run + # with a lower run_id that: + # 1. references debian-build.yaml (i.e. calls the same reusable workflow), and + # 2. has an in-progress or queued job with the same name as ours + # (which encodes ubuntu-version and files-hash). + BUILDER_JOB_ID=$(gh api \ + "/repos/${{ github.repository }}/actions/runs?status=in_progress" \ + --paginate \ + --jq '.workflow_runs[] + | select( + .id != ${{ github.run_id }} + and .id < ${{ github.run_id }} + and .status != "completed" + and ([.referenced_workflows[]?.path // "" | contains("debian-build.yaml")] | any) + ) + | .id' \ + | while read -r other_run_id; do + # Confirm this run has a job matching our exact job name + # (same files-hash and ubuntu-version), to avoid waiting for + # a run that builds a different ubuntu-version or from a + # different branch. Also emit the job ID so we can poll it + # directly rather than waiting for the entire run to finish. + gh api "/repos/${{ github.repository }}/actions/runs/${other_run_id}/jobs" \ + --jq '.jobs[] | select(.name | (contains("(${{ inputs.ubuntu-version }})") and contains("(${{ inputs.files-hash }})"))) | .id' \ + 2>/dev/null || true + done | head -1) + + if [ -z "${BUILDER_JOB_ID}" ]; then + # No earlier run is building this package: we are the builder. + echo "cache-hit=false" >> "${GITHUB_OUTPUT}" + exit 0 + fi + + # Another run (with a lower run_id) is already building this package. + # Wait for the specific build job to finish, then restore from OCI. + # We poll the job (not the run) so we don't wait for autopkgtests or + # other jobs in that run. + # TODO: Use GitHub's 'concurrency' keyword instead of busy-waiting + # once https://github.com/orgs/community/discussions/12835 is implemented. + echo "Job ${BUILDER_JOB_ID} is already building this package, waiting..." + DEADLINE=$(( SECONDS + 30 * 60 )) + while [ "${SECONDS}" -lt "${DEADLINE}" ]; do + sleep 10 + + # Check builder job status to detect completion, failure or cancellation. + BUILDER_JOB=$(gh api "/repos/${{ github.repository }}/actions/jobs/${BUILDER_JOB_ID}" \ + --jq '{status: .status, conclusion: .conclusion}') + BUILDER_STATUS=$(echo "${BUILDER_JOB}" | jq -r '.status') + BUILDER_CONCLUSION=$(echo "${BUILDER_JOB}" | jq -r '.conclusion') + + if [ "${BUILDER_STATUS}" != "completed" ]; then + REMAINING=$(( DEADLINE - SECONDS )) + echo "Still waiting... (${REMAINING}s remaining)" + continue + fi + + if [ "${BUILDER_CONCLUSION}" = "success" ]; then + if try_restore; then + echo "Restored artifact after builder job succeeded." + exit 0 + fi + echo "Builder job succeeded but artifact not found. Proceeding to build ourselves." + else + echo "Builder job finished with conclusion '${BUILDER_CONCLUSION}'. Proceeding to build ourselves." + fi + echo "cache-hit=false" >> "${GITHUB_OUTPUT}" + exit 0 + + done + + echo "Timed out waiting for builder job. Proceeding to build ourselves." + echo "cache-hit=false" >> "${GITHUB_OUTPUT}" + + - name: Build Debian package and sources + if: steps.restore-cache.outputs.cache-hit != 'true' + uses: canonical/desktop-engineering/gh-actions/common/build-debian@main + with: + docker-image: ubuntu:${{ inputs.ubuntu-version }} + # Add the Go backports PPA if we're testing a Ubuntu release which + # doesn't have the required Go version in main. + extra-apt-repositories: ${{ (inputs.ubuntu-version == 'noble' || inputs.ubuntu-version == 'questing') && 'ppa:ubuntu-enterprise-desktop/golang' || '' }} + # Extra build dependencies: + # - systemd-dev: Required to read compile time variables from systemd via pkg-config. + extra-source-build-deps: | + ca-certificates + git + libssl-dev + systemd-dev + extra-source-build-script: | + if [ "${{ inputs.ubuntu-version }}" == noble ]; then + cargo install --locked --root=/usr \ + cargo-vendor-filterer@${{ env.CARGO_VENDOR_FILTERER_VERSION }} + command -v cargo-vendor-filterer + fi + allow-sudo: true + lintian: --fail-on error,warning,info --verbose + run-lrc: true + + - name: Prepare deb and sources for upload + if: steps.restore-cache.outputs.cache-hit != 'true' + run: | + set -euo pipefail + set -x + + # In the next step we upload the deb and sources with 'oras push'. + # When using a relative file path with that command, that path is + # preserved and the directory structure is recreated when downloading + # the file. To avoid that, we copy the files to be uploaded to the + # working directory and upload them from there. + + # Copy binary deb to working directory. In contrast to the dsc and + # changes files, the path to the binary deb is not set in the + # environment by the build-debian action, so we have to find it first. + DEB_PATH=$(find "${{ env.BUILD_OUTPUT_DIR }}" -maxdepth 1 -name "authd_*.deb") + PKG_DEB=$(basename "${DEB_PATH}") + cp "${DEB_PATH}" "${PKG_DEB}" + echo "PKG_DEB=${PKG_DEB}" >> ${GITHUB_ENV} + + # Copy sources to working directory + TARBALL_PATH=$(find "${{ env.SOURCE_OUTPUT_DIR }}" -maxdepth 1 -name "authd_*.tar*") + PKG_TARBALL=$(basename "${TARBALL_PATH}") + cp "${TARBALL_PATH}" "${PKG_TARBALL}" + cp "${{ env.SOURCE_OUTPUT_DIR }}/${{ env.PKG_DSC }}" . + cp "${{ env.SOURCE_OUTPUT_DIR }}/${{ env.PKG_SOURCE_CHANGES }}" . + echo "PKG_TARBALL=${PKG_TARBALL}" >> ${GITHUB_ENV} + + - name: Upload deb and sources as OCI artifacts + run: | + set -euo pipefail + set -x + + OCI_REPO=ghcr.io/${{ github.repository }}/authd-deb-${{ inputs.ubuntu-version }} + OCI_TAG=${{ inputs.files-hash }} + oras push "${OCI_REPO}:${OCI_TAG}" \ + "${{ env.PKG_DEB }}" \ + --artifact-type=application/vnd.debian.binary-package \ + --annotation "org.opencontainers.image.title=authd debian package" \ + --annotation "org.opencontainers.image.version=${{ env.PKG_VERSION }}" + oras tag "${OCI_REPO}:${OCI_TAG}" "${{ github.sha }}" + rm "${{ env.PKG_DEB }}" + + OCI_REPO=ghcr.io/${{ github.repository }}/authd-deb-sources-${{ inputs.ubuntu-version }} + oras push "${OCI_REPO}:${OCI_TAG}" \ + "${{ env.PKG_DSC }}" \ + "${{ env.PKG_SOURCE_CHANGES }}" \ + "${{ env.PKG_TARBALL }}" \ + --artifact-type=application/vnd.debian.source-package \ + --annotation "org.opencontainers.image.title=authd source debian package" \ + --annotation "org.opencontainers.image.version=${{ env.PKG_VERSION }}" + oras tag "${OCI_REPO}:${OCI_TAG}" "${{ github.sha }}" + rm "${{ env.PKG_DSC }}" \ + "${{ env.PKG_SOURCE_CHANGES }}" \ + "${{ env.PKG_TARBALL }}" + + - name: Generate outputs + id: outputs + run: | + ( + echo "pkg-name=${{ env.PKG_NAME }}" + echo "pkg-version=${{ env.PKG_VERSION }}" + echo "pkg-dsc=${{ env.PKG_DSC }}" + echo "pkg-src-changes=${{ env.PKG_SOURCE_CHANGES }}" + ) >> "${GITHUB_OUTPUT}" diff --git a/.github/workflows/debian.yaml b/.github/workflows/debian.yaml new file mode 100644 index 0000000000..f5b7b0c691 --- /dev/null +++ b/.github/workflows/debian.yaml @@ -0,0 +1,117 @@ +name: Debian Packaging + +on: + push: + branches: + - main + paths: + - '**' + - '!.github/**' + - '.github/workflows/debian.yaml' + - '.github/workflows/debian-build.yaml' + - '.github/workflows/debian-build-test-and-sync.yaml' + - '!.gitignore' + - '!.gitmodules' + - '!.golangci.yaml' + - '!*.md' + - '!COPYING*' + - '!authd-oidc-brokers/**' + - '!docs/**' + - '!e2e-tests/**' + - '!examplebroker/**' + - '!gotestcov' + - '!snap/**' + - '!**_test.go' + - '!**/testdata/**' + - '!**/testutils/**' + + tags: + - "*" + pull_request: + paths: + - '**' + - '!.github/**' + - '.github/workflows/debian.yaml' + - '.github/workflows/debian-build.yaml' + - '.github/workflows/debian-build-test-and-sync.yaml' + - '!.gitignore' + - '!.gitmodules' + - '!.golangci.yaml' + - '!*.md' + - '!COPYING*' + - '!authd-oidc-brokers/**' + - '!docs/**' + - '!e2e-tests/**' + - '!examplebroker/**' + - '!gotestcov' + - '!snap/**' + - '!**_test.go' + - '!**/testdata/**' + - '!**/testutils/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + compute-hash: + name: Compute hash of build-relevant files + runs-on: ubuntu-latest + outputs: + files-hash: ${{ steps.hash.outputs.hash }} + steps: + - uses: actions/checkout@v6 + - name: Compute hash of build-relevant files + id: hash + run: | + hash=$(bash .github/scripts/compute-deb-build-hash.sh) + echo "hash=${hash}" >> "${GITHUB_OUTPUT}" + + get-modified-files: + name: Get modified files + runs-on: ubuntu-latest + outputs: + list: ${{ fromJSON(steps.git-diff.outputs.modified_files) }} + + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 100 + + - id: git-diff + name: Get modified files + run: | + set -ue + + base_ref=${{ github.event.pull_request.base.sha }} + if [ -z "${base_ref}" ]; then + base_ref=${{ github.event.before }} + fi + if [ -z "${base_ref}" ]; then + base_ref=$(git log --root --reverse -n1 --format=%H) + fi + + # Build a JSON array of modified paths. + modified_files=$(git diff --name-only "${base_ref}" HEAD | \ + while read line; do + jq -n --arg path "$line" '$path' + done | jq -n '. |= [inputs]') + echo "${modified_files}" + + escaped_json=$(echo "${modified_files}" | jq '.| tostring') + echo "modified_files=${escaped_json}" >> "${GITHUB_OUTPUT}" + + build-deb-and-test: + needs: + - compute-hash + - get-modified-files + strategy: + matrix: + ubuntu-version: [ 'noble', 'questing', 'devel' ] + fail-fast: false + uses: ./.github/workflows/debian-build-test-and-sync.yaml + with: + files-hash: ${{ needs.compute-hash.outputs.files-hash }} + ubuntu-version: ${{ matrix.ubuntu-version }} + modified-files: ${{ needs.get-modified-files.outputs.list }} + secrets: inherit diff --git a/.github/workflows/e2e-tests-provision-and-run.yaml b/.github/workflows/e2e-tests-provision-and-run.yaml new file mode 100644 index 0000000000..69c0fc6bf0 --- /dev/null +++ b/.github/workflows/e2e-tests-provision-and-run.yaml @@ -0,0 +1,141 @@ +name: provision-e2e-test (reusable) + +on: + workflow_call: + inputs: + files-hash: + description: 'Hash of build-relevant files, used as cache key and concurrency group' + type: string + required: true + ubuntu-version: + required: true + type: string + secrets: + E2E_VM_SSH_PRIV_KEY: + required: true + E2E_VM_SSH_PUB_KEY: + required: true + +env: + DEBIAN_FRONTEND: noninteractive + VM_NAME_BASE: e2e-runner + ARTIFACTS_DIR: /tmp/e2e-artifacts + E2E_TESTS_DIR: e2e-tests + +jobs: + provision-vm: + name: Provision VM + runs-on: ubuntu-latest + permissions: + # Needed by actions/checkout + contents: read + # Needed to push OCI artifact to ghcr.io + packages: write + steps: + - uses: actions/checkout@v6 + + - name: Restore cached VM image (if available) + id: restore-cache + uses: actions/cache/restore@v5 + with: + path: ${{ env.ARTIFACTS_DIR }}/${{ env.VM_NAME_BASE }}-${{ inputs.ubuntu-version }}.qcow2 + key: >- + e2e-runner-vm-${{ inputs.ubuntu-version }}-${{ hashFiles( + 'e2e-tests/vm/**', + '.github/actions/e2e-tests/set-up-ssh-keys/**', + '.github/workflows/e2e-tests-provision-and-run.yaml' + ) }} + fail-on-cache-miss: false + + - uses: canonical/desktop-engineering/gh-actions/common/dpkg-install-speedup@main + if: steps.restore-cache.outputs.cache-hit != 'true' + + - name: Install APT dependencies for VM provisioning + if: steps.restore-cache.outputs.cache-hit != 'true' + run: ./${{ env.E2E_TESTS_DIR }}/vm/install-provision-deps.sh + + - name: Set up SSH keys for the VM provisioning + id: set-ssh-keys + if: steps.restore-cache.outputs.cache-hit != 'true' + uses: ./.github/actions/e2e-tests/set-up-ssh-keys + with: + E2E_VM_SSH_PRIV_KEY: ${{ secrets.E2E_VM_SSH_PRIV_KEY }} + E2E_VM_SSH_PUB_KEY: ${{ secrets.E2E_VM_SSH_PUB_KEY }} + + - name: Provision the VM + id: provision-vm + if: steps.restore-cache.outputs.cache-hit != 'true' + run: | + set -eu + export PATH="$(realpath "${{ env.E2E_TESTS_DIR }}/vm/helpers"):${PATH}" + mkdir -p ${{ env.ARTIFACTS_DIR }} + env VM_NAME_BASE="${{ env.VM_NAME_BASE }}" \ + RELEASE="${{ inputs.ubuntu-version }}" \ + ARTIFACTS_DIR="${{ env.ARTIFACTS_DIR }}" \ + SSH_PUBLIC_KEY_FILE="${{ steps.set-ssh-keys.outputs.keys-path }}/id_rsa.pub" \ + ./${{ env.E2E_TESTS_DIR }}/vm/provision-ubuntu.sh + + - name: Clean previous cache + if: always() && steps.provision-vm.outcome == 'success' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + set -eu + gh cache delete e2e-runner-${{ inputs.ubuntu-version }} || true + echo "Previous cache (if any) deleted" + + - name: Cache the VM image + uses: actions/cache/save@v5 + if: always() && steps.provision-vm.outcome == 'success' + with: + path: ${{ env.ARTIFACTS_DIR }}/${{ env.VM_NAME_BASE }}-${{ inputs.ubuntu-version }}.qcow2 + key: >- + e2e-runner-vm-${{ inputs.ubuntu-version }}-${{ hashFiles( + 'e2e-tests/vm/**', + '.github/actions/e2e-tests/set-up-ssh-keys/**', + '.github/workflows/e2e-tests-provision-and-run.yaml' + ) }} + + - uses: ./.github/actions/setup-oras + + - name: Upload VM image as OCI artifact + if: steps.provision-vm.outcome == 'success' || steps.restore-cache.outputs.cache-hit == 'true' + env: + IMAGE_PATH: ${{ env.ARTIFACTS_DIR }}/${{ env.VM_NAME_BASE }}-${{ inputs.ubuntu-version }}.qcow2 + OCI_REPO: ghcr.io/${{ github.repository }}/e2e-runner + OCI_TAG: ${{ inputs.ubuntu-version }} + run: | + set -euo pipefail + + echo "${{ secrets.GITHUB_TOKEN }}" \ + | oras login ghcr.io -u "${{ github.actor }}" --password-stdin + + # Chunk qcow2 into stable fixed-size layers (64 MiB) + WORKDIR=$(mktemp -d) + split -b 64M "$IMAGE_PATH" "$WORKDIR/chunk-" + + # Push as a layered OCI artifact (each chunk becomes a layer) + cd "$WORKDIR" + oras push "${OCI_REPO}:${OCI_TAG}" chunk-* --artifact-type application/vnd.qemu.qcow2 + + build-deb: + uses: ./.github/workflows/debian-build.yaml + with: + files-hash: ${{ inputs.files-hash }} + ubuntu-version: ${{ inputs.ubuntu-version }} + secrets: inherit + + e2e-test: + needs: + - provision-vm + - build-deb + strategy: + matrix: + broker: [ authd-msentraid, authd-google ] + fail-fast: false + uses: ./.github/workflows/e2e-tests-run.yaml + with: + ubuntu-version: ${{ inputs.ubuntu-version }} + broker: ${{ matrix.broker }} + secrets: inherit diff --git a/.github/workflows/e2e-tests-run.yaml b/.github/workflows/e2e-tests-run.yaml new file mode 100644 index 0000000000..e00a4fcc53 --- /dev/null +++ b/.github/workflows/e2e-tests-run.yaml @@ -0,0 +1,310 @@ +name: run-e2e-test (reusable) + +on: + workflow_call: + inputs: + ubuntu-version: + required: true + type: string + broker: + required: true + type: string + secrets: + E2E_VM_SSH_PRIV_KEY: + required: true + E2E_VM_SSH_PUB_KEY: + required: true + E2E_MSENTRA_ISSUER_ID: + required: false + E2E_MSENTRA_CLIENT_ID: + required: false + E2E_MSENTRA_USERNAME: + required: false + E2E_MSENTRA_PASSWORD: + required: false + E2E_MSENTRA_TOTP_SECRET: + required: false + E2E_GOOGLE_CLIENT_ID: + required: false + E2E_GOOGLE_CLIENT_SECRET: + required: false + E2E_GOOGLE_USERNAME: + required: false + E2E_GOOGLE_PASSWORD: + required: false + E2E_GOOGLE_TOTP_SECRET: + required: false + R2_ACCESS_KEY_ID: + required: true + R2_SECRET_ACCESS_KEY: + required: true + R2_ACCOUNT_ID: + required: true + +env: + DEBIAN_FRONTEND: noninteractive + VM_NAME_BASE: e2e-runner + ARTIFACTS_DIR: /tmp/e2e-artifacts + E2E_TESTS_DIR: e2e-tests + OUTPUT_DIR: /tmp/e2e-${{ inputs.broker }}-${{ inputs.ubuntu-version }}/output + +jobs: + build-broker-snap: + uses: ./.github/workflows/build-broker-snap.yaml + with: + broker: ${{ inputs.broker }} + secrets: inherit + + run-tests: + name: Run e2e-tests (${{ inputs.broker }}) + needs: build-broker-snap + runs-on: ubuntu-latest + permissions: + # Needed by actions/checkout + contents: read + # Needed to pull OCI artifacts from ghcr.io + packages: read + # Needed to use the commit statuses API to show a PR check for the e2e-test logs + statuses: write + steps: + - uses: actions/checkout@v6 + + - uses: ./.github/actions/setup-oras + + - name: Download the broker snap + id: download-broker-snap + env: + OCI_REPO: ghcr.io/${{ github.repository }}/${{ inputs.broker }}-snap + OCI_TAG: ${{ github.sha }} + run: | + set -euo pipefail + + echo "${{ secrets.GITHUB_TOKEN }}" \ + | oras login ghcr.io -u "${{ github.actor }}" --password-stdin + + oras pull "${OCI_REPO}:${OCI_TAG}" + + SNAP=$(find . -maxdepth 1 -name '*.snap') + if [ -z "$SNAP" ] || [ ! -f "$SNAP" ]; then + echo "Error: Snap file not found after pulling from OCI registry" + exit 1 + fi + + mkdir -p "${{ env.ARTIFACTS_DIR }}" + mv "$SNAP" "${{ env.ARTIFACTS_DIR }}" + SNAP=$(realpath "${{ env.ARTIFACTS_DIR }}/$SNAP") + echo "snap=${SNAP}" >> $GITHUB_OUTPUT + + - name: Download the authd deb + id: download-authd-deb + env: + OCI_REPO: ghcr.io/${{ github.repository }}/authd-deb-${{ inputs.ubuntu-version }} + OCI_TAG: ${{ github.sha }} + run: | + set -euo pipefail + + echo "${{ secrets.GITHUB_TOKEN }}" \ + | oras login ghcr.io -u "${{ github.actor }}" --password-stdin + + oras pull "${OCI_REPO}:${OCI_TAG}" + + DEB=$(find . -maxdepth 1 -name 'authd_*.deb') + if [ -z "$DEB" ] || [ ! -f "$DEB" ]; then + echo "Error: Deb not found after pulling from OCI registry" + exit 1 + fi + + mkdir -p "${{ env.ARTIFACTS_DIR }}" + mv "$DEB" "${{ env.ARTIFACTS_DIR }}" + DEB=$(realpath "${{ env.ARTIFACTS_DIR }}/$DEB") + echo "deb=${DEB}" >> $GITHUB_OUTPUT + + - name: Download the VM image + id: download-vm-image + env: + OCI_REPO: ghcr.io/${{ github.repository }}/e2e-runner + OCI_TAG: ${{ inputs.ubuntu-version }} + IMAGE_PATH: ${{ env.ARTIFACTS_DIR }}/${{ env.VM_NAME_BASE }}-${{ inputs.ubuntu-version }}.qcow2 + run: | + set -euo pipefail + + mkdir -p "$(dirname "$IMAGE_PATH")" + + echo "${{ secrets.GITHUB_TOKEN }}" \ + | oras login ghcr.io -u "${{ github.actor }}" --password-stdin + + oras pull "${OCI_REPO}:${OCI_TAG}" + cat chunk-* > "$IMAGE_PATH" + rm chunk-* + echo "image-path=${IMAGE_PATH}" >> $GITHUB_OUTPUT + + - uses: canonical/desktop-engineering/gh-actions/common/dpkg-install-speedup@main + + - name: Install APT dependencies for provisioning + run: ${{ env.E2E_TESTS_DIR }}/vm/install-provision-deps.sh + + - name: Set up SSH keys for the VM provisioning + id: set-ssh-keys + uses: ./.github/actions/e2e-tests/set-up-ssh-keys + with: + E2E_VM_SSH_PRIV_KEY: ${{ secrets.E2E_VM_SSH_PRIV_KEY }} + E2E_VM_SSH_PUB_KEY: ${{ secrets.E2E_VM_SSH_PUB_KEY }} + + - name: Provision authd and the broker + run: | + set -eux + export PATH="$(realpath "${{ env.E2E_TESTS_DIR }}/vm/helpers"):${PATH}" + + # Generate the libvirt domain XML file + template="${{ env.E2E_TESTS_DIR }}/vm/e2e-runner-template.xml" + env \ + IMAGE_FILE=${{ steps.download-vm-image.outputs.image-path }} \ + VM_NAME=${{ env.VM_NAME_BASE }}-${{ inputs.ubuntu-version }} \ + envsubst \ + < "${template}" \ + > "${{ env.ARTIFACTS_DIR }}/${{ env.VM_NAME_BASE }}.xml" + + # Create the provisioning config file + cat > "${{ env.E2E_TESTS_DIR }}/vm/config.sh" <<-EOF + export SSH_PUBLIC_KEY_FILE=${{ steps.set-ssh-keys.outputs.keys-path }}/id_rsa.pub + export VM_NAME_BASE=${{ env.VM_NAME_BASE }} + export RELEASE=${{ inputs.ubuntu-version }} + export BROKER=${{ inputs.broker }} + + export AUTHD_MSENTRAID_ISSUER_ID=${{ secrets.E2E_MSENTRA_ISSUER_ID }} + export AUTHD_MSENTRAID_CLIENT_ID=${{ secrets.E2E_MSENTRA_CLIENT_ID }} + export AUTHD_MSENTRAID_USER=${{ secrets.E2E_MSENTRA_USERNAME }} + + export AUTHD_GOOGLE_CLIENT_ID=${{ secrets.E2E_GOOGLE_CLIENT_ID }} + export AUTHD_GOOGLE_CLIENT_SECRET=${{ secrets.E2E_GOOGLE_CLIENT_SECRET }} + export AUTHD_GOOGLE_USER=${{ secrets.E2E_GOOGLE_USERNAME }} + EOF + + # Provision the VM + ${{ env.E2E_TESTS_DIR }}/vm/provision-authd.sh \ + --authd-deb "${{ steps.download-authd-deb.outputs.deb }}" \ + --broker-snap "${{ steps.download-broker-snap.outputs.snap }}" + + - name: Checkout YARF repo + uses: actions/checkout@v6 + with: + repository: adombeck/yarf + path: ${{ env.E2E_TESTS_DIR }}/.yarf + + - name: Install APT dependencies for running the E2E tests + run: ${{ env.E2E_TESTS_DIR }}/install-deps.sh + + - name: Configure YARF + run: ${{ env.E2E_TESTS_DIR }}/setup-yarf.sh + + - name: Run tests + id: run-tests + run: | + set -eux + export PATH="$(realpath "${{ env.E2E_TESTS_DIR }}/vm/helpers"):${PATH}" + + # Set the credentials for the broker + if [ "${{ inputs.broker }}" = "authd-msentraid" ]; then + E2E_USER=${{ secrets.E2E_MSENTRA_USERNAME }} + E2E_PASSWORD=${{ secrets.E2E_MSENTRA_PASSWORD }} + TOTP_SECRET=${{ secrets.E2E_MSENTRA_TOTP_SECRET }} + elif [ "${{ inputs.broker }}" = "authd-google" ]; then + E2E_USER=${{ secrets.E2E_GOOGLE_USERNAME }} + E2E_PASSWORD=${{ secrets.E2E_GOOGLE_PASSWORD }} + TOTP_SECRET=${{ secrets.E2E_GOOGLE_TOTP_SECRET }} + fi + + # Run the tests + ${{ env.E2E_TESTS_DIR }}/run-tests.sh \ + --user "${E2E_USER}" \ + --password "${E2E_PASSWORD}" \ + --totp-secret "${TOTP_SECRET}" \ + --release "${{ inputs.ubuntu-version }}" \ + --broker "${{ inputs.broker }}" \ + --output-dir "${{ env.OUTPUT_DIR }}" \ + || exit_code=$? + + if [ -n "${exit_code:-}" ]; then + state="failure" + else + state="success" + fi + + echo "log=${{ env.OUTPUT_DIR }}/log.html" >> "$GITHUB_OUTPUT" + echo "report=${{ env.OUTPUT_DIR }}/report.html" >> "$GITHUB_OUTPUT" + echo "state=${state}" >> "$GITHUB_OUTPUT" + echo "exit-code=${exit_code:-0}" >> "$GITHUB_OUTPUT" + echo "Exit code: ${exit_code:-0}" + + - name: Upload test results as artifact + if: always() + id: upload-results + uses: actions/upload-artifact@v7 + with: + name: e2e-tests-${{ inputs.ubuntu-version }}-${{ inputs.broker }}-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ env.OUTPUT_DIR }} + + - name: Prepare upload to Pages + id: prepare-pages + # Only run if either the log or the report file exists + if: always() && (steps.run-tests.outputs.log != '' || steps.run-tests.outputs.report != '') + run: | + set -euo pipefail + TARGET_DIR="pr-${{ github.event.pull_request.number }}/run-${{ github.run_id }}-${{ github.run_attempt }}/${{ inputs.ubuntu-version }}-${{ inputs.broker }}" + mkdir -p "pages/${TARGET_DIR}" + cp "${{ steps.run-tests.outputs.log }}" "pages/${TARGET_DIR}/log.html" + cp "${{ steps.run-tests.outputs.report }}" "pages/${TARGET_DIR}/report.html" + echo "target-dir=${TARGET_DIR}" >> "$GITHUB_OUTPUT" + + # Add a link to the parent directory to the log and report pages + for page in "pages/${TARGET_DIR}"/*.html; do + sed -i 's|||' "$page" + done + + # We can't pass this as a secret because then it's masked in the logs + # and we can't pass it as an Action variable because those are not + # available in called reusable workflows. + R2_E2E_TEST_DOMAIN="authd-e2e-test-logs.adrian-dombeck.workers.dev" + echo "log-url=https://${R2_E2E_TEST_DOMAIN}/${TARGET_DIR}/log.html" >> "$GITHUB_OUTPUT" + + - name: Upload test logs + id: upload-pages + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + run: | + aws s3 sync pages/ s3://authd-e2e-test-logs/ \ + --endpoint-url https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com \ + --region auto + + - name: Create commit status with link to the logs + id: create-commit-status + uses: actions/github-script@v8 + if: always() && steps.upload-pages.outcome == 'success' + with: + retries: 3 + script: | + const response = await github.rest.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: context.sha, + state: '${{ steps.run-tests.outputs.state }}', + target_url: '${{ steps.prepare-pages.outputs.log-url }}', + description: 'View logs', + context: 'e2e-tests (${{ inputs.ubuntu-version }}) (${{ inputs.broker }})', + }); + console.log(JSON.stringify(response, null, 2)); + + - name: Check the test result + uses: actions/github-script@v8 + if: always() + with: + script: | + const state = '${{ steps.run-tests.outputs.state }}'; + const logUrl = '${{ steps.prepare-pages.outputs.log-url }}'; + + if (state === 'failure') { + core.setFailed(`e2e-tests failed with exit code ${{ steps.run-tests.outputs.exit-code }}. See the logs at ${logUrl}`); + } else { + console.log(`::notice::e2e-tests passed, see the logs at ${logUrl}`); + } diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml new file mode 100644 index 0000000000..352e8b3b5b --- /dev/null +++ b/.github/workflows/e2e-tests.yaml @@ -0,0 +1,81 @@ +name: e2e-tests + +on: + push: + branches: + - main + paths: + - '**' + - '!.github/**' + - '.github/workflows/build-broker-snap.yaml' + - '.github/workflows/e2e-tests.yaml' + - '.github/workflows/e2e-tests-provision-and-run.yaml' + - '.github/workflows/e2e-tests-run.yaml' + - '.github/workflows/debian-build.yaml' + - '!.gitignore' + - '!.gitmodules' + - '!.golangci.yaml' + - '!*.md' + - '!COPYING*' + - '!docs/**' + - '!examplebroker/**' + - '!gotestcov' + - '!**_test.go' + - '!**/testdata/**' + - '!**/testutils/**' + + tags: + - "*" + pull_request: + paths: + - '**' + - '!.github/**' + - '.github/workflows/build-broker-snap.yaml' + - '.github/workflows/e2e-tests.yaml' + - '.github/workflows/e2e-tests-provision-and-run.yaml' + - '.github/workflows/e2e-tests-run.yaml' + - '.github/workflows/debian-build.yaml' + - '!.gitignore' + - '!.gitmodules' + - '!.golangci.yaml' + - '!*.md' + - '!COPYING*' + - '!docs/**' + - '!examplebroker/**' + - '!gotestcov' + - '!**_test.go' + - '!**/testdata/**' + - '!**/testutils/**' + workflow_dispatch: + schedule: + - cron: '0 0 * * 1' # Runs every Monday at midnight + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + compute-hash: + name: Compute hash of build-relevant files + runs-on: ubuntu-latest + outputs: + files-hash: ${{ steps.hash.outputs.hash }} + steps: + - uses: actions/checkout@v6 + - name: Compute hash of build-relevant files + id: hash + run: | + hash=$(bash .github/scripts/compute-deb-build-hash.sh) + echo "hash=${hash}" >> "${GITHUB_OUTPUT}" + + provision-and-run: + needs: compute-hash + strategy: + matrix: + ubuntu-version: [ 'noble', 'questing' ] + fail-fast: false + uses: ./.github/workflows/e2e-tests-provision-and-run.yaml + with: + ubuntu-version: ${{ matrix.ubuntu-version }} + files-hash: ${{ needs.compute-hash.outputs.files-hash }} + secrets: inherit diff --git a/.github/workflows/git.yaml b/.github/workflows/git.yaml new file mode 100644 index 0000000000..2555375d76 --- /dev/null +++ b/.github/workflows/git.yaml @@ -0,0 +1,25 @@ +name: Git Checks + +on: [pull_request] + +jobs: + block-fixup: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + - name: Block Fixup Commit Merge + run: | + PR_REF="${GITHUB_REF%/merge}/head" + BASE_REF="${GITHUB_BASE_REF}" + git fetch --no-tags --prune --progress --no-recurse-submodules --depth=1 origin "${BASE_REF}:__ci_base" + git fetch --no-tags --prune --progress --no-recurse-submodules --shallow-exclude="${BASE_REF}" origin "${PR_REF}:__ci_pr" + COMMIT_LIST=$(/usr/bin/git log --pretty=format:%s __ci_base..__ci_pr) + echo "Fixup commits:" + if echo "${COMMIT_LIST}" | grep -iE '^(fixup|squash|wip)'; then + exit 1 + fi + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true diff --git a/.github/workflows/git.yml b/.github/workflows/git.yml deleted file mode 100644 index e3f3a5cfc2..0000000000 --- a/.github/workflows/git.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: Git Checks - -on: [pull_request] - -jobs: - block-fixup: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - name: Block Fixup Commit Merge - uses: 13rac1/block-fixup-merge-action@v2.0.0 diff --git a/.github/workflows/oci-artifacts-cleanup.yaml b/.github/workflows/oci-artifacts-cleanup.yaml new file mode 100644 index 0000000000..4fccde4e29 --- /dev/null +++ b/.github/workflows/oci-artifacts-cleanup.yaml @@ -0,0 +1,56 @@ +name: Clean up OCI artifacts +on: + schedule: + # run every day at 5am UTC + - cron: '0 5 * * *' + workflow_dispatch: + +jobs: + cleanup-broker-snap: + runs-on: ubuntu-latest + permissions: + packages: write + strategy: + matrix: + broker: [ authd-msentraid, authd-google ] + steps: + - name: Delete broker snap OCI artifacts + uses: actions/delete-package-versions@v5 + with: + package-name: authd/${{ matrix.broker }}-snap + package-type: container + min-versions-to-keep: 10 + + cleanup-deb: + runs-on: ubuntu-latest + permissions: + packages: write + strategy: + matrix: + ubuntu-version: [ 'noble', 'questing', 'devel' ] + steps: + - name: Delete deb OCI artifacts + uses: actions/delete-package-versions@v5 + with: + package-name: authd/authd-deb-${{ matrix.ubuntu-version }} + package-type: container + min-versions-to-keep: 10 + + - name: Delete deb sources OCI artifacts + uses: actions/delete-package-versions@v5 + with: + package-name: authd/authd-deb-sources-${{ matrix.ubuntu-version }} + package-type: container + min-versions-to-keep: 10 + + cleanup-e2e-test-vm-image: + runs-on: ubuntu-latest + permissions: + packages: write + steps: + - name: Delete e2e-test VM image OCI artifacts + uses: actions/delete-package-versions@v5 + with: + package-name: authd/e2e-runner + package-type: container + min-versions-to-keep: 10 diff --git a/.github/workflows/qa.yaml b/.github/workflows/qa.yaml index 07921a1cb0..315eff5a71 100644 --- a/.github/workflows/qa.yaml +++ b/.github/workflows/qa.yaml @@ -1,57 +1,95 @@ -name: QA & sanity checks +name: authd QA & sanity checks on: push: branches: - main - paths-ignore: - - '.github/workflows/automatic-doc-checks.yml' - - '.readthedocs.yaml' - - 'docs/**' + paths: + - '**' + - '!.github/**' + - '.github/workflows/qa.yaml' + - '!.gitignore' + - '!.gitmodules' + - '!AGENTS.md' + - '!CODE_OF_CONDUCT.md' + - '!CONTRIBUTING.md' + - '!COPYING' + - '!COPYING.LESSER' + - '!README.md' + - '!SECURITY.md' + - '!authd-oidc-brokers/**' + - '!docs/**' + - '!examplebroker/**' + - '!gotestcov' + - '!snap/**' tags: - "*" pull_request: - paths-ignore: - - '.github/workflows/automatic-doc-checks.yml' - - '.readthedocs.yaml' - - 'docs/**' + paths: + - '**' + - '!.github/**' + - '.github/workflows/qa.yaml' + - '!.gitignore' + - '!.gitmodules' + - '!AGENTS.md' + - '!CODE_OF_CONDUCT.md' + - '!CONTRIBUTING.md' + - '!COPYING' + - '!COPYING.LESSER' + - '!README.md' + - '!SECURITY.md' + - '!authd-oidc-brokers/**' + - '!docs/**' + - '!examplebroker/**' + - '!gotestcov' + - '!snap/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true env: DEBIAN_FRONTEND: noninteractive GO_TESTS_TIMEOUT: 20m - apt_deps: >- + AUTHD_SSHD_STDERR_LOG_ALL_PAM_MESSAGES: true + c_build_dependencies: >- + clang-tools + clang + libglib2.0-dev libpam-dev + + go_build_dependencies: >- libglib2.0-dev + libpam-dev libpwquality-dev - test_apt_deps: >- - apparmor-profiles + go_test_dependencies: >- bubblewrap cracklib-runtime git-delta openssh-client openssh-server - # In Rust the grpc stubs are generated at build time - # so we always need to install the protobuf compilers - # when building the NSS crate. - protobuf_compilers: >- - protobuf-compiler - jobs: go-sanity: name: "Go: Code sanity" + permissions: {} runs-on: ubuntu-24.04 # ubuntu-latest-runner steps: + - uses: canonical/desktop-engineering/gh-actions/common/dpkg-install-speedup@main - name: Install dependencies run: | - sudo apt update - sudo apt install -y ${{ env.apt_deps }} - - uses: actions/checkout@v4 + # Install dependencies + set -eu + + sudo apt-get update + sudo apt-get install -y ${{ env.go_build_dependencies }} + - uses: actions/checkout@v6 - name: Go code sanity check - uses: canonical/desktop-engineering/gh-actions/go/code-sanity@main + uses: canonical/desktop-engineering/gh-actions/go/code-sanity@v2 with: golangci-lint-configfile: ".golangci.yaml" tools-directory: "tools" + token: ${{ secrets.GITHUB_TOKEN }} - name: Build cmd/authd with withexamplebroker tag run: | set -eu @@ -75,15 +113,33 @@ jobs: test -e pam/pam_authd.so test -e pam/go-exec/pam_authd_exec.so + shell-sanity: + name: "Shell: Code sanity" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Run ShellCheck + uses: ludeeus/action-shellcheck@master + with: + ignore_paths: "./authd-oidc-brokers" + rust-sanity: name: "Rust: Code sanity" + permissions: {} runs-on: ubuntu-24.04 # ubuntu-latest-runner steps: + - uses: canonical/desktop-engineering/gh-actions/common/dpkg-install-speedup@main - name: Install dependencies run: | - sudo apt update - sudo apt install -y ${{ env.apt_deps }} ${{ env.protobuf_compilers}} - - uses: actions/checkout@v4 + # Install dependencies + set -eu + + sudo apt-get update + # In Rust the grpc stubs are generated at build time + # so we always need to install the protobuf compilers + # when building the NSS crate. + sudo apt-get install -y protobuf-compiler + - uses: actions/checkout@v6 - name: Rust code sanity check uses: canonical/desktop-engineering/gh-actions/rust/code-sanity@main with: @@ -95,19 +151,21 @@ jobs: env: CFLAGS: "-Werror" steps: + - uses: canonical/desktop-engineering/gh-actions/common/dpkg-install-speedup@main - name: Install dependencies run: | + # Install dependencies set -eu - sudo apt update - sudo apt install -y ${{ env.apt_deps }} clang-tools clang + sudo apt-get update + sudo apt-get install -y ${{ env.c_build_dependencies }} - name: Prepare report dir run: | set -eu scan_build_dir=$(mktemp -d --tmpdir scan-build-dir-XXXXXX) echo SCAN_BUILD_REPORTS_PATH="${scan_build_dir}" >> $GITHUB_ENV - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Run scan build on GDM extensions run: | set -eu @@ -125,179 +183,178 @@ jobs: -lpam -shared -fPIC \ pam/go-exec/module.c - name: Upload scan build reports - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: authd-${{ github.job }}-artifacts-${{ github.run_attempt }} path: ${{ env.SCAN_BUILD_REPORTS_PATH }} - go-tests: - name: "Go: Tests" + go-tests-coverage: + name: "Go Tests with Coverage Collection" runs-on: ubuntu-24.04 # ubuntu-latest-runner - strategy: - fail-fast: false - matrix: - test: ["coverage", "race", "asan"] + env: + RAW_COVERAGE_DIR: ${{ github.workspace }}/raw-coverage + COVERAGE_DIR: ${{ github.workspace }}/coverage steps: - - name: Install dependencies - run: | - # Disable installing of locales and manpages - cat <<"EOF" | sudo tee /etc/dpkg/dpkg.cfg.d/01_nodoc - # Delete locales - path-exclude=/usr/share/locale/* - - # Delete man pages - path-exclude=/usr/share/man/* - - # Delete docs - path-exclude=/usr/share/doc/* - path-include=/usr/share/doc/*/copyright - EOF - - sudo apt update - - # The integration tests build the NSS crate, so we need the cargo build dependencies in order to run them. - sudo apt install -y ${{ env.apt_deps }} ${{ env.protobuf_compilers}} ${{ env.test_apt_deps }} - - # Load the apparmor profile for bubblewrap. - sudo ln -s /usr/share/apparmor/extra-profiles/bwrap-userns-restrict /etc/apparmor.d/ - sudo apparmor_parser /etc/apparmor.d/bwrap-userns-restrict - - - name: Install PAM and GLib debug symbols - continue-on-error: true - run: | - set -eu - sudo apt-get install ubuntu-dbgsym-keyring -y - echo "deb http://ddebs.ubuntu.com $(lsb_release -cs) main restricted universe multiverse - deb http://ddebs.ubuntu.com $(lsb_release -cs)-updates main restricted universe multiverse - deb http://ddebs.ubuntu.com $(lsb_release -cs)-proposed main restricted universe multiverse" | \ - sudo tee -a /etc/apt/sources.list.d/ddebs.list - # Sometimes ddebs archive is stuck, so in case of failure we need to go manual - sudo apt update -y || true - if ! sudo apt install -y libpam-modules-dbgsym libpam0*-dbgsym libglib2.0-0*-dbgsym; then - sudo apt install -y ubuntu-dev-tools - for pkg in pam glib2.0; do - pull-lp-debs "${pkg}" $(lsb_release -cs) - pull-lp-ddebs "${pkg}" $(lsb_release -cs) - done - sudo apt install -y ./libpam0*.*deb ./libpam-modules*.*deb ./libglib2.0-0*-dbgsym*.ddeb - sudo apt remove -y ubuntu-dev-tools - sudo apt autoremove -y - fi - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - - name: Install gotestfmt and our wrapper script - uses: canonical/desktop-engineering/gh-actions/go/gotestfmt@main - - - name: Install VHS and ttyd for integration tests - run: | - set -eu - go install github.com/charmbracelet/vhs@latest - - # VHS requires ttyd >= 1.7.2 to work properly. - wget https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.x86_64 - chmod +x ttyd.x86_64 - sudo mv ttyd.x86_64 /usr/bin/ttyd - - # VHS doesn't really use ffmpeg anymore now, but it still checks for it. - # Drop this when https://github.com/charmbracelet/vhs/pull/591 is released. - sudo ln -s /usr/bin/true /usr/local/bin/ffmpeg - - - name: Install rust - if: matrix.test != 'asan' - uses: actions-rs/toolchain@v1 - with: - profile: minimal - toolchain: nightly # We need nightly to enable instrumentation for coverage. - override: true - components: llvm-tools-preview - - name: Install grcov - if: matrix.test == 'coverage' - uses: baptiste0928/cargo-install@v3 - with: - crate: grcov - - name: Prepare tests artifacts path - run: | - set -eu - - artifacts_dir=$(mktemp -d --tmpdir authd-test-artifacts-XXXXXX) - echo AUTHD_TESTS_ARTIFACTS_PATH="${artifacts_dir}" >> $GITHUB_ENV - - echo ASAN_OPTIONS="log_path=${artifacts_dir}/asan.log:print_stats=true" >> $GITHUB_ENV - - - name: Install coverage collection dependencies - if: matrix.test == 'coverage' - run: | - set -eu - - # Dependendencies for C coverage collection - sudo apt install -y gcovr - - # Dependendencies for Go coverage collection - go install github.com/AlekSi/gocov-xml@latest - go install github.com/axw/gocov/gocov@latest - dotnet tool install -g dotnet-reportgenerator-globaltool - - - name: Run tests (with coverage collection) - if: matrix.test == 'coverage' + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-go-tests + - uses: ./.github/actions/setup-go-coverage-tests + # Installation of debug symbols takes a long time (and fails currently), + # so we skip it for now. Enable on demand. + # - uses: ./.github/actions/install-debug-symbols + # continue-on-error: true + + - name: Run tests with coverage collection env: G_DEBUG: "fatal-criticals" run: | set -euo pipefail # The coverage is not written if the output directory does not exist, so we need to create it. - cov_dir=${PWD}/coverage - codecov_dir=${cov_dir}/codecov - raw_cov_dir=${cov_dir}/raw - mkdir -p "${raw_cov_dir}" "${codecov_dir}" + mkdir -p "${RAW_COVERAGE_DIR}" # Print executed commands to ease debugging set -x + # Work around https://github.com/golang/go/issues/75031 + go env -w GOTOOLCHAIN="$(go version | awk '{ print $3 }')+auto" + # Overriding the default coverage directory is not an exported flag of go test (yet), so # we need to override it using the test.gocoverdir flag instead. #TODO: Update when https://go-review.googlesource.com/c/go/+/456595 is merged. go test -json -timeout ${GO_TESTS_TIMEOUT} -cover -covermode=set ./... -coverpkg=./... \ - -shuffle=on -failfast -args -test.gocoverdir="${raw_cov_dir}" | \ + -shuffle=on -args -test.gocoverdir="${RAW_COVERAGE_DIR}" | \ gotestfmt --logfile "${AUTHD_TESTS_ARTIFACTS_PATH}/gotestfmt.cover.log" - # Convert the raw coverage data into textfmt so we can merge the Rust one into it - go tool covdata textfmt -i="${raw_cov_dir}" -o="${cov_dir}/coverage.out" + # Upload the test output for the go-tests-coverage-retry job which retries + # the failed tests. + - name: Upload JSON test output on failure + if: failure() + uses: actions/upload-artifact@v7 + with: + name: coverage-test-output + path: ${{ env.AUTHD_TESTS_ARTIFACTS_PATH }}/gotestfmt.cover.stdout + + # Upload the raw coverage data so that the go-tests-coverage-retry job has + # all the data to generate the coverage report (if the tests succeed on + # retry). + - name: Upload raw coverage on failure + if: failure() + uses: actions/upload-artifact@v7 + with: + name: raw-coverage-data + path: ${{ env.RAW_COVERAGE_DIR }} + + - uses: ./.github/actions/generate-coverage-report + with: + codecov-token: ${{ secrets.CODECOV_TOKEN }} + + - uses: ./.github/actions/upload-test-artifacts + if: always() + + go-tests-coverage-retry: + name: "Retry Go Tests with Coverage Collection" + needs: go-tests-coverage + if: always() && needs.go-tests-coverage.result == 'failure' + runs-on: ubuntu-24.04 + env: + RAW_COVERAGE_DIR: ${{ github.workspace }}/raw-coverage + COVERAGE_DIR: ${{ github.workspace }}/coverage + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-go-tests + - uses: ./.github/actions/setup-go-coverage-tests + # Installation of debug symbols takes a long time (and fails currently), + # so we skip it for now. Enable on demand. + # - uses: ./.github/actions/install-debug-symbols + # continue-on-error: true + + - name: Download JSON output of failed tests + uses: actions/download-artifact@v8 + with: + name: coverage-test-output + path: /tmp/coverage-test-output + + - name: Download raw coverage data + uses: actions/download-artifact@v8 + with: + name: raw-coverage-data + path: ${{ env.RAW_COVERAGE_DIR }} - # Append the Rust coverage data to the Go one - cat "${raw_cov_dir}/rust-cov/rust2go_coverage" >>"${cov_dir}/coverage.out" + - name: Install gotest-rerun-failed + run: go install github.com/adombeck/gotest-rerun-failed@latest - # Filter out the testutils package and the pb.go file - grep -v -e "testutils" -e "pb.go" -e "testsdetection" "${cov_dir}/coverage.out" >"${cov_dir}/coverage.out.filtered" + - name: Retry failed tests with coverage collection + run: | + set -euo pipefail - # Generate the Cobertura report for Go and Rust - gocov convert "${cov_dir}/coverage.out.filtered" | gocov-xml > "${cov_dir}/coverage.xml" - reportgenerator -reports:"${cov_dir}/coverage.xml" -targetdir:"${cov_dir}" -reporttypes:Cobertura + # Print executed commands to ease debugging + set -x - # Generate the Cobertura report for C - gcovr --cobertura "${cov_dir}/Cobertura_C.xml" "${raw_cov_dir}" + test_output="/tmp/coverage-test-output/gotestfmt.cover.stdout" + for i in $(seq 1 3); do + echo "Retrying failed tests (attempt ${i})" + gotest-rerun-failed -json -timeout ${GO_TESTS_TIMEOUT} -cover -covermode=set -- -coverpkg=./... \ + -shuffle=on -args -test.gocoverdir="${RAW_COVERAGE_DIR}" \ + < "${test_output}" \ + | gotestfmt --logfile "${AUTHD_TESTS_ARTIFACTS_PATH}/gotestfmt.cover.retry-$i.log" \ + && exit_code=0 || exit_code=$? + if [ "${exit_code}" -eq 0 ]; then + break + fi + if [ "${i}" -eq 3 ]; then + echo "Tests failed 3 times, giving up" + exit ${exit_code} + fi + test_output="${AUTHD_TESTS_ARTIFACTS_PATH}/gotestfmt.cover.retry-$i.stdout" + done - # Merge Cobertura reports into a single one - reportgenerator -reports:"${cov_dir}/Cobertura.xml;${cov_dir}/Cobertura_C.xml" \ - -targetdir:"${codecov_dir}" -reporttypes:Cobertura + - uses: ./.github/actions/generate-coverage-report + with: + codecov-token: ${{ secrets.CODECOV_TOKEN }} - # Store the coverage directory for the next steps - echo COVERAGE_DIR="${codecov_dir}" >> ${GITHUB_ENV} + - uses: ./.github/actions/upload-test-artifacts + if: always() - - name: Run tests (with race detector) - if: matrix.test == 'race' + go-tests-race: + name: "Go Tests with Race Detector" + runs-on: ubuntu-24.04 # ubuntu-latest-runner + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-go-tests + # Installation of debug symbols takes a long time (and fails currently), + # so we skip it for now. Enable on demand. + # - uses: ./.github/actions/install-debug-symbols + # continue-on-error: true + + - name: Run tests with race detector env: GO_TESTS_TIMEOUT: 35m AUTHD_TESTS_SLEEP_MULTIPLIER: 3 GORACE: log_path=${{ env.AUTHD_TESTS_ARTIFACTS_PATH }}/gorace.log run: | go test -json -timeout ${GO_TESTS_TIMEOUT} -race -failfast ./... | \ - gotestfmt --logfile "${AUTHD_TESTS_ARTIFACTS_PATH}/gotestfmt.race.log" + gotestfmt --logfile "${AUTHD_TESTS_ARTIFACTS_PATH}/gotestfmt.race.log" || exit_code=$? + + if [ "${exit_code:-0}" -ne 0 ]; then + cat "${AUTHD_TESTS_ARTIFACTS_PATH}"/gorace.log* || true + exit ${exit_code} + fi - - name: Run PAM tests (with Address Sanitizer) - if: matrix.test == 'asan' + - uses: ./.github/actions/upload-test-artifacts + if: always() + + go-tests-asan: + name: "Go PAM tests with Address Sanitizer" + runs-on: ubuntu-24.04 # ubuntu-latest-runner + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-go-tests + # Installation of debug symbols takes a long time (and fails currently), + # so we skip it for now. Enable on demand. + # - uses: ./.github/actions/install-debug-symbols + # continue-on-error: true + + - name: Run PAM tests with Address Sanitizer env: # Do not optimize, keep debug symbols and frame pointer for better # stack trace information in case of ASAN errors. @@ -311,6 +368,11 @@ jobs: # Print executed commands to ease debugging set -x + echo "::group::Install llvm-symbolizer" + # For llvm-symbolizer + sudo apt-get install -y llvm + echo "::endgroup::" + go test -C ./pam/internal -json -asan -gcflags=all="${GO_GC_FLAGS}" -failfast -timeout ${GO_TESTS_TIMEOUT} ./... | \ gotestfmt --logfile "${AUTHD_TESTS_ARTIFACTS_PATH}/gotestfmt.pam-internal-asan.log" || exit_code=$? if [ -n "${exit_code:-}" ]; then @@ -348,24 +410,5 @@ jobs: exit ${exit_code} - - name: Upload coverage to Codecov - if: matrix.test == 'coverage' - uses: codecov/codecov-action@v5 - with: - directory: ${{ env.COVERAGE_DIR }} - files: ${{ env.COVERAGE_DIR }}/Cobertura.xml - token: ${{ secrets.CODECOV_TOKEN }} - - - name: Upload coverage artifacts - if: matrix.test == 'coverage' && github.ref == 'refs/heads/main' - uses: actions/upload-artifact@v4 - with: - name: coverage.zip - path: ${{ env.COVERAGE_DIR }} - - - name: Upload test artifacts + - uses: ./.github/actions/upload-test-artifacts if: always() - uses: actions/upload-artifact@v4 - with: - name: authd-${{ github.job }}-${{ matrix.test }}-artifacts-${{ github.run_attempt }} - path: ${{ env.AUTHD_TESTS_ARTIFACTS_PATH }} diff --git a/.github/workflows/tics-run.yaml b/.github/workflows/tics-run.yaml new file mode 100644 index 0000000000..1167a9ed8c --- /dev/null +++ b/.github/workflows/tics-run.yaml @@ -0,0 +1,116 @@ +name: TICS QA Analysis + +on: + schedule: + - cron: '0 0 * * 1' # Runs every Monday at midnight + workflow_dispatch: + + +env: + DEBIAN_FRONTEND: noninteractive + build_dependencies: >- + clang-tools + clang + dotnet8 + libglib2.0-dev + libpam-dev + libpwquality-dev + rustup + +jobs: + tics: + name: TIOBE TICS Framework + runs-on: [self-hosted, amd64, tiobe, noble] + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - uses: canonical/desktop-engineering/gh-actions/common/dpkg-install-speedup@main + - name: Install dependencies + run: | + set -eu + + sudo apt-get update + sudo apt-get install -y ${{ env.build_dependencies }} + + go install honnef.co/go/tools/cmd/staticcheck@latest + + - name: Update Rust version + run: | + rustup update stable + + - uses: canonical/desktop-engineering/gh-actions/go/generate@main + with: + tools-directory: ./tools + + - name: Fetch last successful QA runs ids + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -eu + echo "LAST_AUTHD_QA_ID=$(gh run list --workflow 'authd QA & sanity checks' --limit 1 --status success --json databaseId -b main | jq '.[].databaseId')" >> $GITHUB_ENV + echo "LAST_BROKERS_QA_ID=$(gh run list --workflow 'Brokers QA & sanity checks' --limit 1 --status success --json databaseId -b main | jq '.[].databaseId')" >> $GITHUB_ENV + + - name: Download coverage artifact from authd QA + uses: actions/download-artifact@v8 + with: + github-token: ${{ github.token }} + path: .artifacts/authd + run-id: ${{ env.LAST_AUTHD_QA_ID }} + + - name: Download coverage artifact from brokers QA + uses: actions/download-artifact@v8 + with: + github-token: ${{ github.token }} + path: .artifacts/brokers + run-id: ${{ env.LAST_BROKERS_QA_ID }} + + - name: Merge coverage reports + run: | + set -eu + + dotnet tool install -g dotnet-reportgenerator-globaltool + + export PATH="$PATH:/home/ubuntu/.dotnet/tools" + + mv .artifacts/authd/coverage/Cobertura.xml .artifacts/authd-coverage.xml + mv .artifacts/brokers/Cobertura.xml .artifacts/broker-coverage.xml + + # TICS expects the coverage report to: + # - be in a directory named 'coverage' in the current working directory + mkdir -p coverage + + # - have a single report named coverage.xml + reportgenerator -reports:.artifacts/*.xml -targetdir:coverage -reporttypes:Cobertura + mv coverage/Cobertura.xml coverage/coverage.xml + + - name: Build artifacts + run: | + set -eu + + # TICS needs to build the artifacts in order to run the analysis. + # Since it uses the GOTOOLCHAIN=local stanza, it's better if we prebuild it to make sure that the Go + # toolchain setup by the action is properly updated to the one we defined in go.mod. Prebuilding also + # helps to speed up the TICS analysis, as we would already have the build cache populated. + find pam -name '*.so' -print -delete + go build ./cmd/authd + go -C ./authd-oidc-brokers build -o authd-oidc ./cmd/authd-oidc + go -C ./authd-oidc-brokers build -tags=withgoogle -o authd-google ./cmd/authd-oidc + + # We also need to build libhimmelblau when building the msentraid variant + git submodule update --init + cd ./authd-oidc-brokers + go generate --tags=withmsentraid ./internal/providers/msentraid/... + go build -tags=withmsentraid -o authd-msentraid ./cmd/authd-oidc + + - name: TICS Analysis + uses: tiobe/tics-github-action@v3 + with: + mode: qserver + project: authd + branchdir: . + viewerUrl: https://canonical.tiobe.com/tiobeweb/TICS/api/cfg?name=GoProjects + ticsAuthToken: ${{ secrets.TICSAUTHTOKEN }} + installTics: true diff --git a/.github/workflows/update-broker-variant-branches.yaml b/.github/workflows/update-broker-variant-branches.yaml new file mode 100644 index 0000000000..ce50edee24 --- /dev/null +++ b/.github/workflows/update-broker-variant-branches.yaml @@ -0,0 +1,49 @@ +name: Auto update broker variant branches +on: + push: + branches: + - main + paths: + - "authd-oidc-brokers/**" + - "!authd-oidc-brokers/e2e-tests/**" + - "!authd-oidc-brokers/po/**" + - "!authd-oidc-brokers/.gitignore" + - "snap/**" + workflow_dispatch: + +concurrency: + group: auto-update-broker-variants + cancel-in-progress: true + +permissions: + pull-requests: write + contents: write + +jobs: + update-snap-branches: + name: Update snap branches + strategy: + matrix: + branch_name: ["google","msentraid","oidc"] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Prepare snapcraft.yaml + # We can't symlink the snapcraft.yaml file here because it causes launchpad to fail with: + # "The top level of snapcraft.yaml from ~ubuntu-enterprise-desktop/authd/+git/authd:oidc is not a mapping" + run: ./snap/scripts/prepare-variant --copy --broker ${{ matrix.branch_name }} + + - name: Commit broker variant files + run: | + set -eux + # We have to use --force because we added the broker variant files to .gitignore + git add --force . + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git commit -m "Copy ${{ matrix.branch_name }} variant files" + + - name: Push branch + run: git push --force origin HEAD:${{ matrix.branch_name }} diff --git a/.github/workflows/validate-dependabot.yaml b/.github/workflows/validate-dependabot.yaml index 135dbdb499..d58c0e89e2 100644 --- a/.github/workflows/validate-dependabot.yaml +++ b/.github/workflows/validate-dependabot.yaml @@ -3,13 +3,13 @@ name: dependabot validate on: pull_request: paths: - - '.github/dependabot.yml' + - '.github/dependabot.yaml' jobs: validate: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: marocchino/validate-dependabot@v3 id: validate - uses: marocchino/sticky-pull-request-comment@v2 @@ -17,3 +17,7 @@ jobs: with: header: validate-dependabot message: ${{ steps.validate.outputs.markdown }} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true \ No newline at end of file diff --git a/.gitignore b/.gitignore index 03074c6b0a..63dbb8ad56 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,15 @@ vendor_rust/ # Go workspace file go.work + +# Symlinks to broker variant files +snap/icon.svg +snap/snapcraft.yaml +authd-oidc-brokers/conf/authd.conf +authd-oidc-brokers/conf/broker.conf + +# Useful for running e2e-tests locally +e2e-tests/e2e-tests.env + +# e2e tests python cache +__pycache__/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000000..420518c3f1 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "authd-oidc-brokers/third_party/libhimmelblau"] + path = authd-oidc-brokers/third_party/libhimmelblau + url = https://gitlab.com/samba-team/libhimmelblau.git diff --git a/.golangci.yaml b/.golangci.yaml index 63c379b05d..5bb0c11024 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -1,6 +1,7 @@ # This is for linting. To run it, please use: # golangci-lint run ${MODULE}/... [--fix] +version: "2" linters: # linters to run in addition to default ones enable: @@ -11,9 +12,7 @@ linters: - errorlint - forbidigo - forcetypeassert - - gci - godot - - gofmt - gosec - misspell - nakedret @@ -24,41 +23,65 @@ linters: - unconvert - unparam - whitespace - -run: - timeout: 5m - -# Get all linter issues, even if duplicated + settings: + # Forbid the usage of deprecated ioutil and debug prints + forbidigo: + forbid: + - pattern: ioutil\. + - pattern: ^print.*$ + # Never have naked return ever + nakedret: + max-func-lines: 1 + nolintlint: + # Require an explanation of nonzero length after each nolint directive. + require-explanation: true + # Require nolint directives to mention the specific linter being suppressed. + require-specific: true + exclusions: + generated: lax + rules: + # EXC0001 errcheck: most errors are in defer calls, which are safe to ignore and idiomatic Go (would be good to only ignore defer ones though) + - path: (.+)\.go$ + text: Error return value of .((os\.)?std(out|err)\..*|.*Close|.*Flush|os\.Remove(All)?|.*print(f|ln)?|os\.(Un)?Setenv|w\.Stop). is not checked + # EXC0008 gosec: duplicated of errcheck + - path: (.+)\.go$ + text: (G104|G307) + # EXC0010 gosec: False positive is triggered by 'src, err := ioutil.ReadFile(filename)' + - path: (.+)\.go$ + text: Potential file inclusion via variable + # We don't wrap errors on purpose + - path: (.+)\.go$ + text: non-wrapping format verb for fmt.Errorf. Use `%w` to format errors + # We want named parameters even if unused, as they help better document the function + - path: (.+)\.go$ + text: unused-parameter + # Sometimes it is more readable it do a `if err:=a(); err != nil` tha simpy `return a()` + - path: (.+)\.go$ + text: if-return + # gosec G101: hardcoded credentials - false positive for test data (fake passwords, tokens). + - path: _test\.go$ + text: G101 + # gosec G703: path traversal via taint analysis - false positive in test code where file operation arguments are test-controlled. + - path: (_test\.go$|internal/testutils/) + text: G703 + # We don't want to rename this package at this point + - path: internal/users/types/(.+)\.go$ + text: "var-naming: avoid meaningless package names" + paths: + - third_party$ + - builtin$ + - examples$ issues: - exclude-use-default: false max-issues-per-linter: 0 max-same-issues: 0 - fix: false # we don’t want this in CI - exclude: - # EXC0001 errcheck: most errors are in defer calls, which are safe to ignore and idiomatic Go (would be good to only ignore defer ones though) - - 'Error return value of .((os\.)?std(out|err)\..*|.*Close|.*Flush|os\.Remove(All)?|.*print(f|ln)?|os\.(Un)?Setenv|w\.Stop). is not checked' - # EXC0008 gosec: duplicated of errcheck - - (G104|G307) - # EXC0010 gosec: False positive is triggered by 'src, err := ioutil.ReadFile(filename)' - - Potential file inclusion via variable - # We don't wrap errors on purpose - - non-wrapping format verb for fmt.Errorf. Use `%w` to format errors - # We want named parameters even if unused, as they help better document the function - - unused-parameter - # Sometimes it is more readable it do a `if err:=a(); err != nil` tha simpy `return a()` - - if-return - -linters-settings: - # Forbid the usage of deprecated ioutil and debug prints - forbidigo: - forbid: - - ioutil\. - - ^print.*$ - # Never have naked return ever - nakedret: - max-func-lines: 1 - nolintlint: - # Require an explanation of nonzero length after each nolint directive. - require-explanation: true - # Require nolint directives to mention the specific linter being suppressed. - require-specific: true + fix: false +formatters: + enable: + - gci + - gofmt + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/.shellcheckrc b/.shellcheckrc new file mode 100644 index 0000000000..25c98c8fde --- /dev/null +++ b/.shellcheckrc @@ -0,0 +1,2 @@ +source-path=SCRIPTDIR +external-sources=true diff --git a/.wokeignore b/.wokeignore deleted file mode 120000 index 19f3fe4bda..0000000000 --- a/.wokeignore +++ /dev/null @@ -1 +0,0 @@ -docs/.wokeignore \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..b6501c5d4f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,112 @@ +# authd AI Coding Instructions + +## Project Overview + +authd is an authentication daemon for cloud-based identity providers (MS Entra ID, Google IAM). It's a hybrid Go/Rust/C project that provides: +- **authd daemon** (Go): Main authentication service with gRPC API +- **PAM modules** (Go): Two implementations - native shared library for GDM, and C-wrapper+executable for other PAM apps +- **NSS module** (Rust): Name Service Switch integration for user/group lookups +- **Brokers** (Go): Pluggable D-Bus-based providers that interface with identity providers + +## Architecture Fundamentals + +### Component Communication +- **Internal**: gRPC for PAM/NSS ↔ authd (defined in `internal/proto/authd/authd.proto`) +- **External**: D-Bus for authd ↔ brokers (interface in `examplebroker/com.ubuntu.auth.ExampleBroker.xml`) +- **Daemon**: Systemd socket activation via `internal/daemon/daemon.go` +- **Data flow**: PAM/NSS → gRPC → authd → D-Bus → broker → identity provider + +### Key Directories +- `cmd/authd/`, `cmd/authctl/`: Main binaries +- `internal/brokers/`: Broker manager and D-Bus integration +- `internal/services/`: gRPC service implementations (PAM, NSS, user management) +- `internal/users/`: User/group database management (SQLite + BoltDB legacy) +- `pam/`: PAM module with two build modes (see `pam/Hacking.md`) +- `nss/`: Rust NSS module using `libnss` crate +- `examplebroker/`: Reference broker implementation + +## Building & Testing + +### Build Commands +```bash +# Full Debian package (includes all components + tests) +debuild --prepend-path=${HOME}/.cargo/bin + +# Individual components (development) +go build ./cmd/authd # authd daemon only +go generate ./pam/ && go build -tags pam_binary_exec -o ./pam/authd-pam ./pam # PAM test client +cargo build # NSS (debug mode) +``` + +### Testing Conventions +- **Run tests**: `go test ./...` (add `-race` for race detection) +- **Golden files**: Use `internal/testutils/golden` package + - Update with `TESTS_UPDATE_GOLDEN=1 go test ./...` + - Compare/update: `golden.CheckOrUpdate(t, got)` or `golden.CheckOrUpdateYAML(t, got)` +- **Test helpers with underscores**: Functions prefixed `Z_ForTests_` are test-only exports (e.g., `Z_ForTests_CreateDBFromYAML`) +- **Environment variables**: + - `AUTHD_SKIP_EXTERNAL_DEPENDENT_TESTS=1`: Skip tests requiring external tools (vhs) + - `AUTHD_SKIP_ROOT_TESTS=1`: Skip tests that fail when run as root + +### Code Generation +Critical: Run `go generate` before building PAM or when modifying protobuf files: +```bash +go generate ./pam/ # PAM module (creates .so files) +go generate ./internal/proto/authd/ # Regenerate protobuf +go generate ./shell-completion/ # Shell completions +``` + +## Project-Specific Patterns + +### Broker Integration +- Brokers are discovered from `/usr/share/authd/brokers/*.conf` (D-Bus service files) +- First broker is always the local broker (no config file) +- Manager in `internal/brokers/manager.go` handles session→broker and user→broker mappings +- Brokers must implement the D-Bus interface defined in `internal/brokers/dbusbroker.go` + +### PAM Module Dual Mode +The PAM module has two implementations (see `pam/Hacking.md`): +1. **GDM mode** (`pam_authd.so`): Native Go shared library with GDM JSON protocol support +2. **Generic mode** (`pam_authd_exec.so` + `authd-pam` executable): C wrapper launching Go program via private D-Bus + - Required for reliability with non-GDM PAM apps (avoids Go threading issues) + +### Database & User Management +- Migrating from BoltDB to SQLite: `internal/users/db/` handles both +- User/group data cached locally in `/var/lib/authd/authd.db` +- ID allocation: `internal/users/idlimitsgenerator/` generates UID/GID ranges +- Group file updates: `internal/users/localentries/` handles local system files + +### Testing Patterns +- Use `testify/require` for assertions (not `assert`) +- Golden files in `testdata/golden/` subdirectories matching test structure +- Test-only exports via `export_test.go` files (no build tag, package-level visibility) +- PAM integration tests use `vhs` tapes in `pam/integration-tests/testdata/tapes/` + +## Common Workflows + +### Adding a gRPC Service Method +1. Update `internal/proto/authd/authd.proto` +2. Run `go generate ./internal/proto/authd/` +3. Implement in service (e.g., `internal/services/pam/pam.go`) +4. Add tests with golden files + +### Creating a New Broker +1. Implement D-Bus interface from `examplebroker/com.ubuntu.auth.ExampleBroker.xml` +2. Create `.conf` file in `/usr/share/authd/brokers/` +3. Register D-Bus service with systemd + +### Debugging +- Logs via `github.com/canonical/authd/log` package (supports systemd journal) +- Enable debug: `authd daemon -vvv` (3 levels of verbosity) +- Socket path: `/run/authd.sock` (override with `AUTHD_NSS_SOCKET` for NSS tests) + +## Dependencies & Tools +- **Go**: See `go.mod` for version requirements, uses go modules with vendoring +- **Rust**: Cargo with vendor filtering (see `Cargo.toml` workspace) +- **Required**: `libpam-dev`, `libglib2.0-dev`, `protoc`, `cargo-vendor-filterer` +- **Optional**: `vhs` (PAM CLI tests), `delta` (colored diffs in tests) + +## Code Style +- Follow [Effective Go](https://go.dev/doc/effective_go) for Go style conventions +- Use `go fmt` and `gofmt -s` +- Rust: Standard cargo fmt conventions diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 65921022ef..b6321bdf95 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,9 +23,14 @@ These are mostly guidelines, not rules. Use your best judgment and feel free to - [Building authd only](#building-authd-only) - [Building the PAM module only](#building-the-pam-module-only) - [Building the NSS module only](#building-the-nss-module-only) + - [Building the broker](#building-the-broker) - [About the test suite](#about-the-testsuite) - [Tests with dependencies](#tests-with-dependencies) - [Code style](#code-style) + - [Contributing to the documentation](#contributing-to-the-documentation) + - [Building the documentation](#building-the-documentation) + - [Testing the documentation](#testing-the-documentation) + - [Open Documentation Academy](#open-documentation-academy) - [Contributor License Agreement](#contributor-license-agreement) - [Getting help](#getting-help) @@ -39,16 +44,16 @@ We take our community seriously, holding ourselves and other contributors to hig Contributions are made to this project via Issues and Pull Requests (PRs). These are some general guidelines that cover both: * To report security vulnerabilities, use the advisories page of the repository and not a public bug report. Please use [launchpad private bugs](https://bugs.launchpad.net/ubuntu/+source/authd/+filebug), which is monitored by our security team. On an Ubuntu machine, it’s best to use `ubuntu-bug authd` to collect relevant information. -* General issues or feature requests should be reported to the [GitHub Project](https://github.com/ubuntu/authd/issues) +* General issues or feature requests should be reported to the [GitHub Project](https://github.com/canonical/authd/issues) * If you've never contributed before, see [this post on ubuntu.com](https://ubuntu.com/community/contribute) for resources and tips on how to get started. -* Existing Issues and PRs should be searched for on the [project's repository](https://github.com/ubuntu/authd) before creating your own. +* Existing Issues and PRs should be searched for on the [project's repository](https://github.com/canonical/authd) before creating your own. * While we work hard to ensure that issues are handled in a timely manner, it can take time to investigate the root cause. A friendly ping in the comment thread to the submitter or a contributor can help draw attention if your issue is blocking. ### Issues -Issues can be used to report problems with the software, request a new feature or discuss potential changes before a PR is created. When you [create a new Issue](https://github.com/ubuntu/authd/issues), a template will be loaded that will guide you through collecting and providing the information that we need to investigate. +Issues can be used to report problems with the software, request a new feature or discuss potential changes before a PR is created. When you [create a new Issue](https://github.com/canonical/authd/issues), a template will be loaded that will guide you through collecting and providing the information that we need to investigate. -If you find an Issue that addresses the problem you're having, please add your own reproduction information to the existing issue rather than creating a new one. Adding a [reaction](https://github.blog/2016-03-10-add-reactions-to-pull-requests-issues-and-comments/) can also help be indicating to our maintainers that a particular problem is affecting more than just the reporter. +If you find an Issue that addresses the problem you're having, please add your own reproduction information to the existing issue rather than creating a new one. Adding a [reaction](https://github.blog/2016-03-10-add-reactions-to-pull-requests-issues-and-comments/) can also help by indicating to our maintainers that a particular problem is affecting more than just the reporter. ### Pull Requests @@ -57,7 +62,7 @@ PRs to our project are always welcome and can be a quick way to get your fix or * Only fix/add the functionality in question **OR** address wide-spread whitespace/style issues, not both. * Add unit or integration tests for fixed or changed functionality. * Address a single concern in the least possible number of changed lines. -* Include documentation in the repo or on our [docs site](https://github.com/ubuntu/authd/wiki). +* Include documentation in the repo or on our [docs site](https://documentation.ubuntu.com/authd/stable/). * Be accompanied by a complete Pull Request template (loaded automatically when a PR is created). For changes that address core functionality or that would require breaking changes (e.g. a major release), it's best to open an Issue to discuss your proposal first. This is not required but can save time when creating and reviewing changes. @@ -76,6 +81,11 @@ In general, we follow the ["fork-and-pull" Git workflow](https://github.com/susa Once merged into the main branch, `po` files and any documentation change will be automatically updated. Updates to these files are therefore not necessary in the pull request itself, which helps minimize diff review. +The authd documentation is published in **edge-docs** and **stable-docs** versions. Only the edge version is updated when documentation changes are merged into the main branch. +If a documentation change should be applied to the stable documentation *before* the next release, create a separate PR +against the `stable-docs` branch after your main PR has been merged, with the changes to the documentation cherry-picked +from your main PR. + ## Contributing to the code ### Required dependencies @@ -153,7 +163,7 @@ This last command will produce two libraries (`./pam/pam_authd.so` and `./pam/go These modules must be copied to `/usr/lib/$(gcc -dumpmachine)/security/` while the executable must be copied to `/usr/libexec/authd-pam`. For further information about the PAM module architecture and testing see the -[PAM Hacking](https://github.com/ubuntu/authd/blob/main/pam/Hacking.md) page. +[PAM Hacking](https://github.com/canonical/authd/blob/main/pam/Hacking.md) page. #### Building the NSS module only @@ -167,6 +177,41 @@ This will build a debug release of the NSS module. The library resulting from the build is located in `./target/debug/libnss_authd.so`. This module must be copied to `/usr/lib/$(gcc -dumpmachine)/libnss_authd.so.2`. +### Building the broker + +The authd brokers are packaged as separate snaps that are built and released +independently from authd. The source code for the brokers is located in +`./authd-oidc-brokers` and the snap packaging files are located in `./snap`. + +To build the broker snap for a specific broker variant, follow these steps from the top of the source tree: + +1. Ensure that the submodules are checked out: + + ```shell + git submodule update --init --recursive + ``` + +2. Prepare the `snapcraft.yaml` for the desired broker variant: + + ```shell + ./snap/scripts/prepare-variant --broker + ``` + + where `` is one of `oidc`, `msentraid`, or `google`. + +3. Build the broker snap: + + ```shell + snapcraft pack + ``` + +When the build succeeds, the resulting `.snap` file is created in the current working directory. +You can install the locally built broker snap for development with a command such as: + +```shell +snap install --dangerous ./path/to/broker.snap +``` + ### About the test suite The project includes a comprehensive test suite made of unit and integration tests. All the tests must pass before the review is considered. If you have troubles with the test suite, feel free to mention it in your PR description. @@ -179,17 +224,98 @@ The test suite must pass before merging the PR to our main branch. Any new featu #### Tests with dependencies -Some tests, such as the [PAM CLI tests](https://github.com/ubuntu/authd/blob/5ba54c0a573f34e99782fe624b090ab229798fc3/pam/integration-tests/integration_test.go#L21), use external tools such as [vhs](https://github.com/charmbracelet/vhs) +Some tests, such as the [PAM CLI tests](https://github.com/canonical/authd/blob/5ba54c0a573f34e99782fe624b090ab229798fc3/pam/integration-tests/integration_test.go#L21), use external tools such as [VHS](https://github.com/charmbracelet/vhs) to record and run the tape files needed for the tests. Those tools are not included in the project dependencies and must be installed manually. Information about these tools and their usage will be linked below: -- [vhs](https://github.com/charmbracelet/vhs?tab=readme-ov-file#tutorial): tutorial on using vhs as a CLI-based video recorder +- [VHS](https://github.com/charmbracelet/vhs?tab=readme-ov-file#tutorial): tutorial on using VHS as a CLI-based video recorder ### Code style This project follow the Go code-style. For more detailed information about the code style in use, please check . +## Contributing to the documentation + +You can contribute to the documentation in various ways. + +At the top of each page in the documentation, there is a **Give feedback** +button. If you find an issue in the documentation, clicking this button will +open an Issue submission on GitHub for the specific page. + +For minor changes, such as fixing a single typo, you can click the **pencil** +icon at the top right of any page. This will open up the source file in GitHub so +that you can make edits directly. + +For more significant changes to the content or organization of the +documentation, you should create your own fork and follow the steps +outlined in the section on [pull requests](#pull-requests). + +### Building the documentation + +After cloning your fork, change into the `/docs/` directory. +The documentation is written in markdown files grouped under +[Diátaxis](https://diataxis.fr/) categories. + +A makefile is used to preview and test the documentation locally. +To view all the possible commands, run `make` without arguments. + +The command `make run` will serve the documentation at port `8000` on +`localhost`. You can then preview the documentation in your browser and the +preview will automatically update with each change that you make. + +To clean the build environment at any point, run `make clean`. + +When you submit a PR, there are automated checks for typos and broken links. +Please run the tests locally before submitting the PR to save yourself and your +reviewers time. + +### Testing the documentation + +Automatic checks will be run on any PR relating to documentation to verify +spelling and the validity of links. Before submitting a PR, you can check for +any issues locally: + +- Check the spelling: `make spelling` +- Check the validity of links: `make linkcheck` + +Doing these checks locally is good practice. You are less likely to run into +failed CI checks after your PR is submitted and the reviewer of your PR can +more quickly focus on the substance of your contribution. + +If the documentation builds, your PR will generate a preview of the +documentation on Read the Docs. This preview appears as a check in the CI. +Click on the check to open the preview and confirm that your changes have been +applied successfully. + +### Open Documentation Academy + +authd is a proud member of the [Canonical Open Documentation +Academy](https://github.com/canonical/open-documentation-academy) (CODA). + +CODA is an initiative to encourage open source contributions from the +community, and to provide help, advice and mentorship to people making their +first contributions. + +A key aim of the initiative is to lower the barrier to successful open-source +software contributions by making documentation into the gateway, and it’s a +great way to make your first open source contributions to projects like authd. + +The best way to get started is to take a look at our [project-related +documentation +tasks](https://github.com/canonical/open-documentation-academy/issues) and read +our [Getting started +guide](https://discourse.ubuntu.com/t/getting-started/42769). Tasks typically +include testing and fixing documentation pages, updating outdated content, and +restructuring large documents. We'll help you see those tasks through to +completion. + +You can get involved the with the CODA community through: + +* The [discussion forum](https://discourse.ubuntu.com/c/community/open-documentation-academy/166) on the Ubuntu Community Hub +* The [Matrix channel](https://matrix.to/#/#documentation:ubuntu.com) for interactive chat +* [Fosstodon](https://fosstodon.org/@CanonicalDocumentation) for the latest updates and events + ## Contributor License Agreement It is a requirement that you sign the [Contributor License Agreement](https://ubuntu.com/legal/contributors) in order to contribute to this project. diff --git a/Cargo.lock b/Cargo.lock index 4cdf3abe21..d1c0c85a24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,9 +13,9 @@ dependencies = [ [[package]] name = "adler2" -version = "2.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aho-corasick" @@ -43,15 +43,15 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.98" +version = "1.0.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" [[package]] name = "async-trait" -version = "0.1.88" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", @@ -66,9 +66,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "axum" @@ -138,36 +138,36 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.9.0" +version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d" [[package]] name = "bumpalo" -version = "3.17.0" +version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] name = "bytes" -version = "1.10.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.21" +version = "1.2.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8691782945451c1c383942c4874dbe63814f61cb57ef773cda2972682b7bb3c0" +checksum = "42bc4aea80032b7bf409b0bc7ccad88853858911b7713a8062fdc0623867bedc" dependencies = [ "shlex", ] [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" [[package]] name = "chrono" @@ -199,18 +199,18 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "crc32fast" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] [[package]] name = "ctor" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4735f265ba6a1188052ca32d461028a7d1125868be18e287e756019da7607b5" +checksum = "67773048316103656a637612c4a62477603b777d91d9c62ff2290f9cde178fdb" dependencies = [ "ctor-proc-macro", "dtor", @@ -218,9 +218,9 @@ dependencies = [ [[package]] name = "ctor-proc-macro" -version = "0.0.5" +version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f211af61d8efdd104f96e57adf5e426ba1bc3ed7a4ead616e15e5881fd79c4d" +checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" [[package]] name = "deranged" @@ -233,18 +233,18 @@ dependencies = [ [[package]] name = "dtor" -version = "0.0.6" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97cbdf2ad6846025e8e25df05171abfb30e3ababa12ee0a0e44b9bbe570633a8" +checksum = "e58a0764cddb55ab28955347b45be00ade43d4d6f3ba4bf3dc354e4ec9432934" dependencies = [ "dtor-proc-macro", ] [[package]] name = "dtor-proc-macro" -version = "0.0.5" +version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7454e41ff9012c00d53cf7f475c5e3afa3b91b7c90568495495e8d9bf47a1055" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" [[package]] name = "either" @@ -260,12 +260,12 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.11" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" +checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -282,9 +282,9 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flate2" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" +checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" dependencies = [ "crc32fast", "miniz_oxide", @@ -337,9 +337,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", "libc", @@ -355,9 +355,9 @@ checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "h2" -version = "0.4.10" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9421a676d1b147b16b82c9225157dc629087ef8ec4d5e2960f9437a90dac0a5" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" dependencies = [ "atomic-waker", "bytes", @@ -374,9 +374,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.3" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" [[package]] name = "heck" @@ -449,13 +449,14 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" dependencies = [ + "atomic-waker", "bytes", "futures-channel", - "futures-util", + "futures-core", "h2", "http", "http-body", @@ -463,6 +464,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", + "pin-utils", "smallvec", "tokio", "want", @@ -483,12 +485,13 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.11" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497bbc33a26fdd4af9ed9c70d63f61cf56a938375fbb32df34db9b1cd6d643f2" +checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" dependencies = [ "bytes", "futures-channel", + "futures-core", "futures-util", "http", "http-body", @@ -527,14 +530,25 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.9.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +checksum = "f2481980430f9f78649238835720ddccc57e52df14ffce1c6f37391d61b563e9" dependencies = [ "equivalent", "hashbrown", ] +[[package]] +name = "io-uring" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" +dependencies = [ + "bitflags", + "cfg-if", + "libc", +] + [[package]] name = "itertools" version = "0.14.0" @@ -568,9 +582,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.172" +version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" [[package]] name = "libnss" @@ -609,9 +623,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "memchr" -version = "2.7.4" +version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" [[package]] name = "mime" @@ -621,29 +635,29 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "miniz_oxide" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", ] [[package]] name = "mio" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.52.0", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.59.0", ] [[package]] name = "multimap" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" [[package]] name = "nss" @@ -655,14 +669,17 @@ dependencies = [ "libc", "libnss", "log", + "once_cell", "procfs", "prost", - "rustix 1.0.7", + "rustix 1.0.8", "simple_logger", "syslog", + "time", "tokio", "tonic", - "tonic-build", + "tonic-prost", + "tonic-prost-build", "tower 0.4.13", ] @@ -713,9 +730,9 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "petgraph" @@ -767,9 +784,9 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "prettyplease" -version = "0.2.32" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "664ec5419c51e34154eec046ebcba56312d5a2fc3b09a06da188e1ad21afadf6" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", "syn", @@ -777,9 +794,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" dependencies = [ "unicode-ident", ] @@ -811,9 +828,9 @@ dependencies = [ [[package]] name = "prost" -version = "0.13.5" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +checksum = "7231bd9b3d3d33c86b58adbac74b5ec0ad9f496b19d22801d773636feaa95f3d" dependencies = [ "bytes", "prost-derive", @@ -821,9 +838,9 @@ dependencies = [ [[package]] name = "prost-build" -version = "0.13.5" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +checksum = "ac6c3320f9abac597dcbc668774ef006702672474aad53c6d596b62e487b40b1" dependencies = [ "heck", "itertools", @@ -834,6 +851,8 @@ dependencies = [ "prettyplease", "prost", "prost-types", + "pulldown-cmark", + "pulldown-cmark-to-cmark", "regex", "syn", "tempfile", @@ -841,9 +860,9 @@ dependencies = [ [[package]] name = "prost-derive" -version = "0.13.5" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425" dependencies = [ "anyhow", "itertools", @@ -854,13 +873,33 @@ dependencies = [ [[package]] name = "prost-types" -version = "0.13.5" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +checksum = "b9b4db3d6da204ed77bb26ba83b6122a73aeb2e87e25fbf7ad2e84c4ccbf8f72" dependencies = [ "prost", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + +[[package]] +name = "pulldown-cmark-to-cmark" +version = "21.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5b6a0769a491a08b31ea5c62494a8f144ee0987d86d670a8af4df1e1b7cde75" +dependencies = [ + "pulldown-cmark", +] + [[package]] name = "quote" version = "1.0.40" @@ -872,15 +911,15 @@ dependencies = [ [[package]] name = "r-efi" -version = "5.2.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "regex" -version = "1.11.1" +version = "1.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" dependencies = [ "aho-corasick", "memchr", @@ -890,9 +929,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" dependencies = [ "aho-corasick", "memchr", @@ -901,15 +940,15 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" [[package]] name = "rustc-demangle" -version = "0.1.24" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" [[package]] name = "rustix" @@ -926,22 +965,22 @@ dependencies = [ [[package]] name = "rustix" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" +checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" dependencies = [ "bitflags", "errno", "libc", "linux-raw-sys 0.9.4", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "rustversion" -version = "1.0.20" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "serde" @@ -983,34 +1022,31 @@ dependencies = [ [[package]] name = "slab" -version = "0.4.9" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" -dependencies = [ - "autocfg", -] +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "smallvec" -version = "1.15.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.5.9" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] name = "syn" -version = "2.0.101" +version = "2.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" dependencies = [ "proc-macro2", "quote", @@ -1037,15 +1073,15 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.19.1" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7437ac7763b9b123ccf33c338a5cc1bac6f69b45a136c19bdd8a65e3916435bf" +checksum = "15b61f8f20e3a6f7e0649d825294eaf317edce30f82cf6026e7e4cb9222a7d1e" dependencies = [ "fastrand", "getrandom", "once_cell", - "rustix 1.0.7", - "windows-sys 0.59.0", + "rustix 1.0.8", + "windows-sys 0.60.2", ] [[package]] @@ -1083,18 +1119,20 @@ dependencies = [ [[package]] name = "tokio" -version = "1.45.0" +version = "1.47.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2513ca694ef9ede0fb23fe71a4ee4107cb102b9dc1930f6d0fd77aae068ae165" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" dependencies = [ "backtrace", "bytes", + "io-uring", "libc", "mio", "pin-project-lite", + "slab", "socket2", "tokio-macros", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1121,9 +1159,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.15" +version = "0.7.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" dependencies = [ "bytes", "futures-core", @@ -1134,9 +1172,9 @@ dependencies = [ [[package]] name = "tonic" -version = "0.13.1" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e581ba15a835f4d9ea06c55ab1bd4dce26fc53752c69a04aac00703bfb49ba9" +checksum = "eb7613188ce9f7df5bfe185db26c5814347d110db17920415cf2fbcad85e7203" dependencies = [ "async-trait", "axum", @@ -1151,8 +1189,8 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", - "prost", "socket2", + "sync_wrapper", "tokio", "tokio-stream", "tower 0.5.2", @@ -1163,9 +1201,32 @@ dependencies = [ [[package]] name = "tonic-build" -version = "0.13.1" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac6f67be712d12f0b41328db3137e0d0757645d8904b4cb7d51cd9c2279e847" +checksum = "4c40aaccc9f9eccf2cd82ebc111adc13030d23e887244bc9cfa5d1d636049de3" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tonic-prost" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66bd50ad6ce1252d87ef024b3d64fe4c3cf54a86fb9ef4c631fdd0ded7aeaa67" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a16cba4043dc3ff43fcb3f96b4c5c154c64cbd18ca8dce2ab2c6a451d058a2" dependencies = [ "prettyplease", "proc-macro2", @@ -1173,6 +1234,8 @@ dependencies = [ "prost-types", "quote", "syn", + "tempfile", + "tonic-build", ] [[package]] @@ -1235,9 +1298,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.28" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", @@ -1246,9 +1309,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.33" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" dependencies = [ "once_cell", ] @@ -1259,6 +1322,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + [[package]] name = "unicode-ident" version = "1.0.18" @@ -1276,9 +1345,9 @@ dependencies = [ [[package]] name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasi" @@ -1349,9 +1418,9 @@ dependencies = [ [[package]] name = "windows-core" -version = "0.61.0" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ "windows-implement", "windows-interface", @@ -1384,24 +1453,24 @@ dependencies = [ [[package]] name = "windows-link" -version = "0.1.1" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" [[package]] name = "windows-result" -version = "0.3.2" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ "windows-link", ] [[package]] name = "windows-strings" -version = "0.4.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" dependencies = [ "windows-link", ] @@ -1417,20 +1486,20 @@ dependencies = [ [[package]] name = "windows-sys" -version = "0.52.0" +version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ "windows-targets 0.52.6", ] [[package]] name = "windows-sys" -version = "0.59.0" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.52.6", + "windows-targets 0.53.3", ] [[package]] @@ -1457,13 +1526,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -1476,6 +1562,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -1488,6 +1580,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -1500,12 +1598,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -1518,6 +1628,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -1530,6 +1646,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -1542,6 +1664,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -1554,6 +1682,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + [[package]] name = "wit-bindgen-rt" version = "0.39.0" diff --git a/Cargo.toml b/Cargo.toml index 8b865788a4..dca77e898e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] members = ["nss/"] -exclude = ["vendor_rust/"] +exclude = ["vendor_rust/", "authd-oidc-brokers/third_party/libhimmelblau/", "parts/libhimmelblau/build"] resolver = "2" [profile.release] @@ -11,17 +11,12 @@ platforms = ["*-unknown-linux-gnu"] tier = "2" exclude-crate-paths = [ { name = "*", exclude = "assets" }, - { name = "*", exclude = "Cargo.toml.orig" }, { name = "*", exclude = "docs" }, { name = "*", exclude = "examples" }, { name = "*", exclude = "tests" }, - { name = "*", exclude = ".editorconfig" }, - { name = "*", exclude = ".git-blame-ignore-revs" }, - { name = "*", exclude = ".github" }, - { name = "*", exclude = ".gitignore" }, - { name = "*", exclude = ".gitlab-ci.yml" }, - { name = "*", exclude = "*.png" }, - { name = "*", exclude = "*.svg" }, - { name = "regex", exclude = "record" }, + # For some reason, this file is excluded from the source tarball. Exclude + # it here to exclude it from the checksum calculation to avoid build + # failures due to checksum mismatches. + { name = "*", exclude = "Cargo.toml.orig" }, ] diff --git a/README.md b/README.md index 2095a29335..8b45ebe440 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,22 @@ # authd: an authentication daemon for cloud identity providers -[actions-image]: https://github.com/ubuntu/authd/actions/workflows/qa.yaml/badge.svg -[actions-url]: https://github.com/ubuntu/authd/actions?query=workflow%3AQA +[actions-image]: https://github.com/canonical/authd/actions/workflows/qa.yaml/badge.svg +[actions-url]: https://github.com/canonical/authd/actions?query=workflow%3AQA [license-image]: https://img.shields.io/badge/License-GPL3.0-blue.svg [codecov-image]: https://codecov.io/gh/ubuntu/authd/graph/badge.svg [codecov-url]: https://codecov.io/gh/ubuntu/authd -[reference-documentation-image]: https://pkg.go.dev/badge/github.com/ubuntu/authd.svg -[reference-documentation-url]: https://pkg.go.dev/github.com/ubuntu/authd +[reference-documentation-image]: https://pkg.go.dev/badge/github.com/canonical/authd.svg +[reference-documentation-url]: https://pkg.go.dev/github.com/canonical/authd -[goreport-image]: https://goreportcard.com/badge/github.com/ubuntu/authd -[goreport-url]: https://goreportcard.com/report/github.com/ubuntu/authd +[goreport-image]: https://goreportcard.com/badge/github.com/canonical/authd +[goreport-url]: https://goreportcard.com/report/github.com/canonical/authd -[docs-image]: https://readthedocs.com/projects/canonical-authd/badge/?version=latest -[docs-url-stable]: https://canonical-authd.readthedocs-hosted.com/en/stable/ -[docs-url-latest]: https://canonical-authd.readthedocs-hosted.com/en/latest/ +[docs-image]: https://readthedocs.com/projects/canonical-authd/badge/?version=edge-docs +[docs-url-stable]: https://documentation.ubuntu.com/authd/stable-docs/ +[docs-url-edge]: https://documentation.ubuntu.com/authd/edge-docs/ [![Code quality][actions-image]][actions-url] [![License][license-image]](COPYING) @@ -38,7 +38,7 @@ supported and several other identity providers are under active development. To find out more about using authd, refer to the [official authd documentation][docs-url-stable]. If you are on an edge release then you can also read the -[latest development version of the documentation][docs-url-latest], +[edge version of the documentation][docs-url-edge], which may include features not yet available in the stable release. The documentation includes how-to guides on installing and configuring authd, @@ -47,7 +47,7 @@ in addition to information about authd architecture and troubleshooting. ## Brokers authd uses brokers to interface with cloud identity providers through a -[DBus API](https://github.com/ubuntu/authd/blob/HEAD/examplebroker/com.ubuntu.auth.ExampleBroker.xml). +[DBus API](https://github.com/canonical/authd/blob/HEAD/examplebroker/com.ubuntu.auth.ExampleBroker.xml). Currently [MS Entra ID](https://learn.microsoft.com/en-us/entra/fundamentals/whatis) and [Google IAM](https://cloud.google.com/iam/docs/overview) @@ -55,7 +55,7 @@ are supported as identity providers. They allow you to authenticate using MFA and the device authentication flow. For development purposes, authd also provides an -[example broker](https://github.com/ubuntu/authd/tree/main/examplebroker) +[example broker](https://github.com/canonical/authd/tree/main/examplebroker) to help you develop your own. ## Get involved @@ -66,12 +66,12 @@ contributing, please take a look at our [contribution guidelines](CONTRIBUTING.m first. When reporting an issue you can -[choose from several templates](https://github.com/ubuntu/authd/issues/new/choose): +[choose from several templates](https://github.com/canonical/authd/issues/new/choose): - To report an issue, please file a bug report against our repository, using the - [report an issue](https://github.com/ubuntu/authd/issues/new?assignees=&labels=bug&projects=&template=bug_report.yml&title=Issue%3A+) template. + [report an issue](https://github.com/canonical/authd/issues/new?assignees=&labels=bug&projects=&template=bug_report.yml&title=Issue%3A+) template. - For suggestions and constructive feedback, report a feature request bug report, using the - [request a feature](https://github.com/ubuntu/authd/issues/new?assignees=&labels=feature&projects=&template=feature_request.yml&title=Feature%3A+) template. + [request a feature](https://github.com/canonical/authd/issues/new?assignees=&labels=feature&projects=&template=feature_request.yml&title=Feature%3A+) template. ## Get in touch diff --git a/SECURITY.md b/SECURITY.md index c35f5e0982..a7ddac30b0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -17,7 +17,7 @@ If you discover a security vulnerability within this repository, we encourage re ### Private Vulnerability Reporting -The most straightforward way to report a security vulnerability is via [GitHub](https://github.com/ubuntu/authd/security/advisories/new). +The most straightforward way to report a security vulnerability is via [GitHub](https://github.com/canonical/authd/security/advisories/new). For detailed instructions, please review the [Privately reporting a security vulnerability](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability) documentation. This method enables you to communicate vulnerabilities directly and confidentially with the `authd` maintainers. @@ -28,7 +28,7 @@ The [Ubuntu Security disclosure and embargo policy](https://ubuntu.com/security/ #### Steps to Report a Vulnerability -1. Go to the [Security Advisories Page](https://github.com/ubuntu/authd/security/advisories) of the `authd` repository. +1. Go to the [Security Advisories Page](https://github.com/canonical/authd/security/advisories) of the `authd` repository. 2. Click "Report a Vulnerability." 3. Provide detailed information about the vulnerability, including steps to reproduce, affected versions, and potential impact. @@ -37,6 +37,6 @@ The [Ubuntu Security disclosure and embargo policy](https://ubuntu.com/security/ - [Canonical's Security Site](https://ubuntu.com/security) - [Ubuntu Security disclosure and embargo policy](https://ubuntu.com/security/disclosure-policy) - [Ubuntu Security Notices](https://ubuntu.com/security/notices) -- [authd Documentation](https://canonical-authd.readthedocs-hosted.com/en/latest/) +- [authd Documentation](https://documentation.ubuntu.com/authd/stable-docs/) If you have any questions regarding security vulnerabilities, please reach out to the maintainers via the aforementioned channels. diff --git a/authd-oidc-brokers/.gitignore b/authd-oidc-brokers/.gitignore new file mode 100644 index 0000000000..1c02e90520 --- /dev/null +++ b/authd-oidc-brokers/.gitignore @@ -0,0 +1,29 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.snap +/authd-oidc +cmd/authd-oidc/authd-oidc +/authd-msentraid +cmd/authd-oidc/authd-msentraid +__pycache__ + +# Test binary, built with `go test -c` +*.test +coverage/ + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +vendor/ + +# Go workspace file +go.work + +# Directories created by `snapcraft --destructive-mode` +/parts +/prime +/stage + diff --git a/authd-oidc-brokers/.golangci.yaml b/authd-oidc-brokers/.golangci.yaml new file mode 100644 index 0000000000..fe8913804c --- /dev/null +++ b/authd-oidc-brokers/.golangci.yaml @@ -0,0 +1,83 @@ +# This is for linting. To run it, please use: +# golangci-lint run ${MODULE}/... [--fix] + +version: "2" +linters: + # linters to run in addition to default ones + enable: + - copyloopvar + - dupl + - durationcheck + - errname + - errorlint + - forbidigo + - forcetypeassert + - godot + - gosec + - misspell + - nakedret + - nolintlint + - revive + - tparallel + - unconvert + - unparam + - whitespace + settings: + # Forbid the usage of deprecated ioutil and debug prints + forbidigo: + forbid: + - pattern: ioutil\. + - pattern: ^print.*$ + # Never have naked return ever + nakedret: + max-func-lines: 1 + nolintlint: + # Require an explanation of nonzero length after each nolint directive. + require-explanation: true + # Require nolint directives to mention the specific linter being suppressed. + require-specific: true + exclusions: + generated: lax + rules: + # EXC0001 errcheck: most errors are in defer calls, which are safe to ignore and idiomatic Go (would be good to only ignore defer ones though) + - path: (.+)\.go$ + text: Error return value of .((os\.)?std(out|err)\..*|.*Close|.*Flush|os\.Remove(All)?|.*print(f|ln)?|os\.(Un)?Setenv|w\.Stop). is not checked + # EXC0008 gosec: duplicated of errcheck + - path: (.+)\.go$ + text: (G104|G307) + # EXC0010 gosec: False positive is triggered by 'src, err := ioutil.ReadFile(filename)' + - path: (.+)\.go$ + text: Potential file inclusion via variable + # We don't wrap errors on purpose + - path: (.+)\.go$ + text: non-wrapping format verb for fmt.Errorf. Use `%w` to format errors + # We want named parameters even if unused, as they help better document the function + - path: (.+)\.go$ + text: unused-parameter + # Sometimes it is more readable it do a `if err:=a(); err != nil` tha simpy `return a()` + - path: (.+)\.go$ + text: if-return + # gosec G101: hardcoded credentials - false positive for test data (fake passwords, tokens). + - path: _test\.go$ + text: G101 + # gosec G703: path traversal via taint analysis - false positive in test code where file operation arguments are test-controlled. + - path: (_test\.go$|internal/testutils/) + text: G703 + paths: + - third_party$ + - builtin$ + - examples$ +issues: + max-issues-per-linter: 0 + max-same-issues: 0 + fix: false +formatters: + enable: + - gci + - gofmt + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/authd-oidc-brokers/.shellcheckrc b/authd-oidc-brokers/.shellcheckrc new file mode 100644 index 0000000000..25c98c8fde --- /dev/null +++ b/authd-oidc-brokers/.shellcheckrc @@ -0,0 +1,2 @@ +source-path=SCRIPTDIR +external-sources=true diff --git a/authd-oidc-brokers/cmd/authd-oidc/daemon/config.go b/authd-oidc-brokers/cmd/authd-oidc/daemon/config.go new file mode 100644 index 0000000000..795df3a4b0 --- /dev/null +++ b/authd-oidc-brokers/cmd/authd-oidc/daemon/config.go @@ -0,0 +1,106 @@ +package daemon + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/canonical/authd/authd-oidc-brokers/internal/consts" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "github.com/ubuntu/authd/log" + "github.com/ubuntu/decorate" +) + +// initViperConfig sets verbosity level and add config env variables and file support based on name prefix. +func initViperConfig(name string, cmd *cobra.Command, vip *viper.Viper) (err error) { + defer decorate.OnError(&err, "can't load configuration") + + // Force a visit of the local flags so persistent flags for all parents are merged. + //cmd.LocalFlags() // TODO: still necessary? + + // Get cmdline flag for verbosity to configure logger until we have everything parsed. + v, err := cmd.Flags().GetCount("verbosity") + if err != nil { + return fmt.Errorf("internal error: no persistent verbosity flag installed on cmd: %w", err) + } + setVerboseMode(v) + + // Handle configuration. + if v, err := cmd.Flags().GetString("paths-config"); err == nil && v != "" { + vip.SetConfigFile(v) + } else { + vip.SetConfigName(name) + vip.AddConfigPath("./") + vip.AddConfigPath("$HOME/") + vip.AddConfigPath("$SNAP_DATA/") + vip.AddConfigPath(filepath.Join("/etc", name)) + // Add the executable path to the config search path. + if binPath, err := os.Executable(); err != nil { + log.Warningf(context.Background(), "Failed to get current executable path, not adding it as a config dir: %v", err) + } else { + vip.AddConfigPath(filepath.Dir(binPath)) + } + } + + var configNotFoundErr viper.ConfigFileNotFoundError + err = vip.ReadInConfig() + if err != nil && !errors.As(err, &configNotFoundErr) { + return fmt.Errorf("invalid configuration file: %w", err) + } + if vip.ConfigFileUsed() != "" { + log.Infof(context.Background(), "Using configuration file: %v", vip.ConfigFileUsed()) + } + + // Handle environment. + vip.SetEnvPrefix(name) + vip.AutomaticEnv() + + // Visit manually env to bind every possibly related environment variable to be able to unmarshall + // those into a struct. + // More context on https://github.com/spf13/viper/pull/1429. + prefix := strings.ToUpper(name) + "_" + for _, e := range os.Environ() { + if !strings.HasPrefix(e, prefix) { + continue + } + + s := strings.Split(e, "=") + k := strings.ReplaceAll(strings.TrimPrefix(s[0], prefix), "_", ".") + if err := vip.BindEnv(k, s[0]); err != nil { + return fmt.Errorf("could not bind environment variable: %w", err) + } + } + + return nil +} + +// installConfigFlag installs a --config option. +func installConfigFlag(cmd *cobra.Command) *string { + flag := cmd.PersistentFlags().StringP("config", "c", "", "use a specific configuration file") + if err := cmd.PersistentFlags().MarkHidden("config"); err != nil { + log.Warningf(context.Background(), "Failed to hide --config flag: %v", err) + } + return flag +} + +// SetVerboseMode change ErrorFormat and logs between very, middly and non verbose. +func setVerboseMode(level int) { + //var reportCaller bool + switch level { + case 0: + log.SetLevel(consts.DefaultLevelLog) + case 1: + log.SetLevel(log.InfoLevel) + case 3: + //reportCaller = true + fallthrough + default: + log.SetLevel(log.DebugLevel) + } + + //slog.SetReportCaller(reportCaller) +} diff --git a/authd-oidc-brokers/cmd/authd-oidc/daemon/daemon.go b/authd-oidc-brokers/cmd/authd-oidc/daemon/daemon.go new file mode 100644 index 0000000000..318352f892 --- /dev/null +++ b/authd-oidc-brokers/cmd/authd-oidc/daemon/daemon.go @@ -0,0 +1,216 @@ +// Package daemon represents the oidc broker binary +package daemon + +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" + + "github.com/canonical/authd/authd-oidc-brokers/internal/broker" + "github.com/canonical/authd/authd-oidc-brokers/internal/consts" + "github.com/canonical/authd/authd-oidc-brokers/internal/daemon" + "github.com/canonical/authd/authd-oidc-brokers/internal/dbusservice" + "github.com/spf13/cobra" + "github.com/spf13/viper" + log "github.com/ubuntu/authd/log" +) + +// App encapsulate commands and options of the daemon, which can be controlled by env variables and config files. +type App struct { + rootCmd cobra.Command + viper *viper.Viper + config daemonConfig + + daemon *daemon.Daemon + name string + + ready chan struct{} +} + +// only overriable for tests. +type systemPaths struct { + BrokerConf string + DataDir string +} + +// daemonConfig defines configuration parameters of the daemon. +type daemonConfig struct { + Verbosity int + Paths systemPaths +} + +// New registers commands and return a new App. +func New(name string) *App { + a := App{ready: make(chan struct{}), name: name} + a.rootCmd = cobra.Command{ + Use: fmt.Sprintf("%s COMMAND", name), + Short: fmt.Sprintf("%s authentication broker", name), + Long: fmt.Sprintf("Authentication daemon %s to communicate with our authentication daemon.", name), + Args: cobra.NoArgs, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + // First thing, initialize the log handler + log.InitJournalHandler(false) + + // Command parsing has been successful, so don't print the usage message on errors anymore. + a.rootCmd.SilenceUsage = true + + dataDir := filepath.Join("/var", "lib", name) + configDir := "." + if snapData := os.Getenv("SNAP_DATA"); snapData != "" { + dataDir = snapData + configDir = snapData + } + // Set config defaults + a.config = daemonConfig{ + Paths: systemPaths{ + BrokerConf: filepath.Join(configDir, "broker.conf"), + DataDir: dataDir, + }, + } + + // Install and unmarshall configuration + if err := initViperConfig(name, &a.rootCmd, a.viper); err != nil { + return err + } + if err := a.viper.Unmarshal(&a.config); err != nil { + return fmt.Errorf("unable to decode configuration into struct: %w", err) + } + + // FIXME: for now, config is only the broker.conf file. It should be merged with the viper configuration. + if v, err := cmd.Flags().GetString("config"); err == nil && v != "" { + a.config.Paths.BrokerConf = v + } + + setVerboseMode(a.config.Verbosity) + + log.Infof(context.Background(), "Version: %s", consts.Version) + log.Debug(context.Background(), "Debug mode is enabled") + + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return a.serve(a.config) + }, + // We display usage error ourselves + SilenceErrors: true, + } + viper := viper.New() + + a.viper = viper + + installVerbosityFlag(&a.rootCmd, a.viper) + installConfigFlag(&a.rootCmd) + // FIXME: This option is for the viper path configuration. We should merge --config and this one in the future. + a.rootCmd.PersistentFlags().StringP("paths-config", "", "", "use a specific paths configuration file") + if err := a.rootCmd.PersistentFlags().MarkHidden("paths-config"); err != nil { + log.Warningf(context.Background(), "Failed to hide --paths-config flag: %v", err) + } + + // subcommands + a.installVersion() + + return &a +} + +// serve creates new dbus service on the system bus. This call is blocking until we quit it. +func (a *App) serve(config daemonConfig) error { + ctx := context.Background() + // Ensure that the a.ready channel is closed when the function returns, which is what Quit() waits for before exiting. + readyPtr := &a.ready + closeFunc := func() { + if readyPtr == nil { + return + } + close(*readyPtr) + readyPtr = nil + } + defer closeFunc() + + // When the data directory is SNAP_DATA, it has permission 0755, else we want to create it with 0700. + if err := ensureDirWithPerms(config.Paths.DataDir, 0700, os.Geteuid()); err != nil { + if err := ensureDirWithPerms(config.Paths.DataDir, 0755, os.Geteuid()); err != nil { + return fmt.Errorf("error initializing data directory %q: %v", config.Paths.DataDir, err) + } + } + + brokerConfigDir := broker.GetDropInDir(config.Paths.BrokerConf) + if err := ensureDirWithPerms(brokerConfigDir, 0700, os.Geteuid()); err != nil { + return fmt.Errorf("error initializing broker configuration directory %q: %v", brokerConfigDir, err) + } + + b, err := broker.New(broker.Config{ + ConfigFile: config.Paths.BrokerConf, + DataDir: config.Paths.DataDir, + }) + if err != nil { + return err + } + + s, err := dbusservice.New(ctx, b) + if err != nil { + return err + } + + var daemonopts []daemon.Option + daemon, err := daemon.New(ctx, s, daemonopts...) + if err != nil { + _ = s.Stop() + return err + } + + a.daemon = daemon + closeFunc() + + return daemon.Serve(ctx) +} + +// installVerbosityFlag adds the -v and -vv options and returns the reference to it. +func installVerbosityFlag(cmd *cobra.Command, viper *viper.Viper) *int { + r := cmd.PersistentFlags().CountP("verbosity", "v" /*i18n.G(*/, "issue INFO (-v), DEBUG (-vv) or DEBUG with caller (-vvv) output") //) + + if err := viper.BindPFlag("verbosity", cmd.PersistentFlags().Lookup("verbosity")); err != nil { + log.Warning(context.Background(), err.Error()) + } + + return r +} + +// Run executes the command and associated process. It returns an error on syntax/usage error. +func (a *App) Run() error { + return a.rootCmd.Execute() +} + +// UsageError returns if the error is a command parsing or runtime one. +func (a App) UsageError() bool { + return !a.rootCmd.SilenceUsage +} + +// Hup prints all goroutine stack traces and return false to signal you shouldn't quit. +func (a App) Hup() (shouldQuit bool) { + buf := make([]byte, 1<<16) + runtime.Stack(buf, true) + fmt.Printf("%s", buf) + return false +} + +// Quit gracefully shutdown the service. +func (a *App) Quit() { + a.WaitReady() + if a.daemon == nil { + return + } + a.daemon.Quit() +} + +// WaitReady signals when the daemon is ready +// Note: we need to use a pointer to not copy the App object before the daemon is ready, and thus, creates a data race. +func (a *App) WaitReady() { + <-a.ready +} + +// RootCmd returns a copy of the root command for the app. Shouldn't be in general necessary apart when running generators. +func (a App) RootCmd() cobra.Command { + return a.rootCmd +} diff --git a/authd-oidc-brokers/cmd/authd-oidc/daemon/daemon_test.go b/authd-oidc-brokers/cmd/authd-oidc/daemon/daemon_test.go new file mode 100644 index 0000000000..893ee4daac --- /dev/null +++ b/authd-oidc-brokers/cmd/authd-oidc/daemon/daemon_test.go @@ -0,0 +1,409 @@ +package daemon_test + +import ( + "bytes" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/canonical/authd/authd-oidc-brokers/cmd/authd-oidc/daemon" + "github.com/canonical/authd/authd-oidc-brokers/internal/consts" + "github.com/canonical/authd/authd-oidc-brokers/internal/testutils" + "github.com/stretchr/testify/require" +) + +var issuerURL string + +func TestHelp(t *testing.T) { + a := daemon.NewForTests(t, nil, issuerURL, "--help") + + getStdout := captureStdout(t) + + err := a.Run() + require.NoErrorf(t, err, "Run should not return an error with argument --help. Stdout: %v", getStdout()) +} + +func TestCompletion(t *testing.T) { + a := daemon.NewForTests(t, nil, issuerURL, "completion", "bash") + + getStdout := captureStdout(t) + + err := a.Run() + require.NoError(t, err, "Completion should not start the daemon. Stdout: %v", getStdout()) +} + +func TestVersion(t *testing.T) { + a := daemon.NewForTests(t, nil, issuerURL, "version") + + getStdout := captureStdout(t) + + err := a.Run() + require.NoError(t, err, "Run should not return an error") + + out := getStdout() + + fields := strings.Fields(out) + require.Len(t, fields, 2, "wrong number of fields in version: %s", out) + + require.Equal(t, t.Name(), fields[0], "Wrong executable name") + require.Equal(t, consts.Version, fields[1], "Wrong version") +} + +func TestNoUsageError(t *testing.T) { + a := daemon.NewForTests(t, nil, issuerURL, "completion", "bash") + + getStdout := captureStdout(t) + err := a.Run() + + require.NoError(t, err, "Run should not return an error, stdout: %v", getStdout()) + isUsageError := a.UsageError() + require.False(t, isUsageError, "No usage error is reported as such") +} + +func TestUsageError(t *testing.T) { + a := daemon.NewForTests(t, nil, issuerURL, "doesnotexist") + + err := a.Run() + require.Error(t, err, "Run should return an error, stdout: %v") + isUsageError := a.UsageError() + require.True(t, isUsageError, "Usage error is reported as such") +} + +func TestCanQuitWhenExecute(t *testing.T) { + a, wait := startDaemon(t, nil) + defer wait() + + a.Quit() +} + +func TestCanQuitTwice(t *testing.T) { + a, wait := startDaemon(t, nil) + + a.Quit() + wait() + + require.NotPanics(t, a.Quit) +} + +func TestAppCanQuitWithoutExecute(t *testing.T) { + t.Skipf("This test is skipped because it is flaky. There is no way to guarantee Quit has been called before run.") + + a := daemon.NewForTests(t, nil, issuerURL) + + requireGoroutineStarted(t, a.Quit) + err := a.Run() + require.Error(t, err, "Should return an error") + + require.Containsf(t, err.Error(), "grpc: the server has been stopped", "Unexpected error message") +} + +func TestAppRunFailsOnComponentsCreationAndQuit(t *testing.T) { + const ( + // DataDir errors + dirIsFile = iota + wrongPermission + noParentDir + ) + + tests := map[string]struct { + dataDirBehavior int + configBehavior int + }{ + "Error_on_existing_data_dir_being_a_file": {dataDirBehavior: dirIsFile}, + "Error_on_data_dir_missing_parent_directory": {dataDirBehavior: noParentDir}, + "Error_on_wrong_permission_on_data_dir": {dataDirBehavior: wrongPermission}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + tmpDir := t.TempDir() + dataDir := filepath.Join(tmpDir, "data") + + switch tc.dataDirBehavior { + case dirIsFile: + err := os.WriteFile(dataDir, []byte("file"), 0600) + require.NoError(t, err, "Setup: could not create cache file for tests") + case wrongPermission: + err := os.Mkdir(dataDir, 0600) + require.NoError(t, err, "Setup: could not create cache directory for tests") + case noParentDir: + dataDir = filepath.Join(dataDir, "doesnotexist", "data") + } + + config := daemon.DaemonConfig{ + Verbosity: 0, + Paths: daemon.SystemPaths{ + DataDir: dataDir, + }, + } + + a := daemon.NewForTests(t, &config, issuerURL) + err := a.Run() + require.Error(t, err, "Run should return an error") + }) + } +} + +func TestAppCanSigHupWhenExecute(t *testing.T) { + r, w, err := os.Pipe() + require.NoError(t, err, "Setup: pipe shouldn't fail") + + a, wait := startDaemon(t, nil) + + defer wait() + defer a.Quit() + + orig := os.Stdout + os.Stdout = w + + a.Hup() + + os.Stdout = orig + w.Close() + + var out bytes.Buffer + _, err = io.Copy(&out, r) + require.NoError(t, err, "Couldn't copy stdout to buffer") + require.NotEmpty(t, out.String(), "Stacktrace is printed") +} + +func TestAppCanSigHupAfterExecute(t *testing.T) { + r, w, err := os.Pipe() + require.NoError(t, err, "Setup: pipe shouldn't fail") + + a, wait := startDaemon(t, nil) + a.Quit() + wait() + + orig := os.Stdout + os.Stdout = w + + a.Hup() + + os.Stdout = orig + w.Close() + + var out bytes.Buffer + _, err = io.Copy(&out, r) + require.NoError(t, err, "Couldn't copy stdout to buffer") + require.NotEmpty(t, out.String(), "Stacktrace is printed") +} + +func TestAppCanSigHupWithoutExecute(t *testing.T) { + r, w, err := os.Pipe() + require.NoError(t, err, "Setup: pipe shouldn't fail") + + a := daemon.NewForTests(t, nil, issuerURL) + + orig := os.Stdout + os.Stdout = w + + a.Hup() + + os.Stdout = orig + w.Close() + + var out bytes.Buffer + _, err = io.Copy(&out, r) + require.NoError(t, err, "Couldn't copy stdout to buffer") + require.NotEmpty(t, out.String(), "Stacktrace is printed") +} + +func TestAppGetRootCmd(t *testing.T) { + a := daemon.NewForTests(t, nil, issuerURL) + require.NotNil(t, a.RootCmd(), "Returns root command") +} + +func TestConfigLoad(t *testing.T) { + tmpDir := t.TempDir() + config := daemon.DaemonConfig{ + Verbosity: 1, + Paths: daemon.SystemPaths{ + BrokerConf: filepath.Join(tmpDir, "broker.conf"), + DataDir: filepath.Join(tmpDir, "data"), + }, + } + + a, wait := startDaemon(t, &config) + defer wait() + defer a.Quit() + + require.Equal(t, config, a.Config(), "Config is loaded") +} + +func TestConfigHasPrecedenceOverPathsConfig(t *testing.T) { + tmpDir := t.TempDir() + config := daemon.DaemonConfig{ + Verbosity: 1, + Paths: daemon.SystemPaths{ + BrokerConf: filepath.Join(tmpDir, "broker.conf"), + DataDir: filepath.Join(tmpDir, "data"), + }, + } + + overrideBrokerConfPath := filepath.Join(tmpDir, "override", "via", "config", "broker.conf") + daemon.GenerateBrokerConfig(t, overrideBrokerConfPath, issuerURL) + a := daemon.NewForTests(t, &config, issuerURL, "--config", overrideBrokerConfPath) + + wg := sync.WaitGroup{} + wg.Add(1) + go func() { + defer wg.Done() + err := a.Run() + require.NoError(t, err, "Run should exits without any error") + }() + a.WaitReady() + time.Sleep(50 * time.Millisecond) + + defer wg.Wait() + defer a.Quit() + + want := config + want.Paths.BrokerConf = overrideBrokerConfPath + require.Equal(t, want, a.Config(), "Config is loaded") +} + +func TestAutoDetectConfig(t *testing.T) { + tmpDir := t.TempDir() + config := daemon.DaemonConfig{ + Verbosity: 1, + Paths: daemon.SystemPaths{ + BrokerConf: filepath.Join(tmpDir, "broker.conf"), + DataDir: filepath.Join(tmpDir, "data"), + }, + } + + configPath := daemon.GenerateTestConfig(t, &config, issuerURL) + configNextToBinaryPath := filepath.Join(filepath.Dir(os.Args[0]), t.Name()+".yaml") + err := os.Rename(configPath, configNextToBinaryPath) + require.NoError(t, err, "Could not relocate authd configuration file in the binary directory") + // Remove configuration next binary for other tests to not pick it up. + defer os.Remove(configNextToBinaryPath) + + a := daemon.New(t.Name()) + wg := sync.WaitGroup{} + wg.Add(1) + go func() { + defer wg.Done() + err := a.Run() + require.NoError(t, err, "Run should exits without any error") + }() + a.WaitReady() + time.Sleep(50 * time.Millisecond) + + defer wg.Wait() + defer a.Quit() + + require.Equal(t, config, a.Config(), "Did not load configuration next to binary") +} + +func TestNoConfigSetDefaults(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("SNAP_DATA", tmpDir) + + a := daemon.New(t.Name()) // Use version to still run preExec to load no config but without running server + a.SetArgs("version") + + err := a.Run() + require.NoError(t, err, "Run should not return an error") + + require.Equal(t, 0, a.Config().Verbosity, "Default Verbosity") + require.Equal(t, filepath.Join(tmpDir, "broker.conf"), a.Config().Paths.BrokerConf, "Default broker configuration path") + require.Equal(t, tmpDir, a.Config().Paths.DataDir, "Default data directory") +} + +func TestBadConfigReturnsError(t *testing.T) { + a := daemon.New(t.Name()) // Use version to still run preExec to load no config but without running server + a.SetArgs("version", "--paths-config", "/does/not/exist.yaml") + + err := a.Run() + require.Error(t, err, "Run should return an error on config file") +} + +// requireGoroutineStarted starts a goroutine and blocks until it has been launched. +func requireGoroutineStarted(t *testing.T, f func()) { + t.Helper() + + launched := make(chan struct{}) + + go func() { + close(launched) + f() + }() + + <-launched +} + +// startDaemon prepares and starts the daemon in the background. The done function should be called +// to wait for the daemon to stop. +func startDaemon(t *testing.T, conf *daemon.DaemonConfig) (app *daemon.App, done func()) { + t.Helper() + + a := daemon.NewForTests(t, conf, issuerURL) + + wg := sync.WaitGroup{} + wg.Add(1) + go func() { + defer wg.Done() + err := a.Run() + require.NoError(t, err, "Run should exits without any error") + }() + a.WaitReady() + time.Sleep(50 * time.Millisecond) + + return a, func() { + wg.Wait() + } +} + +// captureStdout capture current process stdout and returns a function to get the captured buffer. +func captureStdout(t *testing.T) func() string { + t.Helper() + + r, w, err := os.Pipe() + require.NoError(t, err, "Setup: pipe shouldn't fail") + + orig := os.Stdout + os.Stdout = w + + t.Cleanup(func() { + os.Stdout = orig + w.Close() + }) + + var out bytes.Buffer + errch := make(chan error) + go func() { + _, err = io.Copy(&out, r) + errch <- err + close(errch) + }() + + return func() string { + w.Close() + w = nil + require.NoError(t, <-errch, "Couldn't copy stdout to buffer") + + return out.String() + } +} + +func TestMain(m *testing.M) { + // Start system bus mock. + cleanup, err := testutils.StartSystemBusMock() + if err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + defer cleanup() + + // Start provider mock + issuerURL, cleanup = testutils.StartMockProviderServer("", nil) + defer cleanup() + + m.Run() +} diff --git a/authd-oidc-brokers/cmd/authd-oidc/daemon/export_test.go b/authd-oidc-brokers/cmd/authd-oidc/daemon/export_test.go new file mode 100644 index 0000000000..fda6f80346 --- /dev/null +++ b/authd-oidc-brokers/cmd/authd-oidc/daemon/export_test.go @@ -0,0 +1,95 @@ +package daemon + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +type ( + DaemonConfig = daemonConfig + SystemPaths = systemPaths +) + +func NewForTests(t *testing.T, conf *DaemonConfig, providerURL string, args ...string) *App { + t.Helper() + + p := GenerateTestConfig(t, conf, providerURL) + argsWithConf := []string{"--paths-config", p} + argsWithConf = append(argsWithConf, args...) + + a := New(t.Name()) + a.rootCmd.SetArgs(argsWithConf) + return a +} + +func GenerateTestConfig(t *testing.T, origConf *daemonConfig, providerURL string) string { + t.Helper() + + var conf daemonConfig + + if origConf != nil { + conf = *origConf + } + + if conf.Verbosity == 0 { + conf.Verbosity = 2 + } + if conf.Paths.DataDir == "" { + conf.Paths.DataDir = t.TempDir() + //nolint: gosec // This is a directory owned only by the current user for tests. + err := os.Chmod(conf.Paths.DataDir, 0700) + require.NoError(t, err, "Setup: could not change permission on cache directory for tests") + } + if conf.Paths.BrokerConf == "" { + conf.Paths.BrokerConf = filepath.Join(t.TempDir(), strings.ReplaceAll(t.Name(), "/", "_")+".yaml") + } + + GenerateBrokerConfig(t, conf.Paths.BrokerConf, providerURL) + + d, err := yaml.Marshal(conf) + require.NoError(t, err, "Setup: could not marshal configuration for tests") + + confPath := filepath.Join(t.TempDir(), "testconfig.yaml") + err = os.WriteFile(confPath, d, 0600) + require.NoError(t, err, "Setup: could not create configuration for tests") + + return confPath +} + +// GenerateBrokerConfig creates a broker configuration file for tests. +func GenerateBrokerConfig(t *testing.T, p, providerURL string) { + t.Helper() + + err := os.MkdirAll(filepath.Dir(p), 0700) + require.NoError(t, err, "Setup: could not create parent broker configuration directory for tests") + + brokerCfg := fmt.Sprintf(` + [authd] + name = %[1]s + brand_icon = broker_icon.png + dbus_name = com.ubuntu.authd.%[1]s + dbus_object = /com/ubuntu/authd/%[1]s + + [oidc] + issuer = %[2]s + client_id = client_id + `, strings.ReplaceAll(t.Name(), "/", "_"), providerURL) + err = os.WriteFile(p, []byte(brokerCfg), 0600) + require.NoError(t, err, "Setup: could not create broker configuration for tests") +} + +// Config returns a DaemonConfig for tests. +func (a App) Config() DaemonConfig { + return a.config +} + +// SetArgs set some arguments on root command for tests. +func (a *App) SetArgs(args ...string) { + a.rootCmd.SetArgs(args) +} diff --git a/authd-oidc-brokers/cmd/authd-oidc/daemon/fs.go b/authd-oidc-brokers/cmd/authd-oidc/daemon/fs.go new file mode 100644 index 0000000000..a5ce4e173f --- /dev/null +++ b/authd-oidc-brokers/cmd/authd-oidc/daemon/fs.go @@ -0,0 +1,32 @@ +package daemon + +import ( + "fmt" + "io/fs" + "os" + "syscall" +) + +// ensureDirWithPerms creates a directory at path if it doesn't exist yet with perm as permissions. +// If the path exists, it will check if it’s a directory with those perms. +func ensureDirWithPerms(path string, perm os.FileMode, owner int) error { + dir, err := os.Stat(path) + if err == nil { + if !dir.IsDir() { + return &os.PathError{Op: "mkdir", Path: path, Err: syscall.ENOTDIR} + } + if dir.Mode() != (perm | fs.ModeDir) { + return fmt.Errorf("permissions should be %v but are %v", perm|fs.ModeDir, dir.Mode()) + } + stat, ok := dir.Sys().(*syscall.Stat_t) + if !ok { + return fmt.Errorf("failed to get syscall.Stat_t for %s", path) + } + if int(stat.Uid) != owner { + return fmt.Errorf("owner should be %d but is %d", owner, stat.Uid) + } + + return nil + } + return os.Mkdir(path, perm) +} diff --git a/authd-oidc-brokers/cmd/authd-oidc/daemon/version.go b/authd-oidc-brokers/cmd/authd-oidc/daemon/version.go new file mode 100644 index 0000000000..6e1fa8cc13 --- /dev/null +++ b/authd-oidc-brokers/cmd/authd-oidc/daemon/version.go @@ -0,0 +1,24 @@ +package daemon + +import ( + "fmt" + + "github.com/canonical/authd/authd-oidc-brokers/internal/consts" + "github.com/spf13/cobra" +) + +func (a *App) installVersion() { + cmd := &cobra.Command{ + Use: "version", + Short:/*i18n.G(*/ "Returns version of daemon and exits", /*)*/ + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { return a.getVersion() }, + } + a.rootCmd.AddCommand(cmd) +} + +// getVersion returns the current service version. +func (a *App) getVersion() (err error) { + fmt.Printf( /*i18n.G(*/ "%s\t%s" /*)*/ +"\n", a.name, consts.Version) + return nil +} diff --git a/authd-oidc-brokers/cmd/authd-oidc/main.go b/authd-oidc-brokers/cmd/authd-oidc/main.go new file mode 100644 index 0000000000..14f520e682 --- /dev/null +++ b/authd-oidc-brokers/cmd/authd-oidc/main.go @@ -0,0 +1,83 @@ +// Package main is the entry point. +package main + +import ( + "context" + "os" + "os/signal" + "path/filepath" + "sync" + "syscall" + + "github.com/canonical/authd/authd-oidc-brokers/cmd/authd-oidc/daemon" + "github.com/canonical/authd/authd-oidc-brokers/internal/consts" + "github.com/canonical/authd/authd-oidc-brokers/po" + "github.com/ubuntu/authd/log" + "github.com/ubuntu/go-i18n" +) + +//FIXME go:generate go run ../generate_completion_documentation.go completion ../../generated +//FIXME go:generate go run ../generate_completion_documentation.go update-readme +//FIXME go:generate go run ../generate_completion_documentation.go update-doc-cli-ref + +func main() { + i18n.InitI18nDomain(consts.TEXTDOMAIN, po.Files) + a := daemon.New(filepath.Base(os.Args[0])) + os.Exit(run(a)) +} + +type app interface { + Run() error + UsageError() bool + Hup() bool + Quit() +} + +func run(a app) int { + defer installSignalHandler(a)() + + if err := a.Run(); err != nil { + log.Error(context.Background(), err.Error()) + + if a.UsageError() { + return 2 + } + return 1 + } + + return 0 +} + +func installSignalHandler(a app) func() { + c := make(chan os.Signal, 1) + signal.Notify(c, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM) + + wg := sync.WaitGroup{} + wg.Add(1) + go func() { + defer wg.Done() + for { + switch v, ok := <-c; v { + case syscall.SIGINT, syscall.SIGTERM: + a.Quit() + return + case syscall.SIGHUP: + if a.Hup() { + a.Quit() + return + } + default: + // channel was closed: we exited + if !ok { + return + } + } + } + }() + + return func() { + signal.Stop(c) + close(c) + wg.Wait() + } +} diff --git a/authd-oidc-brokers/cmd/authd-oidc/main_test.go b/authd-oidc-brokers/cmd/authd-oidc/main_test.go new file mode 100644 index 0000000000..5c7ed40bf1 --- /dev/null +++ b/authd-oidc-brokers/cmd/authd-oidc/main_test.go @@ -0,0 +1,117 @@ +package main + +import ( + "errors" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type myApp struct { + done chan struct{} + + runError bool + usageErrorReturn bool + hupReturn bool +} + +func (a *myApp) Run() error { + <-a.done + if a.runError { + return errors.New("Error requested") + } + return nil +} + +func (a myApp) UsageError() bool { + return a.usageErrorReturn +} + +func (a myApp) Hup() bool { + return a.hupReturn +} + +func (a *myApp) Quit() { + close(a.done) +} + +//nolint:tparallel // Signal handlers tests: subtests can't be parallel +func TestRun(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + runError bool + usageErrorReturn bool + hupReturn bool + sendSig syscall.Signal + + wantReturnCode int + }{ + "Run_and_exit_successfully": {}, + "Run_and_return_error": {runError: true, wantReturnCode: 1}, + "Run_and_return_usage_error": {usageErrorReturn: true, runError: true, wantReturnCode: 2}, + "Run_and_usage_error_only_does_not_fail": {usageErrorReturn: true, runError: false, wantReturnCode: 0}, + + // Signals handling + "Send_SIGINT_exits": {sendSig: syscall.SIGINT}, + "Send_SIGTERM_exits": {sendSig: syscall.SIGTERM}, + "Send_SIGHUP_without_exiting": {sendSig: syscall.SIGHUP}, + "Send_SIGHUP_with_exit": {sendSig: syscall.SIGHUP, hupReturn: true}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + a := myApp{ + done: make(chan struct{}), + runError: tc.runError, + usageErrorReturn: tc.usageErrorReturn, + hupReturn: tc.hupReturn, + } + + var rc int + wait := make(chan struct{}) + go func() { + rc = run(&a) + close(wait) + }() + + time.Sleep(100 * time.Millisecond) + + var exited bool + switch tc.sendSig { + case syscall.SIGINT: + fallthrough + case syscall.SIGTERM: + err := syscall.Kill(syscall.Getpid(), tc.sendSig) + require.NoError(t, err, "Teardown: kill should return no error") + select { + case <-time.After(50 * time.Millisecond): + exited = false + case <-wait: + exited = true + } + require.Equal(t, true, exited, "Expect to exit on SIGINT and SIGTERM") + case syscall.SIGHUP: + err := syscall.Kill(syscall.Getpid(), syscall.SIGHUP) + require.NoError(t, err, "Teardown: kill should return no error") + select { + case <-time.After(50 * time.Millisecond): + exited = false + case <-wait: + exited = true + } + // if SIGHUP returns false: do nothing and still wait. + // Otherwise, it means that we wanted to stop + require.Equal(t, tc.hupReturn, exited, "Expect to exit only on SIGHUP returning True") + } + + if !exited { + a.Quit() + <-wait + } + + require.Equal(t, tc.wantReturnCode, rc, "Return expected code") + }) + } +} diff --git a/authd-oidc-brokers/conf/migrations/pre-0.2.0-pre1/broker.conf.d/00-migration-allowed_users.conf b/authd-oidc-brokers/conf/migrations/pre-0.2.0-pre1/broker.conf.d/00-migration-allowed_users.conf new file mode 100644 index 0000000000..d1b977fdee --- /dev/null +++ b/authd-oidc-brokers/conf/migrations/pre-0.2.0-pre1/broker.conf.d/00-migration-allowed_users.conf @@ -0,0 +1,11 @@ +## This file was generated during the broker upgrade process. DO NOT EDIT. +## +## This file adds the 'allowed_users' option and sets it to 'ALL' +## to preserve backward compatibility, as the default for this +## option is 'OWNER'. +## For more information, refer to 10-allowed_users.conf. +## +## If you want to use the new default setting, simply delete this file. + +[users] +allowed_users = ALL diff --git a/authd-oidc-brokers/conf/migrations/pre-0.2.0-pre1/broker.conf.d/10-allowed_users.conf b/authd-oidc-brokers/conf/migrations/pre-0.2.0-pre1/broker.conf.d/10-allowed_users.conf new file mode 100644 index 0000000000..33377e5227 --- /dev/null +++ b/authd-oidc-brokers/conf/migrations/pre-0.2.0-pre1/broker.conf.d/10-allowed_users.conf @@ -0,0 +1,28 @@ +[users] +## 'allowed_users' specifies the users who are permitted to log in after +## successfully authenticating with the Identity Provider. +## Values are separated by commas. Supported values: +## - 'OWNER': Grants access to the user specified in the 'owner' option +## (see below). This is the default. +## - 'ALL': Grants access to all users who successfully authenticate +## with the Identity Provider. +## - : Grants access to specific additional users +## (e.g. user1@example.com). +## Example: allowed_users = OWNER,user1@example.com,admin@example.com +#allowed_users = OWNER + +## 'owner' specifies the user assigned the owner role. This user is +## permitted to log in if 'OWNER' is included in the 'allowed_users' +## option. +## +## If this option is left unset, the first user to successfully log in +## via this broker will automatically be assigned the owner role. A +## drop-in configuration file will be created in broker.conf.d/ to set +## the 'owner' option. +## +## To disable automatic assignment, you can either: +## 1. Explicitly set this option to an empty value (e.g. owner = "") +## 2. Remove 'OWNER' from the 'allowed_users' option +## +## Example: owner = user2@example.com +#owner = diff --git a/authd-oidc-brokers/conf/variants/google/authd.conf b/authd-oidc-brokers/conf/variants/google/authd.conf new file mode 100644 index 0000000000..18da085caf --- /dev/null +++ b/authd-oidc-brokers/conf/variants/google/authd.conf @@ -0,0 +1,7 @@ +# This section is used by authd to identify and communicate with the broker. +# It should not be edited. +[authd] +name = Google +brand_icon = /snap/authd-google/current/broker_icon.png +dbus_name = com.ubuntu.authd.Google +dbus_object = /com/ubuntu/authd/Google diff --git a/authd-oidc-brokers/conf/variants/google/broker.conf b/authd-oidc-brokers/conf/variants/google/broker.conf new file mode 100644 index 0000000000..31d80afb07 --- /dev/null +++ b/authd-oidc-brokers/conf/variants/google/broker.conf @@ -0,0 +1,82 @@ +[oidc] +issuer = https://accounts.google.com +client_id = +client_secret = + +## Force remote authentication with the identity provider during login, +## even if a local method (e.g. local password) is used. +## This works by forcing a token refresh during login, which fails if the +## user does not have the necessary permissions in the identity provider. +## +## If set to false (the default), remote authentication with the identity +## provider only happens if there is a working internet connection and +## the provider is reachable during login. +## +## Important: Enabling this option prevents authd users from logging in +## if the identity provider is unreachable (e.g. due to network issues). +#force_provider_authentication = false + +[users] +## The directory where the home directories of new users are created. +## Existing users will keep their current home directory. +## The home directories are created in the format / +#home_base_dir = /home + +## By default, SSH only allows logins from users that already exist on the +## system. +## New authd users (who have never logged in before) are *not* allowed to log +## in for the first time via SSH unless this option is configured. +## +## If configured, only users with a suffix in this list are allowed to +## authenticate for the first time directly through SSH. +## Note that this does not affect users that already authenticated for +## the first time and already exist on the system. +## +## Suffixes must be comma-separated (e.g., '@example.com,@example.org'). +## To allow all suffixes, use a single asterisk ('*'). +## +## Example: +## ssh_allowed_suffixes_first_auth = @example.com,@anotherexample.org +## +## Example (allow all): +## ssh_allowed_suffixes_first_auth = * +## +#ssh_allowed_suffixes_first_auth = + +## 'allowed_users' specifies the users who are permitted to log in after +## successfully authenticating with the identity provider. +## Values are separated by commas. Supported values: +## - 'OWNER': Grants access to the user specified in the 'owner' option +## (see below). This is the default. +## - 'ALL': Grants access to all users who successfully authenticate +## with the identity provider. +## - : Grants access to specific additional users +## (e.g. user1@example.com). +## Example: allowed_users = OWNER,user1@example.com,admin@example.com +#allowed_users = OWNER + +## 'owner' specifies the user assigned the owner role. This user is +## permitted to log in if 'OWNER' is included in the 'allowed_users' +## option. +## +## If this option is left unset, the first user to successfully log in +## via this broker will automatically be assigned the owner role. A +## drop-in configuration file will be created in broker.conf.d/ to set +## the 'owner' option. +## +## To disable automatic assignment, you can either: +## 1. Explicitly set this option to an empty value (e.g. owner = "") +## 2. Remove 'OWNER' from the 'allowed_users' option +## +## Example: owner = user2@example.com +#owner = + +## A comma-separated list of local groups which authd users will be +## added to upon login. +## Example: extra_groups = users +#extra_groups = + +## Like 'extra_groups', but only the user assigned the owner role +## (see 'owner' option) will be added to these groups. +## Example: owner_extra_groups = sudo,lpadmin +#owner_extra_groups = diff --git a/authd-oidc-brokers/conf/variants/msentraid/authd.conf b/authd-oidc-brokers/conf/variants/msentraid/authd.conf new file mode 100644 index 0000000000..9a620538cb --- /dev/null +++ b/authd-oidc-brokers/conf/variants/msentraid/authd.conf @@ -0,0 +1,7 @@ +# This section is used by authd to identify and communicate with the broker. +# It should not be edited. +[authd] +name = Microsoft Entra ID +brand_icon = /snap/authd-msentraid/current/broker_icon.png +dbus_name = com.ubuntu.authd.MSEntraID +dbus_object = /com/ubuntu/authd/MSEntraID diff --git a/authd-oidc-brokers/conf/variants/msentraid/broker.conf b/authd-oidc-brokers/conf/variants/msentraid/broker.conf new file mode 100644 index 0000000000..db63181111 --- /dev/null +++ b/authd-oidc-brokers/conf/variants/msentraid/broker.conf @@ -0,0 +1,91 @@ +[oidc] +issuer = https://login.microsoftonline.com//v2.0 +client_id = + +## Force remote authentication with the identity provider during login, +## even if a local method (e.g. local password) is used. +## This works by forcing a token refresh during login, which fails if the +## user does not have the necessary permissions in the identity provider. +## +## If set to false (the default), remote authentication with the identity +## provider only happens if there is a working internet connection and +## the provider is reachable during login. +## +## Important: Enabling this option prevents authd users from logging in +## if the identity provider is unreachable (e.g. due to network issues). +#force_provider_authentication = false + +[msentraid] +## Enable automatic device registration with Microsoft Entra ID +## when a user logs in through this broker. +## +## If set to true, authd will attempt to register the local machine +## as a device in Entra ID upon successful login. +## +## If set to false (the default), device registration will be skipped. +#register_device = false + +[users] +## The directory where the home directories of new users are created. +## Existing users will keep their current home directory. +## The home directories are created in the format / +#home_base_dir = /home + +## By default, SSH only allows logins from users that already exist on the +## system. +## New authd users (who have never logged in before) are *not* allowed to log +## in for the first time via SSH unless this option is configured. +## +## If configured, only users with a suffix in this list are allowed to +## authenticate for the first time directly through SSH. +## Note that this does not affect users that already authenticated for +## the first time and already exist on the system. +## +## Suffixes must be comma-separated (e.g., '@example.com,@example.org'). +## To allow all suffixes, use a single asterisk ('*'). +## +## Example: +## ssh_allowed_suffixes_first_auth = @example.com,@anotherexample.org +## +## Example (allow all): +## ssh_allowed_suffixes_first_auth = * +## +#ssh_allowed_suffixes_first_auth = + +## 'allowed_users' specifies the users who are permitted to log in after +## successfully authenticating with the identity provider. +## Values are separated by commas. Supported values: +## - 'OWNER': Grants access to the user specified in the 'owner' option +## (see below). This is the default. +## - 'ALL': Grants access to all users who successfully authenticate +## with the identity provider. +## - : Grants access to specific additional users +## (e.g. user1@example.com). +## Example: allowed_users = OWNER,user1@example.com,admin@example.com +#allowed_users = OWNER + +## 'owner' specifies the user assigned the owner role. This user is +## permitted to log in if 'OWNER' is included in the 'allowed_users' +## option. +## +## If this option is left unset, the first user to successfully log in +## via this broker will automatically be assigned the owner role. A +## drop-in configuration file will be created in broker.conf.d/ to set +## the 'owner' option. +## +## To disable automatic assignment, you can either: +## 1. Explicitly set this option to an empty value (e.g. owner = "") +## 2. Remove 'OWNER' from the 'allowed_users' option +## +## Example: owner = user2@example.com +#owner = + +## A comma-separated list of local groups which authd users will be +## added to upon login. +## Example: extra_groups = users +#extra_groups = + +## Like 'extra_groups', but only the user assigned the owner role +## (see 'owner' option) will be added to these groups. +## Example: owner_extra_groups = sudo,lpadmin +#owner_extra_groups = diff --git a/authd-oidc-brokers/conf/variants/oidc/authd.conf b/authd-oidc-brokers/conf/variants/oidc/authd.conf new file mode 100644 index 0000000000..4475af5569 --- /dev/null +++ b/authd-oidc-brokers/conf/variants/oidc/authd.conf @@ -0,0 +1,7 @@ +# This section is used by authd to identify and communicate with the broker. +# It should not be edited. +[authd] +name = OIDC +brand_icon = /snap/authd-oidc/current/broker_icon.png +dbus_name = com.ubuntu.authd.Oidc +dbus_object = /com/ubuntu/authd/Oidc diff --git a/authd-oidc-brokers/conf/variants/oidc/broker.conf b/authd-oidc-brokers/conf/variants/oidc/broker.conf new file mode 100644 index 0000000000..8325587984 --- /dev/null +++ b/authd-oidc-brokers/conf/variants/oidc/broker.conf @@ -0,0 +1,90 @@ +[oidc] +issuer = +client_id = + +## Depending on the identity provider, you may need to provide a +## client secret to authenticate with the provider. +#client_secret = + +## Comma-separated list of extra OIDC scopes to request in addition to +## the default scopes. +## Example: extra_scopes = offline_access +#extra_scopes = + +## Force remote authentication with the identity provider during login, +## even if a local method (e.g. local password) is used. +## This works by forcing a token refresh during login, which fails if the +## user does not have the necessary permissions in the identity provider. +## +## If set to false (the default), remote authentication with the identity +## provider only happens if there is a working internet connection and +## the provider is reachable during login. +## +## Important: Enabling this option prevents authd users from logging in +## if the identity provider is unreachable (e.g. due to network issues). +#force_provider_authentication = false + +[users] +## The directory where the home directories of new users are created. +## Existing users will keep their current home directory. +## The home directories are created in the format / +#home_base_dir = /home + +## By default, SSH only allows logins from users that already exist on the +## system. +## New authd users (who have never logged in before) are *not* allowed to log +## in for the first time via SSH unless this option is configured. +## +## If configured, only users with a suffix in this list are allowed to +## authenticate for the first time directly through SSH. +## Note that this does not affect users that already authenticated for +## the first time and already exist on the system. +## +## Suffixes must be comma-separated (e.g., '@example.com,@example.org'). +## To allow all suffixes, use a single asterisk ('*'). +## +## Example: +## ssh_allowed_suffixes_first_auth = @example.com,@anotherexample.org +## +## Example (allow all): +## ssh_allowed_suffixes_first_auth = * +## +#ssh_allowed_suffixes_first_auth = + +## 'allowed_users' specifies the users who are permitted to log in after +## successfully authenticating with the identity provider. +## Values are separated by commas. Supported values: +## - 'OWNER': Grants access to the user specified in the 'owner' option +## (see below). This is the default. +## - 'ALL': Grants access to all users who successfully authenticate +## with the identity provider. +## - : Grants access to specific additional users +## (e.g. user1@example.com). +## Example: allowed_users = OWNER,user1@example.com,admin@example.com +#allowed_users = OWNER + +## 'owner' specifies the user assigned the owner role. This user is +## permitted to log in if 'OWNER' is included in the 'allowed_users' +## option. +## +## If this option is left unset, the first user to successfully log in +## via this broker will automatically be assigned the owner role. A +## drop-in configuration file will be created in broker.conf.d/ to set +## the 'owner' option. +## +## To disable automatic assignment, you can either: +## 1. Explicitly set this option to an empty value (e.g. owner = "") +## 2. Remove 'OWNER' from the 'allowed_users' option +## +## Example: owner = user2@example.com +#owner = + +## A comma-separated list of local groups which authd users will be +## added to upon login. +## Example: extra_groups = users +#extra_groups = + +## Like 'extra_groups', but only the user assigned the owner role +## (see 'owner' option) will be added to these groups. +## Example: owner_extra_groups = sudo,lpadmin +#owner_extra_groups = diff --git a/authd-oidc-brokers/go.mod b/authd-oidc-brokers/go.mod new file mode 100644 index 0000000000..b6001e729c --- /dev/null +++ b/authd-oidc-brokers/go.mod @@ -0,0 +1,71 @@ +module github.com/canonical/authd/authd-oidc-brokers + +go 1.25.0 + +toolchain go1.25.8 + +require ( + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 + github.com/coreos/go-oidc/v3 v3.17.0 + github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf + github.com/go-jose/go-jose/v4 v4.1.3 + github.com/godbus/dbus/v5 v5.2.2 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/uuid v1.6.0 + github.com/k0kubun/pp v3.0.1+incompatible + github.com/microsoftgraph/msgraph-sdk-go v1.96.0 + github.com/microsoftgraph/msgraph-sdk-go-core v1.4.0 + github.com/otiai10/copy v1.14.1 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 + github.com/spf13/cobra v1.10.2 + github.com/spf13/viper v1.21.0 + github.com/stretchr/testify v1.11.1 + github.com/ubuntu/authd v0.5.8 + github.com/ubuntu/decorate v0.0.0-20240301153420-5015d6dbc8e5 + github.com/ubuntu/go-i18n v0.0.0-20231113092927-594c1754ca47 + golang.org/x/crypto v0.48.0 + golang.org/x/oauth2 v0.35.0 + gopkg.in/ini.v1 v1.67.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect + github.com/coreos/go-systemd/v22 v22.5.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88 // indirect + github.com/leonelquinteros/gotext v1.5.3-0.20230829162019-37f474cfb069 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/microsoft/kiota-abstractions-go v1.9.3 // indirect + github.com/microsoft/kiota-authentication-azure-go v1.3.1 // indirect + github.com/microsoft/kiota-http-go v1.5.4 // indirect + github.com/microsoft/kiota-serialization-form-go v1.1.2 // indirect + github.com/microsoft/kiota-serialization-json-go v1.1.2 // indirect + github.com/microsoft/kiota-serialization-multipart-go v1.1.2 // indirect + github.com/microsoft/kiota-serialization-text-go v1.1.3 // indirect + github.com/otiai10/mint v1.6.3 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.3 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel v1.37.0 // indirect + go.opentelemetry.io/otel/metric v1.37.0 // indirect + go.opentelemetry.io/otel/trace v1.37.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect +) diff --git a/authd-oidc-brokers/go.sum b/authd-oidc-brokers/go.sum new file mode 100644 index 0000000000..67c880d216 --- /dev/null +++ b/authd-oidc-brokers/go.sum @@ -0,0 +1,176 @@ +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf h1:iW4rZ826su+pqaw19uhpSCzhj44qo35pNgKFGqzDKkU= +github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88 h1:uC1QfSlInpQF+M0ao65imhwqKnz3Q2z/d8PWZRMQvDM= +github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k= +github.com/k0kubun/pp v3.0.1+incompatible h1:3tqvf7QgUnZ5tXO6pNAZlrvHgl6DvifjDrd9g2S9Z40= +github.com/k0kubun/pp v3.0.1+incompatible/go.mod h1:GWse8YhT0p8pT4ir3ZgBbfZild3tgzSScAn6HmfYukg= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leonelquinteros/gotext v1.5.3-0.20230829162019-37f474cfb069 h1:9ZM/t54vDjI3XRw2+HC2DxrH2Giv/jAyv7+Vq2QgXmg= +github.com/leonelquinteros/gotext v1.5.3-0.20230829162019-37f474cfb069/go.mod h1:AT4NpQrOmyj1L/+hLja6aR0lk81yYYL4ePnj2kp7d6M= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/microsoft/kiota-abstractions-go v1.9.3 h1:cqhbqro+VynJ7kObmo7850h3WN2SbvoyhypPn8uJ1SE= +github.com/microsoft/kiota-abstractions-go v1.9.3/go.mod h1:f06pl3qSyvUHEfVNkiRpXPkafx7khZqQEb71hN/pmuU= +github.com/microsoft/kiota-authentication-azure-go v1.3.1 h1:AGta92S6IL1E6ZMDb8YYB7NVNTIFUakbtLKUdY5RTuw= +github.com/microsoft/kiota-authentication-azure-go v1.3.1/go.mod h1:26zylt2/KfKwEWZSnwHaMxaArpbyN/CuzkbotdYXF0g= +github.com/microsoft/kiota-http-go v1.5.4 h1:wSUmL1J+bTQlAWHjbRkSwr+SPAkMVYeYxxB85Zw0KFs= +github.com/microsoft/kiota-http-go v1.5.4/go.mod h1:L+5Ri+SzwELnUcNA0cpbFKp/pBbvypLh3Cd1PR6sjx0= +github.com/microsoft/kiota-serialization-form-go v1.1.2 h1:SD6MATqNw+Dc5beILlsb/D87C36HKC/Zw7l+N9+HY2A= +github.com/microsoft/kiota-serialization-form-go v1.1.2/go.mod h1:m4tY2JT42jAZmgbqFwPy3zGDF+NPJACuyzmjNXeuHio= +github.com/microsoft/kiota-serialization-json-go v1.1.2 h1:eJrPWeQ665nbjO0gsHWJ0Bw6V/ZHHU1OfFPaYfRG39k= +github.com/microsoft/kiota-serialization-json-go v1.1.2/go.mod h1:deaGt7fjZarywyp7TOTiRsjfYiyWxwJJPQZytXwYQn8= +github.com/microsoft/kiota-serialization-multipart-go v1.1.2 h1:1pUyA1QgIeKslQwbk7/ox1TehjlCUUT3r1f8cNlkvn4= +github.com/microsoft/kiota-serialization-multipart-go v1.1.2/go.mod h1:j2K7ZyYErloDu7Kuuk993DsvfoP7LPWvAo7rfDpdPio= +github.com/microsoft/kiota-serialization-text-go v1.1.3 h1:8z7Cebn0YAAr++xswVgfdxZjnAZ4GOB9O7XP4+r5r/M= +github.com/microsoft/kiota-serialization-text-go v1.1.3/go.mod h1:NDSvz4A3QalGMjNboKKQI9wR+8k+ih8UuagNmzIRgTQ= +github.com/microsoftgraph/msgraph-sdk-go v1.96.0 h1:UnqyTX8Ils9tJ7QLaR2yVF3ctXCvRUp2gl3BThIteJk= +github.com/microsoftgraph/msgraph-sdk-go v1.96.0/go.mod h1:JBHC+/jxEODRr1TmV5caB84mJF4whlpTLHPveVJ0DFA= +github.com/microsoftgraph/msgraph-sdk-go-core v1.4.0 h1:0SrIoFl7TQnMRrsi5TFaeNe0q8KO5lRzRp4GSCCL2So= +github.com/microsoftgraph/msgraph-sdk-go-core v1.4.0/go.mod h1:A1iXs+vjsRjzANxF6UeKv2ACExG7fqTwHHbwh1FL+EE= +github.com/otiai10/copy v1.14.1 h1:5/7E6qsUMBaH5AnQ0sSLzzTg1oTECmcCmT6lvF45Na8= +github.com/otiai10/copy v1.14.1/go.mod h1:oQwrEDDOci3IM8dJF0d8+jnbfPDllW6vUjNc3DoZm9I= +github.com/otiai10/mint v1.6.3 h1:87qsV/aw1F5as1eH1zS/yqHY85ANKVMgkDrf9rcxbQs= +github.com/otiai10/mint v1.6.3/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.3 h1:7hth9376EoQEd1hH4lAp3vnaLP2UMyxuMMghLKzDHyU= +github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.3/go.mod h1:Z5KcoM0YLC7INlNhEezeIZ0TZNYf7WSNO0Lvah4DSeQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/ubuntu/authd v0.5.8 h1:OMHWb4fZ6e9gg0d5Mxbb3fiOSK6XN3+LOOAYZt8mjjo= +github.com/ubuntu/authd v0.5.8/go.mod h1:fAJiEdBA4iusBgaysolNVkOKVvv0qv45HQHxMfxVcnk= +github.com/ubuntu/decorate v0.0.0-20240301153420-5015d6dbc8e5 h1:qO8m+4mLbo1HRpD5lfhEfr7R1PuqZvbAmjaRzYEy+tM= +github.com/ubuntu/decorate v0.0.0-20240301153420-5015d6dbc8e5/go.mod h1:PUpwIgUuCQyuCz/gwiq6WYbo7IvtXXd8JqL01ez+jZE= +github.com/ubuntu/go-i18n v0.0.0-20231113092927-594c1754ca47 h1:CA2dVorxvzdsGtszqhSjyvkrXxZi4bS52ZKvP0Ko634= +github.com/ubuntu/go-i18n v0.0.0-20231113092927-594c1754ca47/go.mod h1:ZRhdDyx6YkKz/YiMWi0gS3uMCltgdaKz9IpkiNf/GRg= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k= +gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/authd-oidc-brokers/internal/broker/authmodes/consts.go b/authd-oidc-brokers/internal/broker/authmodes/consts.go new file mode 100644 index 0000000000..366a9d2af5 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/authmodes/consts.go @@ -0,0 +1,26 @@ +// Package authmodes lists the authentication modes that providers can support. +package authmodes + +const ( + // Password is the ID of the password authentication method. + Password = "password" + + // Device is the ID of the device authentication method. + Device = "device_auth" + + // DeviceQr is the ID of the device authentication method when QrCode rendering is enabled. + DeviceQr = "device_auth_qr" + + // NewPassword is the ID of the new password configuration method. + NewPassword = "newpassword" +) + +var ( + // Label is a map of auth mode IDs to their display labels. + Label = map[string]string{ + Password: "Local Password Authentication", + Device: "Device Authentication", + DeviceQr: "Device Authentication", + NewPassword: "Define your local password", + } +) diff --git a/authd-oidc-brokers/internal/broker/authresponses.go b/authd-oidc-brokers/internal/broker/authresponses.go new file mode 100644 index 0000000000..eae9c4b625 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/authresponses.go @@ -0,0 +1,21 @@ +package broker + +import "github.com/canonical/authd/authd-oidc-brokers/internal/providers/info" + +type isAuthenticatedDataResponse interface { + isAuthenticatedDataResponse() +} + +// userInfoMessage represents the user information message that is returned to authd. +type userInfoMessage struct { + UserInfo info.User `json:"userinfo"` +} + +func (userInfoMessage) isAuthenticatedDataResponse() {} + +// errorMessage represents the error message that is returned to authd. +type errorMessage struct { + Message string `json:"message"` +} + +func (errorMessage) isAuthenticatedDataResponse() {} diff --git a/authd-oidc-brokers/internal/broker/broker.go b/authd-oidc-brokers/internal/broker/broker.go new file mode 100644 index 0000000000..2dee4139b5 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/broker.go @@ -0,0 +1,1140 @@ +// Package broker is the generic oidc business code. +package broker + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "path/filepath" + "slices" + "strings" + "sync" + "time" + + "github.com/canonical/authd/authd-oidc-brokers/internal/broker/authmodes" + "github.com/canonical/authd/authd-oidc-brokers/internal/broker/sessionmode" + "github.com/canonical/authd/authd-oidc-brokers/internal/consts" + "github.com/canonical/authd/authd-oidc-brokers/internal/fileutils" + "github.com/canonical/authd/authd-oidc-brokers/internal/password" + "github.com/canonical/authd/authd-oidc-brokers/internal/providers" + providerErrors "github.com/canonical/authd/authd-oidc-brokers/internal/providers/errors" + "github.com/canonical/authd/authd-oidc-brokers/internal/providers/info" + "github.com/canonical/authd/authd-oidc-brokers/internal/providers/msentraid/himmelblau" + "github.com/canonical/authd/authd-oidc-brokers/internal/token" + "github.com/coreos/go-oidc/v3/oidc" + "github.com/google/uuid" + "github.com/ubuntu/authd/log" + "github.com/ubuntu/decorate" + "golang.org/x/oauth2" +) + +const ( + maxAuthAttempts = 3 + maxRequestDuration = 5 * time.Second +) + +// Config is the configuration for the broker. +type Config struct { + ConfigFile string + DataDir string + + userConfig +} + +// Broker is the real implementation of the broker to track sessions and process oidc calls. +type Broker struct { + cfg Config + + provider providers.Provider + oidcCfg oidc.Config + + currentSessions map[string]session + currentSessionsMu sync.RWMutex + + privateKey *rsa.PrivateKey +} + +type session struct { + username string + lang string + mode string + + selectedMode string + authModes []string + attemptsPerMode map[string]int + nextAuthModes []string + + oidcServer *oidc.Provider + oauth2Config oauth2.Config + isOffline bool + providerConnectionError error + userDataDir string + passwordPath string + tokenPath string + + // Data to pass from one request to another. + deviceAuthResponse *oauth2.DeviceAuthResponse + authInfo *token.AuthCachedInfo + + isAuthenticating *isAuthenticatedCtx +} + +type isAuthenticatedCtx struct { + ctx context.Context + cancelFunc context.CancelFunc +} + +type option struct { + provider providers.Provider +} + +// Option is a func that allows to override some of the broker default settings. +type Option func(*option) + +// New returns a new oidc Broker with the providers listed in the configuration file. +func New(cfg Config, args ...Option) (b *Broker, err error) { + p := providers.CurrentProvider() + + if cfg.ConfigFile != "" { + cfg.userConfig, err = parseConfigFromPath(cfg.ConfigFile, p) + if err != nil { + return nil, fmt.Errorf("could not parse config file '%s': %v", cfg.ConfigFile, err) + } + } + + opts := option{ + provider: p, + } + for _, arg := range args { + arg(&opts) + } + + if cfg.DataDir == "" { + err = errors.Join(err, errors.New("cache path is required and was not provided")) + } + if cfg.issuerURL == "" { + err = errors.Join(err, errors.New("issuer URL is required and was not provided")) + } + if cfg.clientID == "" { + err = errors.Join(err, errors.New("client ID is required and was not provided")) + } + if err != nil { + return nil, err + } + + if cfg.homeBaseDir == "" { + cfg.homeBaseDir = "/home" + } + + // Generate a new private key for the broker. + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + log.Error(context.Background(), err.Error()) + return nil, errors.New("failed to generate broker private key") + } + + clientID := cfg.clientID + if opts.provider.SupportsDeviceRegistration() && cfg.registerDevice { + clientID = consts.MicrosoftBrokerAppID + } + + b = &Broker{ + cfg: cfg, + provider: opts.provider, + oidcCfg: oidc.Config{ClientID: clientID}, + privateKey: privateKey, + + currentSessions: make(map[string]session), + currentSessionsMu: sync.RWMutex{}, + } + return b, nil +} + +// NewSession creates a new session for the user. +func (b *Broker) NewSession(username, lang, mode string) (sessionID, encryptionKey string, err error) { + defer decorate.OnError(&err, "could not create new session for user %q", username) + + sessionID = uuid.New().String() + s := session{ + username: username, + lang: lang, + mode: mode, + + attemptsPerMode: make(map[string]int), + } + + pubASN1, err := x509.MarshalPKIXPublicKey(&b.privateKey.PublicKey) + if err != nil { + return "", "", err + } + + _, issuer, _ := strings.Cut(b.cfg.issuerURL, "://") + issuer = strings.ReplaceAll(issuer, "/", "_") + issuer = strings.ReplaceAll(issuer, ":", "_") + s.userDataDir = filepath.Join(b.cfg.DataDir, issuer, username) + // The token is stored in $DATA_DIR/$ISSUER/$USERNAME/token.json. + s.tokenPath = filepath.Join(s.userDataDir, "token.json") + // The password is stored in $DATA_DIR/$ISSUER/$USERNAME/password. + s.passwordPath = filepath.Join(s.userDataDir, "password") + + // Construct an OIDC provider via OIDC discovery. + s.oidcServer, err = b.connectToOIDCServer(context.Background()) + if err != nil { + log.Noticef(context.Background(), "Could not connect to the provider: %v. Starting session in offline mode.", err) + s.isOffline = true + s.providerConnectionError = err + } + + scopes := append(consts.DefaultScopes, b.provider.AdditionalScopes()...) + if b.provider.SupportsDeviceRegistration() && b.cfg.registerDevice { + scopes = consts.MicrosoftBrokerAppScopes + } + // Append extra scopes from config + scopes = append(scopes, b.cfg.extraScopes...) + + if s.oidcServer != nil { + s.oauth2Config = oauth2.Config{ + ClientID: b.oidcCfg.ClientID, + ClientSecret: b.cfg.clientSecret, + Endpoint: s.oidcServer.Endpoint(), + Scopes: scopes, + } + } + + b.currentSessionsMu.Lock() + b.currentSessions[sessionID] = s + b.currentSessionsMu.Unlock() + + return sessionID, base64.StdEncoding.EncodeToString(pubASN1), nil +} + +func (b *Broker) connectToOIDCServer(ctx context.Context) (*oidc.Provider, error) { + ctx, cancel := context.WithTimeout(ctx, maxRequestDuration) + defer cancel() + + return oidc.NewProvider(ctx, b.cfg.issuerURL) +} + +// GetAuthenticationModes returns the authentication modes available for the user. +func (b *Broker) GetAuthenticationModes(sessionID string, supportedUILayouts []map[string]string) (authModesWithLabels []map[string]string, err error) { + session, err := b.getSession(sessionID) + if err != nil { + return nil, err + } + + availableModes, err := b.availableAuthModes(session) + if err != nil { + return nil, err + } + + // Store the available auth modes, so that we can check in SelectAuthenticationMode if the selected mode is valid. + session.authModes = availableModes + if err := b.updateSession(sessionID, session); err != nil { + return nil, err + } + + modesSupportedByUI := b.authModesSupportedByUI(supportedUILayouts) + + for _, mode := range availableModes { + if !slices.Contains(modesSupportedByUI, mode) { + continue + } + + authModesWithLabels = append(authModesWithLabels, map[string]string{ + "id": mode, + "label": authmodes.Label[mode], + }) + } + + if len(authModesWithLabels) == 0 { + // If we can't use a local authentication mode and we failed to connect to the provider, + // report the connection error. + if session.providerConnectionError != nil { + log.Errorf(context.Background(), "Error connecting to provider: %v", session.providerConnectionError) + //nolint:staticcheck,revive // ST1005 This error is displayed as is to the user, so it should be capitalized + return nil, errors.New("Error connecting to provider. Check your network connection.") + } + return nil, fmt.Errorf("no authentication modes available for user %q", session.username) + } + + return authModesWithLabels, nil +} + +func (b *Broker) availableAuthModes(session session) (availableModes []string, err error) { + if len(session.nextAuthModes) > 0 { + for _, mode := range session.nextAuthModes { + if !b.authModeIsAvailable(session, mode) { + continue + } + availableModes = append(availableModes, mode) + } + if availableModes == nil { + log.Warningf(context.Background(), "None of the next auth modes are available: %v", session.nextAuthModes) + } + return availableModes, nil + } + + switch session.mode { + case sessionmode.ChangePassword, sessionmode.ChangePasswordOld: + // Session is for changing the password. + if !passwordFileExists(session) { + return nil, errors.New("password file does not exist, cannot change password") + } + return []string{authmodes.Password}, nil + + default: + // Session is for login. Check which auth modes are available. + // The order of the modes is important, because authd picks the first supported one. + // Password authentication should be the first option if available, to avoid performing device authentication + // when it's not necessary. + modes := append([]string{authmodes.Password}, b.provider.SupportedOIDCAuthModes()...) + for _, mode := range modes { + if b.authModeIsAvailable(session, mode) { + availableModes = append(availableModes, mode) + } + } + return availableModes, nil + } +} + +func (b *Broker) authModeIsAvailable(session session, authMode string) bool { + switch authMode { + case authmodes.Password: + if !tokenExists(session) { + log.Debugf(context.Background(), "Token does not exist for user %q, so local password authentication is not available", session.username) + return false + } + + if !passwordFileExists(session) { + log.Debugf(context.Background(), "Password file does not exist for user %q, so local password authentication is not available", session.username) + return false + } + + authInfo, err := token.LoadAuthInfo(session.tokenPath) + if err != nil { + log.Warningf(context.Background(), "Could not load token, so local password authentication is not available: %v", err) + return false + } + + if !b.provider.SupportsDeviceRegistration() { + // If the provider does not support device registration, + // we can always use the token for local password authentication. + log.Debugf(context.Background(), "Provider does not support device registration, so local password authentication is available for user %q", session.username) + return true + } + + if session.isOffline { + // If the session is in offline mode, we can't register the device anyway, + // so we can allow the user to use local password authentication. + log.Debugf(context.Background(), "Session is in offline mode, so local password authentication is available for user %q", session.username) + return true + } + + isTokenForDeviceRegistration, err := b.provider.IsTokenForDeviceRegistration(authInfo.Token) + if err != nil { + log.Warningf(context.Background(), "Could not check if token is for device registration, so local password authentication is not available: %v", err) + return false + } + + if b.cfg.registerDevice && !isTokenForDeviceRegistration { + // TODO: We might want to display a message to the user in this case + log.Noticef(context.Background(), "Token exists for user %q, but it cannot be used for device registration, so local password authentication is not available", session.username) + return false + } + if !b.cfg.registerDevice && isTokenForDeviceRegistration { + // TODO: We might want to display a message to the user in this case + log.Noticef(context.Background(), "Token exists for user %q, but it requires device registration, so local password authentication is not available", session.username) + return false + } + + return true + case authmodes.NewPassword: + return true + case authmodes.Device, authmodes.DeviceQr: + if session.oidcServer == nil { + log.Debugf(context.Background(), "OIDC server is not initialized, so device authentication is not available") + return false + } + if session.oidcServer.Endpoint().DeviceAuthURL == "" { + log.Debugf(context.Background(), "OIDC server does not support device authentication, so device authentication is not available") + return false + } + if session.isOffline { + log.Noticef(context.Background(), "Session is in offline mode, so device authentication is not available") + return false + } + return true + } + return false +} + +func tokenExists(session session) bool { + exists, err := fileutils.FileExists(session.tokenPath) + if err != nil { + log.Warningf(context.Background(), "Could not check if token exists: %v", err) + } + return exists +} + +func passwordFileExists(session session) bool { + exists, err := fileutils.FileExists(session.passwordPath) + if err != nil { + log.Warningf(context.Background(), "Could not check if local password file exists: %v", err) + } + return exists +} + +func (b *Broker) authModesSupportedByUI(supportedUILayouts []map[string]string) (supportedModes []string) { + for _, layout := range supportedUILayouts { + mode := b.supportedAuthModeFromLayout(layout) + if mode != "" { + supportedModes = append(supportedModes, mode) + } + } + return supportedModes +} + +func (b *Broker) supportedAuthModeFromLayout(layout map[string]string) string { + supportedEntries := strings.Split(strings.TrimPrefix(layout["entry"], "optional:"), ",") + switch layout["type"] { + case "qrcode": + if !strings.Contains(layout["wait"], "true") { + return "" + } + if layout["renders_qrcode"] == "false" { + return authmodes.Device + } + return authmodes.DeviceQr + + case "form": + if slices.Contains(supportedEntries, "chars_password") { + return authmodes.Password + } + + case "newpassword": + if slices.Contains(supportedEntries, "chars_password") { + return authmodes.NewPassword + } + } + return "" +} + +// SelectAuthenticationMode selects the authentication mode for the user. +func (b *Broker) SelectAuthenticationMode(sessionID, authModeID string) (uiLayoutInfo map[string]string, err error) { + session, err := b.getSession(sessionID) + if err != nil { + return nil, err + } + + // populate UI options based on selected authentication mode + uiLayoutInfo, err = b.generateUILayout(&session, authModeID) + if err != nil { + return nil, err + } + + // Store selected mode + session.selectedMode = authModeID + + if err = b.updateSession(sessionID, session); err != nil { + return nil, err + } + + return uiLayoutInfo, nil +} + +func (b *Broker) generateUILayout(session *session, authModeID string) (map[string]string, error) { + if !slices.Contains(session.authModes, authModeID) { + return nil, fmt.Errorf("selected authentication mode %q does not exist", authModeID) + } + + var uiLayout map[string]string + switch authModeID { + case authmodes.Device, authmodes.DeviceQr: + ctx, cancel := context.WithTimeout(context.Background(), maxRequestDuration) + defer cancel() + + var authOpts []oauth2.AuthCodeOption + + // Workaround to cater for RFC compliant oauth2 server. Public providers do not properly + // implement the RFC, (probably) because they assume that device clients are public. + // As described in https://datatracker.ietf.org/doc/html/rfc8628#section-3.1 + // device authentication requests must provide client authentication, similar to that for + // the token endpoint. + // The golang/oauth2 library does not implement this, see https://github.com/golang/oauth2/issues/685. + // We implement a workaround for implementing the client_secret_post client authn method. + // Supporting client_secret_basic would require us to patch the http client used by the + // oauth2 lib. + // Some providers support both of these authentication methods, some implement only one and + // some implement neither. + // This was tested with the following providers: + // - Ory Hydra: supports client_secret_post + // TODO @shipperizer: client_authentication methods should be configurable + if secret := session.oauth2Config.ClientSecret; secret != "" { + authOpts = append(authOpts, oauth2.SetAuthURLParam("client_secret", secret)) + } + + log.Debug(ctx, "Sending Device Authorization Request to retrieve device code...") + response, err := session.oauth2Config.DeviceAuth(ctx, authOpts...) + if err != nil { + return nil, fmt.Errorf("could not generate Device Authentication code layout: %v", err) + } + log.Debugf(ctx, "Retrieved device code. Device Authorization Response: %#v", response) + session.deviceAuthResponse = response + + label := "Open the URL and enter the code below." + if authModeID == authmodes.DeviceQr { + label = "Scan the QR code or open the URL and enter the code below." + } + + uiLayout = map[string]string{ + "type": "qrcode", + "label": label, + "wait": "true", + "button": "Request new code", + "content": response.VerificationURI, + "code": response.UserCode, + } + + case authmodes.Password: + uiLayout = map[string]string{ + "type": "form", + "label": "Enter your local password", + "entry": "chars_password", + } + + case authmodes.NewPassword: + label := "Create a local password" + if session.mode == sessionmode.ChangePassword || session.mode == sessionmode.ChangePasswordOld { + label = "Update your local password" + } + + uiLayout = map[string]string{ + "type": "newpassword", + "label": label, + "entry": "chars_password", + } + } + + return uiLayout, nil +} + +// IsAuthenticated evaluates the provided authenticationData and returns the authentication status for the user. +func (b *Broker) IsAuthenticated(sessionID, authenticationData string) (string, string, error) { + session, err := b.getSession(sessionID) + if err != nil { + return AuthDenied, "{}", err + } + + var authData map[string]string + if authenticationData != "" { + if err := json.Unmarshal([]byte(authenticationData), &authData); err != nil { + return AuthDenied, "{}", fmt.Errorf("authentication data is not a valid json value: %v", err) + } + } + + ctx, err := b.startAuthenticate(sessionID) + if err != nil { + return AuthDenied, "{}", err + } + + // Cleans up the IsAuthenticated context when the call is done. + defer b.CancelIsAuthenticated(sessionID) + + authDone := make(chan struct{}) + var access string + var iadResponse isAuthenticatedDataResponse + go func() { + access, iadResponse = b.handleIsAuthenticated(ctx, &session, authData) + close(authDone) + }() + + select { + case <-authDone: + case <-ctx.Done(): + // We can ignore the error here since the message is constant. + msg, _ := json.Marshal(errorMessage{Message: "Authentication request cancelled"}) + return AuthCancelled, string(msg), ctx.Err() + } + + if access == AuthRetry { + session.attemptsPerMode[session.selectedMode]++ + if session.attemptsPerMode[session.selectedMode] == maxAuthAttempts { + access = AuthDenied + iadResponse = errorMessage{Message: "Maximum number of authentication attempts reached"} + } + } + + if err = b.updateSession(sessionID, session); err != nil { + return AuthDenied, "{}", err + } + + encoded, err := json.Marshal(iadResponse) + if err != nil { + return AuthDenied, "{}", fmt.Errorf("could not parse data to JSON: %v", err) + } + + data := string(encoded) + if data == "null" { + data = "{}" + } + return access, data, nil +} + +func unexpectedErrMsg(msg string) errorMessage { + return errorMessage{Message: fmt.Sprintf("An unexpected error occurred: %s. Please report this error on https://github.com/canonical/authd/issues", msg)} +} + +func (b *Broker) handleIsAuthenticated(ctx context.Context, session *session, authData map[string]string) (access string, data isAuthenticatedDataResponse) { + rawSecret, ok := authData[AuthDataSecret] + if !ok { + rawSecret = authData[AuthDataSecretOld] + } + + // Decrypt secret if present. + secret, err := decodeRawSecret(b.privateKey, rawSecret) + if err != nil { + log.Errorf(context.Background(), "could not decode secret: %s", err) + return AuthRetry, unexpectedErrMsg("could not decode secret") + } + + switch session.selectedMode { + case authmodes.Device, authmodes.DeviceQr: + return b.deviceAuth(ctx, session) + case authmodes.Password: + return b.passwordAuth(ctx, session, secret) + case authmodes.NewPassword: + return b.newPassword(session, secret) + default: + log.Errorf(context.Background(), "unknown authentication mode %q", session.selectedMode) + return AuthDenied, unexpectedErrMsg("unknown authentication mode") + } +} + +func (b *Broker) deviceAuth(ctx context.Context, session *session) (string, isAuthenticatedDataResponse) { + response := session.deviceAuthResponse + if response == nil { + log.Error(context.Background(), "device auth response is not set") + return AuthDenied, unexpectedErrMsg("device auth response is not set") + } + + if response.Expiry.IsZero() { + response.Expiry = time.Now().Add(time.Hour) + log.Debugf(context.Background(), "Device code does not have an expiry time, using default of %s", response.Expiry) + } else { + log.Debugf(context.Background(), "Device code expiry time: %s", response.Expiry) + } + expiryCtx, cancel := context.WithDeadline(ctx, response.Expiry) + defer cancel() + + // The default interval is 5 seconds, which means the user has to wait up to 5 seconds after + // successful authentication. We're reducing the interval to 1 second to improve UX a bit. + response.Interval = 1 + + log.Debug(ctx, "Polling to exchange device code for token...") + t, err := session.oauth2Config.DeviceAccessToken(expiryCtx, response, b.provider.AuthOptions()...) + if err != nil { + log.Errorf(context.Background(), "Error retrieving access token: %s", err) + return AuthRetry, errorMessage{Message: "Error retrieving access token. Please try again."} + } + log.Debug(ctx, "Exchanged device code for token.") + + if t.RefreshToken == "" { + log.Warningf(context.Background(), "No refresh token returned for user during device authentication. You might have to add the 'offline_access' scope to the 'extra_scopes' setting.") + } + + rawIDToken, ok := t.Extra("id_token").(string) + if !ok { + log.Error(context.Background(), "token response does not contain an ID token") + return AuthDenied, unexpectedErrMsg("token response does not contain an ID token") + } + + authInfo := token.NewAuthCachedInfo(t, rawIDToken, b.provider) + + authInfo.ProviderMetadata, err = b.provider.GetMetadata(session.oidcServer) + if err != nil { + log.Errorf(context.Background(), "could not get provider metadata: %s", err) + return AuthDenied, unexpectedErrMsg("could not get provider metadata") + } + + authInfo.UserInfo, err = b.userInfoFromIDToken(ctx, session, rawIDToken) + if err != nil { + log.Errorf(context.Background(), "could not get user info: %s", err) + return AuthDenied, errorMessageForDisplay(err, "Could not get user info") + } + + if !b.userNameIsAllowed(authInfo.UserInfo.Name) { + log.Warning(context.Background(), b.userNotAllowedLogMsg(authInfo.UserInfo.Name)) + return AuthDenied, errorMessage{Message: "Authentication failure: user not allowed in broker configuration"} + } + + if b.provider.SupportsDeviceRegistration() && b.cfg.registerDevice { + // Load existing device registration data if there is any, to avoid re-registering the device. + var deviceRegistrationData []byte + oldAuthInfo, err := token.LoadAuthInfo(session.tokenPath) + if err == nil { + deviceRegistrationData = oldAuthInfo.DeviceRegistrationData + } + + var cleanup func() + authInfo.DeviceRegistrationData, cleanup, err = b.provider.MaybeRegisterDevice(ctx, t, + session.username, + b.cfg.issuerURL, + deviceRegistrationData, + ) + if err != nil { + log.Errorf(context.Background(), "error registering device: %s", err) + return AuthDenied, errorMessage{Message: "Error registering device"} + } + defer cleanup() + + // Store the auth info, so that the device registration data is not lost if the login fails after this point. + if err := token.CacheAuthInfo(session.tokenPath, authInfo); err != nil { + log.Errorf(context.Background(), "Failed to store token: %s", err) + return AuthDenied, unexpectedErrMsg("failed to store token") + } + } + + // We can only fetch the groups after registering the device, because the token acquired for device registration + // cannot be used with the Microsoft Graph API and a new token must be acquired for the Graph API. + authInfo.UserInfo.Groups, err = b.getGroups(ctx, session, authInfo) + if err != nil { + log.Errorf(context.Background(), "failed to get groups: %s", err) + return AuthDenied, errorMessageForDisplay(err, "Failed to retrieve groups from Microsoft Graph API") + } + + // Store the auth info in the session so that we can use it when handling the + // next IsAuthenticated call for the new password mode. + session.authInfo = authInfo + session.nextAuthModes = []string{authmodes.NewPassword} + + return AuthNext, nil +} + +func (b *Broker) passwordAuth(ctx context.Context, session *session, secret string) (string, isAuthenticatedDataResponse) { + ok, err := password.CheckPassword(secret, session.passwordPath) + if err != nil { + log.Error(context.Background(), err.Error()) + return AuthDenied, unexpectedErrMsg("could not check password") + } + if !ok { + log.Noticef(context.Background(), "Authentication failure: incorrect local password for user %q", session.username) + return AuthRetry, errorMessage{Message: "Incorrect password, please try again."} + } + + authInfo, err := token.LoadAuthInfo(session.tokenPath) + if err != nil { + log.Error(context.Background(), err.Error()) + return AuthDenied, unexpectedErrMsg("could not load stored token") + } + + // If the session is for changing the password, we don't need to refresh the token and user info (and we don't + // want the method call to return an error if refreshing the token or user info fails). + if session.mode == sessionmode.ChangePassword || session.mode == sessionmode.ChangePasswordOld { + // Store the auth info in the session so that we can use it when handling the + // next IsAuthenticated call for the new password mode. + session.authInfo = authInfo + session.nextAuthModes = []string{authmodes.NewPassword} + return AuthNext, nil + } + + if b.cfg.forceProviderAuthentication && session.isOffline { + log.Error(context.Background(), "Remote authentication failed: force_provider_authentication is enabled, but the identity provider is not reachable") + return AuthDenied, errorMessage{Message: "Remote authentication failed: identity provider is not reachable"} + } + + if authInfo.UserIsDisabled && session.isOffline { + log.Errorf(context.Background(), "Login denied: user %q is disabled in Microsoft Entra ID and session is offline", session.username) + return AuthDenied, errorMessage{Message: "This user is disabled in Microsoft Entra ID. Please contact your administrator or try again with a working network connection."} + } + + if authInfo.DeviceIsDisabled && session.isOffline { + log.Errorf(context.Background(), "Login denied: device %q is disabled in Microsoft Entra ID and session is offline", session.username) + return AuthDenied, errorMessage{Message: "This device is disabled in Microsoft Entra ID. Please contact your administrator or try again with a working network connection."} + } + + // Refresh the token if we're online even if the token has not expired + if b.cfg.forceProviderAuthentication || !session.isOffline { + // Check if we have a refresh token before attempting to refresh + if authInfo.Token.RefreshToken == "" { + log.Warningf(context.Background(), "No refresh token available for user %q", session.username) + session.nextAuthModes = []string{authmodes.Device, authmodes.DeviceQr} + return AuthNext, errorMessage{Message: "Remote authentication failed: No refresh token. Please contact your administrator."} + } + + // We have a refresh token, attempt to refresh + oldAuthInfo := authInfo + authInfo, err = b.refreshToken(ctx, session, authInfo) + var retrieveErr *oauth2.RetrieveError + if errors.As(err, &retrieveErr) { + if b.provider.IsTokenExpiredError(retrieveErr) { + log.Noticef(context.Background(), "Refresh token expired for user %q, new device authentication required", session.username) + session.nextAuthModes = []string{authmodes.Device, authmodes.DeviceQr} + return AuthNext, errorMessage{Message: "Refresh token expired, please authenticate again using device authentication."} + } + if b.provider.IsUserDisabledError(retrieveErr) { + log.Error(context.Background(), retrieveErr.Error()) + log.Errorf(context.Background(), "Login failed: User %q is disabled in Microsoft Entra ID, please contact your administrator.", session.username) + + // Store the information that the user is disabled, so that we can deny login on subsequent offline attempts. + oldAuthInfo.UserIsDisabled = true + if err = token.CacheAuthInfo(session.tokenPath, oldAuthInfo); err != nil { + log.Errorf(context.Background(), "Failed to store token: %s", err) + return AuthDenied, unexpectedErrMsg("failed to store token") + } + + return AuthDenied, errorMessage{Message: "This user is disabled in Microsoft Entra ID, please contact your administrator."} + } + } + if err != nil { + log.Errorf(context.Background(), "Failed to refresh token: %s", err) + return AuthDenied, errorMessage{Message: "Failed to refresh token"} + } + } + + // If device registration is enabled, ensure that the device is registered. + if b.provider.SupportsDeviceRegistration() && !session.isOffline && b.cfg.registerDevice { + var cleanup func() + authInfo.DeviceRegistrationData, cleanup, err = b.provider.MaybeRegisterDevice(ctx, + authInfo.Token, + session.username, + b.cfg.issuerURL, + authInfo.DeviceRegistrationData, + ) + if err != nil { + log.Errorf(context.Background(), "error registering device: %s", err) + return AuthDenied, errorMessage{Message: "Error registering device"} + } + defer cleanup() + + // Store the auth info, so that the device registration data is not lost if the login fails after this point. + if err := token.CacheAuthInfo(session.tokenPath, authInfo); err != nil { + log.Errorf(context.Background(), "Failed to store token: %s", err) + return AuthDenied, unexpectedErrMsg("failed to store token") + } + } + + // Try to refresh the groups + groups, err := b.getGroups(ctx, session, authInfo) + if errors.Is(err, himmelblau.ErrDeviceDisabled) { + // The device is disabled, deny login + log.Errorf(context.Background(), "Login failed: %s", err) + + // Store the information that the device is disabled, so that we can deny login on subsequent offline attempts. + authInfo.DeviceIsDisabled = true + if err = token.CacheAuthInfo(session.tokenPath, authInfo); err != nil { + log.Errorf(context.Background(), "Failed to store token: %s", err) + return AuthDenied, unexpectedErrMsg("failed to store token") + } + + return AuthDenied, errorMessage{Message: "This device is disabled in Microsoft Entra ID, please contact your administrator."} + } + if errors.Is(err, himmelblau.ErrInvalidRedirectURI) { + // Deny login if the redirect URI is invalid, so that users and administrators are aware of the issue. + log.Errorf(context.Background(), "Login failed: %s", err) + return AuthDenied, errorMessageForDisplay(err, "Invalid redirect URI") + } + var tokenAcquisitionError himmelblau.TokenAcquisitionError + if errors.As(err, &tokenAcquisitionError) { + log.Errorf(context.Background(), "Token acquisition failed: %s. Try again using device authentication.", err) + // The token acquisition failed unexpectedly. + // One possible reason is that the device was deleted by an administrator in Entra ID. + // In this case, the user can perform device authentication again to get a new token + // and register the device again, allowing the user to log in. + // We delete the device registration data to cause device authentication to re-register the device. + authInfo.DeviceRegistrationData = nil + if err = token.CacheAuthInfo(session.tokenPath, authInfo); err != nil { + log.Errorf(context.Background(), "Failed to store token: %s", err) + return AuthDenied, unexpectedErrMsg("failed to store token") + } + + session.nextAuthModes = []string{authmodes.Device, authmodes.DeviceQr} + msg := "Authentication failed due to a token issue. Please try again using device authentication." + return AuthNext, errorMessage{Message: msg} + } + if err != nil { + // We couldn't fetch the groups, but we have valid cached ones. + log.Warningf(context.Background(), "Could not get groups: %v. Using cached groups.", err) + } else { + authInfo.UserInfo.Groups = groups + } + + return b.finishAuth(session, authInfo) +} + +func (b *Broker) finishAuth(session *session, authInfo *token.AuthCachedInfo) (string, isAuthenticatedDataResponse) { + if b.cfg.shouldRegisterOwner() { + if err := b.cfg.registerOwner(b.cfg.ConfigFile, authInfo.UserInfo.Name); err != nil { + // The user is not allowed if we fail to create the owner-autoregistration file. + // Otherwise the owner might change if the broker is restarted. + log.Errorf(context.Background(), "Failed to assign the owner role: %v", err) + return AuthDenied, unexpectedErrMsg("failed to assign the owner role") + } + } + + if !b.userNameIsAllowed(authInfo.UserInfo.Name) { + log.Warning(context.Background(), b.userNotAllowedLogMsg(authInfo.UserInfo.Name)) + return AuthDenied, errorMessage{Message: "Authentication failure: user not allowed in broker configuration"} + } + + // Add extra groups to the user info. + for _, name := range b.cfg.extraGroups { + log.Debugf(context.Background(), "Adding extra group %q", name) + authInfo.UserInfo.Groups = append(authInfo.UserInfo.Groups, info.Group{Name: name}) + } + + if b.isOwner(authInfo.UserInfo.Name) { + // Add the owner extra groups to the user info. + for _, name := range b.cfg.ownerExtraGroups { + log.Debugf(context.Background(), "Adding owner extra group %q", name) + authInfo.UserInfo.Groups = append(authInfo.UserInfo.Groups, info.Group{Name: name}) + } + } + + if session.isOffline { + return AuthGranted, userInfoMessage{UserInfo: authInfo.UserInfo} + } + + if err := token.CacheAuthInfo(session.tokenPath, authInfo); err != nil { + log.Errorf(context.Background(), "Failed to store token: %s", err) + return AuthDenied, unexpectedErrMsg("failed to store token") + } + + return AuthGranted, userInfoMessage{UserInfo: authInfo.UserInfo} +} + +func (b *Broker) newPassword(session *session, secret string) (string, isAuthenticatedDataResponse) { + if secret == "" { + return AuthRetry, unexpectedErrMsg("empty secret") + } + + // This mode must always come after an authentication mode, so we should have auth info from the previous mode + // stored in the session. + authInfo := session.authInfo + if authInfo == nil { + log.Error(context.Background(), "auth info is not set") + return AuthDenied, unexpectedErrMsg("auth info is not set") + } + + if err := password.HashAndStorePassword(secret, session.passwordPath); err != nil { + log.Errorf(context.Background(), "Failed to store password: %s", err) + return AuthDenied, unexpectedErrMsg("failed to store password") + } + + return b.finishAuth(session, authInfo) +} + +// userNameIsAllowed checks whether the user's username is allowed to access the machine. +func (b *Broker) userNameIsAllowed(userName string) bool { + return b.cfg.userNameIsAllowed(b.provider.NormalizeUsername(userName)) +} + +// isOwner returns true if the user is the owner of the machine. +func (b *Broker) isOwner(userName string) bool { + return b.cfg.owner == b.provider.NormalizeUsername(userName) +} + +func (b *Broker) userNotAllowedLogMsg(userName string) string { + logMsg := fmt.Sprintf("User %q is not in the list of allowed users.", userName) + logMsg += fmt.Sprintf("\nYou can add the user to allowed_users in %s", b.cfg.ConfigFile) + return logMsg +} + +func (b *Broker) startAuthenticate(sessionID string) (context.Context, error) { + session, err := b.getSession(sessionID) + if err != nil { + return nil, err + } + + if session.isAuthenticating != nil { + log.Errorf(context.Background(), "Authentication already running for session %q", sessionID) + return nil, errors.New("authentication already running for this user session") + } + + ctx, cancel := context.WithCancel(context.Background()) + session.isAuthenticating = &isAuthenticatedCtx{ctx: ctx, cancelFunc: cancel} + + if err := b.updateSession(sessionID, session); err != nil { + cancel() + return nil, err + } + + return ctx, nil +} + +// EndSession ends the session for the user. +func (b *Broker) EndSession(sessionID string) error { + session, err := b.getSession(sessionID) + if err != nil { + return err + } + + // Checks if there is a isAuthenticated call running for this session and cancels it before ending the session. + if session.isAuthenticating != nil { + b.CancelIsAuthenticated(sessionID) + } + + b.currentSessionsMu.Lock() + defer b.currentSessionsMu.Unlock() + delete(b.currentSessions, sessionID) + return nil +} + +// CancelIsAuthenticated cancels the IsAuthenticated call for the user. +func (b *Broker) CancelIsAuthenticated(sessionID string) { + session, err := b.getSession(sessionID) + if err != nil { + return + } + + if session.isAuthenticating == nil { + return + } + + session.isAuthenticating.cancelFunc() + session.isAuthenticating = nil + + if err := b.updateSession(sessionID, session); err != nil { + log.Errorf(context.Background(), "Error when cancelling IsAuthenticated: %v", err) + } +} + +// UserPreCheck checks if the user is valid and can be allowed to authenticate. +// It returns the user info in JSON format if the user is valid, or an empty string if the user is not allowed. +func (b *Broker) UserPreCheck(username string) (string, error) { + found := false + for _, suffix := range b.cfg.allowedSSHSuffixes { + if suffix == "" { + continue + } + + // If suffix is only "*", TrimPrefix will return the empty string and that works for the 'match all' case also. + suffix = strings.TrimPrefix(suffix, "*") + if strings.HasSuffix(username, suffix) { + found = true + break + } + } + + if !found { + // The username does not match any of the allowed suffixes. + return "", nil + } + + u := info.NewUser(username, filepath.Join(b.cfg.homeBaseDir, username), "", "", "", nil) + encoded, err := json.Marshal(u) + if err != nil { + return "", fmt.Errorf("could not marshal user info: %v", err) + } + return string(encoded), nil +} + +// getSession returns the session information for the specified session ID or an error if the session is not active. +func (b *Broker) getSession(sessionID string) (session, error) { + b.currentSessionsMu.RLock() + defer b.currentSessionsMu.RUnlock() + s, active := b.currentSessions[sessionID] + if !active { + return session{}, fmt.Errorf("%s is not a current transaction", sessionID) + } + return s, nil +} + +// updateSession checks if the session is still active and updates the session info. +func (b *Broker) updateSession(sessionID string, session session) error { + // Checks if the session was ended in the meantime, otherwise we would just accidentally recreate it. + if _, err := b.getSession(sessionID); err != nil { + return err + } + b.currentSessionsMu.Lock() + defer b.currentSessionsMu.Unlock() + b.currentSessions[sessionID] = session + return nil +} + +// refreshToken refreshes the OAuth2 token and returns the updated AuthCachedInfo. +func (b *Broker) refreshToken(ctx context.Context, session *session, oldToken *token.AuthCachedInfo) (*token.AuthCachedInfo, error) { + timeoutCtx, cancel := context.WithTimeout(ctx, maxRequestDuration) + defer cancel() + // set cached token expiry time to one hour in the past + // this makes sure the token is refreshed even if it has not 'actually' expired + oldToken.Token.Expiry = time.Now().Add(-time.Hour) + oauthToken, err := session.oauth2Config.TokenSource(timeoutCtx, oldToken.Token).Token() + if err != nil { + return nil, err + } + + // Update the raw ID token + rawIDToken, ok := oauthToken.Extra("id_token").(string) + if !ok { + log.Debug(context.Background(), "refreshed token does not contain an ID token, keeping the old one") + rawIDToken = oldToken.RawIDToken + } + + t := token.NewAuthCachedInfo(oauthToken, rawIDToken, b.provider) + t.ProviderMetadata = oldToken.ProviderMetadata + t.DeviceRegistrationData = oldToken.DeviceRegistrationData + + t.UserInfo, err = b.userInfoFromIDToken(ctx, session, rawIDToken) + if err != nil { + return nil, err + } + + t.UserInfo.Groups = oldToken.UserInfo.Groups + + return t, nil +} + +// userInfoFromIDToken verifies and parses the raw ID token and returns the user info from it. +// Note that verifying the ID token requires a working network connection to the provider's JWKs endpoint, +// so make sure to only call this function if the session is online. +func (b *Broker) userInfoFromIDToken(ctx context.Context, session *session, rawIDToken string) (info.User, error) { + idToken, err := session.oidcServer.Verifier(&b.oidcCfg).Verify(ctx, rawIDToken) + if err != nil { + return info.User{}, fmt.Errorf("could not verify token: %v", err) + } + + userInfo, err := b.provider.GetUserInfo(idToken) + if err != nil { + return info.User{}, err + } + + if err = b.provider.VerifyUsername(session.username, userInfo.Name); err != nil { + return info.User{}, fmt.Errorf("username verification failed: %w", err) + } + + // This means that home was not provided by the claims, so we need to set it to the broker default. + if !filepath.IsAbs(userInfo.Home) { + userInfo.Home = filepath.Join(b.cfg.homeBaseDir, userInfo.Home) + } + + return userInfo, nil +} + +func (b *Broker) getGroups(ctx context.Context, session *session, t *token.AuthCachedInfo) ([]info.Group, error) { + if session.isOffline { + return nil, errors.New("session is in offline mode") + } + + return b.provider.GetGroups(ctx, + b.cfg.clientID, + b.cfg.issuerURL, + t.Token, + t.ProviderMetadata, + t.DeviceRegistrationData, + ) +} + +// Checks if the provided error is of type ForDisplayError. If it is, it returns the error message. Else, it returns +// the provided fallback message. +func errorMessageForDisplay(err error, fallback string) errorMessage { + var forDisplayErr *providerErrors.ForDisplayError + if errors.As(err, &forDisplayErr) { + return errorMessage{Message: forDisplayErr.Error()} + } + return errorMessage{Message: fallback} +} diff --git a/authd-oidc-brokers/internal/broker/broker_test.go b/authd-oidc-brokers/internal/broker/broker_test.go new file mode 100644 index 0000000000..3cc46b7cbe --- /dev/null +++ b/authd-oidc-brokers/internal/broker/broker_test.go @@ -0,0 +1,1377 @@ +package broker_test + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/canonical/authd/authd-oidc-brokers/internal/broker" + "github.com/canonical/authd/authd-oidc-brokers/internal/broker/authmodes" + "github.com/canonical/authd/authd-oidc-brokers/internal/broker/sessionmode" + "github.com/canonical/authd/authd-oidc-brokers/internal/consts" + "github.com/canonical/authd/authd-oidc-brokers/internal/password" + "github.com/canonical/authd/authd-oidc-brokers/internal/providers/info" + "github.com/canonical/authd/authd-oidc-brokers/internal/testutils" + "github.com/canonical/authd/authd-oidc-brokers/internal/testutils/golden" + "github.com/stretchr/testify/require" + "github.com/ubuntu/authd/log" + "gopkg.in/yaml.v3" +) + +var defaultIssuerURL string + +func TestNew(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + issuer string + clientID string + dataDir string + + wantErr bool + }{ + "Successfully_create_new_broker": {}, + "Successfully_create_new_even_if_can_not_connect_to_provider": {issuer: "https://notavailable"}, + + "Error_if_issuer_is_not_provided": {issuer: "-", wantErr: true}, + "Error_if_clientID_is_not_provided": {clientID: "-", wantErr: true}, + "Error_if_dataDir_is_not_provided": {dataDir: "-", wantErr: true}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + switch tc.issuer { + case "": + tc.issuer = defaultIssuerURL + case "-": + tc.issuer = "" + } + + if tc.clientID == "-" { + tc.clientID = "" + } else { + tc.clientID = "test-client-id" + } + + if tc.dataDir == "-" { + tc.dataDir = "" + } else { + tc.dataDir = t.TempDir() + } + + bCfg := &broker.Config{DataDir: tc.dataDir} + bCfg.SetIssuerURL(tc.issuer) + bCfg.SetClientID(tc.clientID) + b, err := broker.New(*bCfg) + if tc.wantErr { + require.Error(t, err, "New should have returned an error") + return + } + require.NoError(t, err, "New should not have returned an error") + require.NotNil(t, b, "New should have returned a non-nil broker") + }) + } +} + +func TestNewSession(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + customHandlers map[string]testutils.EndpointHandler + + wantOffline bool + }{ + "Successfully_create_new_session": {}, + "Creates_new_session_in_offline_mode_if_provider_is_not_available": { + customHandlers: map[string]testutils.EndpointHandler{ + "/.well-known/openid-configuration": testutils.UnavailableHandler(), + }, + wantOffline: true, + }, + "Creates_new_session_in_offline_mode_if_provider_connection_times_out": { + customHandlers: map[string]testutils.EndpointHandler{ + "/.well-known/openid-configuration": testutils.HangingHandler(broker.MaxRequestDuration + 1), + }, + wantOffline: true, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + b := newBrokerForTests(t, &brokerForTestConfig{ + customHandlers: tc.customHandlers, + }) + + id, _, err := b.NewSession("test-user", "lang", sessionmode.Login) + require.NoError(t, err, "NewSession should not have returned an error") + + gotOffline, err := b.IsOffline(id) + require.NoError(t, err, "Session should have been created") + + require.Equal(t, tc.wantOffline, gotOffline, "Session should have been created in the expected mode") + }) + } +} + +var supportedUILayouts = map[string]map[string]string{ + "form": { + "type": "form", + "entry": "chars_password", + }, + "form-without-entry": { + "type": "form", + }, + + "qrcode": { + "type": "qrcode", + "wait": "true", + }, + "qrcode-without-wait": { + "type": "qrcode", + }, + "qrcode-without-qrcode": { + "type": "qrcode", + "renders_qrcode": "false", + "wait": "true", + }, + "qrcode-without-wait-and-qrcode": { + "type": "qrcode", + "renders_qrcode": "false", + }, + + "newpassword": { + "type": "newpassword", + "entry": "chars_password", + }, + "newpassword-without-entry": { + "type": "newpassword", + }, +} + +func TestGetAuthenticationModes(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + sessionMode string + sessionID string + supportedLayouts []string + + providerAddress string + token *tokenOptions + noPasswordFile bool + nextAuthMode string + unavailableProvider bool + deviceAuthUnsupported bool + registerDevice bool + providerSupportsDeviceRegistration bool + + wantErr bool + wantModes []string + }{ + // === Authentication session === + "Get_only_device_auth_qr_if_there_is_no_token": { + token: nil, + wantModes: []string{authmodes.DeviceQr}, + }, + "Get_password_and_device_auth_qr_if_token_exists": { + token: &tokenOptions{}, + wantModes: []string{authmodes.Password, authmodes.DeviceQr}, + }, + "Get_only_device_auth_qr_if_token_is_invalid": { + token: &tokenOptions{invalid: true}, + wantModes: []string{authmodes.DeviceQr}, + }, + "Get_only_device_auth_qr_if_there_is_no_password_file": { + token: &tokenOptions{}, + noPasswordFile: true, + wantModes: []string{authmodes.DeviceQr}, + }, + + // --- Next auth mode --- + "Get_only_newpassword_if_next_auth_mode_is_newpassword": { + nextAuthMode: authmodes.NewPassword, + wantModes: []string{authmodes.NewPassword}, + }, + "Get_only_device_auth_qr_if_next_auth_mode_is_device_qr": { + nextAuthMode: authmodes.DeviceQr, + wantModes: []string{authmodes.DeviceQr}, + }, + + // --- Device registration --- + "Get_password_and_device_auth_qr_if_device_should_be_registered_and_token_is_for_device_registration": { + registerDevice: true, + providerSupportsDeviceRegistration: true, + token: &tokenOptions{isForDeviceRegistration: true}, + wantModes: []string{authmodes.Password, authmodes.DeviceQr}, + }, + "Get_only_device_auth_qr_if_device_should_be_registered_and_token_is_not_for_device_registration": { + registerDevice: true, + providerSupportsDeviceRegistration: true, + token: &tokenOptions{isForDeviceRegistration: false}, + wantModes: []string{authmodes.DeviceQr}, + }, + "Get_password_and_device_auth_qr_if_device_should_be_registered_and_token_is_not_for_device_registration_and_provider_does_not_support_it": { + registerDevice: true, + providerSupportsDeviceRegistration: false, + token: &tokenOptions{isForDeviceRegistration: false}, + wantModes: []string{authmodes.Password, authmodes.DeviceQr}, + }, + "Get_only_device_auth_qr_if_device_should_not_be_registered_and_token_is_for_device_registration": { + registerDevice: false, + providerSupportsDeviceRegistration: true, + token: &tokenOptions{isForDeviceRegistration: true}, + wantModes: []string{authmodes.DeviceQr}, + }, + "Get_password_and_device_auth_qr_if_device_should_not_be_registered_and_token_is_not_for_device_registration": { + registerDevice: false, + providerSupportsDeviceRegistration: true, + token: &tokenOptions{isForDeviceRegistration: false}, + wantModes: []string{authmodes.Password, authmodes.DeviceQr}, + }, + "Get_password_and_device_auth_qr_if_token_is_not_for_device_registration_but_provider_does_not_support_it": { + registerDevice: false, + providerSupportsDeviceRegistration: false, + token: &tokenOptions{isForDeviceRegistration: false}, + wantModes: []string{authmodes.Password, authmodes.DeviceQr}, + }, + // Note: We don't care about the weird case that the token is for device registration but the provider doesn't + // support it, because that never happens (providers which don't support device registration always return + // false for IsTokenForDeviceRegistration). + + "Get_only_password_if_device_should_be_registered_and_token_is_not_for_device_registration_but_provider_is_not_available": { + registerDevice: true, + providerSupportsDeviceRegistration: true, + token: &tokenOptions{isForDeviceRegistration: false}, + unavailableProvider: true, + // TODO: Automatically set providerAddress if unavailableProvider or deviceAuthUnsupported is set + providerAddress: "127.0.0.1:31308", + wantModes: []string{authmodes.Password}, + }, + "Get_only_password_if_device_should_not_be_registered_and_token_is_for_device_registration_but_provider_is_not_available": { + registerDevice: true, + providerSupportsDeviceRegistration: true, + token: &tokenOptions{isForDeviceRegistration: true}, + unavailableProvider: true, + providerAddress: "127.0.0.1:31309", + wantModes: []string{authmodes.Password}, + }, + + "Get_only_password_if_token_exists_and_provider_is_not_available": { + token: &tokenOptions{}, + providerAddress: "127.0.0.1:31310", + unavailableProvider: true, + wantModes: []string{authmodes.Password}, + }, + "Get_only_password_if_token_exists_and_provider_does_not_support_device_auth_qr": { + token: &tokenOptions{}, + providerAddress: "127.0.0.1:31311", + deviceAuthUnsupported: true, + wantModes: []string{authmodes.Password}, + }, + "Get_only_device_auth_if_token_exists_but_checking_if_it_is_for_device_registration_fails": { + token: &tokenOptions{noIsForDeviceRegistration: true}, + providerSupportsDeviceRegistration: true, + wantModes: []string{authmodes.DeviceQr}, + }, + + // === Change password session === + "Get_only_password_if_token_exists_and_session_is_for_changing_password": { + sessionMode: sessionmode.ChangePassword, + token: &tokenOptions{}, + wantModes: []string{authmodes.Password}, + }, + "Get_only_newpassword_if_session_is_for changing_password_and_next_auth_mode_is_newpassword": { + sessionMode: sessionmode.ChangePassword, + token: &tokenOptions{}, + nextAuthMode: authmodes.NewPassword, + wantModes: []string{authmodes.NewPassword}, + }, + "Get_only_password_if_token_exists_and_session_mode_is_the_old_passwd_value": { + sessionMode: sessionmode.ChangePasswordOld, + token: &tokenOptions{}, + wantModes: []string{authmodes.Password}, + }, + + // === Errors === + // --- General errors --- + "Error_if_there_is_no_session": { + sessionID: "-", + wantErr: true, + }, + "Error_if_no_authentication_mode_is_supported": { + providerAddress: "127.0.0.1:31312", + deviceAuthUnsupported: true, + wantErr: true, + }, + "Error_if_expecting_device_auth_qr_but_not_supported": { + supportedLayouts: []string{"qrcode-without-wait"}, + wantErr: true, + }, + "Error_if_expecting_device_auth_but_not_supported": { + supportedLayouts: []string{"qrcode-without-wait-and-qrcode"}, + wantErr: true, + }, + "Error_if_expecting_newpassword_but_not_supported": { + supportedLayouts: []string{"newpassword-without-entry"}, + wantErr: true, + }, + "Error_if_expecting_password_but_not_supported": { + supportedLayouts: []string{"form-without-entry"}, + wantErr: true, + }, + "Error_if_next_auth_mode_is_invalid": { + nextAuthMode: "invalid", + wantErr: true, + }, + + // --- Change password session errors --- + "Error_if_session_is_for_changing_password_but_password_file_does_not_exist": { + sessionMode: sessionmode.ChangePassword, + noPasswordFile: true, + wantErr: true, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + if tc.sessionMode == "" { + tc.sessionMode = sessionmode.Login + } + + cfg := &brokerForTestConfig{ + registerDevice: tc.registerDevice, + supportsDeviceRegistration: tc.providerSupportsDeviceRegistration, + } + if tc.providerAddress == "" { + // Use the default provider URL if no address is provided. + cfg.issuerURL = defaultIssuerURL + } else { + cfg.listenAddress = tc.providerAddress + + const wellKnown = "/.well-known/openid-configuration" + if tc.deviceAuthUnsupported { + cfg.customHandlers = map[string]testutils.EndpointHandler{ + wellKnown: testutils.OpenIDHandlerWithNoDeviceEndpoint("http://" + tc.providerAddress), + } + } + if tc.unavailableProvider { + cfg.customHandlers = map[string]testutils.EndpointHandler{ + wellKnown: testutils.UnavailableHandler(), + } + } + } + b := newBrokerForTests(t, cfg) + + sessionID, _ := newSessionForTests(t, b, "", tc.sessionMode) + if tc.sessionID == "-" { + sessionID = "" + } + if tc.token != nil { + generateAndStoreCachedInfo(t, *tc.token, b.TokenPathForSession(sessionID)) + } + if !tc.noPasswordFile && sessionID != "" { + err := password.HashAndStorePassword("password", b.PasswordFilepathForSession(sessionID)) + require.NoError(t, err, "Setup: HashAndStorePassword should not have returned an error") + } + if tc.nextAuthMode != "" { + b.SetNextAuthModes(sessionID, []string{tc.nextAuthMode}) + } + + if tc.supportedLayouts == nil { + tc.supportedLayouts = []string{"form", "qrcode", "newpassword"} + } + var layouts []map[string]string + for _, layout := range tc.supportedLayouts { + layouts = append(layouts, supportedUILayouts[layout]) + } + + modes, err := b.GetAuthenticationModes(sessionID, layouts) + if tc.wantErr { + require.Error(t, err, "GetAuthenticationModes should have returned an error") + return + } + require.NoError(t, err, "GetAuthenticationModes should not have returned an error") + + var modeIDs []string + for _, mode := range modes { + id, exists := mode["id"] + require.True(t, exists, "Each mode should have an 'id' field. Mode: %v", mode) + modeIDs = append(modeIDs, id) + } + require.Equal(t, tc.wantModes, modeIDs, "GetAuthenticationModes should have returned the expected modes") + + golden.CheckOrUpdateYAML(t, modes) + }) + } +} + +var supportedLayouts = []map[string]string{ + supportedUILayouts["form"], + supportedUILayouts["qrcode"], + supportedUILayouts["newpassword"], +} + +var supportedLayoutsWithoutQrCode = []map[string]string{ + supportedUILayouts["form"], + supportedUILayouts["qrcode-without-qrcode"], + supportedUILayouts["newpassword"], +} + +func TestSelectAuthenticationMode(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + modeName string + + tokenExists bool + nextAuthMode string + passwdSession bool + customHandlers map[string]testutils.EndpointHandler + supportedLayouts []map[string]string + + wantErr bool + }{ + "Successfully_select_password": {modeName: authmodes.Password, tokenExists: true}, + "Successfully_select_device_auth_qr": {modeName: authmodes.DeviceQr}, + "Successfully_select_device_auth": {supportedLayouts: supportedLayoutsWithoutQrCode, modeName: authmodes.Device}, + "Successfully_select_newpassword": {modeName: authmodes.NewPassword, nextAuthMode: authmodes.NewPassword}, + + "Selected_newpassword_shows_correct_label_in_passwd_session": {modeName: authmodes.NewPassword, passwdSession: true, tokenExists: true, nextAuthMode: authmodes.NewPassword}, + + "Error_when_selecting_invalid_mode": {modeName: "invalid", wantErr: true}, + "Error_when_selecting_device_auth_qr_but_provider_is_unavailable": {modeName: authmodes.DeviceQr, wantErr: true, + customHandlers: map[string]testutils.EndpointHandler{ + "/device_auth": testutils.UnavailableHandler(), + }, + }, + "Error_when_selecting_device_auth_but_provider_is_unavailable": { + supportedLayouts: supportedLayoutsWithoutQrCode, + modeName: authmodes.Device, + customHandlers: map[string]testutils.EndpointHandler{ + "/device_auth": testutils.UnavailableHandler(), + }, + wantErr: true, + }, + "Error_when_selecting_device_auth_qr_but_request_times_out": {modeName: authmodes.DeviceQr, wantErr: true, + customHandlers: map[string]testutils.EndpointHandler{ + "/device_auth": testutils.HangingHandler(broker.MaxRequestDuration + 1), + }, + }, + "Error_when_selecting_device_auth_but_request_times_out": { + supportedLayouts: supportedLayoutsWithoutQrCode, + modeName: authmodes.Device, + customHandlers: map[string]testutils.EndpointHandler{ + "/device_auth": testutils.HangingHandler(broker.MaxRequestDuration + 1), + }, + wantErr: true, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + cfg := &brokerForTestConfig{} + if tc.customHandlers == nil { + // Use the default provider URL if no custom handlers are provided. + cfg.issuerURL = defaultIssuerURL + } else { + cfg.customHandlers = tc.customHandlers + } + b := newBrokerForTests(t, cfg) + + sessionType := sessionmode.Login + if tc.passwdSession { + sessionType = sessionmode.ChangePassword + } + sessionID, _ := newSessionForTests(t, b, "", sessionType) + + if tc.tokenExists { + generateAndStoreCachedInfo(t, tokenOptions{}, b.TokenPathForSession(sessionID)) + err := password.HashAndStorePassword("password", b.PasswordFilepathForSession(sessionID)) + require.NoError(t, err, "Setup: HashAndStorePassword should not have returned an error") + } + if tc.nextAuthMode != "" { + b.SetNextAuthModes(sessionID, []string{tc.nextAuthMode}) + } + if tc.supportedLayouts == nil { + tc.supportedLayouts = supportedLayouts + } + + // We need to do a GAM call first to get all the modes. + _, err := b.GetAuthenticationModes(sessionID, tc.supportedLayouts) + require.NoError(t, err, "Setup: GetAuthenticationModes should not have returned an error") + + got, err := b.SelectAuthenticationMode(sessionID, tc.modeName) + if tc.wantErr { + require.Error(t, err, "SelectAuthenticationMode should have returned an error") + return + } + require.NoError(t, err, "SelectAuthenticationMode should not have returned an error") + + golden.CheckOrUpdateYAML(t, got) + }) + } +} + +type isAuthenticatedResponse struct { + Access string + Data string + Err string +} + +func TestIsAuthenticated(t *testing.T) { + t.Parallel() + + correctPassword := "password" + + tests := map[string]struct { + sessionMode string + sessionOffline bool + username string + forceProviderAuthentication bool + userDoesNotBecomeOwner bool + allUsersAllowed bool + extraGroups []string + ownerExtraGroups []string + providerSupportsDeviceRegistration bool + registerDevice bool + + firstMode string + firstSecret string + badFirstKey bool + getGroupsFails bool + useOldNameForSecretField bool + groupsReturnedByProvider []info.Group + + customHandlers map[string]testutils.EndpointHandler + address string + + wantSecondCall bool + secondMode string + secondSecret string + + token *tokenOptions + invalidAuthData bool + dontWaitForFirstCall bool + readOnlyDataDir bool + wantGroups []info.Group + wantNextAuthModes []string + }{ + "Successfully_authenticate_user_with_device_auth_and_newpassword": {firstSecret: "-", wantSecondCall: true}, + "Successfully_authenticate_user_with_password": {firstMode: authmodes.Password, token: &tokenOptions{}}, + + "Authenticating_with_qrcode_reacquires_token": {firstSecret: "-", wantSecondCall: true, token: &tokenOptions{}}, + "Authenticating_with_password_refreshes_expired_token": {firstMode: authmodes.Password, token: &tokenOptions{expired: true}}, + "Authenticating_with_password_still_allowed_if_server_is_unreachable": { + firstMode: authmodes.Password, + token: &tokenOptions{}, + customHandlers: map[string]testutils.EndpointHandler{ + "/.well-known/openid-configuration": testutils.UnavailableHandler(), + }, + }, + "Authenticating_with_password_still_allowed_if_token_is_expired_and_server_is_unreachable": { + firstMode: authmodes.Password, + token: &tokenOptions{expired: true}, + customHandlers: map[string]testutils.EndpointHandler{ + "/.well-known/openid-configuration": testutils.UnavailableHandler(), + }, + }, + "Authenticating_still_allowed_if_token_is_missing_scopes": { + firstSecret: "-", + wantSecondCall: true, + customHandlers: map[string]testutils.EndpointHandler{ + "/token": testutils.TokenHandler("http://127.0.0.1:31313", nil), + }, + address: "127.0.0.1:31313", + }, + "Authenticating_with_password_refreshes_groups": { + firstMode: authmodes.Password, + token: &tokenOptions{}, + groupsReturnedByProvider: []info.Group{{Name: "refreshed-group"}}, + wantGroups: []info.Group{{Name: "refreshed-group"}}, + }, + "Authenticating_with_password_keeps_old_groups_if_fetching_groups_fails": { + firstMode: authmodes.Password, + token: &tokenOptions{groups: []info.Group{{Name: "old-group"}}}, + getGroupsFails: true, + wantGroups: []info.Group{{Name: "old-group"}}, + }, + "Authenticating_with_password_keeps_old_groups_if_session_is_offline": { + firstMode: authmodes.Password, + token: &tokenOptions{groups: []info.Group{{Name: "old-group"}}}, + sessionOffline: true, + wantGroups: []info.Group{{Name: "old-group"}}, + }, + "Authenticating_when_the_auth_data_secret_field_uses_the_old_name": { + firstMode: authmodes.Password, + token: &tokenOptions{}, + useOldNameForSecretField: true, + }, + "Authenticating_to_change_password_still_allowed_if_fetching_groups_fails": { + sessionMode: sessionmode.ChangePassword, + firstMode: authmodes.Password, + wantNextAuthModes: []string{authmodes.NewPassword}, + token: &tokenOptions{noUserInfo: true}, + getGroupsFails: true, + }, + "Authenticating_with_password_when_refresh_token_is_expired_results_in_device_auth_as_next_mode": { + firstMode: authmodes.Password, + token: &tokenOptions{refreshTokenExpired: true}, + wantNextAuthModes: []string{authmodes.Device, authmodes.DeviceQr}, + wantSecondCall: true, + secondMode: authmodes.DeviceQr, + }, + "Authenticating_with_password_when_no_refresh_token_results_in_device_auth_as_next_mode": { + firstMode: authmodes.Password, + token: &tokenOptions{noRefreshToken: true}, + wantNextAuthModes: []string{authmodes.Device, authmodes.DeviceQr}, + wantSecondCall: true, + secondMode: authmodes.DeviceQr, + }, + "Authenticating_with_password_still_allowed_if_no_refresh_token_and_server_is_unreachable": { + firstMode: authmodes.Password, + token: &tokenOptions{noRefreshToken: true, groups: []info.Group{{Name: "old-group"}}}, + customHandlers: map[string]testutils.EndpointHandler{ + "/.well-known/openid-configuration": testutils.UnavailableHandler(), + }, + wantGroups: []info.Group{{Name: "old-group"}}, + }, + "Authenticating_with_password_when_provider_authentication_is_forced": { + firstMode: authmodes.Password, + token: &tokenOptions{}, + forceProviderAuthentication: true, + }, + "Extra_groups_configured": { + firstMode: authmodes.Password, + token: &tokenOptions{}, + groupsReturnedByProvider: []info.Group{{Name: "remote-group"}}, + extraGroups: []string{"extra-group"}, + wantGroups: []info.Group{{Name: "remote-group"}, {Name: "extra-group"}}, + }, + "Owner_extra_groups_configured": { + firstMode: authmodes.Password, + token: &tokenOptions{}, + groupsReturnedByProvider: []info.Group{{Name: "remote-group"}}, + ownerExtraGroups: []string{"owner-group"}, + wantGroups: []info.Group{{Name: "remote-group"}, {Name: "owner-group"}}, + }, + "Owner_extra_groups_configured_but_user_does_not_become_owner": { + firstMode: authmodes.Password, + token: &tokenOptions{}, + groupsReturnedByProvider: []info.Group{{Name: "remote-group"}}, + userDoesNotBecomeOwner: true, + allUsersAllowed: true, + ownerExtraGroups: []string{"owner-group"}, + wantGroups: []info.Group{{Name: "remote-group"}}, + }, + "Authenticating_with_device_auth_when_provider_supports_device_registration": { + firstSecret: "-", + wantSecondCall: true, + providerSupportsDeviceRegistration: true, + registerDevice: true, + customHandlers: map[string]testutils.EndpointHandler{ + "/token": testutils.TokenHandler("http://127.0.0.1:31314", &testutils.TokenHandlerOptions{ + IDTokenClaims: []map[string]interface{}{ + {"aud": consts.MicrosoftBrokerAppID}, + }, + }), + }, + address: "127.0.0.1:31314", + }, + "Authenticating_with_password_when_provider_supports_device_registration": { + firstMode: authmodes.Password, + token: &tokenOptions{}, + providerSupportsDeviceRegistration: true, + registerDevice: true, + customHandlers: map[string]testutils.EndpointHandler{ + "/token": testutils.TokenHandler("http://127.0.0.1:31315", &testutils.TokenHandlerOptions{ + IDTokenClaims: []map[string]interface{}{ + {"aud": consts.MicrosoftBrokerAppID}, + }, + }), + }, + address: "127.0.0.1:31315", + }, + + "Error_when_authentication_data_is_invalid": {invalidAuthData: true}, + "Error_when_secret_can_not_be_decrypted": {firstMode: authmodes.Password, badFirstKey: true}, + "Error_when_provided_wrong_secret": {firstMode: authmodes.Password, token: &tokenOptions{}, firstSecret: "wrongpassword"}, + "Error_when_can_not_cache_token": {firstSecret: "-", wantSecondCall: true, readOnlyDataDir: true}, + "Error_when_IsAuthenticated_is_ongoing_for_session": {dontWaitForFirstCall: true, wantSecondCall: true}, + + "Error_when_mode_is_password_and_token_does_not_exist": {firstMode: authmodes.Password}, + "Error_when_mode_is_password_but_server_returns_error": { + firstMode: authmodes.Password, + token: &tokenOptions{expired: true}, + customHandlers: map[string]testutils.EndpointHandler{ + "/token": testutils.BadRequestHandler(), + }, + }, + "Error_when_mode_is_password_and_token_is_invalid": {firstMode: authmodes.Password, token: &tokenOptions{invalid: true}}, + "Error_when_mode_is_password_and_no_refresh_token": {firstMode: authmodes.Password, token: &tokenOptions{noRefreshToken: true}}, + "Error_when_token_is_expired_and_refreshing_token_fails": {firstMode: authmodes.Password, token: &tokenOptions{expired: true, noRefreshToken: true}}, + "Error_when_mode_is_password_and_token_refresh_times_out": {firstMode: authmodes.Password, token: &tokenOptions{expired: true}, + customHandlers: map[string]testutils.EndpointHandler{ + "/token": testutils.HangingHandler(broker.MaxRequestDuration + 1), + }, + }, + + "Error_when_mode_is_qrcode_and_link_expires": { + customHandlers: map[string]testutils.EndpointHandler{ + "/device_auth": testutils.ExpiryDeviceAuthHandler(), + }, + }, + "Error_when_mode_is_qrcode_and_can_not_get_token": { + customHandlers: map[string]testutils.EndpointHandler{ + "/token": testutils.UnavailableHandler(), + }, + }, + "Error_when_mode_is_qrcode_and_can_not_get_token_due_to_timeout": { + customHandlers: map[string]testutils.EndpointHandler{ + "/token": testutils.HangingHandler(broker.MaxRequestDuration + 1), + }, + }, + "Error_when_mode_is_link_code_and_link_expires": { + customHandlers: map[string]testutils.EndpointHandler{ + "/device_auth": testutils.ExpiryDeviceAuthHandler(), + }, + }, + "Error_when_mode_is_link_code_and_can_not_get_token": { + firstMode: authmodes.Device, + customHandlers: map[string]testutils.EndpointHandler{ + "/token": testutils.UnavailableHandler(), + }, + }, + "Error_when_mode_is_link_code_and_can_not_get_token_due_to_timeout": { + firstMode: authmodes.Device, + customHandlers: map[string]testutils.EndpointHandler{ + "/token": testutils.HangingHandler(broker.MaxRequestDuration + 1), + }, + }, + "Error_when_empty_secret_is_provided_for_local_password": {firstSecret: "-", wantSecondCall: true, secondSecret: "-"}, + "Error_when_mode_is_newpassword_and_session_has_no_token": {firstMode: authmodes.NewPassword}, + // This test case also tests that errors with double quotes are marshaled to JSON correctly. + "Error_when_selected_username_does_not_match_the_provider_one": {username: "not-matching", firstSecret: "-"}, + "Error_when_provider_authentication_is_forced_and_session_is_offline": { + firstMode: authmodes.Password, + token: &tokenOptions{}, + forceProviderAuthentication: true, + sessionOffline: true, + }, + "Error_when_user_is_disabled_and_session_is_offline": { + firstMode: authmodes.Password, + token: &tokenOptions{userIsDisabled: true}, + sessionOffline: true, + }, + "Error_when_device_is_disabled_and_session_is_offline": { + firstMode: authmodes.Password, + token: &tokenOptions{deviceIsDisabled: true}, + sessionOffline: true, + }, + "Error_when_mode_is_invalid": {firstMode: "invalid"}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + if tc.sessionMode == "" { + tc.sessionMode = sessionmode.Login + } + + if tc.sessionOffline { + tc.customHandlers = map[string]testutils.EndpointHandler{ + "/.well-known/openid-configuration": testutils.UnavailableHandler(), + } + } + + outDir := t.TempDir() + dataDir := filepath.Join(outDir, "data") + + err := os.Mkdir(dataDir, 0700) + require.NoError(t, err, "Setup: Mkdir should not have returned an error") + + cfg := &brokerForTestConfig{ + Config: broker.Config{DataDir: dataDir}, + getGroupsFails: tc.getGroupsFails, + ownerAllowed: true, + firstUserBecomesOwner: !tc.userDoesNotBecomeOwner, + allUsersAllowed: tc.allUsersAllowed, + forceProviderAuthentication: tc.forceProviderAuthentication, + extraGroups: tc.extraGroups, + ownerExtraGroups: tc.ownerExtraGroups, + supportsDeviceRegistration: tc.providerSupportsDeviceRegistration, + registerDevice: tc.registerDevice, + } + if tc.customHandlers == nil { + // Use the default provider URL if no custom handlers are provided. + cfg.issuerURL = defaultIssuerURL + } else { + cfg.customHandlers = tc.customHandlers + cfg.listenAddress = tc.address + } + if tc.groupsReturnedByProvider != nil { + cfg.getGroupsFunc = func() ([]info.Group, error) { + return tc.groupsReturnedByProvider, nil + } + } + b := newBrokerForTests(t, cfg) + + sessionID, key := newSessionForTests(t, b, tc.username, tc.sessionMode) + + if tc.token != nil { + generateAndStoreCachedInfo(t, *tc.token, b.TokenPathForSession(sessionID)) + err = password.HashAndStorePassword(correctPassword, b.PasswordFilepathForSession(sessionID)) + require.NoError(t, err, "Setup: HashAndStorePassword should not have returned an error") + } + + var readOnlyDataCleanup, readOnlyTokenCleanup func() + if tc.readOnlyDataDir { + if tc.token != nil { + readOnlyTokenCleanup = testutils.MakeReadOnly(t, b.TokenPathForSession(sessionID)) + t.Cleanup(readOnlyTokenCleanup) + } + readOnlyDataCleanup = testutils.MakeReadOnly(t, b.DataDir()) + t.Cleanup(readOnlyDataCleanup) + } + + switch tc.firstSecret { + case "": + tc.firstSecret = correctPassword + case "-": + tc.firstSecret = "" + } + + authData := "{}" + if tc.firstSecret != "" { + eKey := key + if tc.badFirstKey { + eKey = "" + } + secret := encryptSecret(t, tc.firstSecret, eKey) + field := broker.AuthDataSecret + if tc.useOldNameForSecretField { + field = broker.AuthDataSecretOld + } + authData = fmt.Sprintf(`{"%s":"%s"}`, field, secret) + } + if tc.invalidAuthData { + authData = "invalid json" + } + + firstCallDone := make(chan struct{}) + go func() { + defer close(firstCallDone) + + if tc.firstMode == "" { + tc.firstMode = authmodes.DeviceQr + } + updateAuthModes(t, b, sessionID, tc.firstMode) + + access, data, err := b.IsAuthenticated(sessionID, authData) + require.True(t, json.Valid([]byte(data)), "IsAuthenticated returned data must be a valid JSON") + + got := isAuthenticatedResponse{Access: access, Data: data, Err: fmt.Sprint(err)} + out, err := yaml.Marshal(got) + require.NoError(t, err, "Failed to marshal first response") + + err = os.WriteFile(filepath.Join(outDir, "first_call"), out, 0600) + require.NoError(t, err, "Failed to write first response") + + if tc.wantNextAuthModes != nil { + nextAuthModes := b.GetNextAuthModes(sessionID) + require.ElementsMatch(t, tc.wantNextAuthModes, nextAuthModes, "Next auth modes should match") + } + + if tc.wantGroups != nil { + type userInfoMsgType struct { + UserInfo info.User `json:"userinfo"` + } + userInfoMsg := userInfoMsgType{} + err = json.Unmarshal([]byte(data), &userInfoMsg) + require.NoError(t, err, "Failed to unmarshal user info message") + userInfo := userInfoMsg.UserInfo + require.ElementsMatch(t, tc.wantGroups, userInfo.Groups, "Groups should match") + } + }() + + if !tc.dontWaitForFirstCall { + <-firstCallDone + } + + if tc.wantSecondCall { + // Give some time for the first call + time.Sleep(10 * time.Millisecond) + + secret := "passwordpassword" + if tc.secondSecret == "-" { + secret = "" + } + + secret = encryptSecret(t, secret, key) + field := broker.AuthDataSecret + if tc.useOldNameForSecretField { + field = broker.AuthDataSecretOld + } + secondAuthData := fmt.Sprintf(`{"%s":"%s"}`, field, secret) + if tc.invalidAuthData { + secondAuthData = "invalid json" + } + + if tc.secondMode == "" { + tc.secondMode = authmodes.NewPassword + } + + secondCallDone := make(chan struct{}) + go func() { + defer close(secondCallDone) + + updateAuthModes(t, b, sessionID, tc.secondMode) + + access, data, err := b.IsAuthenticated(sessionID, secondAuthData) + require.True(t, json.Valid([]byte(data)), "IsAuthenticated returned data must be a valid JSON") + + got := isAuthenticatedResponse{Access: access, Data: data, Err: fmt.Sprint(err)} + out, err := yaml.Marshal(got) + require.NoError(t, err, "Failed to marshal second response") + + err = os.WriteFile(filepath.Join(outDir, "second_call"), out, 0600) + require.NoError(t, err, "Failed to write second response") + }() + <-secondCallDone + } + <-firstCallDone + + // We need to restore some permissions in order to save the golden files. + if tc.readOnlyDataDir { + readOnlyDataCleanup() + if tc.token != nil { + readOnlyTokenCleanup() + } + } + + // Ensure that the token content is generic to avoid golden file conflicts + if _, err := os.Stat(b.TokenPathForSession(sessionID)); err == nil { + err := os.WriteFile(b.TokenPathForSession(sessionID), []byte("Definitely a token"), 0600) + require.NoError(t, err, "Teardown: Failed to write generic token file") + } + passwordPath := b.PasswordFilepathForSession(sessionID) + if _, err := os.Stat(passwordPath); err == nil { + err := os.WriteFile(passwordPath, []byte("Definitely a hashed password"), 0600) + require.NoError(t, err, "Teardown: Failed to write generic password file") + } + + // Ensure that the directory structure is generic to avoid golden file conflicts + if _, err := os.Stat(filepath.Dir(b.TokenPathForSession(sessionID))); err == nil { + issuerDir := filepath.Dir(filepath.Dir(b.TokenPathForSession(sessionID))) + newIsserDir := filepath.Join(filepath.Dir(issuerDir), "provider_url") + err := os.Rename(issuerDir, newIsserDir) + if err != nil { + require.ErrorIs(t, err, os.ErrNotExist, "Teardown: Failed to rename token directory") + t.Logf("Failed to rename token directory: %v", err) + } + } + + golden.CheckOrUpdateFileTree(t, outDir) + }) + } +} + +// Due to ordering restrictions, this test can not be run in parallel, otherwise the routines would not be ordered as expected. +func TestConcurrentIsAuthenticated(t *testing.T) { + tests := map[string]struct { + firstCallDelay int + secondCallDelay int + ownerAllowed bool + allUsersAllowed bool + firstUserBecomesOwner bool + + timeBetween time.Duration + }{ + "First_auth_starts_and_finishes_before_second": { + secondCallDelay: 1, + timeBetween: 2 * time.Second, + allUsersAllowed: true, + }, + "First_auth_starts_first_but_second_finishes_first": { + firstCallDelay: 3, + timeBetween: time.Second, + allUsersAllowed: true, + }, + "First_auth_starts_first_then_second_starts_and_first_finishes": { + firstCallDelay: 2, + secondCallDelay: 3, + timeBetween: time.Second, + allUsersAllowed: true, + }, + "First_auth_starts_first_but_second_finishes_first_and_is_registered_as_the_owner": { + firstCallDelay: 3, + timeBetween: time.Second, + ownerAllowed: true, + firstUserBecomesOwner: true, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + outDir := t.TempDir() + dataDir := filepath.Join(outDir, "data") + err := os.Mkdir(dataDir, 0700) + require.NoError(t, err, "Setup: Mkdir should not have returned an error") + + username1 := "user1@example.com" + username2 := "user2@example.com" + + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: dataDir}, + allUsersAllowed: tc.allUsersAllowed, + ownerAllowed: tc.ownerAllowed, + firstUserBecomesOwner: tc.firstUserBecomesOwner, + firstCallDelay: tc.firstCallDelay, + secondCallDelay: tc.secondCallDelay, + tokenHandlerOptions: &testutils.TokenHandlerOptions{ + IDTokenClaims: []map[string]interface{}{ + {"sub": "user1", "name": "user1", "email": username1}, + {"sub": "user2", "name": "user2", "email": username2}, + }, + }, + }) + + firstSession, firstKey := newSessionForTests(t, b, username1, "") + firstToken := tokenOptions{username: username1} + generateAndStoreCachedInfo(t, firstToken, b.TokenPathForSession(firstSession)) + err = password.HashAndStorePassword("password", b.PasswordFilepathForSession(firstSession)) + require.NoError(t, err, "Setup: HashAndStorePassword should not have returned an error") + + secondSession, secondKey := newSessionForTests(t, b, username2, "") + secondToken := tokenOptions{username: username2} + generateAndStoreCachedInfo(t, secondToken, b.TokenPathForSession(secondSession)) + err = password.HashAndStorePassword("password", b.PasswordFilepathForSession(secondSession)) + require.NoError(t, err, "Setup: HashAndStorePassword should not have returned an error") + + firstCallDone := make(chan struct{}) + go func() { + t.Logf("%s: First auth starting", t.Name()) + defer close(firstCallDone) + + updateAuthModes(t, b, firstSession, authmodes.Password) + + secret := encryptSecret(t, "password", firstKey) + authData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, secret) + + access, data, err := b.IsAuthenticated(firstSession, authData) + require.True(t, json.Valid([]byte(data)), "IsAuthenticated returned data must be a valid JSON") + + got := isAuthenticatedResponse{Access: access, Data: data, Err: fmt.Sprint(err)} + out, err := yaml.Marshal(got) + require.NoError(t, err, "Failed to marshal first response") + + err = os.WriteFile(filepath.Join(outDir, "first_auth"), out, 0600) + require.NoError(t, err, "Failed to write first response") + + t.Logf("%s: First auth done", t.Name()) + }() + + time.Sleep(tc.timeBetween) + + secondCallDone := make(chan struct{}) + go func() { + t.Logf("%s: Second auth starting", t.Name()) + defer close(secondCallDone) + + updateAuthModes(t, b, secondSession, authmodes.Password) + + secret := encryptSecret(t, "password", secondKey) + authData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, secret) + + access, data, err := b.IsAuthenticated(secondSession, authData) + require.True(t, json.Valid([]byte(data)), "IsAuthenticated returned data must be a valid JSON") + + got := isAuthenticatedResponse{Access: access, Data: data, Err: fmt.Sprint(err)} + out, err := yaml.Marshal(got) + require.NoError(t, err, "Failed to marshal second response") + + err = os.WriteFile(filepath.Join(outDir, "second_auth"), out, 0600) + require.NoError(t, err, "Failed to write second response") + + t.Logf("%s: Second auth done", t.Name()) + }() + + <-firstCallDone + <-secondCallDone + + for _, sessionID := range []string{firstSession, secondSession} { + // Ensure that the token content is generic to avoid golden file conflicts + if _, err := os.Stat(b.TokenPathForSession(sessionID)); err == nil { + err := os.WriteFile(b.TokenPathForSession(sessionID), []byte("Definitely a token"), 0600) + require.NoError(t, err, "Teardown: Failed to write generic token file") + } + passwordPath := b.PasswordFilepathForSession(sessionID) + if _, err := os.Stat(passwordPath); err == nil { + err := os.WriteFile(passwordPath, []byte("Definitely a hashed password"), 0600) + require.NoError(t, err, "Teardown: Failed to write generic password file") + } + } + + // Ensure that the directory structure is generic to avoid golden file conflicts + issuerDataDir := filepath.Dir(b.UserDataDirForSession(firstSession)) + if _, err := os.Stat(issuerDataDir); err == nil { + err := os.Rename(issuerDataDir, filepath.Join(filepath.Dir(issuerDataDir), "provider_url")) + if err != nil { + require.ErrorIs(t, err, os.ErrNotExist, "Teardown: Failed to rename issuer data directory") + t.Logf("Failed to rename issuer data directory: %v", err) + } + } + golden.CheckOrUpdateFileTree(t, outDir) + }) + } +} + +func TestIsAuthenticatedAllowedUsersConfig(t *testing.T) { + t.Parallel() + + u1 := "u1" + u2 := "u2" + u3 := "U3" + allUsers := []string{u1, u2, u3} + + idTokenClaims := []map[string]interface{}{} + for _, uname := range allUsers { + idTokenClaims = append(idTokenClaims, map[string]interface{}{"sub": "user", "name": "user", "email": uname}) + } + + tests := map[string]struct { + allowedUsers map[string]struct{} + owner string + ownerAllowed bool + allUsersAllowed bool + firstUserBecomesOwner bool + + wantAllowedUsers []string + wantUnallowedUsers []string + }{ + "No_users_allowed": { + wantUnallowedUsers: allUsers, + }, + "No_users_allowed_when_owner_is_allowed_but_not_set": { + ownerAllowed: true, + wantUnallowedUsers: allUsers, + }, + "No_users_allowed_when_owner_is_set_but_not_allowed": { + owner: u1, + wantUnallowedUsers: allUsers, + }, + + "All_users_are_allowed": { + allUsersAllowed: true, + wantAllowedUsers: allUsers, + }, + "Only_owner_allowed": { + ownerAllowed: true, + owner: u1, + wantAllowedUsers: []string{u1}, + wantUnallowedUsers: []string{u2, u3}, + }, + "Only_first_user_allowed": { + ownerAllowed: true, + firstUserBecomesOwner: true, + wantAllowedUsers: []string{u1}, + wantUnallowedUsers: []string{u2, u3}, + }, + "Specific_users_allowed": { + allowedUsers: map[string]struct{}{u1: {}, u2: {}}, + wantAllowedUsers: []string{u1, u2}, + wantUnallowedUsers: []string{u3}, + }, + "Specific_users_and_owner": { + ownerAllowed: true, + allowedUsers: map[string]struct{}{u1: {}}, + owner: u2, + wantAllowedUsers: []string{u1, u2}, + wantUnallowedUsers: []string{u3}, + }, + "Usernames_are_normalized": { + ownerAllowed: true, + allowedUsers: map[string]struct{}{u3: {}}, + owner: strings.ToLower(u3), + wantAllowedUsers: []string{u3}, + wantUnallowedUsers: []string{u1, u2}, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + outDir := t.TempDir() + dataDir := filepath.Join(outDir, "data") + err := os.Mkdir(dataDir, 0700) + require.NoError(t, err, "Setup: Mkdir should not have returned an error") + + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: dataDir}, + allowedUsers: tc.allowedUsers, + owner: tc.owner, + ownerAllowed: tc.ownerAllowed, + allUsersAllowed: tc.allUsersAllowed, + firstUserBecomesOwner: tc.firstUserBecomesOwner, + tokenHandlerOptions: &testutils.TokenHandlerOptions{ + IDTokenClaims: idTokenClaims, + }, + }) + + for _, u := range allUsers { + sessionID, key := newSessionForTests(t, b, u, "") + token := tokenOptions{username: u} + generateAndStoreCachedInfo(t, token, b.TokenPathForSession(sessionID)) + err = password.HashAndStorePassword("password", b.PasswordFilepathForSession(sessionID)) + require.NoError(t, err, "Setup: HashAndStorePassword should not have returned an error") + + updateAuthModes(t, b, sessionID, authmodes.Password) + + secret := encryptSecret(t, "password", key) + authData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, secret) + + access, data, err := b.IsAuthenticated(sessionID, authData) + require.True(t, json.Valid([]byte(data)), "IsAuthenticated returned data must be a valid JSON") + require.NoError(t, err) + if slices.Contains(tc.wantAllowedUsers, u) { + require.Equal(t, broker.AuthGranted, access, "authentication failed") + continue + } + if slices.Contains(tc.wantUnallowedUsers, u) { + require.Equal(t, broker.AuthDenied, access, "authentication failed") + continue + } + t.Fatalf("user %s is not in the allowed or unallowed users list", u) + } + }) + } +} + +func TestCancelIsAuthenticated(t *testing.T) { + t.Parallel() + + b := newBrokerForTests(t, &brokerForTestConfig{ + customHandlers: map[string]testutils.EndpointHandler{ + "/token": testutils.HangingHandler(3 * time.Second), + }, + }) + sessionID, _ := newSessionForTests(t, b, "", "") + + updateAuthModes(t, b, sessionID, authmodes.DeviceQr) + + stopped := make(chan struct{}) + go func() { + defer close(stopped) + _, _, err := b.IsAuthenticated(sessionID, `{}`) + require.Error(t, err, "IsAuthenticated should have returned an error") + }() + + // Wait for the call to hang + time.Sleep(50 * time.Millisecond) + + b.CancelIsAuthenticated(sessionID) + <-stopped +} + +func TestEndSession(t *testing.T) { + t.Parallel() + + b := newBrokerForTests(t, &brokerForTestConfig{ + issuerURL: defaultIssuerURL, + }) + + sessionID, _ := newSessionForTests(t, b, "", "") + + // Try to end a session that does not exist + err := b.EndSession("nonexistent") + require.Error(t, err, "EndSession should have returned an error when ending a nonexistent session") + + // End a session that exists + err = b.EndSession(sessionID) + require.NoError(t, err, "EndSession should not have returned an error when ending an existent session") +} + +func TestUserPreCheck(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + username string + allowedSuffixes []string + homePrefix string + }{ + "Successfully_allow_username_with_matching_allowed_suffix": { + username: "user@allowed", + allowedSuffixes: []string{"@allowed"}}, + "Successfully_allow_username_that_matches_at_least_one_allowed_suffix": { + username: "user@allowed", + allowedSuffixes: []string{"@other", "@something", "@allowed"}, + }, + "Successfully_allow_username_if_suffix_is_allow_all": { + username: "user@doesnotmatter", + allowedSuffixes: []string{"*"}, + }, + "Successfully_allow_username_if_suffix_has_asterisk": { + username: "user@allowed", + allowedSuffixes: []string{"*@allowed"}, + }, + "Successfully_allow_username_ignoring_empty_string_in_config": { + username: "user@allowed", + allowedSuffixes: []string{"@anothersuffix", "", "@allowed"}, + }, + "Return_userinfo_with_correct_homedir_after_precheck": { + username: "user@allowed", + allowedSuffixes: []string{"@allowed"}, + homePrefix: "/home/allowed/", + }, + + "Empty_userinfo_if_username_does_not_match_allowed_suffix": { + username: "user@notallowed", + allowedSuffixes: []string{"@allowed"}, + }, + "Empty_userinfo_if_username_does_not_match_any_of_the_allowed_suffixes": { + username: "user@notallowed", + allowedSuffixes: []string{"@other", "@something", "@allowed", ""}, + }, + "Empty_userinfo_if_no_allowed_suffixes_are_provided": { + username: "user@allowed", + }, + "Empty_userinfo_if_allowed_suffixes_has_only_empty_string": { + username: "user@allowed", + allowedSuffixes: []string{""}, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + b := newBrokerForTests(t, &brokerForTestConfig{ + issuerURL: defaultIssuerURL, + homeBaseDir: tc.homePrefix, + allowedSSHSuffixes: tc.allowedSuffixes, + }) + + got, err := b.UserPreCheck(tc.username) + require.NoError(t, err, "UserPreCheck should not have returned an error") + + golden.CheckOrUpdate(t, got) + }) + } +} + +func TestMain(m *testing.M) { + log.SetLevel(log.DebugLevel) + + var cleanup func() + defaultIssuerURL, cleanup = testutils.StartMockProviderServer("", nil) + defer cleanup() + + m.Run() +} diff --git a/authd-oidc-brokers/internal/broker/config.go b/authd-oidc-brokers/internal/broker/config.go new file mode 100644 index 0000000000..68b7753f45 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/config.go @@ -0,0 +1,316 @@ +package broker + +import ( + "embed" + "errors" + "fmt" + "html/template" + "io/fs" + "os" + "path/filepath" + "strings" + "sync" + + "gopkg.in/ini.v1" +) + +// Configuration sections and keys. +const ( + // forceProviderAuthenticationKey is the key in the config file for the option to force provider authentication during login. + forceProviderAuthenticationKey = "force_provider_authentication" + + // oidcSection is the section name in the config file for the OIDC specific configuration. + oidcSection = "oidc" + // issuerKey is the key in the config file for the issuer. + issuerKey = "issuer" + // clientIDKey is the key in the config file for the client ID. + clientIDKey = "client_id" + // clientSecret is the optional client secret for this client. + clientSecret = "client_secret" + // extraScopesKey is the key in the config file for extra OIDC scopes. + extraScopesKey = "extra_scopes" + + // entraIDSection is the section name in the config file for Microsoft Entra ID specific configuration. + entraIDSection = "msentraid" + // registerDeviceKey is the key in the config file for the setting that enables automatic device registration. + registerDeviceKey = "register_device" + + // usersSection is the section name in the config file for the users and broker specific configuration. + usersSection = "users" + // allowedUsersKey is the key in the config file for the users that are allowed to access the machine. + allowedUsersKey = "allowed_users" + // ownerKey is the key in the config file for the owner of the machine. + ownerKey = "owner" + // homeDirKey is the key in the config file for the home directory prefix. + homeDirKey = "home_base_dir" + // sshSuffixesKey is the key in the config file for the SSH allowed suffixes. + sshSuffixesKey = "ssh_allowed_suffixes_first_auth" + // sshSuffixesKeyOld is the old key in the config file for the SSH allowed suffixes. It should be removed later. + sshSuffixesKeyOld = "ssh_allowed_suffixes" + // extraGroupsKey is the key in the config file for the extra groups to add to each authd user. + extraGroupsKey = "extra_groups" + // ownerExtraGroupsKey is the key in the config file for the extra groups to add to the owner. + ownerExtraGroupsKey = "owner_extra_groups" + // allUsersKeyword is the keyword for the `allowed_users` key that allows access to all users. + allUsersKeyword = "ALL" + // ownerUserKeyword is the keyword for the `allowed_users` key that allows access to the owner. + ownerUserKeyword = "OWNER" + + // ownerAutoRegistrationConfigPath is the name of the file that will be auto-generated to register the owner. + ownerAutoRegistrationConfigPath = "20-owner-autoregistration.conf" + ownerAutoRegistrationConfigTemplate = "templates/20-owner-autoregistration.conf.tmpl" +) + +var ( + //go:embed templates/20-owner-autoregistration.conf.tmpl + ownerAutoRegistrationConfig embed.FS +) + +type provider interface { + NormalizeUsername(username string) string +} + +type templateEnv struct { + Owner string +} + +type userConfig struct { + clientID string + clientSecret string + issuerURL string + + forceProviderAuthentication bool + registerDevice bool + + allowedUsers map[string]struct{} + allUsersAllowed bool + ownerAllowed bool + firstUserBecomesOwner bool + owner string + ownerMutex *sync.RWMutex + homeBaseDir string + allowedSSHSuffixes []string + extraGroups []string + ownerExtraGroups []string + extraScopes []string + + provider provider +} + +// GetDropInDir takes the broker configuration path and returns the drop in dir path. +func GetDropInDir(cfgPath string) string { + return cfgPath + ".d" +} + +func readDropInFiles(cfgPath string) ([]any, error) { + // Check if a .d directory exists and return the paths to the files in it. + dropInDir := GetDropInDir(cfgPath) + files, err := os.ReadDir(dropInDir) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + + var dropInFiles []any + // files is empty if the directory does not exist + for _, file := range files { + if file.IsDir() { + continue + } + + dropInFile, err := os.ReadFile(filepath.Join(dropInDir, file.Name())) + if err != nil { + return nil, fmt.Errorf("could not read drop-in file %q: %v", file.Name(), err) + } + dropInFiles = append(dropInFiles, dropInFile) + } + + return dropInFiles, nil +} + +func (uc *userConfig) populateUsersConfig(users *ini.Section) { + uc.ownerMutex.Lock() + defer uc.ownerMutex.Unlock() + + if users == nil { + // The default behavior is to allow only the owner + uc.ownerAllowed = true + uc.firstUserBecomesOwner = true + return + } + + uc.homeBaseDir = users.Key(homeDirKey).String() + + suffixesKey := sshSuffixesKey + // If we don't have the new key, we should try reading the old one instead. + if !users.HasKey(sshSuffixesKey) { + suffixesKey = sshSuffixesKeyOld + } + uc.allowedSSHSuffixes = strings.Split(users.Key(suffixesKey).String(), ",") + + if uc.allowedUsers == nil { + uc.allowedUsers = make(map[string]struct{}) + } + + allowedUsers := users.Key(allowedUsersKey).Strings(",") + if len(allowedUsers) == 0 { + allowedUsers = append(allowedUsers, ownerUserKeyword) + } + + for _, user := range allowedUsers { + if user == allUsersKeyword { + uc.allUsersAllowed = true + continue + } + if user == ownerUserKeyword { + uc.ownerAllowed = true + if !users.HasKey(ownerKey) { + // If owner is unset, then the first user becomes owner + uc.firstUserBecomesOwner = true + } + continue + } + + uc.allowedUsers[uc.provider.NormalizeUsername(user)] = struct{}{} + } + + // We need to read the owner key after we call HasKey, because the key is created + // when we call the "Key" function and we can't distinguish between empty and unset. + uc.owner = uc.provider.NormalizeUsername(users.Key(ownerKey).String()) + + uc.extraGroups = users.Key(extraGroupsKey).Strings(",") + uc.ownerExtraGroups = users.Key(ownerExtraGroupsKey).Strings(",") +} + +// parseConfigFromPath parses the config file and returns a map with the configuration keys and values. +func parseConfigFromPath(cfgPath string, p provider) (userConfig, error) { + cfgFile, err := os.ReadFile(cfgPath) + if err != nil { + return userConfig{}, fmt.Errorf("could not open config file %q: %v", cfgPath, err) + } + + dropInFiles, err := readDropInFiles(cfgPath) + if err != nil { + return userConfig{}, err + } + + return parseConfig(cfgFile, dropInFiles, p) +} + +// parseConfig parses the config file and returns a userConfig struct with the configuration keys and values. +// It also checks if the keys contain any placeholders and returns an error if they do. +func parseConfig(cfgContent []byte, dropInContent []any, p provider) (userConfig, error) { + cfg := userConfig{provider: p, ownerMutex: &sync.RWMutex{}} + + iniCfg, err := ini.Load(cfgContent, dropInContent...) + if err != nil { + return userConfig{}, err + } + + // Check if any of the keys still contain the placeholders. + for _, section := range iniCfg.Sections() { + for _, key := range section.Keys() { + if strings.Contains(key.Value(), "<") && strings.Contains(key.Value(), ">") { + err = errors.Join(err, fmt.Errorf("found invalid character in section %q, key %q", section.Name(), key.Name())) + } + } + } + if err != nil { + return userConfig{}, fmt.Errorf("config file has invalid values, did you edit the config file?\n%w", err) + } + + oidc := iniCfg.Section(oidcSection) + if oidc != nil { + cfg.issuerURL = oidc.Key(issuerKey).String() + cfg.clientID = oidc.Key(clientIDKey).String() + cfg.clientSecret = oidc.Key(clientSecret).String() + cfg.extraScopes = oidc.Key(extraScopesKey).Strings(",") + + if oidc.HasKey(forceProviderAuthenticationKey) { + cfg.forceProviderAuthentication, err = oidc.Key(forceProviderAuthenticationKey).Bool() + if err != nil { + return userConfig{}, fmt.Errorf("error parsing '%s': %w", forceProviderAuthenticationKey, err) + } + } + } + + entraID := iniCfg.Section(entraIDSection) + if entraID != nil && entraID.HasKey(registerDeviceKey) { + cfg.registerDevice, err = entraID.Key(registerDeviceKey).Bool() + if err != nil { + return userConfig{}, fmt.Errorf("error parsing '%s': %w", registerDeviceKey, err) + } + } + + cfg.populateUsersConfig(iniCfg.Section(usersSection)) + + return cfg, nil +} + +func (uc *userConfig) userNameIsAllowed(userName string) bool { + uc.ownerMutex.RLock() + defer uc.ownerMutex.RUnlock() + + // The user is allowed to log in if: + // - ALL users are allowed + // - the user's name is in the list of allowed_users + // - OWNER is in the allowed_users list and the user is the owner of the machine + // - The user will be registered as the owner + if uc.allUsersAllowed { + return true + } + if _, ok := uc.allowedUsers[userName]; ok { + return true + } + if uc.ownerAllowed && uc.owner == userName { + return true + } + + return uc.shouldRegisterOwner() +} + +// shouldRegisterOwner returns true if the first user to log in should be registered as the owner. +// Only call this with the ownerMutex locked. +func (uc *userConfig) shouldRegisterOwner() bool { + return uc.ownerAllowed && uc.firstUserBecomesOwner && uc.owner == "" +} + +func (uc *userConfig) registerOwner(cfgPath, userName string) error { + // We need to lock here to avoid a race condition where two users log in at the same time, causing both to be + // considered the owner. + uc.ownerMutex.Lock() + defer uc.ownerMutex.Unlock() + + if cfgPath == "" { + uc.owner = uc.provider.NormalizeUsername(userName) + uc.firstUserBecomesOwner = false + return nil + } + + p := filepath.Join(GetDropInDir(cfgPath), ownerAutoRegistrationConfigPath) + + templateName := filepath.Base(ownerAutoRegistrationConfigTemplate) + t, err := template.New(templateName).ParseFS(ownerAutoRegistrationConfig, ownerAutoRegistrationConfigTemplate) + if err != nil { + return fmt.Errorf("failed to open autoregistration template: %v", err) + } + + f, err := os.OpenFile(p, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return fmt.Errorf("failed to create owner registration file: %v", err) + } + defer f.Close() + + if err := t.Execute(f, templateEnv{Owner: userName}); err != nil { + return fmt.Errorf("failed to write owner registration file: %v", err) + } + + // We set the owner after we create the autoregistration file, so that in case of an error + // the owner is not updated. + uc.owner = uc.provider.NormalizeUsername(userName) + uc.firstUserBecomesOwner = false + + return nil +} diff --git a/authd-oidc-brokers/internal/broker/config_test.go b/authd-oidc-brokers/internal/broker/config_test.go new file mode 100644 index 0000000000..15d3538d19 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/config_test.go @@ -0,0 +1,363 @@ +package broker + +import ( + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + "testing" + "unsafe" + + "github.com/canonical/authd/authd-oidc-brokers/internal/testutils" + "github.com/canonical/authd/authd-oidc-brokers/internal/testutils/golden" + "github.com/stretchr/testify/require" +) + +var configTypes = map[string]string{ + "valid": ` +[oidc] +issuer = https://issuer.url.com +client_id = client_id +`, + + "valid+optional": ` +[oidc] +issuer = https://issuer.url.com +client_id = client_id +force_provider_authentication = true +extra_scopes = groups,offline_access, some_other_scope + +[users] +home_base_dir = /home +allowed_ssh_suffixes = @issuer.url.com +`, + + "invalid_boolean_value": ` +[oidc] +issuer = https://issuer.url.com +client_id = client_id +force_provider_authentication = invalid +`, + + "singles": ` +[oidc] +issuer = https://ISSUER_URL> +client_id = +client_id = +`, + + "overwrite_lower_precedence": ` +[oidc] +issuer = https://lower-precedence-issuer.url.com +client_id = lower_precedence_client_id +`, + + "overwrite_higher_precedence": ` +[oidc] +issuer = https://higher-precedence-issuer.url.com +`, +} + +func TestParseConfig(t *testing.T) { + t.Parallel() + p := &testutils.MockProvider{} + ignoredFields := map[string]struct{}{"provider": {}, "ownerMutex": {}} + + tests := map[string]struct { + configType string + dropInType string + + wantErr bool + }{ + "Successfully_parse_config_file": {}, + "Successfully_parse_config_file_with_optional_values": {configType: "valid+optional"}, + "Successfully_parse_config_with_drop_in_files": {dropInType: "valid"}, + + "Do_not_fail_if_values_contain_a_single_template_delimiter": {configType: "singles"}, + + "Error_if_file_does_not_exist": {configType: "inexistent", wantErr: true}, + "Error_if_file_is_unreadable": {configType: "unreadable", wantErr: true}, + "Error_if_file_is_not_updated": {configType: "template", wantErr: true}, + "Error_if_drop_in_directory_is_unreadable": {dropInType: "unreadable-dir", wantErr: true}, + "Error_if_drop_in_file_is_unreadable": {dropInType: "unreadable-file", wantErr: true}, + "Error_if_config_contains_invalid_values": {configType: "invalid_boolean_value", wantErr: true}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + confPath := filepath.Join(t.TempDir(), "broker.conf") + + if tc.configType == "" { + tc.configType = "valid" + } + err := os.WriteFile(confPath, []byte(configTypes[tc.configType]), 0600) + require.NoError(t, err, "Setup: Failed to write config file") + + switch tc.configType { + case "inexistent": + err = os.Remove(confPath) + require.NoError(t, err, "Setup: Failed to remove config file") + case "unreadable": + err = os.Chmod(confPath, 0000) + require.NoError(t, err, "Setup: Failed to make config file unreadable") + } + + dropInDir := GetDropInDir(confPath) + if tc.dropInType != "" { + err = os.Mkdir(dropInDir, 0700) + require.NoError(t, err, "Setup: Failed to create drop-in directory") + } + + switch tc.dropInType { + case "valid": + // Create multiple drop-in files to test that they are loaded in the correct order. + err = os.WriteFile(filepath.Join(dropInDir, "00-drop-in.conf"), []byte(configTypes["overwrite_lower_precedence"]), 0600) + require.NoError(t, err, "Setup: Failed to write drop-in file") + err = os.WriteFile(filepath.Join(dropInDir, "01-drop-in.conf"), []byte(configTypes["overwrite_higher_precedence"]), 0600) + require.NoError(t, err, "Setup: Failed to write drop-in file") + // Create the main config file, to test that the options which are not overwritten by the drop-in files + // are still present. + err = os.WriteFile(confPath, []byte(configTypes["valid+optional"]), 0600) + require.NoError(t, err, "Setup: Failed to write config file") + case "unreadable-dir": + err = os.Chmod(dropInDir, 0000) + require.NoError(t, err, "Setup: Failed to make drop-in directory unreadable") + case "unreadable-file": + err = os.WriteFile(filepath.Join(dropInDir, "00-drop-in.conf"), []byte(configTypes["valid"]), 0000) + require.NoError(t, err, "Setup: Failed to make drop-in file unreadable") + } + + cfg, err := parseConfigFromPath(confPath, p) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, cfg.provider, p) + + outDir := t.TempDir() + // Write the names and values of all fields in the config to a file. We can't use the json or yaml + // packages because they can't access unexported fields. + var fields []string + val := reflect.ValueOf(&cfg).Elem() + typ := reflect.TypeOf(&cfg).Elem() + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + if _, ok := ignoredFields[field.Name]; ok { + continue + } + fieldValue := val.Field(i) + if field.PkgPath != "" { + //nolint: gosec // We are using unsafe to access unexported fields for testing purposes + fieldValue = reflect.NewAt(fieldValue.Type(), unsafe.Pointer(fieldValue.UnsafeAddr())).Elem() + } + fields = append(fields, fmt.Sprintf("%s=%v", field.Name, fieldValue)) + } + err = os.WriteFile(filepath.Join(outDir, "config.txt"), []byte(strings.Join(fields, "\n")), 0600) + require.NoError(t, err) + + golden.CheckOrUpdateFileTree(t, outDir) + }) + } +} + +var testParseUserConfigTypes = map[string]string{ + "All_are_allowed": ` +[oidc] +issuer = https://issuer.url.com +client_id = client_id + +[users] +allowed_users = ALL +owner = machine_owner +home_base_dir = /home +allowed_ssh_suffixes_first_auth = @issuer.url.com +`, + "Only_owner_is_allowed": ` +[oidc] +issuer = https://issuer.url.com +client_id = client_id + +[users] +allowed_users = OWNER +owner = machine_owner +home_base_dir = /home +allowed_ssh_suffixes_first_auth = @issuer.url.com +`, + "By_default_only_owner_is_allowed": ` +[oidc] +issuer = https://issuer.url.com +client_id = client_id + +[users] +owner = machine_owner +home_base_dir = /home +allowed_ssh_suffixes_first_auth = @issuer.url.com +`, + "Only_owner_is_allowed_but_is_unset": ` +[oidc] +issuer = https://issuer.url.com +client_id = client_id + +[users] +home_base_dir = /home +allowed_ssh_suffixes_first_auth = @issuer.url.com +`, + "Only_owner_is_allowed_but_is_empty": ` +[oidc] +issuer = https://issuer.url.com +client_id = client_id + +[users] +owner = +home_base_dir = /home +allowed_ssh_suffixes_first_auth = @issuer.url.com +`, + "Users_u1_and_u2_are_allowed": ` +[oidc] +issuer = https://issuer.url.com +client_id = client_id + +[users] +allowed_users = u1,u2 +home_base_dir = /home +allowed_ssh_suffixes_first_auth = @issuer.url.com +`, + "Unset_owner_and_u1_is_allowed": ` +[oidc] +issuer = https://issuer.url.com +client_id = client_id + +[users] +allowed_users = OWNER,u1 +home_base_dir = /home +allowed_ssh_suffixes_first_auth = @issuer.url.com +`, + "Set_owner_and_u1_is_allowed": ` +[oidc] +issuer = https://issuer.url.com +client_id = client_id + +[users] +allowed_users = OWNER,u1 +owner = machine_owner +home_base_dir = /home +allowed_ssh_suffixes_first_auth = @issuer.url.com +`, + "Support_old_suffixes_key": ` +[oidc] +issuer = https://issuer.url.com +client_id = client_id + +[users] +allowed_users = ALL +owner = machine_owner +home_base_dir = /home +allowed_ssh_suffixes = @issuer.url.com +`, +} + +func TestParseUserConfig(t *testing.T) { + t.Parallel() + p := &testutils.MockProvider{} + + tests := map[string]struct { + wantAllUsersAllowed bool + wantOwnerAllowed bool + wantFirstUserBecomesOwner bool + wantOwner string + wantAllowedUsers []string + }{ + "All_are_allowed": {wantAllUsersAllowed: true, wantOwner: "machine_owner"}, + "Only_owner_is_allowed": {wantOwnerAllowed: true, wantOwner: "machine_owner"}, + "By_default_only_owner_is_allowed": {wantOwnerAllowed: true, wantOwner: "machine_owner"}, + "Only_owner_is_allowed_but_is_unset": {wantOwnerAllowed: true, wantFirstUserBecomesOwner: true}, + "Only_owner_is_allowed_but_is_empty": {wantOwnerAllowed: true}, + "Users_u1_and_u2_are_allowed": {wantAllowedUsers: []string{"u1", "u2"}}, + "Unset_owner_and_u1_is_allowed": { + wantOwnerAllowed: true, + wantFirstUserBecomesOwner: true, + wantAllowedUsers: []string{"u1"}, + }, + "Set_owner_and_u1_is_allowed": { + wantOwnerAllowed: true, + wantOwner: "machine_owner", + wantAllowedUsers: []string{"u1"}, + }, + "Support_old_suffixes_key": {wantAllUsersAllowed: true, wantOwner: "machine_owner"}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + outDir := t.TempDir() + confPath := filepath.Join(outDir, "broker.conf") + + err := os.WriteFile(confPath, []byte(testParseUserConfigTypes[name]), 0600) + require.NoError(t, err, "Setup: Failed to write config file") + + dropInDir := GetDropInDir(confPath) + err = os.Mkdir(dropInDir, 0700) + require.NoError(t, err, "Setup: Failed to create drop-in directory") + + cfg, err := parseConfigFromPath(confPath, p) + + // convert the allowed users array to a map + allowedUsersMap := map[string]struct{}{} + for _, k := range tc.wantAllowedUsers { + allowedUsersMap[k] = struct{}{} + } + + require.Equal(t, tc.wantAllUsersAllowed, cfg.allUsersAllowed) + require.Equal(t, tc.wantOwnerAllowed, cfg.ownerAllowed) + require.Equal(t, tc.wantOwner, cfg.owner) + require.Equal(t, tc.wantFirstUserBecomesOwner, cfg.firstUserBecomesOwner) + require.Equal(t, allowedUsersMap, cfg.allowedUsers) + + require.NoError(t, err) + golden.CheckOrUpdateFileTree(t, outDir) + }) + } +} + +func TestRegisterOwner(t *testing.T) { + p := &testutils.MockProvider{} + outDir := t.TempDir() + userName := "owner_name" + confPath := filepath.Join(outDir, "broker.conf") + + err := os.WriteFile(confPath, []byte(configTypes["valid"]), 0600) + require.NoError(t, err, "Setup: Failed to write config file") + + dropInDir := GetDropInDir(confPath) + err = os.Mkdir(dropInDir, 0700) + require.NoError(t, err, "Setup: Failed to create drop-in directory") + + cfg := userConfig{firstUserBecomesOwner: true, ownerAllowed: true, provider: p, ownerMutex: &sync.RWMutex{}} + err = cfg.registerOwner(confPath, userName) + require.NoError(t, err) + + require.Equal(t, cfg.owner, userName) + require.Equal(t, cfg.firstUserBecomesOwner, false) + + f, err := os.Open(filepath.Join(dropInDir, "20-owner-autoregistration.conf")) + require.NoError(t, err, "failed to open 20-owner-autoregistration.conf") + defer f.Close() + + golden.CheckOrUpdateFileTree(t, outDir) +} + +func FuzzParseConfig(f *testing.F) { + p := &testutils.MockProvider{} + f.Fuzz(func(t *testing.T, a []byte) { + _, _ = parseConfig(a, nil, p) + }) +} diff --git a/authd-oidc-brokers/internal/broker/consts.go b/authd-oidc-brokers/internal/broker/consts.go new file mode 100644 index 0000000000..0365cf2f91 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/consts.go @@ -0,0 +1,26 @@ +package broker + +// Broker responses. +const ( + // AuthGranted is the response when the authentication is granted. + AuthGranted = "granted" + // AuthDenied is the response when the authentication is denied. + AuthDenied = "denied" + // AuthCancelled is the response when the authentication is cancelled. + AuthCancelled = "cancelled" + // AuthRetry is the response when the authentication needs to be retried (another chance). + AuthRetry = "retry" + // AuthNext is the response when another MFA (including changing password) authentication is necessary. + AuthNext = "next" +) + +// AuthReplies is the list of all possible authentication replies. +var AuthReplies = []string{AuthGranted, AuthDenied, AuthCancelled, AuthRetry, AuthNext} + +const ( + // AuthDataSecret is the key for the secret in the authentication data. + AuthDataSecret = "secret" + // AuthDataSecretOld is the old key for the secret in the authentication data, which is now deprecated + // TODO(UDENG-5844): Remove this once all authd installations use "secret" instead of "challenge". + AuthDataSecretOld = "challenge" +) diff --git a/authd-oidc-brokers/internal/broker/encrypt.go b/authd-oidc-brokers/internal/broker/encrypt.go new file mode 100644 index 0000000000..5163d1b25e --- /dev/null +++ b/authd-oidc-brokers/internal/broker/encrypt.go @@ -0,0 +1,40 @@ +package broker + +import ( + "context" + "crypto/rsa" + "crypto/sha512" + "encoding/base64" + "errors" + "fmt" + + "github.com/ubuntu/authd/log" +) + +// decodeRawSecret extract the base64 secret and try to decrypt it with the private key. +func decodeRawSecret(priv *rsa.PrivateKey, rawSecret string) (decoded string, err error) { + defer func() { + // Override the error so that we don't leak information. Also, abstract it for the user. + // We still log as error for the admin to get access. + if err != nil { + log.Errorf(context.Background(), "Error when decoding secret: %v", err) + err = errors.New("could not decode secret") + } + }() + + if rawSecret == "" { + return "", nil + } + + ciphertext, err := base64.StdEncoding.DecodeString(rawSecret) + if err != nil { + return "", err + } + + plaintext, err := rsa.DecryptOAEP(sha512.New(), nil, priv, ciphertext, nil) + if err != nil { + return "", fmt.Errorf("could not decrypt secret: %v", err) + } + + return string(plaintext), nil +} diff --git a/authd-oidc-brokers/internal/broker/export_test.go b/authd-oidc-brokers/internal/broker/export_test.go new file mode 100644 index 0000000000..816d8209c3 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/export_test.go @@ -0,0 +1,174 @@ +package broker + +import ( + "sync" +) + +func (cfg *Config) Init() { + cfg.ownerMutex = &sync.RWMutex{} +} + +func (cfg *Config) SetClientID(clientID string) { + cfg.clientID = clientID +} + +func (cfg *Config) SetIssuerURL(issuerURL string) { + cfg.issuerURL = issuerURL +} + +func (cfg *Config) SetForceProviderAuthentication(value bool) { + cfg.forceProviderAuthentication = value +} + +func (cfg *Config) SetRegisterDevice(value bool) { + cfg.registerDevice = value +} + +func (cfg *Config) SetHomeBaseDir(homeBaseDir string) { + cfg.homeBaseDir = homeBaseDir +} + +func (cfg *Config) SetAllowedUsers(allowedUsers map[string]struct{}) { + cfg.allowedUsers = allowedUsers +} + +func (cfg *Config) SetOwner(owner string) { + cfg.ownerMutex.Lock() + defer cfg.ownerMutex.Unlock() + + cfg.owner = owner +} + +func (cfg *Config) SetFirstUserBecomesOwner(firstUserBecomesOwner bool) { + cfg.ownerMutex.Lock() + defer cfg.ownerMutex.Unlock() + + cfg.firstUserBecomesOwner = firstUserBecomesOwner +} + +func (cfg *Config) SetAllUsersAllowed(allUsersAllowed bool) { + cfg.allUsersAllowed = allUsersAllowed +} + +func (cfg *Config) SetOwnerAllowed(ownerAllowed bool) { + cfg.ownerMutex.Lock() + defer cfg.ownerMutex.Unlock() + + cfg.ownerAllowed = ownerAllowed +} + +func (cfg *Config) SetExtraGroups(extraGroups []string) { + cfg.extraGroups = extraGroups +} + +func (cfg *Config) SetOwnerExtraGroups(ownerExtraGroups []string) { + cfg.ownerExtraGroups = ownerExtraGroups +} + +func (cfg *Config) SetAllowedSSHSuffixes(allowedSSHSuffixes []string) { + cfg.allowedSSHSuffixes = allowedSSHSuffixes +} + +func (cfg *Config) SetProvider(provider provider) { + cfg.provider = provider +} + +func (cfg *Config) ClientID() string { + return cfg.clientID +} + +func (cfg *Config) IssuerURL() string { + return cfg.issuerURL +} + +// TokenPathForSession returns the path to the token file for the given session. +func (b *Broker) TokenPathForSession(sessionID string) string { + b.currentSessionsMu.Lock() + defer b.currentSessionsMu.Unlock() + + session, ok := b.currentSessions[sessionID] + if !ok { + return "" + } + + return session.tokenPath +} + +// PasswordFilepathForSession returns the path to the password file for the given session. +func (b *Broker) PasswordFilepathForSession(sessionID string) string { + b.currentSessionsMu.Lock() + defer b.currentSessionsMu.Unlock() + + session, ok := b.currentSessions[sessionID] + if !ok { + return "" + } + + return session.passwordPath +} + +// UserDataDirForSession returns the path to the user data directory for the given session. +func (b *Broker) UserDataDirForSession(sessionID string) string { + b.currentSessionsMu.Lock() + defer b.currentSessionsMu.Unlock() + + session, ok := b.currentSessions[sessionID] + if !ok { + return "" + } + + return session.userDataDir +} + +// DataDir returns the path to the data directory for tests. +func (b *Broker) DataDir() string { + return b.cfg.DataDir +} + +// GetNextAuthModes returns the next auth mode of the specified session. +func (b *Broker) GetNextAuthModes(sessionID string) []string { + b.currentSessionsMu.Lock() + defer b.currentSessionsMu.Unlock() + + session, ok := b.currentSessions[sessionID] + if !ok { + return nil + } + return session.nextAuthModes +} + +// SetNextAuthModes sets the next auth mode of the specified session. +func (b *Broker) SetNextAuthModes(sessionID string, authModes []string) { + b.currentSessionsMu.Lock() + defer b.currentSessionsMu.Unlock() + + session, ok := b.currentSessions[sessionID] + if !ok { + return + } + + session.nextAuthModes = authModes + b.currentSessions[sessionID] = session +} + +func (b *Broker) SetAvailableMode(sessionID, mode string) error { + s, err := b.getSession(sessionID) + if err != nil { + return err + } + s.authModes = []string{mode} + + return b.updateSession(sessionID, s) +} + +// IsOffline returns whether the given session is offline or an error if the session does not exist. +func (b *Broker) IsOffline(sessionID string) (bool, error) { + session, err := b.getSession(sessionID) + if err != nil { + return false, err + } + return session.isOffline, nil +} + +// MaxRequestDuration exposes the broker's maxRequestDuration for tests. +const MaxRequestDuration = maxRequestDuration diff --git a/authd-oidc-brokers/internal/broker/helper_test.go b/authd-oidc-brokers/internal/broker/helper_test.go new file mode 100644 index 0000000000..eaf23aa0c1 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/helper_test.go @@ -0,0 +1,302 @@ +package broker_test + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/sha512" + "crypto/x509" + "encoding/base64" + "os" + "path/filepath" + "testing" + "time" + + "github.com/canonical/authd/authd-oidc-brokers/internal/broker" + "github.com/canonical/authd/authd-oidc-brokers/internal/broker/sessionmode" + "github.com/canonical/authd/authd-oidc-brokers/internal/providers" + "github.com/canonical/authd/authd-oidc-brokers/internal/providers/info" + "github.com/canonical/authd/authd-oidc-brokers/internal/testutils" + "github.com/canonical/authd/authd-oidc-brokers/internal/token" + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" +) + +type brokerForTestConfig struct { + broker.Config + issuerURL string + forceProviderAuthentication bool + registerDevice bool + allowedUsers map[string]struct{} + allUsersAllowed bool + ownerAllowed bool + firstUserBecomesOwner bool + owner string + extraGroups []string + ownerExtraGroups []string + homeBaseDir string + allowedSSHSuffixes []string + provider providers.Provider + + getGroupsFails bool + supportsDeviceRegistration bool + firstCallDelay int + secondCallDelay int + getGroupsFunc func() ([]info.Group, error) + + listenAddress string + tokenHandlerOptions *testutils.TokenHandlerOptions + customHandlers map[string]testutils.EndpointHandler +} + +// newBrokerForTests is a helper function to easily create a new broker for tests. +func newBrokerForTests(t *testing.T, cfg *brokerForTestConfig) (b *broker.Broker) { + t.Helper() + + cfg.Init() + if cfg.issuerURL != "" { + cfg.SetIssuerURL(cfg.issuerURL) + } + if cfg.forceProviderAuthentication { + cfg.SetForceProviderAuthentication(cfg.forceProviderAuthentication) + } + if cfg.registerDevice { + cfg.SetRegisterDevice(cfg.registerDevice) + } + if cfg.homeBaseDir != "" { + cfg.SetHomeBaseDir(cfg.homeBaseDir) + } + if cfg.allowedSSHSuffixes != nil { + cfg.SetAllowedSSHSuffixes(cfg.allowedSSHSuffixes) + } + if cfg.allowedUsers != nil { + cfg.SetAllowedUsers(cfg.allowedUsers) + } + if cfg.owner != "" { + cfg.SetOwner(cfg.owner) + } + if cfg.firstUserBecomesOwner != false { + cfg.SetFirstUserBecomesOwner(cfg.firstUserBecomesOwner) + } + if cfg.allUsersAllowed != false { + cfg.SetAllUsersAllowed(cfg.allUsersAllowed) + } + if cfg.ownerAllowed != false { + cfg.SetOwnerAllowed(cfg.ownerAllowed) + } + if cfg.extraGroups != nil { + cfg.SetExtraGroups(cfg.extraGroups) + } + if cfg.ownerExtraGroups != nil { + cfg.SetOwnerExtraGroups(cfg.ownerExtraGroups) + } + + provider := &testutils.MockProvider{ + GetGroupsFails: cfg.getGroupsFails, + ProviderSupportsDeviceRegistration: cfg.supportsDeviceRegistration, + FirstCallDelay: cfg.firstCallDelay, + SecondCallDelay: cfg.secondCallDelay, + GetGroupsFunc: cfg.getGroupsFunc, + } + + if cfg.provider == nil { + cfg.SetProvider(provider) + } + if cfg.DataDir == "" { + cfg.DataDir = t.TempDir() + } + if cfg.ClientID() == "" { + cfg.SetClientID("test-client-id") + } + + if cfg.IssuerURL() == "" { + var serverOpts []testutils.ProviderServerOption + for endpoint, handler := range cfg.customHandlers { + serverOpts = append(serverOpts, testutils.WithHandler(endpoint, handler)) + } + issuerURL, cleanup := testutils.StartMockProviderServer( + cfg.listenAddress, + cfg.tokenHandlerOptions, + serverOpts..., + ) + t.Cleanup(cleanup) + cfg.SetIssuerURL(issuerURL) + } + + b, err := broker.New(cfg.Config, broker.WithCustomProvider(provider)) + require.NoError(t, err, "Setup: New should not have returned an error") + return b +} + +// newSessionForTests is a helper function to easily create a new session for tests. +// If kept empty, username and mode will be assigned default values. +func newSessionForTests(t *testing.T, b *broker.Broker, username, mode string) (id, key string) { + t.Helper() + + if username == "" { + username = "test-user@email.com" + } + if mode == "" { + mode = sessionmode.Login + } + + id, key, err := b.NewSession(username, "some lang", mode) + require.NoError(t, err, "Setup: NewSession should not have returned an error") + + return id, key +} + +func encryptSecret(t *testing.T, secret, strKey string) string { + t.Helper() + + if strKey == "" { + return secret + } + + pubASN1, err := base64.StdEncoding.DecodeString(strKey) + require.NoError(t, err, "Setup: base64 decoding should not have failed") + + pubKey, err := x509.ParsePKIXPublicKey(pubASN1) + require.NoError(t, err, "Setup: parsing public key should not have failed") + + rsaPubKey, ok := pubKey.(*rsa.PublicKey) + require.True(t, ok, "Setup: public key should be an RSA key") + + ciphertext, err := rsa.EncryptOAEP(sha512.New(), rand.Reader, rsaPubKey, []byte(secret), nil) + require.NoError(t, err, "Setup: encryption should not have failed") + + // encrypt it to base64 and replace the secret with it + return base64.StdEncoding.EncodeToString(ciphertext) +} + +func updateAuthModes(t *testing.T, b *broker.Broker, sessionID, selectedMode string) { + t.Helper() + + err := b.SetAvailableMode(sessionID, selectedMode) + require.NoError(t, err, "Setup: SetAvailableMode should not have returned an error") + _, err = b.SelectAuthenticationMode(sessionID, selectedMode) + require.NoError(t, err, "Setup: SelectAuthenticationMode should not have returned an error") +} + +func generateAndStoreCachedInfo(t *testing.T, options tokenOptions, path string) { + t.Helper() + + tok := generateCachedInfo(t, options) + if tok == nil { + writeTrashToken(t, path) + return + } + err := token.CacheAuthInfo(path, tok) + require.NoError(t, err, "Setup: storing token should not have failed") +} + +type tokenOptions struct { + username string + issuer string + groups []info.Group + + expired bool + noRefreshToken bool + refreshTokenExpired bool + noIDToken bool + invalid bool + invalidClaims bool + noUserInfo bool + isForDeviceRegistration bool + noIsForDeviceRegistration bool + deviceIsDisabled bool + userIsDisabled bool +} + +func generateCachedInfo(t *testing.T, options tokenOptions) *token.AuthCachedInfo { + t.Helper() + + if options.invalid { + return nil + } + + if options.username == "" { + options.username = "test-user@email.com" + } + if options.username == "-" { + options.username = "" + } + + idToken := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "iss": options.issuer, + "sub": "saved-user-id", + "aud": "test-client-id", + "exp": 9999999999, + "name": "test-user", + "preferred_username": "test-user-preferred-username@email.com", + "email": options.username, + "email_verified": true, + }) + encodedIDToken, err := idToken.SignedString(testutils.MockKey) + require.NoError(t, err, "Setup: signing ID token should not have failed") + + tok := token.AuthCachedInfo{ + Token: &oauth2.Token{ + AccessToken: "accesstoken", + RefreshToken: "refreshtoken", + Expiry: time.Now().Add(1000 * time.Hour), + }, + DeviceIsDisabled: options.deviceIsDisabled, + UserIsDisabled: options.userIsDisabled, + } + + if options.expired { + tok.Token.Expiry = time.Now().Add(-1000 * time.Hour) + } + if options.noRefreshToken { + tok.Token.RefreshToken = "" + } + if options.refreshTokenExpired { + tok.Token.RefreshToken = testutils.ExpiredRefreshToken + } + if !options.noIsForDeviceRegistration { + tok.ExtraFields = map[string]any{testutils.IsForDeviceRegistrationClaim: options.isForDeviceRegistration} + } + + if !options.noUserInfo { + tok.UserInfo = info.User{ + Name: options.username, + UUID: "saved-user-id", + Home: "/home/" + options.username, + Gecos: options.username, + Shell: "/usr/bin/bash", + Groups: []info.Group{ + {Name: "saved-remote-group", UGID: "12345"}, + {Name: "saved-local-group", UGID: ""}, + }, + } + if options.groups != nil { + tok.UserInfo.Groups = options.groups + } + } + + if options.invalidClaims { + encodedIDToken = ".invalid." + tok.UserInfo = info.User{} + } + + if !options.noIDToken { + tok.Token = tok.Token.WithExtra(map[string]string{"id_token": encodedIDToken}) + tok.RawIDToken = encodedIDToken + } + + return &tok +} + +func writeTrashToken(t *testing.T, path string) { + t.Helper() + + content := []byte("This is a trash token that is not valid for authentication") + + // Create issuer specific cache directory if it doesn't exist. + err := os.MkdirAll(filepath.Dir(path), 0700) + require.NoError(t, err, "Setup: creating token directory should not have failed") + + err = os.WriteFile(path, content, 0600) + require.NoError(t, err, "Setup: writing trash token should not have failed") +} diff --git a/authd-oidc-brokers/internal/broker/options_test.go b/authd-oidc-brokers/internal/broker/options_test.go new file mode 100644 index 0000000000..e0cc0e8de2 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/options_test.go @@ -0,0 +1,10 @@ +package broker + +import "github.com/canonical/authd/authd-oidc-brokers/internal/providers" + +// WithCustomProvider returns an option that sets a custom provider for the broker. +func WithCustomProvider(p providers.Provider) Option { + return func(o *option) { + o.provider = p + } +} diff --git a/authd-oidc-brokers/internal/broker/sessionmode/consts.go b/authd-oidc-brokers/internal/broker/sessionmode/consts.go new file mode 100644 index 0000000000..2472e65506 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/sessionmode/consts.go @@ -0,0 +1,15 @@ +// Package sessionmode defines the session modes supported by the broker. +package sessionmode + +const ( + // Login is used when the session is for user login. + Login = "login" + // LoginOld is the old name for the login session, which is now deprecated but still used by authd until all broker + // installations are updated. + LoginOld = "auth" + // ChangePassword is used when the session is for changing the user password. + ChangePassword = "change-password" + // ChangePasswordOld is the old name for the change-password session, which is now deprecated but still used by authd + // until all broker installations are updated. + ChangePasswordOld = "passwd" +) diff --git a/authd-oidc-brokers/internal/broker/templates/20-owner-autoregistration.conf.tmpl b/authd-oidc-brokers/internal/broker/templates/20-owner-autoregistration.conf.tmpl new file mode 100644 index 0000000000..4673fe2c2b --- /dev/null +++ b/authd-oidc-brokers/internal/broker/templates/20-owner-autoregistration.conf.tmpl @@ -0,0 +1,13 @@ +## This file was generated automatically by the broker. DO NOT EDIT. +## +## This file registers the first authenticated user as the owner of +## this device. +## +## The 'owner' option is only considered for authentication if +## 'allowed_users' contains the 'OWNER' keyword. +## +## To register a different owner for the machine on the next +## successful authentication, delete this file. + +[users] +owner = {{ .Owner }} diff --git a/authd-oidc-brokers/internal/broker/testdata/TestParseConfig/golden/Successfully_parse_config_with_drop_in_files/config.txt b/authd-oidc-brokers/internal/broker/testdata/TestParseConfig/golden/Successfully_parse_config_with_drop_in_files/config.txt new file mode 100644 index 0000000000..7761f024d6 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/TestParseConfig/golden/Successfully_parse_config_with_drop_in_files/config.txt @@ -0,0 +1,5 @@ +clientID=lower_precedence_client_id +clientSecret= +issuerURL=https://higher-precedence-issuer.url.com +homeBaseDir=/home +allowedSSHSuffixes=[] \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/fuzz/FuzzParseConfig/00 b/authd-oidc-brokers/internal/broker/testdata/fuzz/FuzzParseConfig/00 new file mode 100644 index 0000000000..b349e97638 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/fuzz/FuzzParseConfig/00 @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("clientID = client_id\nclientSecret = client_secret\nissuerURL = https://issuer.url.com\nhomeBaseDir = /home\nallowedSSHSuffixes = []\n") diff --git a/authd-oidc-brokers/internal/broker/testdata/fuzz/FuzzParseConfig/01 b/authd-oidc-brokers/internal/broker/testdata/fuzz/FuzzParseConfig/01 new file mode 100644 index 0000000000..ee3e7da3b2 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/fuzz/FuzzParseConfig/01 @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("clientID = \nclientSecret = client_secret\nissuerURL = https://issuer.url.com\nhomeBaseDir = /home\nallowedSSHSuffixes = []\n") diff --git a/authd-oidc-brokers/internal/broker/testdata/fuzz/FuzzParseConfig/02 b/authd-oidc-brokers/internal/broker/testdata/fuzz/FuzzParseConfig/02 new file mode 100644 index 0000000000..34393fab24 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/fuzz/FuzzParseConfig/02 @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("[oidc]\nforce_provider_authentication = nope\n") diff --git a/authd-oidc-brokers/internal/broker/testdata/fuzz/FuzzParseConfig/93a65ad473ce3e3c893c1c1225351f4a3b9d700b1836c1087afdc595cf44fb54 b/authd-oidc-brokers/internal/broker/testdata/fuzz/FuzzParseConfig/93a65ad473ce3e3c893c1c1225351f4a3b9d700b1836c1087afdc595cf44fb54 new file mode 100644 index 0000000000..c61979ae4c --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/fuzz/FuzzParseConfig/93a65ad473ce3e3c893c1c1225351f4a3b9d700b1836c1087afdc595cf44fb54 @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("[oidc]\nissuer = \nclient_id = \n\n## Depending on the identity provider, you may need to provide a\n## client secret to authenticate with the provider.\n#client_secret = \n\n## Force remote authentication with the identity provider during login,\n## even if a local method (e.g. local password) is used.\n## This works by forcing a token refresh during login, which fails if the\n## user does not have the necessary permissions in the identity provider.\n##\n## If set to false (the default), remote authentication with the identity\n## provider only happens if there is a working internet connection and\n## the provider is reachable during login.\n##\n## Important: Enabling this option prevents authd users from logging in\n## if the identity provider is unreachable (e.g. due to network issues).\n#force_provider_authentication = false\n\n[users]\n## The directory where the home directories of new users are created.\n## Existing users will keep their current home directory.\n## The home directories are created in the format /\n#home_base_dir = /home\n\n## By default, SSH only allows logins from users that already exist on the\n## system.\n## New authd users (who have never logged in before) are *not* allowed to log\n## in for the first time via SSH unless this option is configured.\n##\n## If configured, only users with a suffix in this list are allowed to\n## authenticate for the first time directly through SSH.\n## Note that this does not affect users that already authenticated for\n## the first time and already exist on the system.\n##\n## Suffixes must be comma-separated (e.g., '@example.com,@example.org').\n## To allow all suffixes, use a single asterisk ('*').\n##\n## Example:\n## ssh_allowed_suffixes_first_auth = @example.com,@anotherexample.org\n##\n## Example (allow all):\n## ssh_allowed_suffixes_first_auth = *\n##\n#ssh_allowed_suffixes_first_auth =\n\n## 'allowed_users' specifies the users who are permitted to log in after\n## successfully authenticating with the identity provider.\n## Values are separated by commas. Supported values:\n## - 'OWNER': Grants access to the user specified in the 'owner' option\n## (see below). This is the default.\n## - 'ALL': Grants access to all users who successfully authenticate\n## with the identity provider.\n## - : Grants access to specific additional users\n## (e.g. user1@example.com).\n## Example: allowed_users = OWNER,user1@example.com,admin@example.com\n#allowed_users = OWNER\n\n## 'owner' specifies the user assigned the owner role. This user is\n## permitted to log in if 'OWNER' is included in the 'allowed_users'\n## option.\n##\n## If this option is left unset, the first user to successfully log in\n## via this broker will automatically be assigned the owner role. A\n## drop-in configuration file will be created in broker.conf.d/ to set\n## the 'owner' option.\n##\n## To disable automatic assignment, you can either:\n## 1. Explicitly set this option to an empty value (e.g. owner = \"\")\n## 2. Remove 'OWNER' from the 'allowed_users' option\n##\n## Example: owner = user2@example.com\n#owner =\n\n## A comma-separated list of local groups which authd users will be\n## added to upon login.\n## Example: extra_groups = users\n#extra_groups =\n\n## Like 'extra_groups', but only the user assigned the owner role\n## (see 'owner' option) will be added to these groups.\n## Example: owner_extra_groups = sudo,lpadmin\n#owner_extra_groups =\n") \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/fuzz/FuzzParseConfig/caf81e9797b19c76 b/authd-oidc-brokers/internal/broker/testdata/fuzz/FuzzParseConfig/caf81e9797b19c76 new file mode 100644 index 0000000000..67322c7048 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/fuzz/FuzzParseConfig/caf81e9797b19c76 @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("") diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_and_finishes_before_second/data/provider_url/user1@example.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_and_finishes_before_second/data/provider_url/user1@example.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_and_finishes_before_second/data/provider_url/user1@example.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_and_finishes_before_second/data/provider_url/user1@example.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_and_finishes_before_second/data/provider_url/user1@example.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_and_finishes_before_second/data/provider_url/user1@example.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_and_finishes_before_second/data/provider_url/user2@example.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_and_finishes_before_second/data/provider_url/user2@example.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_and_finishes_before_second/data/provider_url/user2@example.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_and_finishes_before_second/data/provider_url/user2@example.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_and_finishes_before_second/data/provider_url/user2@example.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_and_finishes_before_second/data/provider_url/user2@example.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_and_finishes_before_second/first_auth b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_and_finishes_before_second/first_auth new file mode 100644 index 0000000000..89c182d5f3 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_and_finishes_before_second/first_auth @@ -0,0 +1,3 @@ +access: granted +data: '{"userinfo":{"name":"user1@example.com","uuid":"user1","dir":"/home/user1@example.com","shell":"/usr/bin/bash","gecos":"user1@example.com","groups":[{"name":"remote-test-group","ugid":"12345"},{"name":"local-test-group","ugid":""}]}}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_and_finishes_before_second/second_auth b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_and_finishes_before_second/second_auth new file mode 100644 index 0000000000..f0643dd9fe --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_and_finishes_before_second/second_auth @@ -0,0 +1,3 @@ +access: granted +data: '{"userinfo":{"name":"user2@example.com","uuid":"user2","dir":"/home/user2@example.com","shell":"/usr/bin/bash","gecos":"user2@example.com","groups":[{"name":"remote-test-group","ugid":"12345"},{"name":"local-test-group","ugid":""}]}}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_but_second_finishes_first/data/provider_url/user1@example.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_but_second_finishes_first/data/provider_url/user1@example.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_but_second_finishes_first/data/provider_url/user1@example.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_but_second_finishes_first/data/provider_url/user1@example.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_but_second_finishes_first/data/provider_url/user1@example.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_but_second_finishes_first/data/provider_url/user1@example.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_but_second_finishes_first/data/provider_url/user2@example.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_but_second_finishes_first/data/provider_url/user2@example.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_but_second_finishes_first/data/provider_url/user2@example.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_but_second_finishes_first/data/provider_url/user2@example.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_but_second_finishes_first/data/provider_url/user2@example.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_but_second_finishes_first/data/provider_url/user2@example.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_but_second_finishes_first/first_auth b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_but_second_finishes_first/first_auth new file mode 100644 index 0000000000..89c182d5f3 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_but_second_finishes_first/first_auth @@ -0,0 +1,3 @@ +access: granted +data: '{"userinfo":{"name":"user1@example.com","uuid":"user1","dir":"/home/user1@example.com","shell":"/usr/bin/bash","gecos":"user1@example.com","groups":[{"name":"remote-test-group","ugid":"12345"},{"name":"local-test-group","ugid":""}]}}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_but_second_finishes_first/second_auth b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_but_second_finishes_first/second_auth new file mode 100644 index 0000000000..f0643dd9fe --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_but_second_finishes_first/second_auth @@ -0,0 +1,3 @@ +access: granted +data: '{"userinfo":{"name":"user2@example.com","uuid":"user2","dir":"/home/user2@example.com","shell":"/usr/bin/bash","gecos":"user2@example.com","groups":[{"name":"remote-test-group","ugid":"12345"},{"name":"local-test-group","ugid":""}]}}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_but_second_finishes_first_and_is_registered_as_the_owner/data/provider_url/user1@example.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_but_second_finishes_first_and_is_registered_as_the_owner/data/provider_url/user1@example.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_but_second_finishes_first_and_is_registered_as_the_owner/data/provider_url/user1@example.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_but_second_finishes_first_and_is_registered_as_the_owner/data/provider_url/user1@example.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_but_second_finishes_first_and_is_registered_as_the_owner/data/provider_url/user1@example.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_but_second_finishes_first_and_is_registered_as_the_owner/data/provider_url/user1@example.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_but_second_finishes_first_and_is_registered_as_the_owner/data/provider_url/user2@example.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_but_second_finishes_first_and_is_registered_as_the_owner/data/provider_url/user2@example.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_but_second_finishes_first_and_is_registered_as_the_owner/data/provider_url/user2@example.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_but_second_finishes_first_and_is_registered_as_the_owner/data/provider_url/user2@example.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_but_second_finishes_first_and_is_registered_as_the_owner/data/provider_url/user2@example.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_but_second_finishes_first_and_is_registered_as_the_owner/data/provider_url/user2@example.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_but_second_finishes_first_and_is_registered_as_the_owner/first_auth b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_but_second_finishes_first_and_is_registered_as_the_owner/first_auth new file mode 100644 index 0000000000..d8fe78633c --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_but_second_finishes_first_and_is_registered_as_the_owner/first_auth @@ -0,0 +1,3 @@ +access: denied +data: '{"message":"Authentication failure: user not allowed in broker configuration"}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_but_second_finishes_first_and_is_registered_as_the_owner/second_auth b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_but_second_finishes_first_and_is_registered_as_the_owner/second_auth new file mode 100644 index 0000000000..f0643dd9fe --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_but_second_finishes_first_and_is_registered_as_the_owner/second_auth @@ -0,0 +1,3 @@ +access: granted +data: '{"userinfo":{"name":"user2@example.com","uuid":"user2","dir":"/home/user2@example.com","shell":"/usr/bin/bash","gecos":"user2@example.com","groups":[{"name":"remote-test-group","ugid":"12345"},{"name":"local-test-group","ugid":""}]}}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_then_second_starts_and_first_finishes/data/provider_url/user1@example.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_then_second_starts_and_first_finishes/data/provider_url/user1@example.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_then_second_starts_and_first_finishes/data/provider_url/user1@example.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_then_second_starts_and_first_finishes/data/provider_url/user1@example.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_then_second_starts_and_first_finishes/data/provider_url/user1@example.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_then_second_starts_and_first_finishes/data/provider_url/user1@example.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_then_second_starts_and_first_finishes/data/provider_url/user2@example.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_then_second_starts_and_first_finishes/data/provider_url/user2@example.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_then_second_starts_and_first_finishes/data/provider_url/user2@example.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_then_second_starts_and_first_finishes/data/provider_url/user2@example.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_then_second_starts_and_first_finishes/data/provider_url/user2@example.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_then_second_starts_and_first_finishes/data/provider_url/user2@example.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_then_second_starts_and_first_finishes/first_auth b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_then_second_starts_and_first_finishes/first_auth new file mode 100644 index 0000000000..89c182d5f3 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_then_second_starts_and_first_finishes/first_auth @@ -0,0 +1,3 @@ +access: granted +data: '{"userinfo":{"name":"user1@example.com","uuid":"user1","dir":"/home/user1@example.com","shell":"/usr/bin/bash","gecos":"user1@example.com","groups":[{"name":"remote-test-group","ugid":"12345"},{"name":"local-test-group","ugid":""}]}}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_then_second_starts_and_first_finishes/second_auth b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_then_second_starts_and_first_finishes/second_auth new file mode 100644 index 0000000000..f0643dd9fe --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestConcurrentIsAuthenticated/First_auth_starts_first_then_second_starts_and_first_finishes/second_auth @@ -0,0 +1,3 @@ +access: granted +data: '{"userinfo":{"name":"user2@example.com","uuid":"user2","dir":"/home/user2@example.com","shell":"/usr/bin/bash","gecos":"user2@example.com","groups":[{"name":"remote-test-group","ugid":"12345"},{"name":"local-test-group","ugid":""}]}}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_device_auth_if_token_exists_but_checking_if_it_is_for_device_registration_fails b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_device_auth_if_token_exists_but_checking_if_it_is_for_device_registration_fails new file mode 100644 index 0000000000..0742ddffe6 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_device_auth_if_token_exists_but_checking_if_it_is_for_device_registration_fails @@ -0,0 +1,2 @@ +- id: device_auth_qr + label: Device Authentication diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_device_auth_qr_if_device_should_be_registered_and_token_is_not_for_device_registration b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_device_auth_qr_if_device_should_be_registered_and_token_is_not_for_device_registration new file mode 100644 index 0000000000..0742ddffe6 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_device_auth_qr_if_device_should_be_registered_and_token_is_not_for_device_registration @@ -0,0 +1,2 @@ +- id: device_auth_qr + label: Device Authentication diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_device_auth_qr_if_device_should_not_be_registered_and_token_is_for_device_registration b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_device_auth_qr_if_device_should_not_be_registered_and_token_is_for_device_registration new file mode 100644 index 0000000000..0742ddffe6 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_device_auth_qr_if_device_should_not_be_registered_and_token_is_for_device_registration @@ -0,0 +1,2 @@ +- id: device_auth_qr + label: Device Authentication diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_device_auth_qr_if_next_auth_mode_is_device_qr b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_device_auth_qr_if_next_auth_mode_is_device_qr new file mode 100644 index 0000000000..0742ddffe6 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_device_auth_qr_if_next_auth_mode_is_device_qr @@ -0,0 +1,2 @@ +- id: device_auth_qr + label: Device Authentication diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_device_auth_qr_if_there_is_no_password_file b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_device_auth_qr_if_there_is_no_password_file new file mode 100644 index 0000000000..0742ddffe6 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_device_auth_qr_if_there_is_no_password_file @@ -0,0 +1,2 @@ +- id: device_auth_qr + label: Device Authentication diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_device_auth_qr_if_there_is_no_token b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_device_auth_qr_if_there_is_no_token new file mode 100644 index 0000000000..0742ddffe6 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_device_auth_qr_if_there_is_no_token @@ -0,0 +1,2 @@ +- id: device_auth_qr + label: Device Authentication diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_device_auth_qr_if_token_is_invalid b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_device_auth_qr_if_token_is_invalid new file mode 100644 index 0000000000..0742ddffe6 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_device_auth_qr_if_token_is_invalid @@ -0,0 +1,2 @@ +- id: device_auth_qr + label: Device Authentication diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_newpassword_if_next_auth_mode_is_newpassword b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_newpassword_if_next_auth_mode_is_newpassword new file mode 100644 index 0000000000..1bf8f6dae4 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_newpassword_if_next_auth_mode_is_newpassword @@ -0,0 +1,2 @@ +- id: newpassword + label: Define your local password diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_newpassword_if_session_is_for_changing_password_and_next_auth_mode_is_newpassword b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_newpassword_if_session_is_for_changing_password_and_next_auth_mode_is_newpassword new file mode 100644 index 0000000000..1bf8f6dae4 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_newpassword_if_session_is_for_changing_password_and_next_auth_mode_is_newpassword @@ -0,0 +1,2 @@ +- id: newpassword + label: Define your local password diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_password_if_device_should_be_registered_and_token_is_not_for_device_registration_but_provider_is_not_available b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_password_if_device_should_be_registered_and_token_is_not_for_device_registration_but_provider_is_not_available new file mode 100644 index 0000000000..e4e7cb1d35 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_password_if_device_should_be_registered_and_token_is_not_for_device_registration_but_provider_is_not_available @@ -0,0 +1,2 @@ +- id: password + label: Local Password Authentication diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_password_if_device_should_not_be_registered_and_token_is_for_device_registration_but_provider_is_not_available b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_password_if_device_should_not_be_registered_and_token_is_for_device_registration_but_provider_is_not_available new file mode 100644 index 0000000000..e4e7cb1d35 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_password_if_device_should_not_be_registered_and_token_is_for_device_registration_but_provider_is_not_available @@ -0,0 +1,2 @@ +- id: password + label: Local Password Authentication diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_password_if_token_exists_and_provider_does_not_support_device_auth_qr b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_password_if_token_exists_and_provider_does_not_support_device_auth_qr new file mode 100644 index 0000000000..e4e7cb1d35 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_password_if_token_exists_and_provider_does_not_support_device_auth_qr @@ -0,0 +1,2 @@ +- id: password + label: Local Password Authentication diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_password_if_token_exists_and_provider_is_not_available b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_password_if_token_exists_and_provider_is_not_available new file mode 100644 index 0000000000..e4e7cb1d35 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_password_if_token_exists_and_provider_is_not_available @@ -0,0 +1,2 @@ +- id: password + label: Local Password Authentication diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_password_if_token_exists_and_session_is_for_changing_password b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_password_if_token_exists_and_session_is_for_changing_password new file mode 100644 index 0000000000..e4e7cb1d35 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_password_if_token_exists_and_session_is_for_changing_password @@ -0,0 +1,2 @@ +- id: password + label: Local Password Authentication diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_password_if_token_exists_and_session_mode_is_the_old_passwd_value b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_password_if_token_exists_and_session_mode_is_the_old_passwd_value new file mode 100644 index 0000000000..e4e7cb1d35 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_only_password_if_token_exists_and_session_mode_is_the_old_passwd_value @@ -0,0 +1,2 @@ +- id: password + label: Local Password Authentication diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_password_and_device_auth_qr_if_device_should_be_registered_and_token_is_for_device_registration b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_password_and_device_auth_qr_if_device_should_be_registered_and_token_is_for_device_registration new file mode 100644 index 0000000000..6327db6363 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_password_and_device_auth_qr_if_device_should_be_registered_and_token_is_for_device_registration @@ -0,0 +1,4 @@ +- id: password + label: Local Password Authentication +- id: device_auth_qr + label: Device Authentication diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_password_and_device_auth_qr_if_device_should_be_registered_and_token_is_not_for_device_registration_and_provider_does_not_support_it b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_password_and_device_auth_qr_if_device_should_be_registered_and_token_is_not_for_device_registration_and_provider_does_not_support_it new file mode 100644 index 0000000000..6327db6363 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_password_and_device_auth_qr_if_device_should_be_registered_and_token_is_not_for_device_registration_and_provider_does_not_support_it @@ -0,0 +1,4 @@ +- id: password + label: Local Password Authentication +- id: device_auth_qr + label: Device Authentication diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_password_and_device_auth_qr_if_device_should_not_be_registered_and_token_is_not_for_device_registration b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_password_and_device_auth_qr_if_device_should_not_be_registered_and_token_is_not_for_device_registration new file mode 100644 index 0000000000..6327db6363 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_password_and_device_auth_qr_if_device_should_not_be_registered_and_token_is_not_for_device_registration @@ -0,0 +1,4 @@ +- id: password + label: Local Password Authentication +- id: device_auth_qr + label: Device Authentication diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_password_and_device_auth_qr_if_token_exists b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_password_and_device_auth_qr_if_token_exists new file mode 100644 index 0000000000..6327db6363 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_password_and_device_auth_qr_if_token_exists @@ -0,0 +1,4 @@ +- id: password + label: Local Password Authentication +- id: device_auth_qr + label: Device Authentication diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_password_and_device_auth_qr_if_token_is_not_for_device_registration_but_provider_does_not_support_it b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_password_and_device_auth_qr_if_token_is_not_for_device_registration_but_provider_does_not_support_it new file mode 100644 index 0000000000..6327db6363 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestGetAuthenticationModes/Get_password_and_device_auth_qr_if_token_is_not_for_device_registration_but_provider_does_not_support_it @@ -0,0 +1,4 @@ +- id: password + label: Local Password Authentication +- id: device_auth_qr + label: Device Authentication diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_still_allowed_if_token_is_missing_scopes/data/provider_url/test-user@email.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_still_allowed_if_token_is_missing_scopes/data/provider_url/test-user@email.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_still_allowed_if_token_is_missing_scopes/data/provider_url/test-user@email.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_still_allowed_if_token_is_missing_scopes/data/provider_url/test-user@email.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_still_allowed_if_token_is_missing_scopes/data/provider_url/test-user@email.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_still_allowed_if_token_is_missing_scopes/data/provider_url/test-user@email.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_still_allowed_if_token_is_missing_scopes/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_still_allowed_if_token_is_missing_scopes/first_call new file mode 100644 index 0000000000..d0887a134f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_still_allowed_if_token_is_missing_scopes/first_call @@ -0,0 +1,3 @@ +access: next +data: '{}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_still_allowed_if_token_is_missing_scopes/second_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_still_allowed_if_token_is_missing_scopes/second_call new file mode 100644 index 0000000000..f50b5eb551 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_still_allowed_if_token_is_missing_scopes/second_call @@ -0,0 +1,3 @@ +access: granted +data: '{"userinfo":{"name":"test-user@email.com","uuid":"test-user-id","dir":"/home/test-user@email.com","shell":"/usr/bin/bash","gecos":"test-user@email.com","groups":[{"name":"remote-test-group","ugid":"12345"},{"name":"local-test-group","ugid":""}]}}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_to_change_password_still_allowed_if_fetching_groups_fails/data/provider_url/test-user@email.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_to_change_password_still_allowed_if_fetching_groups_fails/data/provider_url/test-user@email.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_to_change_password_still_allowed_if_fetching_groups_fails/data/provider_url/test-user@email.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_to_change_password_still_allowed_if_fetching_groups_fails/data/provider_url/test-user@email.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_to_change_password_still_allowed_if_fetching_groups_fails/data/provider_url/test-user@email.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_to_change_password_still_allowed_if_fetching_groups_fails/data/provider_url/test-user@email.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_to_change_password_still_allowed_if_fetching_groups_fails/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_to_change_password_still_allowed_if_fetching_groups_fails/first_call new file mode 100644 index 0000000000..d0887a134f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_to_change_password_still_allowed_if_fetching_groups_fails/first_call @@ -0,0 +1,3 @@ +access: next +data: '{}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_when_the_auth_data_secret_field_uses_the_old_name/data/provider_url/test-user@email.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_when_the_auth_data_secret_field_uses_the_old_name/data/provider_url/test-user@email.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_when_the_auth_data_secret_field_uses_the_old_name/data/provider_url/test-user@email.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_when_the_auth_data_secret_field_uses_the_old_name/data/provider_url/test-user@email.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_when_the_auth_data_secret_field_uses_the_old_name/data/provider_url/test-user@email.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_when_the_auth_data_secret_field_uses_the_old_name/data/provider_url/test-user@email.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_when_the_auth_data_secret_field_uses_the_old_name/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_when_the_auth_data_secret_field_uses_the_old_name/first_call new file mode 100644 index 0000000000..f50b5eb551 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_when_the_auth_data_secret_field_uses_the_old_name/first_call @@ -0,0 +1,3 @@ +access: granted +data: '{"userinfo":{"name":"test-user@email.com","uuid":"test-user-id","dir":"/home/test-user@email.com","shell":"/usr/bin/bash","gecos":"test-user@email.com","groups":[{"name":"remote-test-group","ugid":"12345"},{"name":"local-test-group","ugid":""}]}}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_device_auth_when_provider_supports_device_registration/data/provider_url/test-user@email.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_device_auth_when_provider_supports_device_registration/data/provider_url/test-user@email.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_device_auth_when_provider_supports_device_registration/data/provider_url/test-user@email.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_device_auth_when_provider_supports_device_registration/data/provider_url/test-user@email.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_device_auth_when_provider_supports_device_registration/data/provider_url/test-user@email.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_device_auth_when_provider_supports_device_registration/data/provider_url/test-user@email.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_device_auth_when_provider_supports_device_registration/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_device_auth_when_provider_supports_device_registration/first_call new file mode 100644 index 0000000000..d0887a134f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_device_auth_when_provider_supports_device_registration/first_call @@ -0,0 +1,3 @@ +access: next +data: '{}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_device_auth_when_provider_supports_device_registration/second_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_device_auth_when_provider_supports_device_registration/second_call new file mode 100644 index 0000000000..f50b5eb551 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_device_auth_when_provider_supports_device_registration/second_call @@ -0,0 +1,3 @@ +access: granted +data: '{"userinfo":{"name":"test-user@email.com","uuid":"test-user-id","dir":"/home/test-user@email.com","shell":"/usr/bin/bash","gecos":"test-user@email.com","groups":[{"name":"remote-test-group","ugid":"12345"},{"name":"local-test-group","ugid":""}]}}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_keeps_old_groups_if_fetching_groups_fails/data/provider_url/test-user@email.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_keeps_old_groups_if_fetching_groups_fails/data/provider_url/test-user@email.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_keeps_old_groups_if_fetching_groups_fails/data/provider_url/test-user@email.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_keeps_old_groups_if_fetching_groups_fails/data/provider_url/test-user@email.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_keeps_old_groups_if_fetching_groups_fails/data/provider_url/test-user@email.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_keeps_old_groups_if_fetching_groups_fails/data/provider_url/test-user@email.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_keeps_old_groups_if_fetching_groups_fails/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_keeps_old_groups_if_fetching_groups_fails/first_call new file mode 100644 index 0000000000..4b9e9db6b0 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_keeps_old_groups_if_fetching_groups_fails/first_call @@ -0,0 +1,3 @@ +access: granted +data: '{"userinfo":{"name":"test-user@email.com","uuid":"test-user-id","dir":"/home/test-user@email.com","shell":"/usr/bin/bash","gecos":"test-user@email.com","groups":[{"name":"old-group","ugid":""}]}}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_keeps_old_groups_if_session_is_offline/data/provider_url/test-user@email.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_keeps_old_groups_if_session_is_offline/data/provider_url/test-user@email.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_keeps_old_groups_if_session_is_offline/data/provider_url/test-user@email.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_keeps_old_groups_if_session_is_offline/data/provider_url/test-user@email.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_keeps_old_groups_if_session_is_offline/data/provider_url/test-user@email.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_keeps_old_groups_if_session_is_offline/data/provider_url/test-user@email.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_keeps_old_groups_if_session_is_offline/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_keeps_old_groups_if_session_is_offline/first_call new file mode 100644 index 0000000000..aca00f387f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_keeps_old_groups_if_session_is_offline/first_call @@ -0,0 +1,3 @@ +access: granted +data: '{"userinfo":{"name":"test-user@email.com","uuid":"saved-user-id","dir":"/home/test-user@email.com","shell":"/usr/bin/bash","gecos":"test-user@email.com","groups":[{"name":"old-group","ugid":""}]}}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_refreshes_expired_token/data/provider_url/test-user@email.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_refreshes_expired_token/data/provider_url/test-user@email.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_refreshes_expired_token/data/provider_url/test-user@email.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_refreshes_expired_token/data/provider_url/test-user@email.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_refreshes_expired_token/data/provider_url/test-user@email.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_refreshes_expired_token/data/provider_url/test-user@email.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_refreshes_expired_token/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_refreshes_expired_token/first_call new file mode 100644 index 0000000000..f50b5eb551 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_refreshes_expired_token/first_call @@ -0,0 +1,3 @@ +access: granted +data: '{"userinfo":{"name":"test-user@email.com","uuid":"test-user-id","dir":"/home/test-user@email.com","shell":"/usr/bin/bash","gecos":"test-user@email.com","groups":[{"name":"remote-test-group","ugid":"12345"},{"name":"local-test-group","ugid":""}]}}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_refreshes_groups/data/provider_url/test-user@email.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_refreshes_groups/data/provider_url/test-user@email.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_refreshes_groups/data/provider_url/test-user@email.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_refreshes_groups/data/provider_url/test-user@email.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_refreshes_groups/data/provider_url/test-user@email.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_refreshes_groups/data/provider_url/test-user@email.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_refreshes_groups/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_refreshes_groups/first_call new file mode 100644 index 0000000000..0d246a68ad --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_refreshes_groups/first_call @@ -0,0 +1,3 @@ +access: granted +data: '{"userinfo":{"name":"test-user@email.com","uuid":"test-user-id","dir":"/home/test-user@email.com","shell":"/usr/bin/bash","gecos":"test-user@email.com","groups":[{"name":"refreshed-group","ugid":""}]}}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_still_allowed_if_no_refresh_token_and_server_is_unreachable/data/provider_url/test-user@email.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_still_allowed_if_no_refresh_token_and_server_is_unreachable/data/provider_url/test-user@email.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_still_allowed_if_no_refresh_token_and_server_is_unreachable/data/provider_url/test-user@email.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_still_allowed_if_no_refresh_token_and_server_is_unreachable/data/provider_url/test-user@email.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_still_allowed_if_no_refresh_token_and_server_is_unreachable/data/provider_url/test-user@email.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_still_allowed_if_no_refresh_token_and_server_is_unreachable/data/provider_url/test-user@email.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_still_allowed_if_no_refresh_token_and_server_is_unreachable/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_still_allowed_if_no_refresh_token_and_server_is_unreachable/first_call new file mode 100644 index 0000000000..aca00f387f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_still_allowed_if_no_refresh_token_and_server_is_unreachable/first_call @@ -0,0 +1,3 @@ +access: granted +data: '{"userinfo":{"name":"test-user@email.com","uuid":"saved-user-id","dir":"/home/test-user@email.com","shell":"/usr/bin/bash","gecos":"test-user@email.com","groups":[{"name":"old-group","ugid":""}]}}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_still_allowed_if_server_is_unreachable/data/provider_url/test-user@email.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_still_allowed_if_server_is_unreachable/data/provider_url/test-user@email.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_still_allowed_if_server_is_unreachable/data/provider_url/test-user@email.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_still_allowed_if_server_is_unreachable/data/provider_url/test-user@email.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_still_allowed_if_server_is_unreachable/data/provider_url/test-user@email.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_still_allowed_if_server_is_unreachable/data/provider_url/test-user@email.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_still_allowed_if_server_is_unreachable/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_still_allowed_if_server_is_unreachable/first_call new file mode 100644 index 0000000000..2928c3a331 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_still_allowed_if_server_is_unreachable/first_call @@ -0,0 +1,3 @@ +access: granted +data: '{"userinfo":{"name":"test-user@email.com","uuid":"saved-user-id","dir":"/home/test-user@email.com","shell":"/usr/bin/bash","gecos":"test-user@email.com","groups":[{"name":"saved-remote-group","ugid":"12345"},{"name":"saved-local-group","ugid":""}]}}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_still_allowed_if_token_is_expired_and_server_is_unreachable/data/provider_url/test-user@email.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_still_allowed_if_token_is_expired_and_server_is_unreachable/data/provider_url/test-user@email.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_still_allowed_if_token_is_expired_and_server_is_unreachable/data/provider_url/test-user@email.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_still_allowed_if_token_is_expired_and_server_is_unreachable/data/provider_url/test-user@email.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_still_allowed_if_token_is_expired_and_server_is_unreachable/data/provider_url/test-user@email.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_still_allowed_if_token_is_expired_and_server_is_unreachable/data/provider_url/test-user@email.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_still_allowed_if_token_is_expired_and_server_is_unreachable/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_still_allowed_if_token_is_expired_and_server_is_unreachable/first_call new file mode 100644 index 0000000000..2928c3a331 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_still_allowed_if_token_is_expired_and_server_is_unreachable/first_call @@ -0,0 +1,3 @@ +access: granted +data: '{"userinfo":{"name":"test-user@email.com","uuid":"saved-user-id","dir":"/home/test-user@email.com","shell":"/usr/bin/bash","gecos":"test-user@email.com","groups":[{"name":"saved-remote-group","ugid":"12345"},{"name":"saved-local-group","ugid":""}]}}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_no_refresh_token_results_in_device_auth_as_next_mode/data/provider_url/test-user@email.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_no_refresh_token_results_in_device_auth_as_next_mode/data/provider_url/test-user@email.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_no_refresh_token_results_in_device_auth_as_next_mode/data/provider_url/test-user@email.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_no_refresh_token_results_in_device_auth_as_next_mode/data/provider_url/test-user@email.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_no_refresh_token_results_in_device_auth_as_next_mode/data/provider_url/test-user@email.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_no_refresh_token_results_in_device_auth_as_next_mode/data/provider_url/test-user@email.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_no_refresh_token_results_in_device_auth_as_next_mode/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_no_refresh_token_results_in_device_auth_as_next_mode/first_call new file mode 100644 index 0000000000..7b2d780726 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_no_refresh_token_results_in_device_auth_as_next_mode/first_call @@ -0,0 +1,3 @@ +access: next +data: '{"message":"Remote authentication failed: No refresh token. Please contact your administrator."}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_no_refresh_token_results_in_device_auth_as_next_mode/second_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_no_refresh_token_results_in_device_auth_as_next_mode/second_call new file mode 100644 index 0000000000..d0887a134f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_no_refresh_token_results_in_device_auth_as_next_mode/second_call @@ -0,0 +1,3 @@ +access: next +data: '{}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_provider_authentication_is_forced/data/provider_url/test-user@email.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_provider_authentication_is_forced/data/provider_url/test-user@email.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_provider_authentication_is_forced/data/provider_url/test-user@email.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_provider_authentication_is_forced/data/provider_url/test-user@email.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_provider_authentication_is_forced/data/provider_url/test-user@email.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_provider_authentication_is_forced/data/provider_url/test-user@email.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_provider_authentication_is_forced/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_provider_authentication_is_forced/first_call new file mode 100644 index 0000000000..f50b5eb551 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_provider_authentication_is_forced/first_call @@ -0,0 +1,3 @@ +access: granted +data: '{"userinfo":{"name":"test-user@email.com","uuid":"test-user-id","dir":"/home/test-user@email.com","shell":"/usr/bin/bash","gecos":"test-user@email.com","groups":[{"name":"remote-test-group","ugid":"12345"},{"name":"local-test-group","ugid":""}]}}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_provider_supports_device_registration/data/provider_url/test-user@email.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_provider_supports_device_registration/data/provider_url/test-user@email.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_provider_supports_device_registration/data/provider_url/test-user@email.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_provider_supports_device_registration/data/provider_url/test-user@email.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_provider_supports_device_registration/data/provider_url/test-user@email.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_provider_supports_device_registration/data/provider_url/test-user@email.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_provider_supports_device_registration/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_provider_supports_device_registration/first_call new file mode 100644 index 0000000000..f50b5eb551 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_provider_supports_device_registration/first_call @@ -0,0 +1,3 @@ +access: granted +data: '{"userinfo":{"name":"test-user@email.com","uuid":"test-user-id","dir":"/home/test-user@email.com","shell":"/usr/bin/bash","gecos":"test-user@email.com","groups":[{"name":"remote-test-group","ugid":"12345"},{"name":"local-test-group","ugid":""}]}}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_refresh_token_is_expired_results_in_device_auth_as_next_mode/data/provider_url/test-user@email.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_refresh_token_is_expired_results_in_device_auth_as_next_mode/data/provider_url/test-user@email.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_refresh_token_is_expired_results_in_device_auth_as_next_mode/data/provider_url/test-user@email.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_refresh_token_is_expired_results_in_device_auth_as_next_mode/data/provider_url/test-user@email.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_refresh_token_is_expired_results_in_device_auth_as_next_mode/data/provider_url/test-user@email.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_refresh_token_is_expired_results_in_device_auth_as_next_mode/data/provider_url/test-user@email.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_refresh_token_is_expired_results_in_device_auth_as_next_mode/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_refresh_token_is_expired_results_in_device_auth_as_next_mode/first_call new file mode 100644 index 0000000000..7cfe409ecb --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_refresh_token_is_expired_results_in_device_auth_as_next_mode/first_call @@ -0,0 +1,3 @@ +access: next +data: '{"message":"Refresh token expired, please authenticate again using device authentication."}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_refresh_token_is_expired_results_in_device_auth_as_next_mode/second_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_refresh_token_is_expired_results_in_device_auth_as_next_mode/second_call new file mode 100644 index 0000000000..d0887a134f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_refresh_token_is_expired_results_in_device_auth_as_next_mode/second_call @@ -0,0 +1,3 @@ +access: next +data: '{}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_qrcode_reacquires_token/data/provider_url/test-user@email.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_qrcode_reacquires_token/data/provider_url/test-user@email.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_qrcode_reacquires_token/data/provider_url/test-user@email.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_qrcode_reacquires_token/data/provider_url/test-user@email.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_qrcode_reacquires_token/data/provider_url/test-user@email.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_qrcode_reacquires_token/data/provider_url/test-user@email.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_qrcode_reacquires_token/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_qrcode_reacquires_token/first_call new file mode 100644 index 0000000000..d0887a134f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_qrcode_reacquires_token/first_call @@ -0,0 +1,3 @@ +access: next +data: '{}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_qrcode_reacquires_token/second_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_qrcode_reacquires_token/second_call new file mode 100644 index 0000000000..f50b5eb551 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_qrcode_reacquires_token/second_call @@ -0,0 +1,3 @@ +access: granted +data: '{"userinfo":{"name":"test-user@email.com","uuid":"test-user-id","dir":"/home/test-user@email.com","shell":"/usr/bin/bash","gecos":"test-user@email.com","groups":[{"name":"remote-test-group","ugid":"12345"},{"name":"local-test-group","ugid":""}]}}' +err: diff --git a/internal/users/db/testdata/golden/TestMigrationToLowercaseUserAndGroupNamesEmptyDB/groups b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_IsAuthenticated_is_ongoing_for_session/data/.empty similarity index 100% rename from internal/users/db/testdata/golden/TestMigrationToLowercaseUserAndGroupNamesEmptyDB/groups rename to authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_IsAuthenticated_is_ongoing_for_session/data/.empty diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_IsAuthenticated_is_ongoing_for_session/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_IsAuthenticated_is_ongoing_for_session/first_call new file mode 100644 index 0000000000..d0887a134f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_IsAuthenticated_is_ongoing_for_session/first_call @@ -0,0 +1,3 @@ +access: next +data: '{}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_IsAuthenticated_is_ongoing_for_session/second_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_IsAuthenticated_is_ongoing_for_session/second_call new file mode 100644 index 0000000000..1cd265a88e --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_IsAuthenticated_is_ongoing_for_session/second_call @@ -0,0 +1,3 @@ +access: denied +data: '{}' +err: authentication already running for this user session diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_authentication_data_is_invalid/data/.empty b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_authentication_data_is_invalid/data/.empty new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_authentication_data_is_invalid/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_authentication_data_is_invalid/first_call new file mode 100644 index 0000000000..1ed5d6d1d0 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_authentication_data_is_invalid/first_call @@ -0,0 +1,3 @@ +access: denied +data: '{}' +err: 'authentication data is not a valid json value: invalid character ''i'' looking for beginning of value' diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_can_not_cache_token/data/.empty b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_can_not_cache_token/data/.empty new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_can_not_cache_token/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_can_not_cache_token/first_call new file mode 100644 index 0000000000..d0887a134f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_can_not_cache_token/first_call @@ -0,0 +1,3 @@ +access: next +data: '{}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_can_not_cache_token/second_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_can_not_cache_token/second_call new file mode 100644 index 0000000000..a329231079 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_can_not_cache_token/second_call @@ -0,0 +1,3 @@ +access: denied +data: '{"message":"An unexpected error occurred: failed to store password. Please report this error on https://github.com/canonical/authd/issues"}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_device_is_disabled_and_session_is_offline/data/provider_url/test-user@email.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_device_is_disabled_and_session_is_offline/data/provider_url/test-user@email.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_device_is_disabled_and_session_is_offline/data/provider_url/test-user@email.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_device_is_disabled_and_session_is_offline/data/provider_url/test-user@email.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_device_is_disabled_and_session_is_offline/data/provider_url/test-user@email.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_device_is_disabled_and_session_is_offline/data/provider_url/test-user@email.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_device_is_disabled_and_session_is_offline/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_device_is_disabled_and_session_is_offline/first_call new file mode 100644 index 0000000000..6d17fa8918 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_device_is_disabled_and_session_is_offline/first_call @@ -0,0 +1,3 @@ +access: denied +data: '{"message":"This device is disabled in Microsoft Entra ID. Please contact your administrator or try again with a working network connection."}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_empty_secret_is_provided_for_local_password/data/.empty b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_empty_secret_is_provided_for_local_password/data/.empty new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_empty_secret_is_provided_for_local_password/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_empty_secret_is_provided_for_local_password/first_call new file mode 100644 index 0000000000..d0887a134f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_empty_secret_is_provided_for_local_password/first_call @@ -0,0 +1,3 @@ +access: next +data: '{}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_empty_secret_is_provided_for_local_password/second_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_empty_secret_is_provided_for_local_password/second_call new file mode 100644 index 0000000000..3629f92c63 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_empty_secret_is_provided_for_local_password/second_call @@ -0,0 +1,3 @@ +access: retry +data: '{"message":"An unexpected error occurred: empty secret. Please report this error on https://github.com/canonical/authd/issues"}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_invalid/data/.empty b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_invalid/data/.empty new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_invalid/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_invalid/first_call new file mode 100644 index 0000000000..d55df3d2e4 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_invalid/first_call @@ -0,0 +1,3 @@ +access: denied +data: '{"message":"An unexpected error occurred: unknown authentication mode. Please report this error on https://github.com/canonical/authd/issues"}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_link_code_and_can_not_get_token/data/.empty b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_link_code_and_can_not_get_token/data/.empty new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_link_code_and_can_not_get_token/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_link_code_and_can_not_get_token/first_call new file mode 100644 index 0000000000..20ac2d889e --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_link_code_and_can_not_get_token/first_call @@ -0,0 +1,3 @@ +access: retry +data: '{"message":"Error retrieving access token. Please try again."}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_link_code_and_can_not_get_token_due_to_timeout/data/.empty b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_link_code_and_can_not_get_token_due_to_timeout/data/.empty new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_link_code_and_can_not_get_token_due_to_timeout/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_link_code_and_can_not_get_token_due_to_timeout/first_call new file mode 100644 index 0000000000..20ac2d889e --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_link_code_and_can_not_get_token_due_to_timeout/first_call @@ -0,0 +1,3 @@ +access: retry +data: '{"message":"Error retrieving access token. Please try again."}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_link_code_and_link_expires/data/.empty b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_link_code_and_link_expires/data/.empty new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_link_code_and_link_expires/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_link_code_and_link_expires/first_call new file mode 100644 index 0000000000..20ac2d889e --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_link_code_and_link_expires/first_call @@ -0,0 +1,3 @@ +access: retry +data: '{"message":"Error retrieving access token. Please try again."}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_newpassword_and_session_has_no_token/data/.empty b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_newpassword_and_session_has_no_token/data/.empty new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_newpassword_and_session_has_no_token/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_newpassword_and_session_has_no_token/first_call new file mode 100644 index 0000000000..af5a5ed71f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_newpassword_and_session_has_no_token/first_call @@ -0,0 +1,3 @@ +access: denied +data: '{"message":"An unexpected error occurred: auth info is not set. Please report this error on https://github.com/canonical/authd/issues"}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_and_no_refresh_token/data/provider_url/test-user@email.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_and_no_refresh_token/data/provider_url/test-user@email.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_and_no_refresh_token/data/provider_url/test-user@email.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_and_no_refresh_token/data/provider_url/test-user@email.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_and_no_refresh_token/data/provider_url/test-user@email.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_and_no_refresh_token/data/provider_url/test-user@email.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_and_no_refresh_token/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_and_no_refresh_token/first_call new file mode 100644 index 0000000000..7b2d780726 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_and_no_refresh_token/first_call @@ -0,0 +1,3 @@ +access: next +data: '{"message":"Remote authentication failed: No refresh token. Please contact your administrator."}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_and_token_does_not_exist/data/.empty b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_and_token_does_not_exist/data/.empty new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_and_token_does_not_exist/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_and_token_does_not_exist/first_call new file mode 100644 index 0000000000..e28fc80a14 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_and_token_does_not_exist/first_call @@ -0,0 +1,3 @@ +access: denied +data: '{"message":"An unexpected error occurred: could not check password. Please report this error on https://github.com/canonical/authd/issues"}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_and_token_is_invalid/data/provider_url/test-user@email.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_and_token_is_invalid/data/provider_url/test-user@email.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_and_token_is_invalid/data/provider_url/test-user@email.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_and_token_is_invalid/data/provider_url/test-user@email.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_and_token_is_invalid/data/provider_url/test-user@email.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_and_token_is_invalid/data/provider_url/test-user@email.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_and_token_is_invalid/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_and_token_is_invalid/first_call new file mode 100644 index 0000000000..c57483aac8 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_and_token_is_invalid/first_call @@ -0,0 +1,3 @@ +access: denied +data: '{"message":"An unexpected error occurred: could not load stored token. Please report this error on https://github.com/canonical/authd/issues"}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_and_token_refresh_times_out/data/provider_url/test-user@email.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_and_token_refresh_times_out/data/provider_url/test-user@email.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_and_token_refresh_times_out/data/provider_url/test-user@email.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_and_token_refresh_times_out/data/provider_url/test-user@email.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_and_token_refresh_times_out/data/provider_url/test-user@email.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_and_token_refresh_times_out/data/provider_url/test-user@email.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_and_token_refresh_times_out/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_and_token_refresh_times_out/first_call new file mode 100644 index 0000000000..9ddc2b8193 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_and_token_refresh_times_out/first_call @@ -0,0 +1,3 @@ +access: denied +data: '{"message":"Failed to refresh token"}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_but_server_returns_error/data/provider_url/test-user@email.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_but_server_returns_error/data/provider_url/test-user@email.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_but_server_returns_error/data/provider_url/test-user@email.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_but_server_returns_error/data/provider_url/test-user@email.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_but_server_returns_error/data/provider_url/test-user@email.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_but_server_returns_error/data/provider_url/test-user@email.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_but_server_returns_error/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_but_server_returns_error/first_call new file mode 100644 index 0000000000..9ddc2b8193 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_password_but_server_returns_error/first_call @@ -0,0 +1,3 @@ +access: denied +data: '{"message":"Failed to refresh token"}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_qrcode_and_can_not_get_token/data/.empty b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_qrcode_and_can_not_get_token/data/.empty new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_qrcode_and_can_not_get_token/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_qrcode_and_can_not_get_token/first_call new file mode 100644 index 0000000000..20ac2d889e --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_qrcode_and_can_not_get_token/first_call @@ -0,0 +1,3 @@ +access: retry +data: '{"message":"Error retrieving access token. Please try again."}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_qrcode_and_can_not_get_token_due_to_timeout/data/.empty b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_qrcode_and_can_not_get_token_due_to_timeout/data/.empty new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_qrcode_and_can_not_get_token_due_to_timeout/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_qrcode_and_can_not_get_token_due_to_timeout/first_call new file mode 100644 index 0000000000..20ac2d889e --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_qrcode_and_can_not_get_token_due_to_timeout/first_call @@ -0,0 +1,3 @@ +access: retry +data: '{"message":"Error retrieving access token. Please try again."}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_qrcode_and_link_expires/data/.empty b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_qrcode_and_link_expires/data/.empty new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_qrcode_and_link_expires/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_qrcode_and_link_expires/first_call new file mode 100644 index 0000000000..20ac2d889e --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_mode_is_qrcode_and_link_expires/first_call @@ -0,0 +1,3 @@ +access: retry +data: '{"message":"Error retrieving access token. Please try again."}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_provided_wrong_secret/data/provider_url/test-user@email.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_provided_wrong_secret/data/provider_url/test-user@email.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_provided_wrong_secret/data/provider_url/test-user@email.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_provided_wrong_secret/data/provider_url/test-user@email.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_provided_wrong_secret/data/provider_url/test-user@email.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_provided_wrong_secret/data/provider_url/test-user@email.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_provided_wrong_secret/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_provided_wrong_secret/first_call new file mode 100644 index 0000000000..ba81768a7a --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_provided_wrong_secret/first_call @@ -0,0 +1,3 @@ +access: retry +data: '{"message":"Incorrect password, please try again."}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_provider_authentication_is_forced_and_session_is_offline/data/provider_url/test-user@email.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_provider_authentication_is_forced_and_session_is_offline/data/provider_url/test-user@email.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_provider_authentication_is_forced_and_session_is_offline/data/provider_url/test-user@email.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_provider_authentication_is_forced_and_session_is_offline/data/provider_url/test-user@email.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_provider_authentication_is_forced_and_session_is_offline/data/provider_url/test-user@email.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_provider_authentication_is_forced_and_session_is_offline/data/provider_url/test-user@email.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_provider_authentication_is_forced_and_session_is_offline/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_provider_authentication_is_forced_and_session_is_offline/first_call new file mode 100644 index 0000000000..6df9858167 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_provider_authentication_is_forced_and_session_is_offline/first_call @@ -0,0 +1,3 @@ +access: denied +data: '{"message":"Remote authentication failed: identity provider is not reachable"}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_secret_can_not_be_decrypted/data/.empty b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_secret_can_not_be_decrypted/data/.empty new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_secret_can_not_be_decrypted/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_secret_can_not_be_decrypted/first_call new file mode 100644 index 0000000000..b5e4d86786 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_secret_can_not_be_decrypted/first_call @@ -0,0 +1,3 @@ +access: retry +data: '{"message":"An unexpected error occurred: could not decode secret. Please report this error on https://github.com/canonical/authd/issues"}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_selected_username_does_not_match_the_provider_one/data/.empty b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_selected_username_does_not_match_the_provider_one/data/.empty new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_selected_username_does_not_match_the_provider_one/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_selected_username_does_not_match_the_provider_one/first_call new file mode 100644 index 0000000000..7ade8c427e --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_selected_username_does_not_match_the_provider_one/first_call @@ -0,0 +1,3 @@ +access: denied +data: '{"message":"Authentication failure: requested username \"not-matching\" does not match the authenticated user \"test-user@email.com\""}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_token_is_expired_and_refreshing_token_fails/data/provider_url/test-user@email.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_token_is_expired_and_refreshing_token_fails/data/provider_url/test-user@email.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_token_is_expired_and_refreshing_token_fails/data/provider_url/test-user@email.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_token_is_expired_and_refreshing_token_fails/data/provider_url/test-user@email.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_token_is_expired_and_refreshing_token_fails/data/provider_url/test-user@email.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_token_is_expired_and_refreshing_token_fails/data/provider_url/test-user@email.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_token_is_expired_and_refreshing_token_fails/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_token_is_expired_and_refreshing_token_fails/first_call new file mode 100644 index 0000000000..7b2d780726 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_token_is_expired_and_refreshing_token_fails/first_call @@ -0,0 +1,3 @@ +access: next +data: '{"message":"Remote authentication failed: No refresh token. Please contact your administrator."}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_user_is_disabled_and_session_is_offline/data/provider_url/test-user@email.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_user_is_disabled_and_session_is_offline/data/provider_url/test-user@email.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_user_is_disabled_and_session_is_offline/data/provider_url/test-user@email.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_user_is_disabled_and_session_is_offline/data/provider_url/test-user@email.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_user_is_disabled_and_session_is_offline/data/provider_url/test-user@email.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_user_is_disabled_and_session_is_offline/data/provider_url/test-user@email.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_user_is_disabled_and_session_is_offline/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_user_is_disabled_and_session_is_offline/first_call new file mode 100644 index 0000000000..a2db4ae326 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_user_is_disabled_and_session_is_offline/first_call @@ -0,0 +1,3 @@ +access: denied +data: '{"message":"This user is disabled in Microsoft Entra ID. Please contact your administrator or try again with a working network connection."}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Extra_groups_configured/data/provider_url/test-user@email.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Extra_groups_configured/data/provider_url/test-user@email.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Extra_groups_configured/data/provider_url/test-user@email.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Extra_groups_configured/data/provider_url/test-user@email.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Extra_groups_configured/data/provider_url/test-user@email.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Extra_groups_configured/data/provider_url/test-user@email.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Extra_groups_configured/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Extra_groups_configured/first_call new file mode 100644 index 0000000000..c767dfccbf --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Extra_groups_configured/first_call @@ -0,0 +1,3 @@ +access: granted +data: '{"userinfo":{"name":"test-user@email.com","uuid":"test-user-id","dir":"/home/test-user@email.com","shell":"/usr/bin/bash","gecos":"test-user@email.com","groups":[{"name":"remote-group","ugid":""},{"name":"extra-group","ugid":""}]}}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Owner_extra_groups_configured/data/provider_url/test-user@email.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Owner_extra_groups_configured/data/provider_url/test-user@email.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Owner_extra_groups_configured/data/provider_url/test-user@email.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Owner_extra_groups_configured/data/provider_url/test-user@email.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Owner_extra_groups_configured/data/provider_url/test-user@email.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Owner_extra_groups_configured/data/provider_url/test-user@email.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Owner_extra_groups_configured/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Owner_extra_groups_configured/first_call new file mode 100644 index 0000000000..26add22993 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Owner_extra_groups_configured/first_call @@ -0,0 +1,3 @@ +access: granted +data: '{"userinfo":{"name":"test-user@email.com","uuid":"test-user-id","dir":"/home/test-user@email.com","shell":"/usr/bin/bash","gecos":"test-user@email.com","groups":[{"name":"remote-group","ugid":""},{"name":"owner-group","ugid":""}]}}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Owner_extra_groups_configured_but_user_does_not_become_owner/data/provider_url/test-user@email.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Owner_extra_groups_configured_but_user_does_not_become_owner/data/provider_url/test-user@email.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Owner_extra_groups_configured_but_user_does_not_become_owner/data/provider_url/test-user@email.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Owner_extra_groups_configured_but_user_does_not_become_owner/data/provider_url/test-user@email.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Owner_extra_groups_configured_but_user_does_not_become_owner/data/provider_url/test-user@email.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Owner_extra_groups_configured_but_user_does_not_become_owner/data/provider_url/test-user@email.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Owner_extra_groups_configured_but_user_does_not_become_owner/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Owner_extra_groups_configured_but_user_does_not_become_owner/first_call new file mode 100644 index 0000000000..e97c996716 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Owner_extra_groups_configured_but_user_does_not_become_owner/first_call @@ -0,0 +1,3 @@ +access: granted +data: '{"userinfo":{"name":"test-user@email.com","uuid":"test-user-id","dir":"/home/test-user@email.com","shell":"/usr/bin/bash","gecos":"test-user@email.com","groups":[{"name":"remote-group","ugid":""}]}}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Successfully_authenticate_user_with_device_auth_and_newpassword/data/provider_url/test-user@email.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Successfully_authenticate_user_with_device_auth_and_newpassword/data/provider_url/test-user@email.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Successfully_authenticate_user_with_device_auth_and_newpassword/data/provider_url/test-user@email.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Successfully_authenticate_user_with_device_auth_and_newpassword/data/provider_url/test-user@email.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Successfully_authenticate_user_with_device_auth_and_newpassword/data/provider_url/test-user@email.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Successfully_authenticate_user_with_device_auth_and_newpassword/data/provider_url/test-user@email.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Successfully_authenticate_user_with_device_auth_and_newpassword/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Successfully_authenticate_user_with_device_auth_and_newpassword/first_call new file mode 100644 index 0000000000..d0887a134f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Successfully_authenticate_user_with_device_auth_and_newpassword/first_call @@ -0,0 +1,3 @@ +access: next +data: '{}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Successfully_authenticate_user_with_device_auth_and_newpassword/second_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Successfully_authenticate_user_with_device_auth_and_newpassword/second_call new file mode 100644 index 0000000000..f50b5eb551 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Successfully_authenticate_user_with_device_auth_and_newpassword/second_call @@ -0,0 +1,3 @@ +access: granted +data: '{"userinfo":{"name":"test-user@email.com","uuid":"test-user-id","dir":"/home/test-user@email.com","shell":"/usr/bin/bash","gecos":"test-user@email.com","groups":[{"name":"remote-test-group","ugid":"12345"},{"name":"local-test-group","ugid":""}]}}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Successfully_authenticate_user_with_password/data/provider_url/test-user@email.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Successfully_authenticate_user_with_password/data/provider_url/test-user@email.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Successfully_authenticate_user_with_password/data/provider_url/test-user@email.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Successfully_authenticate_user_with_password/data/provider_url/test-user@email.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Successfully_authenticate_user_with_password/data/provider_url/test-user@email.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Successfully_authenticate_user_with_password/data/provider_url/test-user@email.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Successfully_authenticate_user_with_password/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Successfully_authenticate_user_with_password/first_call new file mode 100644 index 0000000000..f50b5eb551 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Successfully_authenticate_user_with_password/first_call @@ -0,0 +1,3 @@ +access: granted +data: '{"userinfo":{"name":"test-user@email.com","uuid":"test-user-id","dir":"/home/test-user@email.com","shell":"/usr/bin/bash","gecos":"test-user@email.com","groups":[{"name":"remote-test-group","ugid":"12345"},{"name":"local-test-group","ugid":""}]}}' +err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Do_not_fail_if_values_contain_a_single_template_delimiter/config.txt b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Do_not_fail_if_values_contain_a_single_template_delimiter/config.txt new file mode 100644 index 0000000000..c68cb5a100 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Do_not_fail_if_values_contain_a_single_template_delimiter/config.txt @@ -0,0 +1,15 @@ +clientID= +forceProviderAuthentication=false +registerDevice=false +allowedUsers=map[] +allUsersAllowed=false +ownerAllowed=true +firstUserBecomesOwner=true +owner= +homeBaseDir= +allowedSSHSuffixes=[] +extraGroups=[] +ownerExtraGroups=[] +extraScopes=[] \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Successfully_parse_config_file/config.txt b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Successfully_parse_config_file/config.txt new file mode 100644 index 0000000000..ce860f6ee7 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Successfully_parse_config_file/config.txt @@ -0,0 +1,15 @@ +clientID=client_id +clientSecret= +issuerURL=https://issuer.url.com +forceProviderAuthentication=false +registerDevice=false +allowedUsers=map[] +allUsersAllowed=false +ownerAllowed=true +firstUserBecomesOwner=true +owner= +homeBaseDir= +allowedSSHSuffixes=[] +extraGroups=[] +ownerExtraGroups=[] +extraScopes=[] \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Successfully_parse_config_file_with_optional_values/config.txt b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Successfully_parse_config_file_with_optional_values/config.txt new file mode 100644 index 0000000000..186c12ac12 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Successfully_parse_config_file_with_optional_values/config.txt @@ -0,0 +1,15 @@ +clientID=client_id +clientSecret= +issuerURL=https://issuer.url.com +forceProviderAuthentication=true +registerDevice=false +allowedUsers=map[] +allUsersAllowed=false +ownerAllowed=true +firstUserBecomesOwner=true +owner= +homeBaseDir=/home +allowedSSHSuffixes=[] +extraGroups=[] +ownerExtraGroups=[] +extraScopes=[groups offline_access some_other_scope] \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Successfully_parse_config_with_drop_in_files/config.txt b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Successfully_parse_config_with_drop_in_files/config.txt new file mode 100644 index 0000000000..984aee7a70 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Successfully_parse_config_with_drop_in_files/config.txt @@ -0,0 +1,15 @@ +clientID=lower_precedence_client_id +clientSecret= +issuerURL=https://higher-precedence-issuer.url.com +forceProviderAuthentication=true +registerDevice=false +allowedUsers=map[] +allUsersAllowed=false +ownerAllowed=true +firstUserBecomesOwner=true +owner= +homeBaseDir=/home +allowedSSHSuffixes=[] +extraGroups=[] +ownerExtraGroups=[] +extraScopes=[groups offline_access some_other_scope] \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/All_are_allowed/broker.conf b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/All_are_allowed/broker.conf new file mode 100644 index 0000000000..18badfb1e6 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/All_are_allowed/broker.conf @@ -0,0 +1,10 @@ + +[oidc] +issuer = https://issuer.url.com +client_id = client_id + +[users] +allowed_users = ALL +owner = machine_owner +home_base_dir = /home +allowed_ssh_suffixes_first_auth = @issuer.url.com diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/All_are_allowed/broker.conf.d/.empty b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/All_are_allowed/broker.conf.d/.empty new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/By_default_only_owner_is_allowed/broker.conf b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/By_default_only_owner_is_allowed/broker.conf new file mode 100644 index 0000000000..1c8f8105bf --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/By_default_only_owner_is_allowed/broker.conf @@ -0,0 +1,9 @@ + +[oidc] +issuer = https://issuer.url.com +client_id = client_id + +[users] +owner = machine_owner +home_base_dir = /home +allowed_ssh_suffixes_first_auth = @issuer.url.com diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/By_default_only_owner_is_allowed/broker.conf.d/.empty b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/By_default_only_owner_is_allowed/broker.conf.d/.empty new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/Only_owner_is_allowed/broker.conf b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/Only_owner_is_allowed/broker.conf new file mode 100644 index 0000000000..4146b36d1c --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/Only_owner_is_allowed/broker.conf @@ -0,0 +1,10 @@ + +[oidc] +issuer = https://issuer.url.com +client_id = client_id + +[users] +allowed_users = OWNER +owner = machine_owner +home_base_dir = /home +allowed_ssh_suffixes_first_auth = @issuer.url.com diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/Only_owner_is_allowed/broker.conf.d/.empty b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/Only_owner_is_allowed/broker.conf.d/.empty new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/Only_owner_is_allowed_but_is_empty/broker.conf b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/Only_owner_is_allowed_but_is_empty/broker.conf new file mode 100644 index 0000000000..6fedf4fead --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/Only_owner_is_allowed_but_is_empty/broker.conf @@ -0,0 +1,9 @@ + +[oidc] +issuer = https://issuer.url.com +client_id = client_id + +[users] +owner = +home_base_dir = /home +allowed_ssh_suffixes_first_auth = @issuer.url.com diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/Only_owner_is_allowed_but_is_empty/broker.conf.d/.empty b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/Only_owner_is_allowed_but_is_empty/broker.conf.d/.empty new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/Only_owner_is_allowed_but_is_unset/broker.conf b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/Only_owner_is_allowed_but_is_unset/broker.conf new file mode 100644 index 0000000000..7657c12ad9 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/Only_owner_is_allowed_but_is_unset/broker.conf @@ -0,0 +1,8 @@ + +[oidc] +issuer = https://issuer.url.com +client_id = client_id + +[users] +home_base_dir = /home +allowed_ssh_suffixes_first_auth = @issuer.url.com diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/Only_owner_is_allowed_but_is_unset/broker.conf.d/.empty b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/Only_owner_is_allowed_but_is_unset/broker.conf.d/.empty new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/Set_owner_and_u1_is_allowed/broker.conf b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/Set_owner_and_u1_is_allowed/broker.conf new file mode 100644 index 0000000000..4d5ee91a36 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/Set_owner_and_u1_is_allowed/broker.conf @@ -0,0 +1,10 @@ + +[oidc] +issuer = https://issuer.url.com +client_id = client_id + +[users] +allowed_users = OWNER,u1 +owner = machine_owner +home_base_dir = /home +allowed_ssh_suffixes_first_auth = @issuer.url.com diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/Set_owner_and_u1_is_allowed/broker.conf.d/.empty b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/Set_owner_and_u1_is_allowed/broker.conf.d/.empty new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/Support_old_suffixes_key/broker.conf b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/Support_old_suffixes_key/broker.conf new file mode 100644 index 0000000000..b777f7fc85 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/Support_old_suffixes_key/broker.conf @@ -0,0 +1,10 @@ + +[oidc] +issuer = https://issuer.url.com +client_id = client_id + +[users] +allowed_users = ALL +owner = machine_owner +home_base_dir = /home +allowed_ssh_suffixes = @issuer.url.com diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/Support_old_suffixes_key/broker.conf.d/.empty b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/Support_old_suffixes_key/broker.conf.d/.empty new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/Unset_owner_and_u1_is_allowed/broker.conf b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/Unset_owner_and_u1_is_allowed/broker.conf new file mode 100644 index 0000000000..39fc9aa19b --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/Unset_owner_and_u1_is_allowed/broker.conf @@ -0,0 +1,9 @@ + +[oidc] +issuer = https://issuer.url.com +client_id = client_id + +[users] +allowed_users = OWNER,u1 +home_base_dir = /home +allowed_ssh_suffixes_first_auth = @issuer.url.com diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/Unset_owner_and_u1_is_allowed/broker.conf.d/.empty b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/Unset_owner_and_u1_is_allowed/broker.conf.d/.empty new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/Users_u1_and_u2_are_allowed/broker.conf b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/Users_u1_and_u2_are_allowed/broker.conf new file mode 100644 index 0000000000..fd4ce484d7 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/Users_u1_and_u2_are_allowed/broker.conf @@ -0,0 +1,9 @@ + +[oidc] +issuer = https://issuer.url.com +client_id = client_id + +[users] +allowed_users = u1,u2 +home_base_dir = /home +allowed_ssh_suffixes_first_auth = @issuer.url.com diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/Users_u1_and_u2_are_allowed/broker.conf.d/.empty b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseUserConfig/Users_u1_and_u2_are_allowed/broker.conf.d/.empty new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestRegisterOwner/broker.conf b/authd-oidc-brokers/internal/broker/testdata/golden/TestRegisterOwner/broker.conf new file mode 100644 index 0000000000..aa6cfd3e38 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestRegisterOwner/broker.conf @@ -0,0 +1,4 @@ + +[oidc] +issuer = https://issuer.url.com +client_id = client_id diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestRegisterOwner/broker.conf.d/20-owner-autoregistration.conf b/authd-oidc-brokers/internal/broker/testdata/golden/TestRegisterOwner/broker.conf.d/20-owner-autoregistration.conf new file mode 100644 index 0000000000..f6c5a43bef --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestRegisterOwner/broker.conf.d/20-owner-autoregistration.conf @@ -0,0 +1,13 @@ +## This file was generated automatically by the broker. DO NOT EDIT. +## +## This file registers the first authenticated user as the owner of +## this device. +## +## The 'owner' option is only considered for authentication if +## 'allowed_users' contains the 'OWNER' keyword. +## +## To register a different owner for the machine on the next +## successful authentication, delete this file. + +[users] +owner = owner_name diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestSelectAuthenticationMode/Selected_newpassword_shows_correct_label_in_passwd_session b/authd-oidc-brokers/internal/broker/testdata/golden/TestSelectAuthenticationMode/Selected_newpassword_shows_correct_label_in_passwd_session new file mode 100644 index 0000000000..31798b45da --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestSelectAuthenticationMode/Selected_newpassword_shows_correct_label_in_passwd_session @@ -0,0 +1,3 @@ +entry: chars_password +label: Update your local password +type: newpassword diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestSelectAuthenticationMode/Successfully_select_device_auth b/authd-oidc-brokers/internal/broker/testdata/golden/TestSelectAuthenticationMode/Successfully_select_device_auth new file mode 100644 index 0000000000..7eb58122b9 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestSelectAuthenticationMode/Successfully_select_device_auth @@ -0,0 +1,6 @@ +button: Request new code +code: user_code +content: https://verification_uri.com +label: Open the URL and enter the code below. +type: qrcode +wait: "true" diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestSelectAuthenticationMode/Successfully_select_device_auth_qr b/authd-oidc-brokers/internal/broker/testdata/golden/TestSelectAuthenticationMode/Successfully_select_device_auth_qr new file mode 100644 index 0000000000..848a4175a7 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestSelectAuthenticationMode/Successfully_select_device_auth_qr @@ -0,0 +1,6 @@ +button: Request new code +code: user_code +content: https://verification_uri.com +label: Scan the QR code or open the URL and enter the code below. +type: qrcode +wait: "true" diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestSelectAuthenticationMode/Successfully_select_newpassword b/authd-oidc-brokers/internal/broker/testdata/golden/TestSelectAuthenticationMode/Successfully_select_newpassword new file mode 100644 index 0000000000..246a5f3b8d --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestSelectAuthenticationMode/Successfully_select_newpassword @@ -0,0 +1,3 @@ +entry: chars_password +label: Create a local password +type: newpassword diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestSelectAuthenticationMode/Successfully_select_password b/authd-oidc-brokers/internal/broker/testdata/golden/TestSelectAuthenticationMode/Successfully_select_password new file mode 100644 index 0000000000..f5c748db40 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestSelectAuthenticationMode/Successfully_select_password @@ -0,0 +1,3 @@ +entry: chars_password +label: Enter your local password +type: form diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestUserPreCheck/Empty_userinfo_if_allowed_suffixes_has_only_empty_string b/authd-oidc-brokers/internal/broker/testdata/golden/TestUserPreCheck/Empty_userinfo_if_allowed_suffixes_has_only_empty_string new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestUserPreCheck/Empty_userinfo_if_no_allowed_suffixes_are_provided b/authd-oidc-brokers/internal/broker/testdata/golden/TestUserPreCheck/Empty_userinfo_if_no_allowed_suffixes_are_provided new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestUserPreCheck/Empty_userinfo_if_username_does_not_match_allowed_suffix b/authd-oidc-brokers/internal/broker/testdata/golden/TestUserPreCheck/Empty_userinfo_if_username_does_not_match_allowed_suffix new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestUserPreCheck/Empty_userinfo_if_username_does_not_match_any_of_the_allowed_suffixes b/authd-oidc-brokers/internal/broker/testdata/golden/TestUserPreCheck/Empty_userinfo_if_username_does_not_match_any_of_the_allowed_suffixes new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestUserPreCheck/Return_userinfo_with_correct_homedir_after_precheck b/authd-oidc-brokers/internal/broker/testdata/golden/TestUserPreCheck/Return_userinfo_with_correct_homedir_after_precheck new file mode 100644 index 0000000000..fadbc5c469 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestUserPreCheck/Return_userinfo_with_correct_homedir_after_precheck @@ -0,0 +1 @@ +{"name":"user@allowed","uuid":"","dir":"/home/allowed/user@allowed","shell":"/usr/bin/bash","gecos":"user@allowed","groups":null} \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestUserPreCheck/Successfully_allow_username_if_suffix_has_asterisk b/authd-oidc-brokers/internal/broker/testdata/golden/TestUserPreCheck/Successfully_allow_username_if_suffix_has_asterisk new file mode 100644 index 0000000000..1c1ddfb545 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestUserPreCheck/Successfully_allow_username_if_suffix_has_asterisk @@ -0,0 +1 @@ +{"name":"user@allowed","uuid":"","dir":"/home/user@allowed","shell":"/usr/bin/bash","gecos":"user@allowed","groups":null} \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestUserPreCheck/Successfully_allow_username_if_suffix_is_allow_all b/authd-oidc-brokers/internal/broker/testdata/golden/TestUserPreCheck/Successfully_allow_username_if_suffix_is_allow_all new file mode 100644 index 0000000000..6a420fc5e0 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestUserPreCheck/Successfully_allow_username_if_suffix_is_allow_all @@ -0,0 +1 @@ +{"name":"user@doesnotmatter","uuid":"","dir":"/home/user@doesnotmatter","shell":"/usr/bin/bash","gecos":"user@doesnotmatter","groups":null} \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestUserPreCheck/Successfully_allow_username_ignoring_empty_string_in_config b/authd-oidc-brokers/internal/broker/testdata/golden/TestUserPreCheck/Successfully_allow_username_ignoring_empty_string_in_config new file mode 100644 index 0000000000..1c1ddfb545 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestUserPreCheck/Successfully_allow_username_ignoring_empty_string_in_config @@ -0,0 +1 @@ +{"name":"user@allowed","uuid":"","dir":"/home/user@allowed","shell":"/usr/bin/bash","gecos":"user@allowed","groups":null} \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestUserPreCheck/Successfully_allow_username_that_matches_at_least_one_allowed_suffix b/authd-oidc-brokers/internal/broker/testdata/golden/TestUserPreCheck/Successfully_allow_username_that_matches_at_least_one_allowed_suffix new file mode 100644 index 0000000000..1c1ddfb545 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestUserPreCheck/Successfully_allow_username_that_matches_at_least_one_allowed_suffix @@ -0,0 +1 @@ +{"name":"user@allowed","uuid":"","dir":"/home/user@allowed","shell":"/usr/bin/bash","gecos":"user@allowed","groups":null} \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestUserPreCheck/Successfully_allow_username_with_matching_allowed_suffix b/authd-oidc-brokers/internal/broker/testdata/golden/TestUserPreCheck/Successfully_allow_username_with_matching_allowed_suffix new file mode 100644 index 0000000000..1c1ddfb545 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestUserPreCheck/Successfully_allow_username_with_matching_allowed_suffix @@ -0,0 +1 @@ +{"name":"user@allowed","uuid":"","dir":"/home/user@allowed","shell":"/usr/bin/bash","gecos":"user@allowed","groups":null} \ No newline at end of file diff --git a/authd-oidc-brokers/internal/consts/consts.go b/authd-oidc-brokers/internal/consts/consts.go new file mode 100644 index 0000000000..0d04593cf6 --- /dev/null +++ b/authd-oidc-brokers/internal/consts/consts.go @@ -0,0 +1,40 @@ +// Package consts defines the constants used by the project. +package consts + +import ( + "github.com/coreos/go-oidc/v3/oidc" + "github.com/ubuntu/authd/log" +) + +var ( + // Version is the version of the executable. + Version = "Dev" +) + +const ( + // TEXTDOMAIN is the gettext domain for l10n. + TEXTDOMAIN = "authd-oidc" + + // DefaultLevelLog is the default logging level selected without any option. + DefaultLevelLog = log.WarnLevel + + // The application ID of the Microsoft-owned Azure Portal app. + azurePortalAppID = "c44b4083-3bb0-49c1-b47d-974e53cbdf3c" + // The ".default" scope for the Azure Portal app. + azurePortalScope = azurePortalAppID + "/.default" + + // MicrosoftBrokerAppID is the application ID of the Microsoft-owned Microsoft Authentication Broker app. + // This app is used in OAuth 2.0 authentication to acquire a token with the ".default" scope for the Azure Portal app. + // That token can then be used to acquire a token for device registration. + MicrosoftBrokerAppID = "29d9ed98-a469-4536-ade2-f981bc1d605e" +) + +var ( + // MicrosoftBrokerAppScopes contains the OIDC scopes that we require for the Microsoft Authentication Broker app. + // The ".default" scope for the Azure Portal app is needed to acquire a token for device registration. + MicrosoftBrokerAppScopes = []string{oidc.ScopeOpenID, "profile", oidc.ScopeOfflineAccess, azurePortalScope} + + // DefaultScopes contains the OIDC scopes that we require for all providers. + // Provider implementations can append additional scopes. + DefaultScopes = []string{oidc.ScopeOpenID, "profile", "email"} +) diff --git a/authd-oidc-brokers/internal/consts/google.go b/authd-oidc-brokers/internal/consts/google.go new file mode 100644 index 0000000000..5393e11731 --- /dev/null +++ b/authd-oidc-brokers/internal/consts/google.go @@ -0,0 +1,10 @@ +//go:build withgoogle + +package consts + +const ( + // DbusName owned by the broker for authd to contact us. + DbusName = "com.ubuntu.authd.Google" + // DbusObject main object path for authd to contact us. + DbusObject = "/com/ubuntu/authd/Google" +) diff --git a/authd-oidc-brokers/internal/consts/msentraid.go b/authd-oidc-brokers/internal/consts/msentraid.go new file mode 100644 index 0000000000..29532acc22 --- /dev/null +++ b/authd-oidc-brokers/internal/consts/msentraid.go @@ -0,0 +1,10 @@ +//go:build withmsentraid + +package consts + +const ( + // DbusName owned by the broker for authd to contact us. + DbusName = "com.ubuntu.authd.MSEntraID" + // DbusObject main object path for authd to contact us. + DbusObject = "/com/ubuntu/authd/MSEntraID" +) diff --git a/authd-oidc-brokers/internal/consts/oidc.go b/authd-oidc-brokers/internal/consts/oidc.go new file mode 100644 index 0000000000..7bb571c74d --- /dev/null +++ b/authd-oidc-brokers/internal/consts/oidc.go @@ -0,0 +1,10 @@ +//go:build !withgoogle && !withmsentraid + +package consts + +const ( + // DbusName owned by the broker for authd to contact us. + DbusName = "com.ubuntu.authd.Oidc" + // DbusObject main object path for authd to contact us. + DbusObject = "/com/ubuntu/authd/Oidc" +) diff --git a/authd-oidc-brokers/internal/daemon/daemon.go b/authd-oidc-brokers/internal/daemon/daemon.go new file mode 100644 index 0000000000..50082d620c --- /dev/null +++ b/authd-oidc-brokers/internal/daemon/daemon.go @@ -0,0 +1,82 @@ +// Package daemon handles the dbus daemon with systemd support. +package daemon + +import ( + "context" + "fmt" + + "github.com/coreos/go-systemd/daemon" + "github.com/ubuntu/authd/log" + "github.com/ubuntu/decorate" +) + +// Daemon is a grpc daemon with systemd support. +type Daemon struct { + service Service + + systemdSdNotifier systemdSdNotifier +} + +type options struct { + // private member that we export for tests. + systemdSdNotifier func(unsetEnvironment bool, state string) (bool, error) +} + +type systemdSdNotifier func(unsetEnvironment bool, state string) (bool, error) + +// Option is the function signature used to tweak the daemon creation. +type Option func(*options) + +// Service is a server that can Serve and be Stopped by our daemon. +type Service interface { + Addr() string + Serve() error + Stop() error +} + +// New returns an new, initialized daemon server, which handles systemd activation. +// If systemd activation is used, it will override any socket passed here. +func New(ctx context.Context, service Service, args ...Option) (d *Daemon, err error) { + defer decorate.OnError(&err, "can't create daemon") + + log.Debug(context.Background(), "Building new daemon") + + // Set default options. + opts := options{ + systemdSdNotifier: daemon.SdNotify, + } + // Apply given args. + for _, f := range args { + f(&opts) + } + + return &Daemon{ + service: service, + + systemdSdNotifier: opts.systemdSdNotifier, + }, nil +} + +// Serve signals systemd that we are ready to receive from the service. +func (d *Daemon) Serve(ctx context.Context) (err error) { + defer decorate.OnError(&err, "error while serving") + + log.Debug(context.Background(), "Starting to serve requests") + + // Signal to systemd that we are ready. + if sent, err := d.systemdSdNotifier(false, "READY=1"); err != nil { + return fmt.Errorf("couldn't send ready notification to systemd: %v", err) + } else if sent { + log.Debug(context.Background(), "Ready state sent to systemd") + } + + log.Infof(context.Background(), "Serving requests as %v", d.service.Addr()) + return d.service.Serve() +} + +// Quit gracefully quits listening loop and stops the grpc server. +// It can drops any existing connexion is force is true. +func (d Daemon) Quit() { + log.Info(context.Background(), "Stopping daemon requested.") + _ = d.service.Stop() +} diff --git a/authd-oidc-brokers/internal/dbusservice/dbusservice.go b/authd-oidc-brokers/internal/dbusservice/dbusservice.go new file mode 100644 index 0000000000..d9b1c52648 --- /dev/null +++ b/authd-oidc-brokers/internal/dbusservice/dbusservice.go @@ -0,0 +1,118 @@ +// Package dbusservice is the dbus service implementation delegating its functional call to brokers. +package dbusservice + +import ( + "context" + "fmt" + + "github.com/canonical/authd/authd-oidc-brokers/internal/broker" + "github.com/canonical/authd/authd-oidc-brokers/internal/consts" + "github.com/godbus/dbus/v5" + "github.com/godbus/dbus/v5/introspect" +) + +const intro = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ` + introspect.IntrospectDataString + ` ` + +// Service is the handler exposing our broker methods on the system bus. +type Service struct { + name string + broker *broker.Broker + + serve chan struct{} + disconnect func() +} + +// New returns a new dbus service after exporting to the system bus our name. +func New(_ context.Context, broker *broker.Broker) (s *Service, err error) { + name := consts.DbusName + object := dbus.ObjectPath(consts.DbusObject) + iface := "com.ubuntu.authd.Broker" + s = &Service{ + name: name, + broker: broker, + serve: make(chan struct{}), + } + + conn, err := s.getBus() + if err != nil { + return nil, err + } + + if err := conn.Export(s, object, iface); err != nil { + return nil, err + } + if err := conn.Export(introspect.Introspectable(fmt.Sprintf(intro, iface)), object, "org.freedesktop.DBus.Introspectable"); err != nil { + return nil, err + } + + reply, err := conn.RequestName(consts.DbusName, dbus.NameFlagDoNotQueue) + if err != nil { + s.disconnect() + return nil, err + } + if reply != dbus.RequestNameReplyPrimaryOwner { + s.disconnect() + return nil, fmt.Errorf("%q is already taken in the bus", name) + } + + return s, nil +} + +// Addr returns the address of the service. +func (s *Service) Addr() string { + return s.name +} + +// Serve wait for the service. +func (s *Service) Serve() error { + <-s.serve + return nil +} + +// Stop stop the service and do all the necessary cleanup operation. +func (s *Service) Stop() error { + // Check if already stopped. + select { + case <-s.serve: + default: + close(s.serve) + s.disconnect() + } + return nil +} diff --git a/authd-oidc-brokers/internal/dbusservice/localbus.go b/authd-oidc-brokers/internal/dbusservice/localbus.go new file mode 100644 index 0000000000..f496f7ae5a --- /dev/null +++ b/authd-oidc-brokers/internal/dbusservice/localbus.go @@ -0,0 +1,32 @@ +// TiCS: disabled // This is a helper file for tests. + +//go:build withlocalbus + +package dbusservice + +import ( + "os" + + "github.com/canonical/authd/authd-oidc-brokers/internal/testutils" + "github.com/godbus/dbus/v5" +) + +// getBus creates the local bus and returns a connection to the bus. +// It attaches a disconnect handler to stop the local bus subprocess. +func (s *Service) getBus() (*dbus.Conn, error) { + cleanup, err := testutils.StartSystemBusMock() + if err != nil { + return nil, err + } + log.Infof(context.Background(), "Using local bus address: %s", os.Getenv("DBUS_SYSTEM_BUS_ADDRESS")) + conn, err := dbus.ConnectSystemBus() + if err != nil { + return nil, err + } + + s.disconnect = func() { + conn.Close() + cleanup() + } + return conn, err +} diff --git a/authd-oidc-brokers/internal/dbusservice/methods.go b/authd-oidc-brokers/internal/dbusservice/methods.go new file mode 100644 index 0000000000..3a12774694 --- /dev/null +++ b/authd-oidc-brokers/internal/dbusservice/methods.go @@ -0,0 +1,92 @@ +package dbusservice + +import ( + "context" + "errors" + + "github.com/canonical/authd/authd-oidc-brokers/internal/broker" + "github.com/godbus/dbus/v5" + "github.com/ubuntu/authd/log" +) + +// NewSession is the method through which the broker and the daemon will communicate once dbusInterface.NewSession is called. +func (s *Service) NewSession(username, lang, mode string) (sessionID, encryptionKey string, dbusErr *dbus.Error) { + log.Debugf(context.Background(), "Creating new session (username=%s, lang=%s, mode=%s)", username, lang, mode) + sessionID, encryptionKey, err := s.broker.NewSession(username, lang, mode) + if err != nil { + return "", "", dbus.MakeFailedError(err) + } + log.Debugf(context.Background(), "Created new session %s", sessionID) + return sessionID, encryptionKey, nil +} + +// GetAuthenticationModes is the method through which the broker and the daemon will communicate once dbusInterface.GetAuthenticationModes is called. +func (s *Service) GetAuthenticationModes(sessionID string, supportedUILayouts []map[string]string) (authenticationModes []map[string]string, dbusErr *dbus.Error) { + log.Debugf(context.Background(), "Getting authentication modes for session %s", sessionID) + authenticationModes, err := s.broker.GetAuthenticationModes(sessionID, supportedUILayouts) + if err != nil { + return nil, dbus.MakeFailedError(err) + } + log.Debugf(context.Background(), "Got authentication modes for session %s: %v", sessionID, authenticationModes) + return authenticationModes, nil +} + +// SelectAuthenticationMode is the method through which the broker and the daemon will communicate once dbusInterface.SelectAuthenticationMode is called. +func (s *Service) SelectAuthenticationMode(sessionID, authenticationModeName string) (uiLayoutInfo map[string]string, dbusErr *dbus.Error) { + log.Debugf(context.Background(), "Selecting authentication mode %s for session %s", authenticationModeName, sessionID) + uiLayoutInfo, err := s.broker.SelectAuthenticationMode(sessionID, authenticationModeName) + if err != nil { + return nil, dbus.MakeFailedError(err) + } + log.Debugf(context.Background(), "Selected authentication mode %s for session %s: %v", authenticationModeName, sessionID, uiLayoutInfo) + return uiLayoutInfo, nil +} + +// IsAuthenticated is the method through which the broker and the daemon will communicate once dbusInterface.IsAuthenticated is called. +func (s *Service) IsAuthenticated(sessionID, authenticationData string) (access, data string, dbusErr *dbus.Error) { + // Do *not* log authenticationData here, because it may contain the user's password in cleartext. + log.Debugf(context.Background(), "Handling IsAuthenticated call for session %s", sessionID) + access, data, err := s.broker.IsAuthenticated(sessionID, authenticationData) + if errors.Is(err, context.Canceled) { + return access, data, makeCanceledError() + } + if err != nil { + log.Warningf(context.Background(), "IsAuthenticated error: %v", err) + return broker.AuthDenied, "", dbus.MakeFailedError(err) + } + log.Debugf(context.Background(), "IsAuthenticated result (session %s): %s, %s", sessionID, access, data) + return access, data, nil +} + +// EndSession is the method through which the broker and the daemon will communicate once dbusInterface.EndSession is called. +func (s *Service) EndSession(sessionID string) (dbusErr *dbus.Error) { + log.Debugf(context.Background(), "Ending session %s", sessionID) + err := s.broker.EndSession(sessionID) + if err != nil { + return dbus.MakeFailedError(err) + } + return nil +} + +// CancelIsAuthenticated is the method through which the broker and the daemon will communicate once dbusInterface.CancelIsAuthenticated is called. +func (s *Service) CancelIsAuthenticated(sessionID string) (dbusErr *dbus.Error) { + log.Debugf(context.Background(), "Cancelling IsAuthenticated call for session %s", sessionID) + s.broker.CancelIsAuthenticated(sessionID) + return nil +} + +// UserPreCheck is the method through which the broker and the daemon will communicate once dbusInterface.UserPreCheck is called. +func (s *Service) UserPreCheck(username string) (userinfo string, dbusErr *dbus.Error) { + log.Debugf(context.Background(), "UserPreCheck: %s", username) + userinfo, err := s.broker.UserPreCheck(username) + if err != nil { + return "", dbus.MakeFailedError(err) + } + log.Debugf(context.Background(), "UserPreCheck result: %s", userinfo) + return userinfo, nil +} + +// makeCanceledError creates a dbus.Error for a canceled operation. +func makeCanceledError() *dbus.Error { + return &dbus.Error{Name: "com.ubuntu.authd.Canceled"} +} diff --git a/authd-oidc-brokers/internal/dbusservice/systembus.go b/authd-oidc-brokers/internal/dbusservice/systembus.go new file mode 100644 index 0000000000..7ac7a6a0dd --- /dev/null +++ b/authd-oidc-brokers/internal/dbusservice/systembus.go @@ -0,0 +1,18 @@ +//go:build !withlocalbus + +package dbusservice + +import ( + "github.com/godbus/dbus/v5" +) + +// getBus returns the system bus and attach a disconnect handler. +func (s *Service) getBus() (*dbus.Conn, error) { + conn, err := dbus.ConnectSystemBus() + if err != nil { + return nil, err + } + s.disconnect = func() { _ = conn.Close() } + + return conn, nil +} diff --git a/authd-oidc-brokers/internal/fileutils/fileutils.go b/authd-oidc-brokers/internal/fileutils/fileutils.go new file mode 100644 index 0000000000..584aeef45c --- /dev/null +++ b/authd-oidc-brokers/internal/fileutils/fileutils.go @@ -0,0 +1,45 @@ +// Package fileutils provides utility functions for file operations. +package fileutils + +import ( + "errors" + "io" + "os" +) + +// FileExists checks if a file exists at the given path. +func FileExists(path string) (bool, error) { + _, err := os.Stat(path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return false, err + } + return !errors.Is(err, os.ErrNotExist), nil +} + +// IsDirEmpty checks if the specified directory is empty. +func IsDirEmpty(path string) (bool, error) { + f, err := os.Open(path) + if err != nil { + return false, err + } + defer f.Close() + + _, err = f.Readdirnames(1) + if errors.Is(err, io.EOF) { + return true, nil + } + return false, err +} + +// Touch creates an empty file at the given path, if it doesn't already exist. +func Touch(path string) error { + file, err := os.OpenFile(path, os.O_RDONLY|os.O_CREATE, 0o600) + if err != nil && !errors.Is(err, os.ErrExist) { + return err + } + err = file.Close() + if err != nil { + return err + } + return nil +} diff --git a/authd-oidc-brokers/internal/fileutils/fileutils_test.go b/authd-oidc-brokers/internal/fileutils/fileutils_test.go new file mode 100644 index 0000000000..069d4a7482 --- /dev/null +++ b/authd-oidc-brokers/internal/fileutils/fileutils_test.go @@ -0,0 +1,156 @@ +package fileutils_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/canonical/authd/authd-oidc-brokers/internal/fileutils" + "github.com/stretchr/testify/require" +) + +func TestFileExists(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + name string + fileExists bool + parentDirIsFile bool + + wantExists bool + wantError bool + }{ + "Returns_true_when_file_exists": {fileExists: true, wantExists: true}, + "Returns_false_when_file_does_not_exist": {fileExists: false, wantExists: false}, + "Returns_false_when_parent_directory_does_not_exist": {fileExists: false, wantExists: false}, + + "Error_when_parent_directory_is_a_file": {parentDirIsFile: true, wantError: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + path := filepath.Join(tempDir, "file") + if tc.fileExists { + err := fileutils.Touch(path) + require.NoError(t, err, "Touch should not return an error") + } + if tc.parentDirIsFile { + path = filepath.Join(tempDir, "file", "file") + err := fileutils.Touch(filepath.Join(tempDir, "file")) + require.NoError(t, err, "Touch should not return an error") + } + + exists, err := fileutils.FileExists(path) + if tc.wantError { + require.Error(t, err, "FileExists should return an error") + } else { + require.NoError(t, err, "FileExists should not return an error") + } + require.Equal(t, tc.wantExists, exists, "FileExists should return the expected result") + }) + } +} + +func TestIsDirEmpty(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + isEmpty bool + isFile bool + doesNotExist bool + + wantEmpty bool + wantError bool + }{ + "Returns_true_when_directory_is_empty": {isEmpty: true, wantEmpty: true}, + "Returns_false_when_directory_is_not_empty": {wantEmpty: false}, + + "Error_when_directory_does_not_exist": {doesNotExist: true, wantError: true}, + "Error_when_directory_is_a_file": {isFile: true, wantError: true}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + path := filepath.Join(tempDir, "dir") + + if !tc.doesNotExist { + err := os.Mkdir(path, 0o700) + require.NoError(t, err, "Mkdir should not return an error") + } + + if !tc.isEmpty && !tc.doesNotExist && !tc.isFile { + err := fileutils.Touch(filepath.Join(tempDir, "dir", "file")) + require.NoError(t, err, "Touch should not return an error") + } + if tc.isFile { + path = filepath.Join(tempDir, "file") + err := fileutils.Touch(path) + require.NoError(t, err, "Touch should not return an error") + } + + empty, err := fileutils.IsDirEmpty(path) + if tc.wantError { + require.Error(t, err, "IsDirEmpty should return an error") + } else { + require.NoError(t, err, "IsDirEmpty should not return an error") + } + require.Equal(t, tc.wantEmpty, empty, "IsDirEmpty should return the expected result") + }) + } +} + +func TestTouch(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + name string + fileExists bool + fileIsDir bool + parentDoesNotExist bool + + wantError bool + }{ + "Creates_file_when_it_does_not_exist": {fileExists: false}, + "Does_not_return_error_when_file_already_exists": {fileExists: true}, + + "Returns_error_when_file_is_a_directory": {fileIsDir: true, wantError: true}, + "Returns_error_when_parent_directory_does_not_exist": {parentDoesNotExist: true, wantError: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + path := filepath.Join(tempDir, "file") + + if tc.fileExists && !tc.fileIsDir { + err := fileutils.Touch(path) + require.NoError(t, err, "Touch should not return an error") + } + + if tc.fileIsDir { + path = filepath.Join(tempDir, "dir") + err := os.Mkdir(path, 0o700) + require.NoError(t, err, "Mkdir should not return an error") + } + + if tc.parentDoesNotExist { + path = filepath.Join(tempDir, "dir", "file") + } + + err := fileutils.Touch(path) + if tc.wantError { + require.Error(t, err, "Touch should return an error") + return + } + + require.NoError(t, err, "Touch should not return an error") + }) + } +} diff --git a/authd-oidc-brokers/internal/password/password.go b/authd-oidc-brokers/internal/password/password.go new file mode 100644 index 0000000000..93477d3efa --- /dev/null +++ b/authd-oidc-brokers/internal/password/password.go @@ -0,0 +1,59 @@ +// Package password provides functions for creating and using the hashed password file. +package password + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "os" + "path/filepath" + "slices" + + "golang.org/x/crypto/argon2" +) + +// HashAndStorePassword hashes the password and stores it in the data directory. +func HashAndStorePassword(password, path string) error { + // Ensure that the password file's parent directory exists. + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("could not create password parent directory: %w", err) + } + + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return fmt.Errorf("could not generate salt: %w", err) + } + + hash := hashPassword(password, salt) + s := base64.StdEncoding.EncodeToString(append(salt, hash...)) + if err := os.WriteFile(path, []byte(s), 0o600); err != nil { + return fmt.Errorf("could not store password: %w", err) + } + + return nil +} + +// CheckPassword checks if the provided password matches the hash stored in the password file. +func CheckPassword(password, path string) (bool, error) { + data, err := os.ReadFile(path) + if err != nil { + return false, fmt.Errorf("could not read password file: %w", err) + } + + decoded, err := base64.StdEncoding.DecodeString(string(data)) + if err != nil { + return false, fmt.Errorf("could not decode password: %w", err) + } + + salt, hash := decoded[:16], decoded[16:] + if !slices.Equal(hash, hashPassword(password, salt)) { + return false, nil + } + + return true, nil +} + +func hashPassword(password string, salt []byte) []byte { + // If you change these parameters, update the section in the security overview doc. + return argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32) +} diff --git a/authd-oidc-brokers/internal/password/password_test.go b/authd-oidc-brokers/internal/password/password_test.go new file mode 100644 index 0000000000..5da3031314 --- /dev/null +++ b/authd-oidc-brokers/internal/password/password_test.go @@ -0,0 +1,106 @@ +package password_test + +import ( + "encoding/base64" + "os" + "path/filepath" + "testing" + + "github.com/canonical/authd/authd-oidc-brokers/internal/fileutils" + "github.com/canonical/authd/authd-oidc-brokers/internal/password" + "github.com/stretchr/testify/require" +) + +func TestHashAndStorePassword(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + password string + path string + pathExists bool + parentDirExists bool + + wantErr bool + }{ + "Success_when_password_file_and_parent_dir_do_not_exist_yet": {password: "test123"}, + "Success_when_parent_directory_already_exists": {password: "test123", parentDirExists: true}, + "Success_when_password_file_already_exists": {password: "test123", pathExists: true}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + if tc.pathExists { + // The parent directory must also exist for the file to exist. + tc.parentDirExists = true + } + + parentDir := t.TempDir() + if !tc.parentDirExists { + err := os.Remove(parentDir) + require.NoError(t, err, "Removing parent directory failed") + } + path := filepath.Join(parentDir, "password") + + if tc.pathExists { + err := fileutils.Touch(path) + require.NoError(t, err, "Creating empty password file failed") + } + + err := password.HashAndStorePassword(tc.password, path) + if err != nil { + t.Fatalf("HashAndStorePassword() failed: %v", err) + } + }) + } +} + +func TestCheckPassword(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + password string + pathToRead string + writeGarbage bool + + wantMatch bool + expectedError error + }{ + "Success_when_password_matches": {password: "test123", wantMatch: true}, + "No_match_when_password_does_not_match": {password: "not-test123", wantMatch: false}, + + "Error_when_password_file_does_not_exist": {password: "test123", pathToRead: "nonexistent", expectedError: os.ErrNotExist}, + "Error_when_password_file_contains_garbage": {password: "test123", writeGarbage: true, expectedError: base64.CorruptInputError(0)}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + parentDir := t.TempDir() + path := filepath.Join(parentDir, "password") + + if tc.pathToRead == "" { + tc.pathToRead = path + } + + err := password.HashAndStorePassword("test123", path) + require.NoError(t, err, "HashAndStorePassword() failed") + + if tc.writeGarbage { + err := os.WriteFile(path, []byte{0x00}, 0o600) + require.NoError(t, err, "Writing garbage to password file failed") + } + + match, err := password.CheckPassword(tc.password, tc.pathToRead) + if tc.expectedError != nil { + require.ErrorIs(t, err, tc.expectedError, "CheckPassword() failed") + } else { + require.NoError(t, err, "CheckPassword() failed") + } + + require.Equal(t, tc.wantMatch, match, "CheckPassword() returned unexpected result") + }) + } +} diff --git a/authd-oidc-brokers/internal/providers/default.go b/authd-oidc-brokers/internal/providers/default.go new file mode 100644 index 0000000000..a8251bad51 --- /dev/null +++ b/authd-oidc-brokers/internal/providers/default.go @@ -0,0 +1,12 @@ +//go:build !withgoogle && !withmsentraid + +package providers + +import ( + "github.com/canonical/authd/authd-oidc-brokers/internal/providers/genericprovider" +) + +// CurrentProvider returns a generic oidc provider implementation. +func CurrentProvider() Provider { + return genericprovider.New() +} diff --git a/authd-oidc-brokers/internal/providers/errors/errors.go b/authd-oidc-brokers/internal/providers/errors/errors.go new file mode 100644 index 0000000000..2058e2ac1c --- /dev/null +++ b/authd-oidc-brokers/internal/providers/errors/errors.go @@ -0,0 +1,20 @@ +// Package errors provides custom error types which can be returned by the providers +// +// The package name conflicts with `errors` from the standard library. +// That's not ideal, but we're planning a major refactoring of the broker and +// provider packages in the future, so it's not worth the effort to fix this now. +package errors + +// ForDisplayError is an error type for errors that are meant to be displayed to the user. +type ForDisplayError struct { + Message string + Err error +} + +func (e *ForDisplayError) Error() string { + return e.Message +} + +func (e *ForDisplayError) Unwrap() error { + return e.Err +} diff --git a/authd-oidc-brokers/internal/providers/genericprovider/genericprovider.go b/authd-oidc-brokers/internal/providers/genericprovider/genericprovider.go new file mode 100644 index 0000000000..4278b5f4ae --- /dev/null +++ b/authd-oidc-brokers/internal/providers/genericprovider/genericprovider.go @@ -0,0 +1,140 @@ +// Package genericprovider is the generic oidc extension. +package genericprovider + +import ( + "context" + "fmt" + "strings" + + "github.com/canonical/authd/authd-oidc-brokers/internal/broker/authmodes" + providerErrors "github.com/canonical/authd/authd-oidc-brokers/internal/providers/errors" + "github.com/canonical/authd/authd-oidc-brokers/internal/providers/info" + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +// GenericProvider is a generic OIDC provider. +type GenericProvider struct{} + +// New returns a new GenericProvider. +func New() GenericProvider { + return GenericProvider{} +} + +// AdditionalScopes returns the generic scopes required by the provider. +func (p GenericProvider) AdditionalScopes() []string { + return []string{} +} + +// AuthOptions is a no-op when no specific provider is in use. +func (p GenericProvider) AuthOptions() []oauth2.AuthCodeOption { + return []oauth2.AuthCodeOption{} +} + +// GetExtraFields returns the extra fields of the token which should be stored persistently. +func (p GenericProvider) GetExtraFields(token *oauth2.Token) map[string]interface{} { + return nil +} + +// GetMetadata is a no-op when no specific provider is in use. +func (p GenericProvider) GetMetadata(provider *oidc.Provider) (map[string]interface{}, error) { + return nil, nil +} + +// GetUserInfo is a no-op when no specific provider is in use. +func (p GenericProvider) GetUserInfo(idToken info.Claimer) (info.User, error) { + userClaims, err := p.userClaims(idToken) + if err != nil { + return info.User{}, err + } + + if userClaims.Sub == "" { + return info.User{}, fmt.Errorf("authentication failure: sub claim is missing in the ID token") + } + + if userClaims.Email == "" { + return info.User{}, fmt.Errorf("authentication failure: email claim is missing in the ID token") + } + + if !userClaims.EmailVerified { + return info.User{}, &providerErrors.ForDisplayError{Message: "Authentication failure: email not verified"} + } + + return info.NewUser( + userClaims.Email, + userClaims.Home, + userClaims.Sub, + userClaims.Shell, + userClaims.Gecos, + nil, + ), nil +} + +// GetGroups is a no-op when no specific provider is in use. +func (GenericProvider) GetGroups(ctx context.Context, clientID string, issuerURL string, token *oauth2.Token, providerMetadata map[string]interface{}, deviceRegistrationData []byte) ([]info.Group, error) { + return nil, nil +} + +// NormalizeUsername parses a username into a normalized version. +func (p GenericProvider) NormalizeUsername(username string) string { + return username +} + +// VerifyUsername checks if the requested username matches the authenticated user. +func (p GenericProvider) VerifyUsername(requestedUsername, username string) error { + if p.NormalizeUsername(requestedUsername) != p.NormalizeUsername(username) { + msg := fmt.Sprintf("Authentication failure: requested username %q does not match the authenticated user %q", requestedUsername, username) + return &providerErrors.ForDisplayError{Message: msg} + } + return nil +} + +// SupportedOIDCAuthModes returns the OIDC authentication modes supported by the provider. +func (p GenericProvider) SupportedOIDCAuthModes() []string { + return []string{authmodes.Device, authmodes.DeviceQr} +} + +type claims struct { + Email string `json:"email"` + Sub string `json:"sub"` + Home string `json:"home"` + Shell string `json:"shell"` + Gecos string `json:"gecos"` + EmailVerified bool `json:"email_verified"` +} + +// userClaims returns the user claims parsed from the ID token. +func (p GenericProvider) userClaims(idToken info.Claimer) (claims, error) { + var userClaims claims + if err := idToken.Claims(&userClaims); err != nil { + return claims{}, fmt.Errorf("failed to get ID token claims: %v", err) + } + return userClaims, nil +} + +// IsTokenExpiredError returns true if the reason for the error is that the refresh token is expired. +func (p GenericProvider) IsTokenExpiredError(err *oauth2.RetrieveError) bool { + // TODO: This is an msentraid specific error code and description. + // Change it to the ones from Google once we know them. + return err.ErrorCode == "invalid_grant" && strings.HasPrefix(err.ErrorDescription, "AADSTS50173:") +} + +// IsUserDisabledError returns false, as the generic provider does not support disabling users. +func (p GenericProvider) IsUserDisabledError(_ *oauth2.RetrieveError) bool { + return false +} + +// SupportsDeviceRegistration returns false, as the generic provider does not support device registration. +func (p GenericProvider) SupportsDeviceRegistration() bool { + return false +} + +// IsTokenForDeviceRegistration returns false, as the generic provider does not support device registration. +func (p GenericProvider) IsTokenForDeviceRegistration(_ *oauth2.Token) (bool, error) { + return false, nil +} + +// MaybeRegisterDevice is a no-op when no specific provider is in use. +func (p GenericProvider) MaybeRegisterDevice(_ context.Context, _ *oauth2.Token, _, _ string, _ []byte) ([]byte, func(), error) { + return nil, func() {}, nil +} diff --git a/authd-oidc-brokers/internal/providers/genericprovider/genericprovider_test.go b/authd-oidc-brokers/internal/providers/genericprovider/genericprovider_test.go new file mode 100644 index 0000000000..2f7fb7cd32 --- /dev/null +++ b/authd-oidc-brokers/internal/providers/genericprovider/genericprovider_test.go @@ -0,0 +1,114 @@ +package genericprovider_test + +import ( + "encoding/json" + "fmt" + "testing" + + providerErrors "github.com/canonical/authd/authd-oidc-brokers/internal/providers/errors" + "github.com/canonical/authd/authd-oidc-brokers/internal/providers/genericprovider" + "github.com/canonical/authd/authd-oidc-brokers/internal/providers/info" + "github.com/stretchr/testify/require" +) + +func TestGetUserInfo(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + claims map[string]interface{} + wantUser info.User + wantErr bool + wantErrType error + }{ + "Successfully_get_user_info_with_all_fields": { + claims: map[string]interface{}{ + "sub": "sub123", + "email": "user@example.com", + "email_verified": true, + "home": "/home/user", + "shell": "/bin/bash", + "gecos": "Test User", + }, + wantUser: info.NewUser("user@example.com", "/home/user", "sub123", "/bin/bash", "Test User", nil), + }, + "Successfully_get_user_info_with_minimal_fields": { + claims: map[string]interface{}{ + "sub": "sub123", + "email": "user@example.com", + "email_verified": true, + }, + wantUser: info.NewUser("user@example.com", "", "sub123", "", "", nil), + }, + + "Error_when_sub_is_missing": { + claims: map[string]interface{}{ + "email": "user@example.com", + "email_verified": false, + }, + wantErr: true, + }, + "Error_when_email_is_missing": { + claims: map[string]interface{}{ + "sub": "sub123", + }, + wantErr: true, + }, + "Error_when_email_verified_is_missing": { + claims: map[string]interface{}{ + "sub": "sub123", + "email": "user@example.com", + }, + wantErr: true, + wantErrType: &providerErrors.ForDisplayError{}, + }, + "Error_when_email_is_not_verified": { + claims: map[string]interface{}{ + "email": "user@example.com", + "sub": "sub123", + "email_verified": false, + }, + wantErr: true, + wantErrType: &providerErrors.ForDisplayError{}, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + p := genericprovider.New() + mockToken := &mockIDToken{claims: tc.claims} + + user, err := p.GetUserInfo(mockToken) + t.Logf("GetUserInfo error: %v", err) + + if tc.wantErr { + require.Error(t, err) + return + } + if tc.wantErrType != nil { + require.ErrorIs(t, err, tc.wantErrType) + return + } + require.NoError(t, err) + require.Equal(t, tc.wantUser, user) + }) + } +} + +type mockIDToken struct { + claims map[string]interface{} +} + +func (m *mockIDToken) Claims(v interface{}) error { + data, err := json.Marshal(m.claims) + if err != nil { + return fmt.Errorf("failed to marshal claims: %v", err) + } + + if err := json.Unmarshal(data, v); err != nil { + return fmt.Errorf("failed to unmarshal claims: %v", err) + } + + return nil +} diff --git a/authd-oidc-brokers/internal/providers/google/google.go b/authd-oidc-brokers/internal/providers/google/google.go new file mode 100644 index 0000000000..b0235a2526 --- /dev/null +++ b/authd-oidc-brokers/internal/providers/google/google.go @@ -0,0 +1,28 @@ +// Package google is the google specific extension. +package google + +import ( + "github.com/canonical/authd/authd-oidc-brokers/internal/providers/genericprovider" +) + +// Provider is the google provider implementation. +type Provider struct { + genericprovider.GenericProvider +} + +// New returns a new GoogleProvider. +func New() Provider { + return Provider{ + GenericProvider: genericprovider.New(), + } +} + +// AdditionalScopes returns the generic scopes required by the provider. +// Note that we do not return oidc.ScopeOfflineAccess, as for TV/limited input devices, the API call will fail as not +// supported by this application type. However, the refresh token will be acquired and is functional to refresh without +// user interaction. +// If we start to support other kinds of applications, we should revisit this. +// More info on https://developers.google.com/identity/protocols/oauth2/limited-input-device#allowedscopes. +func (Provider) AdditionalScopes() []string { + return []string{} +} diff --git a/authd-oidc-brokers/internal/providers/google/google_test.go b/authd-oidc-brokers/internal/providers/google/google_test.go new file mode 100644 index 0000000000..b51c2c2f5a --- /dev/null +++ b/authd-oidc-brokers/internal/providers/google/google_test.go @@ -0,0 +1,24 @@ +package google_test + +import ( + "testing" + + "github.com/canonical/authd/authd-oidc-brokers/internal/providers/google" + "github.com/stretchr/testify/require" +) + +func TestNew(t *testing.T) { + t.Parallel() + + p := google.New() + + require.Empty(t, p, "New should return the default provider implementation with no parameters") +} + +func TestAdditionalScopes(t *testing.T) { + t.Parallel() + + p := google.New() + + require.Empty(t, p.AdditionalScopes(), "Google provider should not require additional scopes") +} diff --git a/authd-oidc-brokers/internal/providers/info/info.go b/authd-oidc-brokers/internal/providers/info/info.go new file mode 100644 index 0000000000..e2eb936a28 --- /dev/null +++ b/authd-oidc-brokers/internal/providers/info/info.go @@ -0,0 +1,49 @@ +// Package info defines types used by the broker. +package info + +// Group represents the group information that is fetched by the broker. +type Group struct { + Name string `json:"name"` + UGID string `json:"ugid"` +} + +// User represents the user information obtained from the provider. +type User struct { + Name string `json:"name"` + UUID string `json:"uuid"` + Home string `json:"dir"` + Shell string `json:"shell"` + Gecos string `json:"gecos"` + Groups []Group `json:"groups"` +} + +// NewUser creates a new user with the specified values. +// +// It fills the defaults for Shell and Gecos if they are empty. +func NewUser(name, home, uuid, shell, gecos string, groups []Group) User { + u := User{ + Name: name, + Home: home, + UUID: uuid, + Shell: shell, + Gecos: gecos, + Groups: groups, + } + + if u.Home == "" { + u.Home = u.Name + } + if u.Shell == "" { + u.Shell = "/usr/bin/bash" + } + if u.Gecos == "" { + u.Gecos = u.Name + } + + return u +} + +// Claimer is an interface that defines a method to extract the claims from the ID token. +type Claimer interface { + Claims(any) error +} diff --git a/authd-oidc-brokers/internal/providers/info/info_test.go b/authd-oidc-brokers/internal/providers/info/info_test.go new file mode 100644 index 0000000000..58902ad5ce --- /dev/null +++ b/authd-oidc-brokers/internal/providers/info/info_test.go @@ -0,0 +1,82 @@ +package info_test + +import ( + "testing" + + "github.com/canonical/authd/authd-oidc-brokers/internal/providers/info" + "github.com/stretchr/testify/require" +) + +func TestNewUser(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + name string + home string + uuid string + shell string + gecos string + groups []info.Group + }{ + "Create_a_new_user": { + name: "test-user", + home: "/home/test-user", + uuid: "some-uuid", + shell: "/usr/bin/zsh", + gecos: "Test User", + groups: []info.Group{{Name: "test-group", UGID: "12345"}}, + }, + + // Default values + "Create_a_new_user_with_default_home": { + name: "test-user", + home: "", + uuid: "some-uuid", + shell: "/usr/bin/zsh", + gecos: "Test User", + groups: []info.Group{{Name: "test-group", UGID: "12345"}}, + }, + "Create_a_new_user_with_default_shell": { + name: "test-user", + home: "/home/test-user", + uuid: "some-uuid", + shell: "", + gecos: "Test User", + groups: []info.Group{{Name: "test-group", UGID: "12345"}}, + }, + "Create_a_new_user_with_default_gecos": {name: "test-user", + home: "/home/test-user", + uuid: "some-uuid", + shell: "/usr/bin/zsh", + gecos: "", + groups: []info.Group{{Name: "test-group", UGID: "12345"}}}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + wantHome := tc.home + if tc.home == "" { + wantHome = tc.name + } + + wantShell := tc.shell + if tc.shell == "" { + wantShell = "/usr/bin/bash" + } + + wantGecos := tc.gecos + if tc.gecos == "" { + wantGecos = tc.name + } + + got := info.NewUser(tc.name, tc.home, tc.uuid, tc.shell, tc.gecos, tc.groups) + require.Equal(t, tc.name, got.Name, "Name does not match the expected value") + require.Equal(t, wantHome, got.Home, "Home does not match the expected value") + require.Equal(t, tc.uuid, got.UUID, "UUID does not match the expected value") + require.Equal(t, wantShell, got.Shell, "Shell does not match the expected value") + require.Equal(t, wantGecos, got.Gecos, "Gecos does not match the expected value") + require.Equal(t, tc.groups, got.Groups, "Groups do not match the expected value") + }) + } +} diff --git a/authd-oidc-brokers/internal/providers/msentraid/dummy.go b/authd-oidc-brokers/internal/providers/msentraid/dummy.go new file mode 100644 index 0000000000..ffbb368fb0 --- /dev/null +++ b/authd-oidc-brokers/internal/providers/msentraid/dummy.go @@ -0,0 +1,8 @@ +// Package msentraid is the Microsoft Entra ID specific extension. +package msentraid + +// This file is here to make the build pass. It doesn't have any build tags, +// so it avoids the build failure: +// +// build constraints exclude all Go files in internal/providers/msentraid +// diff --git a/authd-oidc-brokers/internal/providers/msentraid/export_test.go b/authd-oidc-brokers/internal/providers/msentraid/export_test.go new file mode 100644 index 0000000000..1899f9019d --- /dev/null +++ b/authd-oidc-brokers/internal/providers/msentraid/export_test.go @@ -0,0 +1,19 @@ +//go:build withmsentraid + +package msentraid + +import "strings" + +// AllExpectedScopes returns all the default expected scopes for a new provider. +func AllExpectedScopes() string { + return strings.Join(New().expectedScopes, " ") +} + +func (p *Provider) SetNeedsAccessTokenForGraphAPI(value bool) { + p.needsAccessTokenForGraphAPI = value +} + +// SetTokenScopesForGraphAPI can be used in tests to set the scopes for the Microsoft Graph API access token. +func (p *Provider) SetTokenScopesForGraphAPI(scopes []string) { + p.tokenScopesForGraphAPI = scopes +} diff --git a/authd-oidc-brokers/internal/providers/msentraid/helper_test.go b/authd-oidc-brokers/internal/providers/msentraid/helper_test.go new file mode 100644 index 0000000000..88fab6fc5e --- /dev/null +++ b/authd-oidc-brokers/internal/providers/msentraid/helper_test.go @@ -0,0 +1,29 @@ +//go:build withmsentraid + +package msentraid_test + +import ( + "encoding/json" +) + +var ( + validIDToken = &testIDToken{ + claims: `{"preferred_username": "valid-user", + "sub": "valid-sub", + "home": "/home/valid-user", + "shell": "/bin/bash", + "name": "Valid User"}`, + } + + invalidIDToken = &testIDToken{ + claims: "invalid claims", + } +) + +type testIDToken struct { + claims string +} + +func (t *testIDToken) Claims(v interface{}) error { + return json.Unmarshal([]byte(t.claims), v) +} diff --git a/authd-oidc-brokers/internal/providers/msentraid/himmelblau/.gitignore b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/.gitignore new file mode 100644 index 0000000000..72914cb857 --- /dev/null +++ b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/.gitignore @@ -0,0 +1,3 @@ +himmelblau.h +libhimmelblau.so +libhimmelblau.so.* diff --git a/authd-oidc-brokers/internal/providers/msentraid/himmelblau/errors.go b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/errors.go new file mode 100644 index 0000000000..fbe7eca709 --- /dev/null +++ b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/errors.go @@ -0,0 +1,19 @@ +// Package himmelblau provides functions to use the libhimmelblau library. +package himmelblau + +import "fmt" + +// ErrDeviceDisabled is returned when the device is disabled in Microsoft Entra ID. +var ErrDeviceDisabled = fmt.Errorf("device is disabled in Microsoft Entra ID") + +// ErrInvalidRedirectURI is returned when the redirect URI of the client application is missing or invalid. +var ErrInvalidRedirectURI = fmt.Errorf("invalid redirect URI") + +// TokenAcquisitionError is returned when an error occurs while acquiring a token via libhimmelblau. +type TokenAcquisitionError struct { + msg string +} + +func (e TokenAcquisitionError) Error() string { + return e.msg +} diff --git a/authd-oidc-brokers/internal/providers/msentraid/himmelblau/generate.sh b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/generate.sh new file mode 100755 index 0000000000..90718e9534 --- /dev/null +++ b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/generate.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR=$(readlink -f "$(dirname "$0")") +cd "$SCRIPT_DIR" + +GIT_DIR=$(git rev-parse --show-toplevel) + +# Verify we are in a git repo and get the top-level dir +if [ -z "${GIT_DIR}" ]; then + echo >&2 "Error: Not inside a git repository" + exit 1 +fi + +cargo install cargo-c cbindgen + +cd "${GIT_DIR}/authd-oidc-brokers/third_party/libhimmelblau" + +mkdir -p himmelblau + +# Print executed commands to ease debugging +set -x + +"${CARGO_HOME:-$HOME/.cargo}"/bin/cbindgen --config ./cbindgen.toml > himmelblau/himmelblau.h + +FEATURES="broker,changepassword,on_behalf_of" +# Enable custom_oidc_discovery_url feature when not building a release, +# which is the case when building inside snapcraft or when the RELEASE env +# var is set (the latter can be used during development). +if [ -z "${SNAPCRAFT_PROJECT_NAME:-}" ] && [ -z "${RELEASE:-}" ]; then + FEATURES="${FEATURES},custom_oidc_discovery_url" +fi +cargo cbuild --release --lib --features="${FEATURES}" + +# Copy header and shared library +TARGET_TRIPLE=$(rustc -vV | awk '/host:/ {print $2}') +cp "target/${TARGET_TRIPLE}/release/himmelblau.h" "${SCRIPT_DIR}/" +cp "target/${TARGET_TRIPLE}/release/libhimmelblau.so" "${SCRIPT_DIR}/libhimmelblau.so.0" +ln -sf libhimmelblau.so.0 "${SCRIPT_DIR}/libhimmelblau.so" diff --git a/authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau.go b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau.go new file mode 100644 index 0000000000..722599c9bd --- /dev/null +++ b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau.go @@ -0,0 +1,265 @@ +//go:build withmsentraid + +// Package himmelblau provides functions to use the libhimmelblau library +package himmelblau + +import ( + "context" + "fmt" + "net/url" + "os" + "strings" + "sync" + + "github.com/ubuntu/authd/log" + "golang.org/x/oauth2" +) + +var ( + tpm *boxedDynTPM + tpmInitOnce sync.Once + //nolint:errname // This is not a sentinel error. + tpmInitErr error + + brokerClientApp *brokerClientApplication + brokerClientAppInitOnce sync.Once + //nolint:errname // This is not a sentinel error. + brokerClientAppInitErr error + + authorityBaseURL = "https://login.microsoftonline.com" + authorityBaseURLMu sync.RWMutex + + deviceRegistrationMu sync.RWMutex +) + +func ensureTPMInitialized() error { + tpmInitOnce.Do(func() { + filters := []string{"warn"} + logLevel := log.GetLevel() + if logLevel <= log.DebugLevel { + log.Debug(context.Background(), "Setting libhimmelblau tracing level to DEBUG") + filters = append(filters, "himmelblau=debug") + } else if logLevel <= log.InfoLevel { + filters = append(filters, "himmelblau=info") + } + + if tpmInitErr = setTracingFilter(strings.Join(filters, ",")); tpmInitErr != nil { + return + } + + // An optional TPM Transmission Interface. If this parameter is empty, a soft TPM is initialized. + var tctiName string + tpm, tpmInitErr = initTPM(tctiName) + if tpmInitErr != nil { + return + } + }) + + return tpmInitErr +} + +func ensureBrokerClientAppInitialized(tenantID string, data *DeviceRegistrationData) error { + if err := ensureTPMInitialized(); err != nil { + return err + } + + brokerClientAppInitOnce.Do(func() { + authorityBaseURLMu.RLock() + authority, err := url.JoinPath(authorityBaseURL, tenantID) + authorityBaseURLMu.RUnlock() + if err != nil { + brokerClientAppInitErr = fmt.Errorf("failed to construct authority URL: %v", err) + return + } + var transportKey []byte + var certKey []byte + if data != nil { + transportKey = data.TransportKey + certKey = data.CertKey + } + + brokerClientApp, brokerClientAppInitErr = initBroker(authority, "", transportKey, certKey) + if brokerClientAppInitErr != nil { + return + } + }) + + return brokerClientAppInitErr +} + +// DeviceRegistrationData contains the data returned by RegisterDevice +// which is needed to acquire an access token later. +type DeviceRegistrationData struct { + DeviceID string `json:"device_id"` + CertKey []byte `json:"cert_key"` + TransportKey []byte `json:"transport_key"` + AuthValue string `json:"auth_value"` + TPMMachineKey []byte `json:"tpm_machine_key"` +} + +// IsValid checks whether all fields of the DeviceRegistrationData are set. +func (d *DeviceRegistrationData) IsValid() bool { + return d.DeviceID != "" && + d.CertKey != nil && + d.TransportKey != nil && + d.AuthValue != "" && + d.TPMMachineKey != nil +} + +// RegisterDevice registers the device with Microsoft Entra ID and returns the +// device registration data required for subsequent access token acquisition via +// AcquireAccessTokenForGraphAPI. +// +// The returned cleanup function must be called after AcquireAccessTokenForGraphAPI +// or if that function will not be called, to release an internal mutex and allow +// future device registrations. +// +// RegisterDevice is thread-safe due to an internal mutex that serializes access +// to libhimmelblau, which modifies shared state during registration. +func RegisterDevice( + ctx context.Context, + token *oauth2.Token, + tenantID string, + domain string, +) (registrationData *DeviceRegistrationData, cleanup func(), err error) { + deviceRegistrationMu.Lock() + // libhimmelblau modifies BrokerClientApplication.cert_key during registration. + // This key is reused in later calls, including acquire_token_by_refresh_token. + // If cert_key changes because another device registration was done concurrently, + // libhimmelblau returns "TPM error: Failed to load IdentityKey: Aes256GcmDecrypt". + // The mutex also prevents concurrent modifications to TPM state. + unlock := deviceRegistrationMu.Unlock + + // Ensure that the mutex is unlocked if an error occurs. + // We can't rename `unlock` to `cleanup` because `return nil, nil, err` sets + // the return value `cleanup` to `nil`, so calling `cleanup()` would panic. + defer func() { + if err != nil { + unlock() + } + }() + + if err := ensureBrokerClientAppInitialized(tenantID, nil); err != nil { + return nil, nil, fmt.Errorf("failed to initialize broker client application: %v", err) + } + + authValue, err := generateAuthValue() + if err != nil { + return nil, nil, err + } + + loadableMachineKey, tpmCleanup, err := createTPMMachineKey(tpm, authValue) + if err != nil { + return nil, nil, err + } + defer tpmCleanup() + + attrs, err := initEnrollAttrs(domain, hostname(), OSVersion()) + if err != nil { + return nil, nil, err + } + + machineKey, tpmCleanup, err := loadTPMMachineKey(tpm, authValue, loadableMachineKey) + if err != nil { + return nil, nil, err + } + defer tpmCleanup() + + data, err := enrollDevice(brokerClientApp, token.RefreshToken, attrs, tpm, machineKey) + if err != nil { + return nil, nil, err + } + + log.Infof(ctx, "Enrolled device with ID: %v", data.DeviceID) + + data.TPMMachineKey, err = serializeLoadableMachineKey(loadableMachineKey) + if err != nil { + return nil, nil, err + } + + data.AuthValue = authValue + + return data, unlock, nil +} + +func hostname() string { + name, err := os.Hostname() + if err != nil { + log.Warningf(context.Background(), "Failed to get hostname: %v", err) + return "unknown" + } + return name +} + +// OSVersion gets the pretty name of the OS release from the system. +// Since we're running in a snap, this returns the version of the core base snap +// (which is not that helpful when it's shown as the device's OS in Entra, so +// might want to change this in the future, to somehow get the host's OS version). +var OSVersion = sync.OnceValue(func() string { + data, err := os.ReadFile("/etc/os-release") + if err != nil { + log.Warningf(context.Background(), "Failed to read /etc/os-release: %v", err) + return "unknown" + } + + for _, line := range strings.Split(string(data), "\n") { + if name, found := strings.CutPrefix(line, "PRETTY_NAME="); found && name != "" { + return name + } + } + + log.Warningf(context.Background(), "PRETTY_NAME not found in /etc/os-release") + return "unknown" +}) + +// AcquireAccessTokenForGraphAPI uses the refresh token from the provided +// OAuth 2.0 token with the required scopes to access the Microsoft Graph API. +func AcquireAccessTokenForGraphAPI( + ctx context.Context, + clientID string, + tenantID string, + token *oauth2.Token, + data DeviceRegistrationData, +) (string, error) { + if err := ensureBrokerClientAppInitialized(tenantID, &data); err != nil { + return "", fmt.Errorf("failed to initialize broker client application: %v", err) + } + + loadableMachineKey, cleanup, err := deserializeLoadableMachineKey(data.TPMMachineKey) + if err != nil { + return "", err + } + defer cleanup() + + machineKey, cleanup, err := loadTPMMachineKey(tpm, data.AuthValue, loadableMachineKey) + if err != nil { + return "", err + } + defer cleanup() + + userToken, cleanup, err := acquireTokenByRefreshToken( + brokerClientApp, + token.RefreshToken, + []string{"GroupMember.Read.All"}, + "", + // We could use `nil` here instead of the client ID if we also use `nil` as the client ID + // in the `broker_init` call, which means that the user doesn't even have to register + // an OIDC app in Entra. However, that has the effect that we can't fetch the groups + // of the user. + clientID, + tpm, + machineKey, + ) + if err != nil { + return "", err + } + defer cleanup() + + accessToken, err := accessTokenFromUserToken(userToken) + if err != nil { + return "", err + } + log.Info(ctx, "Acquired access token") + + return accessToken, nil +} diff --git a/authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau_c.go b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau_c.go new file mode 100644 index 0000000000..d45e064470 --- /dev/null +++ b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau_c.go @@ -0,0 +1,350 @@ +//go:build withmsentraid + +package himmelblau + +//go:generate ./generate.sh + +/* +#cgo LDFLAGS: -L${SRCDIR} -lhimmelblau +// Add the current directory to the library search path if we're building for testing, +// because libhimmelblau is not installed in the standard search directories. +#cgo !release LDFLAGS: -Wl,-rpath,${SRCDIR} +#include "himmelblau.h" +*/ +import "C" + +import ( + "context" + "fmt" + "strconv" + "strings" + "unsafe" + + "github.com/ubuntu/authd/log" +) + +// Entra AADSTS error codes as defined in +// https://learn.microsoft.com/en-us/entra/identity-platform/reference-error-codes +const ( + // AADSTS135011 Device used during the authentication is disabled. + deviceDisabledErrorCode = 135011 + // AADSTS50011 InvalidReplyTo - The reply address is missing, misconfigured, + // or doesn't match reply addresses configured for the app. As a resolution + // ensures to add this missing reply address to the Microsoft Entra + // application or have someone with the permissions to manage your + // application in Microsoft Entra IF do this for you. To learn more, see the + // troubleshooting article for error AADSTS50011. + invalidRedirectURIErrorCode = 50011 +) + +type boxedDynTPM C.BoxedDynTpm +type brokerClientApplication C.BrokerClientApplication + +func setTracingFilter(filter string) error { + if msalErr := C.set_module_tracing_filter(C.CString(filter)); msalErr != nil { + return fmt.Errorf("failed to set libhimmelblau tracing filter: %v", C.GoString(msalErr.msg)) + } + + return nil +} + +func initTPM(tctiName string) (tpm *boxedDynTPM, err error) { + var cTctiName *C.char + if tctiName != "" { + cTctiName = C.CString(tctiName) + defer C.free(unsafe.Pointer(cTctiName)) + } + + if msalErr := C.tpm_init(cTctiName, (**C.BoxedDynTpm)(unsafe.Pointer(&tpm))); msalErr != nil { + return nil, fmt.Errorf("failed to initialize TPM: %v", C.GoString(msalErr.msg)) + } + + return tpm, nil +} + +func initBroker(authority, clientID string, transportKeyBytes, certKeyBytes []byte) (broker *brokerClientApplication, err error) { + cAuthority := C.CString(authority) + defer C.free(unsafe.Pointer(cAuthority)) + + var cClientID *C.char + if clientID != "" { + cClientID = C.CString(clientID) + defer C.free(unsafe.Pointer(cClientID)) + } + + var cTransportKey *C.LoadableMsOapxbcRsaKey + if len(transportKeyBytes) > 0 { + msalErr := C.deserialize_loadable_ms_oapxbc_rsa_key( + (*C.uint8_t)(unsafe.Pointer(&transportKeyBytes[0])), + C.size_t(len(transportKeyBytes)), + &cTransportKey, + ) + if msalErr != nil { + return nil, fmt.Errorf("failed to deserialize transport key: %v", C.GoString(msalErr.msg)) + } + defer C.loadable_ms_oapxbc_rsa_key_free(cTransportKey) + } + + var cCertKey *C.LoadableMsDeviceEnrolmentKey + if len(certKeyBytes) > 0 { + msalErr := C.deserialize_loadable_ms_device_enrolment_key( + (*C.uint8_t)(unsafe.Pointer(&certKeyBytes[0])), + C.size_t(len(certKeyBytes)), + &cCertKey, + ) + if msalErr != nil { + return nil, fmt.Errorf("failed to deserialize cert key: %v", C.GoString(msalErr.msg)) + } + defer C.loadable_ms_device_enrollment_key_free(cCertKey) + } + + msalErr := C.broker_init( + cAuthority, + cClientID, + cTransportKey, + cCertKey, + (**C.BrokerClientApplication)(unsafe.Pointer(&broker)), + ) + if msalErr != nil { + return nil, fmt.Errorf("failed to initialize broker client: %v", C.GoString(msalErr.msg)) + } + + return broker, nil +} + +func initEnrollAttrs(domain, hostname, osVersion string) (attrs *C.EnrollAttrs, err error) { + cDomain := C.CString(domain) + defer C.free(unsafe.Pointer(cDomain)) + cHostname := C.CString(hostname) + defer C.free(unsafe.Pointer(cHostname)) + cOSVersion := C.CString(osVersion) + defer C.free(unsafe.Pointer(cOSVersion)) + + msalErr := C.enroll_attrs_init( + cDomain, + cHostname, + nil, /* device_type - default is "Linux" */ + 0, /* join_type - 0: Azure AD join */ + cOSVersion, + &attrs, + ) + if msalErr != nil { + return nil, fmt.Errorf("failed to initialize enroll attributes: %v", C.GoString(msalErr.msg)) + } + + // TODO: Do we not have to free the attrs? + + return attrs, nil +} + +func generateAuthValue() (authValue string, err error) { + var cAuthValue *C.char + if msalErr := C.auth_value_generate(&cAuthValue); msalErr != nil { + return "", fmt.Errorf("failed to generate auth value: %v", C.GoString(msalErr.msg)) + } + defer C.free(unsafe.Pointer(cAuthValue)) + + return C.GoString(cAuthValue), nil +} + +func createTPMMachineKey(tpm *boxedDynTPM, authValue string) (key *C.LoadableMachineKey, cleanup func(), err error) { + cAuthValue := C.CString(authValue) + defer C.free(unsafe.Pointer(cAuthValue)) + + var loadableMachineKey *C.LoadableMachineKey + msalErr := C.tpm_machine_key_create((*C.BoxedDynTpm)(unsafe.Pointer(tpm)), cAuthValue, &loadableMachineKey) + if msalErr != nil { + return nil, nil, fmt.Errorf("failed to create loadable machine key: %v", C.GoString(msalErr.msg)) + } + + cleanup = func() { C.loadable_machine_key_free(loadableMachineKey) } + + return loadableMachineKey, cleanup, nil +} + +func loadTPMMachineKey(tpm *boxedDynTPM, authValue string, loadableMachineKey *C.LoadableMachineKey) (key *C.MachineKey, cleanup func(), err error) { + cAuthValue := C.CString(authValue) + defer C.free(unsafe.Pointer(cAuthValue)) + + if msalErr := C.tpm_machine_key_load((*C.BoxedDynTpm)(unsafe.Pointer(tpm)), cAuthValue, loadableMachineKey, &key); msalErr != nil { + return nil, nil, fmt.Errorf("failed to load TPM machine key: %v", C.GoString(msalErr.msg)) + } + + cleanup = func() { C.machine_key_free(key) } + + return key, cleanup, nil +} + +func enrollDevice(broker *brokerClientApplication, refreshToken string, attrs *C.EnrollAttrs, tpm *boxedDynTPM, machineKey *C.MachineKey) (data *DeviceRegistrationData, err error) { + cRefreshToken := C.CString(refreshToken) + defer C.free(unsafe.Pointer(cRefreshToken)) + + var cTransportKey *C.LoadableMsOapxbcRsaKey + var cCertKey *C.LoadableMsDeviceEnrolmentKey + var cDeviceID *C.char + + msalErr := C.broker_enroll_device( + (*C.BrokerClientApplication)(unsafe.Pointer(broker)), + cRefreshToken, + attrs, + (*C.BoxedDynTpm)(unsafe.Pointer(tpm)), + machineKey, + &cTransportKey, + &cCertKey, + &cDeviceID, + ) + if msalErr != nil { + return nil, fmt.Errorf("failed to enroll device: %v", C.GoString(msalErr.msg)) + } + defer C.loadable_ms_oapxbc_rsa_key_free(cTransportKey) + defer C.loadable_ms_device_enrollment_key_free(cCertKey) + defer C.free(unsafe.Pointer(cDeviceID)) + + deviceID := C.GoString(cDeviceID) + + var certKey []byte + var cSerializedCertKey *C.char + var cSerializedCertKeyLen C.size_t + defer C.free(unsafe.Pointer(cSerializedCertKey)) + msalErr = C.serialize_loadable_ms_device_enrolment_key(cCertKey, &cSerializedCertKey, &cSerializedCertKeyLen) + if msalErr != nil { + return nil, fmt.Errorf("failed to serialize device enrollment key: %v", C.GoString(msalErr.msg)) + } + if cSerializedCertKeyLen > 0 { + certKey = C.GoBytes(unsafe.Pointer(cSerializedCertKey), C.int(cSerializedCertKeyLen)) + } + + var transportKey []byte + var cSerializedTransportKey *C.char + var cSerializedTransportKeyLen C.size_t + defer C.free(unsafe.Pointer(cSerializedTransportKey)) + msalErr = C.serialize_loadable_ms_oapxbc_rsa_key(cTransportKey, &cSerializedTransportKey, &cSerializedTransportKeyLen) + if msalErr != nil { + return nil, fmt.Errorf("failed to serialize transport key: %v", C.GoString(msalErr.msg)) + } + if cSerializedTransportKeyLen > 0 { + transportKey = C.GoBytes(unsafe.Pointer(cSerializedTransportKey), C.int(cSerializedTransportKeyLen)) + } + + return &DeviceRegistrationData{ + DeviceID: deviceID, + CertKey: certKey, + TransportKey: transportKey, + }, nil +} + +func serializeLoadableMachineKey(loadableMachineKey *C.LoadableMachineKey) (key []byte, err error) { + var cSerializedKey *C.char + var cSerializedKeyLen C.size_t + defer C.free(unsafe.Pointer(cSerializedKey)) + msalErr := C.serialize_loadable_machine_key(loadableMachineKey, &cSerializedKey, &cSerializedKeyLen) + if msalErr != nil { + return nil, fmt.Errorf("failed to serialize loadable machine key: %v", C.GoString(msalErr.msg)) + } + if cSerializedKeyLen > 0 { + key = C.GoBytes(unsafe.Pointer(cSerializedKey), C.int(cSerializedKeyLen)) + } + + return key, nil +} + +func deserializeLoadableMachineKey(key []byte) (loadableMachineKey *C.LoadableMachineKey, cleanup func(), err error) { + msalErr := C.deserialize_loadable_machine_key( + (*C.uint8_t)(unsafe.Pointer(&key[0])), + C.size_t(len(key)), + &loadableMachineKey, + ) + if msalErr != nil { + return nil, nil, fmt.Errorf("failed to deserialize loadable machine key: %v", C.GoString(msalErr.msg)) + } + + cleanup = func() { C.loadable_machine_key_free(loadableMachineKey) } + + return loadableMachineKey, cleanup, nil +} + +func acquireTokenByRefreshToken(broker *brokerClientApplication, refreshToken string, scopes []string, requestResource string, clientID string, tpm *boxedDynTPM, machineKey *C.MachineKey) (token *C.UserToken, cleanup func(), err error) { + cRefreshToken := C.CString(refreshToken) + defer C.free(unsafe.Pointer(cRefreshToken)) + + var cScopes []*C.char + for _, scope := range scopes { + cScope := C.CString(scope) + cScopes = append(cScopes, cScope) + defer C.free(unsafe.Pointer(cScope)) + } + + var cRequestResource *C.char + if requestResource != "" { + cRequestResource = C.CString(requestResource) + defer C.free(unsafe.Pointer(cRequestResource)) + } + + var cClientID *C.char + if clientID != "" { + cClientID = C.CString(clientID) + defer C.free(unsafe.Pointer(cClientID)) + } + + var userToken *C.UserToken + + msalErr := C.broker_acquire_token_by_refresh_token( + (*C.BrokerClientApplication)(unsafe.Pointer(broker)), + cRefreshToken, + &cScopes[0], + C.int(len(scopes)), + cRequestResource, + // We could use `nil` here instead of the client ID if we also use `nil` as the client ID + // in the `broker_init` call, which means that the user doesn't even have to register + // an OIDC app in Entra. However, that has the effect that we can't fetch the groups + // of the user. + cClientID, + (*C.BoxedDynTpm)(unsafe.Pointer(tpm)), + machineKey, + &userToken, + ) + if msalErr != nil { + // Error codes can be returned by libhimmelblau as a single code in the aadsts_code field or + // as a list of error codes in the acquire_token_error_codes field. + errorCodes := []C.uint32_t{msalErr.aadsts_code} + if msalErr.acquire_token_error_codes != nil && msalErr.acquire_token_error_codes_len > 0 { + errorCodes = unsafe.Slice(msalErr.acquire_token_error_codes, msalErr.acquire_token_error_codes_len) + } + + for _, errorCode := range errorCodes { + errorCodeStr := strconv.Itoa(int(errorCode)) + switch { + // AADSTS error codes can have additional digits or subcodes appended + // (e.g. AADSTS500113 as a variation of AADSTS50011). + // Checking the prefix ensures we catch all variations of the base error code. + case strings.HasPrefix(errorCodeStr, strconv.Itoa(deviceDisabledErrorCode)): + log.Error(context.Background(), C.GoString(msalErr.msg)) + return nil, nil, ErrDeviceDisabled + case strings.HasPrefix(errorCodeStr, strconv.Itoa(invalidRedirectURIErrorCode)): + log.Errorf(context.Background(), "Token acquisition failed: %v", C.GoString(msalErr.msg)) + return nil, nil, ErrInvalidRedirectURI + } + } + + // The token acquisition failed unexpectedly. + // One possible reason is that the device was deleted by an administrator in Entra ID. + // Unfortunately, Microsoft doesn't return a specific error code for that case, + // it returns the generic error "AADSTS50155: Device authentication failed". + return nil, nil, TokenAcquisitionError{msg: fmt.Sprintf("error acquiring access token using refresh token: %v", C.GoString(msalErr.msg))} + } + + cleanup = func() { C.user_token_free(userToken) } + + return userToken, cleanup, nil +} + +func accessTokenFromUserToken(userToken *C.UserToken) (accessToken string, err error) { + var cAccessToken *C.char + msalErr := C.user_token_access_token(userToken, &cAccessToken) + if msalErr != nil { + return "", fmt.Errorf("failed to get access token: %v", C.GoString(msalErr.msg)) + } + defer C.free(unsafe.Pointer(cAccessToken)) + + return C.GoString(cAccessToken), nil +} diff --git a/authd-oidc-brokers/internal/providers/msentraid/himmelblau/testutils.go b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/testutils.go new file mode 100644 index 0000000000..538b1cb8c6 --- /dev/null +++ b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/testutils.go @@ -0,0 +1,13 @@ +//go:build withmsentraid + +package himmelblau + +import "testing" + +// SetAuthorityBaseURL sets the base URL for the token authority, used in tests to override the default. +// This is not thread-safe. +func SetAuthorityBaseURL(_ *testing.T, url string) { + authorityBaseURLMu.Lock() + authorityBaseURL = url + authorityBaseURLMu.Unlock() +} diff --git a/authd-oidc-brokers/internal/providers/msentraid/msentraid.go b/authd-oidc-brokers/internal/providers/msentraid/msentraid.go new file mode 100644 index 0000000000..b03a2fdcb8 --- /dev/null +++ b/authd-oidc-brokers/internal/providers/msentraid/msentraid.go @@ -0,0 +1,542 @@ +//go:build withmsentraid + +// Package msentraid is the Microsoft Entra ID specific extension. +package msentraid + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "regexp" + "slices" + "strings" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/canonical/authd/authd-oidc-brokers/internal/broker/authmodes" + "github.com/canonical/authd/authd-oidc-brokers/internal/consts" + providerErrors "github.com/canonical/authd/authd-oidc-brokers/internal/providers/errors" + "github.com/canonical/authd/authd-oidc-brokers/internal/providers/info" + "github.com/canonical/authd/authd-oidc-brokers/internal/providers/msentraid/himmelblau" + "github.com/coreos/go-oidc/v3/oidc" + "github.com/golang-jwt/jwt/v5" + "github.com/k0kubun/pp" + msgraphsdk "github.com/microsoftgraph/msgraph-sdk-go" + msgraphauth "github.com/microsoftgraph/msgraph-sdk-go-core/authentication" + msgraphmodels "github.com/microsoftgraph/msgraph-sdk-go/models" + "github.com/ubuntu/authd/log" + "golang.org/x/oauth2" +) + +func init() { + pp.ColoringEnabled = false +} + +const ( + localGroupPrefix = "linux-" + defaultMSGraphHost = "graph.microsoft.com" + msgraphAPIVersion = "v1.0" +) + +// Provider is the Microsoft Entra ID provider implementation. +type Provider struct { + expectedScopes []string + needsAccessTokenForGraphAPI bool + + // Used as the token scopes of the access token for the Microsoft Graph API in tests. + tokenScopesForGraphAPI []string +} + +// New returns a new MSEntraID provider. +func New() *Provider { + return &Provider{ + expectedScopes: append(consts.DefaultScopes, "GroupMember.Read.All", "User.Read"), + } +} + +// AdditionalScopes returns the generic scopes required by the EntraID provider. +func (p *Provider) AdditionalScopes() []string { + return []string{oidc.ScopeOfflineAccess, "GroupMember.Read.All", "User.Read"} +} + +// AuthOptions returns the generic auth options required by the EntraID provider. +func (p *Provider) AuthOptions() []oauth2.AuthCodeOption { + return []oauth2.AuthCodeOption{} +} + +func (p *Provider) getTokenScopes(token *jwt.Token) ([]string, error) { + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, fmt.Errorf("failed to cast token claims to MapClaims: %v", token.Claims) + } + scopesStr, ok := claims["scp"].(string) + if !ok { + return nil, fmt.Errorf("failed to cast scp claim to string: %v", claims["scp"]) + } + return strings.Split(scopesStr, " "), nil +} + +func (p *Provider) getAppID(token *jwt.Token) (string, error) { + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return "", fmt.Errorf("failed to cast token claims to MapClaims: %v", token.Claims) + } + appID, ok := claims["appid"].(string) + if !ok { + return "", fmt.Errorf("failed to cast appid claim to string: %v", claims["appid"]) + } + return appID, nil +} + +// GetExtraFields returns the extra fields of the token which should be stored persistently. +func (p *Provider) GetExtraFields(token *oauth2.Token) map[string]interface{} { + return map[string]interface{}{ + "scope": token.Extra("scope"), + "scp": token.Extra("scp"), + } +} + +// GetMetadata returns relevant metadata about the provider. +func (p *Provider) GetMetadata(provider *oidc.Provider) (map[string]interface{}, error) { + var claims struct { + MSGraphHost string `json:"msgraph_host"` + } + + if err := provider.Claims(&claims); err != nil { + return nil, fmt.Errorf("failed to get provider claims: %v", err) + } + + return map[string]interface{}{ + "msgraph_host": fmt.Sprintf("https://%s/%s", claims.MSGraphHost, msgraphAPIVersion), + }, nil +} + +// GetUserInfo returns the user info from the ID token. +func (p *Provider) GetUserInfo(idToken info.Claimer) (info.User, error) { + var err error + + userClaims, err := p.userClaims(idToken) + if err != nil { + return info.User{}, err + } + + return info.NewUser( + userClaims.PreferredUserName, + userClaims.Home, + userClaims.Sub, + userClaims.Shell, + userClaims.Gecos, + nil, + ), nil +} + +// GetGroups retrieves the groups the user is a member of via the Microsoft Graph API. +func (p *Provider) GetGroups( + ctx context.Context, + clientID string, + issuerURL string, + token *oauth2.Token, + providerMetadata map[string]interface{}, + deviceRegistrationDataJSON []byte, +) ([]info.Group, error) { + accessTokenStr := token.AccessToken + if p.needsAccessTokenForGraphAPI { + var data himmelblau.DeviceRegistrationData + err := json.Unmarshal(deviceRegistrationDataJSON, &data) + if err != nil { + log.Noticef(ctx, "Device registration JSON data: %s", deviceRegistrationDataJSON) + return nil, fmt.Errorf("failed to unmarshal device registration data: %v", err) + } + + tenantID := tenantID(issuerURL) + accessTokenStr, err = himmelblau.AcquireAccessTokenForGraphAPI(ctx, clientID, tenantID, token, data) + if errors.Is(err, himmelblau.ErrDeviceDisabled) { + return nil, err + } + if errors.Is(err, himmelblau.ErrInvalidRedirectURI) { + msg := "Token acquisition failed: The app is misconfigured in Microsoft Entra (the redirect URI is missing or invalid). Please contact your administrator." + return nil, &providerErrors.ForDisplayError{Message: msg, Err: err} + } + if err != nil { + return nil, fmt.Errorf("failed to acquire access token for Microsoft Graph API: %w", err) + } + } + // Parse the access token without signature verification, because we're not the audience of the token (that's + // the Microsoft Graph API) and we don't use it for authentication, but only to access the Microsoft Graph API. + accessToken, _, err := new(jwt.Parser).ParseUnverified(accessTokenStr, jwt.MapClaims{}) + if err != nil { + return nil, fmt.Errorf("failed to parse access token: %w", err) + } + + msgraphHost := fmt.Sprintf("https://%s/%s", defaultMSGraphHost, msgraphAPIVersion) + if providerMetadata["msgraph_host"] != nil { + var ok bool + msgraphHost, ok = providerMetadata["msgraph_host"].(string) + if !ok { + return nil, fmt.Errorf("failed to cast msgraph_host to string: %v", providerMetadata["msgraph_host"]) + } + + // Handle the case that the provider metadata only contains the host without the protocol and API version, + // as was the case before 5fc98520c45294ffb85bb27a81929e2ec1b89fcb. This fixes #858. + if !strings.Contains(msgraphHost, "://") { + msgraphHost = fmt.Sprintf("https://%s/%s", msgraphHost, msgraphAPIVersion) + } + } + + return p.fetchUserGroups(accessToken, msgraphHost) +} + +type claims struct { + PreferredUserName string `json:"preferred_username"` + Sub string `json:"sub"` + Home string `json:"home"` + Shell string `json:"shell"` + Gecos string `json:"name"` +} + +// userClaims returns the user claims parsed from the ID token. +func (p *Provider) userClaims(idToken info.Claimer) (claims, error) { + var userClaims claims + if err := idToken.Claims(&userClaims); err != nil { + return claims{}, fmt.Errorf("failed to get ID token claims: %v", err) + } + return userClaims, nil +} + +// fetchUserGroups access the Microsoft Graph API to get the groups the user is a member of. +func (p *Provider) fetchUserGroups(token *jwt.Token, msgraphHost string) ([]info.Group, error) { + log.Debug(context.Background(), "Getting user groups from Microsoft Graph API") + + var err error + scopes := p.tokenScopesForGraphAPI + + if scopes == nil { + scopes, err = p.getTokenScopes(token) + if err != nil { + return nil, err + } + } + + // Check if the token has the GroupMember.Read.All scope + if !slices.Contains(scopes, "GroupMember.Read.All") { + msg := "Error: the Microsoft Entra ID app is missing the GroupMember.Read.All permission" + return nil, &providerErrors.ForDisplayError{Message: msg} + } + + cred := azureTokenCredential{token: token} + auth, err := msgraphauth.NewAzureIdentityAuthenticationProvider(cred) + if err != nil { + return nil, fmt.Errorf("failed to create AzureIdentityAuthenticationProvider: %v", err) + } + + adapter, err := msgraphsdk.NewGraphRequestAdapter(auth) + if err != nil { + return nil, fmt.Errorf("failed to create GraphRequestAdapter: %v", err) + } + adapter.SetBaseUrl(msgraphHost) + + client := msgraphsdk.NewGraphServiceClient(adapter) + + // Get the groups (only the groups, not directory roles or administrative units, because that would require + // additional permissions) which the user is a member of. + graphGroups, err := getSecurityGroups(client) + if err != nil { + return nil, err + } + + var groups []info.Group + var msGroupNames []string + for _, msGroup := range graphGroups { + var group info.Group + + idPtr := msGroup.GetId() + if idPtr == nil { + log.Warning(context.Background(), pp.Sprintf("Could not get ID for group: %v", msGroup)) + return nil, errors.New("could not get group id") + } + id := *idPtr + + msGroupNamePtr := msGroup.GetDisplayName() + if msGroupNamePtr == nil { + log.Warning(context.Background(), pp.Sprintf("Could not get display name for group object (ID: %s): %v", id, msGroup)) + return nil, errors.New("could not get group name") + } + msGroupName := *msGroupNamePtr + + // Check if there is a name conflict with another group returned by the Graph API. It's not clear in which case + // the Graph API returns multiple groups with the same name (or the same group twice), but we've seen it happen + // in https://github.com/canonical/authd/issues/789. + if checkGroupIsDuplicate(msGroupName, msGroupNames) { + continue + } + + // Microsoft groups are case-insensitive, see https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/resource-name-rules + group.Name = strings.ToLower(msGroupName) + + isLocalGroup := strings.HasPrefix(group.Name, localGroupPrefix) + if isLocalGroup { + group.Name = strings.TrimPrefix(group.Name, localGroupPrefix) + } + + // Don't set the UGID for local groups, because that's how the user manager differentiates between local and + // remote groups. + if !isLocalGroup { + group.UGID = id + } + + groups = append(groups, group) + msGroupNames = append(msGroupNames, msGroupName) + } + + return groups, nil +} + +func checkGroupIsDuplicate(groupName string, groupNames []string) bool { + for _, name := range groupNames { + // We don't want to treat local groups without the prefix as duplicates of non-local groups + // (e.g. "linux-sudo" and "sudo"), so we compare the names as returned by the Graph API - except that we + // ignore the case, because we use the group names in lowercase. + if !strings.EqualFold(name, groupName) { + // Not a duplicate + continue + } + + // To make debugging easier, check if the groups differ in case, and mention that in the log message. + if name == groupName { + log.Warningf(context.Background(), "The Microsoft Graph API returned the group %q multiple times, ignoring the duplicate", name) + } else { + log.Warningf(context.Background(), "The Microsoft Graph API returned the group %[1]q multiple times, but with different case (%[2]q and %[1]q), ignoring the duplicate", groupName, name) + } + + return true + } + + return false +} + +func removeNonSecurityGroups(groups []msgraphmodels.Groupable) []msgraphmodels.Groupable { + var securityGroups []msgraphmodels.Groupable + for _, group := range groups { + if !isSecurityGroup(group) { + var s string + if groupNamePtr := group.GetDisplayName(); groupNamePtr != nil { + s = *groupNamePtr + } else if description := group.GetDescription(); description != nil { + s = *description + } else if uniqueName := group.GetUniqueName(); uniqueName != nil { + s = *uniqueName + } + if s == "" { + log.Debugf(context.Background(), "Removing unnamed non-security group") + } else { + log.Debugf(context.Background(), "Removing non-security group %s", s) + } + continue + } + securityGroups = append(securityGroups, group) + } + return securityGroups +} + +func getSecurityGroups(client *msgraphsdk.GraphServiceClient) ([]msgraphmodels.Groupable, error) { + // Initial request to get groups + requestBuilder := client.Me().TransitiveMemberOf().GraphGroup() + result, err := requestBuilder.Get(context.Background(), nil) + if err != nil { + return nil, fmt.Errorf("failed to get user groups: %v", err) + } + if result == nil { + log.Debug(context.Background(), "Got nil response from Microsoft Graph API for user's groups, assuming that user is not a member of any group.") + return []msgraphmodels.Groupable{}, nil + } + + groups := result.GetValue() + + // Continue fetching groups using paging if a next link is available + for result.GetOdataNextLink() != nil { + nextLink := *result.GetOdataNextLink() + + result, err = requestBuilder.WithUrl(nextLink).Get(context.Background(), nil) + if err != nil { + return nil, fmt.Errorf("failed to get next page of user groups: %v", err) + } + + groups = append(groups, result.GetValue()...) + } + + // Remove the groups which are not security groups (but for example Microsoft 365 groups, which can be created + // by non-admin users). + groups = removeNonSecurityGroups(groups) + + var groupNames []string + for _, group := range groups { + groupNamePtr := group.GetDisplayName() + if groupNamePtr != nil { + groupNames = append(groupNames, *groupNamePtr) + } + } + log.Debugf(context.Background(), "Got groups: %s", strings.Join(groupNames, ", ")) + + return groups, nil +} + +func isSecurityGroup(group msgraphmodels.Groupable) bool { + // A group is a security group if the `securityEnabled` property is true and the `groupTypes` property does not + // contain "Unified". + securityEnabledPtr := group.GetSecurityEnabled() + if securityEnabledPtr == nil || !*securityEnabledPtr { + return false + } + + return !slices.Contains(group.GetGroupTypes(), "Unified") +} + +// NormalizeUsername parses a username into a normalized version. +func (p *Provider) NormalizeUsername(username string) string { + // Microsoft Entra usernames are case-insensitive. We can safely use strings.ToLower here without worrying about + // different Unicode characters that fold to the same lowercase letter, because the Microsoft Entra username policy + // (which we check in VerifyUsername) ensures that the username only contains ASCII characters. + return strings.ToLower(username) +} + +// SupportedOIDCAuthModes returns the OIDC authentication modes supported by the provider. +func (p *Provider) SupportedOIDCAuthModes() []string { + return []string{authmodes.Device, authmodes.DeviceQr} +} + +// VerifyUsername checks if the authenticated username matches the requested username and that both are valid. +func (p *Provider) VerifyUsername(requestedUsername, authenticatedUsername string) error { + if p.NormalizeUsername(requestedUsername) != p.NormalizeUsername(authenticatedUsername) { + msg := fmt.Sprintf("Authentication failure: requested username %q does not match the authenticated username %q", requestedUsername, authenticatedUsername) + return &providerErrors.ForDisplayError{Message: msg} + } + + // Check that the usernames only contain the characters allowed by the Microsoft Entra username policy + // https://learn.microsoft.com/en-us/entra/identity/authentication/concept-sspr-policy#username-policies + usernameRegexp := regexp.MustCompile(`^[a-zA-Z0-9'.\-_!#^~@]+$`) + if !usernameRegexp.MatchString(authenticatedUsername) { + // If this error occurs, we should investigate and probably relax the username policy, so we ask the user + // explicitly to report this error. + msg := fmt.Sprintf("Authentication failure: the authenticated username %q contains invalid characters. Please report this error on https://github.com/canonical/authd/issues", authenticatedUsername) + return &providerErrors.ForDisplayError{Message: msg} + } + if !usernameRegexp.MatchString(requestedUsername) { + msg := fmt.Sprintf("Authentication failure: requested username %q contains invalid characters", requestedUsername) + return &providerErrors.ForDisplayError{Message: msg} + } + + return nil +} + +// SupportsDeviceRegistration checks if the provider supports device registration. +func (p *Provider) SupportsDeviceRegistration() bool { + // The Microsoft Entra ID provider supports device registration. + return true +} + +// IsTokenForDeviceRegistration checks if the token is for device registration. +func (p *Provider) IsTokenForDeviceRegistration(token *oauth2.Token) (bool, error) { + accessToken, _, err := new(jwt.Parser).ParseUnverified(token.AccessToken, jwt.MapClaims{}) + if err != nil { + return false, fmt.Errorf("failed to parse access token: %v", err) + } + + appID, err := p.getAppID(accessToken) + if err != nil { + return false, fmt.Errorf("failed to get app ID from access token: %v", err) + } + + return appID == consts.MicrosoftBrokerAppID, nil +} + +// MaybeRegisterDevice checks if the device is already registered and registers it if not. +func (p *Provider) MaybeRegisterDevice( + ctx context.Context, + token *oauth2.Token, + username string, + issuerURL string, + jsonData []byte, +) (registrationData []byte, cleanup func(), err error) { + // If this function is called, it means that the token that we have is for device registration, + // so we can't use it to access the Microsoft Graph API. + p.needsAccessTokenForGraphAPI = true + + nop := func() {} + + // Check if the device is already registered + if len(jsonData) > 0 { + var data himmelblau.DeviceRegistrationData + if err := json.Unmarshal(jsonData, &data); err != nil { + log.Noticef(ctx, "Device registration JSON data: %s", string(jsonData)) + return nil, nil, fmt.Errorf("failed to unmarshal device registration data: %v", err) + } + if data.IsValid() { + return jsonData, nop, nil + } + } + + nameParts := strings.Split(username, "@") + if len(nameParts) != 2 { + return nil, nop, fmt.Errorf("invalid username format: %s, expected format is 'username@domain'", username) + } + domain := nameParts[1] + + data, cleanup, err := himmelblau.RegisterDevice(ctx, token, tenantID(issuerURL), domain) + if err != nil { + return nil, nop, err + } + + // Ensure that the cleanup function is called if we return an error. + defer func() { + if err != nil { + cleanup() + } + }() + + jsonData, err = json.Marshal(data) + if err != nil { + return nil, nop, fmt.Errorf("failed to marshal device registration data: %v", err) + } + + return jsonData, cleanup, nil +} + +// tenantID extracts the tenant ID from a Microsoft Entra ID issuer URL. +// For example, given: https://login.microsoftonline.com/8de88d99-6d0f-44d7-a8a5-925b012e5940/v2.0 +// it returns: 8de88d99-6d0f-44d7-a8a5-925b012e5940. +func tenantID(issuerURL string) string { + return strings.Split(strings.TrimPrefix(issuerURL, "https://login.microsoftonline.com/"), "/")[0] +} + +type azureTokenCredential struct { + token *jwt.Token +} + +// GetToken creates an azcore.AccessToken from an oauth2.Token. +func (c azureTokenCredential) GetToken(_ context.Context, _ policy.TokenRequestOptions) (azcore.AccessToken, error) { + claims, ok := c.token.Claims.(jwt.MapClaims) + if !ok { + return azcore.AccessToken{}, fmt.Errorf("failed to cast token claims to MapClaims: %v", c.token.Claims) + } + expiresOn, ok := claims["exp"].(float64) + if !ok { + return azcore.AccessToken{}, fmt.Errorf("failed to cast token expiration to float64: %v", claims["exp"]) + } + + return azcore.AccessToken{ + Token: c.token.Raw, + ExpiresOn: time.Unix(int64(expiresOn), 0), + }, nil +} + +// IsTokenExpiredError returns true if the reason for the error is that the refresh token is expired. +func (p *Provider) IsTokenExpiredError(err *oauth2.RetrieveError) bool { + return err.ErrorCode == "invalid_grant" && (strings.HasPrefix(err.ErrorDescription, "AADSTS50173:") || strings.HasPrefix(err.ErrorDescription, "AADSTS70043:")) +} + +// IsUserDisabledError returns true if the reason for the error is that the user is disabled. +func (p *Provider) IsUserDisabledError(err *oauth2.RetrieveError) bool { + return err.ErrorCode == "invalid_grant" && strings.HasPrefix(err.ErrorDescription, "AADSTS50057:") +} diff --git a/authd-oidc-brokers/internal/providers/msentraid/msentraid_test.go b/authd-oidc-brokers/internal/providers/msentraid/msentraid_test.go new file mode 100644 index 0000000000..673c234c01 --- /dev/null +++ b/authd-oidc-brokers/internal/providers/msentraid/msentraid_test.go @@ -0,0 +1,391 @@ +//go:build withmsentraid + +package msentraid_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/canonical/authd/authd-oidc-brokers/internal/consts" + "github.com/canonical/authd/authd-oidc-brokers/internal/providers/msentraid" + "github.com/canonical/authd/authd-oidc-brokers/internal/providers/msentraid/himmelblau" + "github.com/canonical/authd/authd-oidc-brokers/internal/testutils" + "github.com/canonical/authd/authd-oidc-brokers/internal/testutils/golden" + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/require" + "github.com/ubuntu/authd/log" + "golang.org/x/oauth2" +) + +var discoveryURLMu sync.RWMutex + +func TestNew(t *testing.T) { + p := msentraid.New() + + require.NotEmpty(t, p, "New should return a non-empty provider") +} + +func TestNormalizeUsername(t *testing.T) { + t.Parallel() + tests := map[string]struct { + username string + + wantNormalized string + }{ + "Shouldnt_change_all_lower_case": { + username: "name@email.com", + wantNormalized: "name@email.com", + }, + "Should_convert_all_to_lower_case": { + username: "NAME@email.com", + wantNormalized: "name@email.com", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + p := msentraid.New() + ret := p.NormalizeUsername(tc.username) + require.Equal(t, tc.wantNormalized, ret) + }) + } +} + +func TestVerifyUsername(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + requestedUsername string + authenticatedUser string + + wantErr bool + }{ + "Success_when_usernames_are_the_same": {requestedUsername: "foo-bar@example", authenticatedUser: "foo-bar@example"}, + "Success_when_usernames_differ_in_case": {requestedUsername: "foo-bar@example", authenticatedUser: "Foo-Bar@example"}, + + "Error_when_usernames_differ": {requestedUsername: "foo@example", authenticatedUser: "bar@foo", wantErr: true}, + "Error_when_requested_username_contains_invalid_characters": { + requestedUsername: "fóó@example", authenticatedUser: "foo@example", wantErr: true, + }, + "Error_when_authenticated_username_contains_invalid_characters": { + requestedUsername: "foo@example", authenticatedUser: "fóó@example", wantErr: true, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + p := msentraid.New() + + err := p.VerifyUsername(tc.requestedUsername, tc.authenticatedUser) + if tc.wantErr { + require.Error(t, err, "VerifyUsername should return an error") + return + } + + require.NoError(t, err, "VerifyUsername should not return an error") + }) + } +} + +func TestGetUserInfo(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + invalidIDToken bool + tokenScopes []string + providerMetadata map[string]any + acquireAccessToken bool + + groupEndpointHandler http.HandlerFunc + + wantErr bool + }{ + "Successfully_get_user_info": {}, + + "Error_when_id_token_claims_are_invalid": {invalidIDToken: true, wantErr: true}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + idToken := validIDToken + if tc.invalidIDToken { + idToken = invalidIDToken + } + + p := msentraid.New() + + got, err := p.GetUserInfo(idToken) + if tc.wantErr { + require.Error(t, err, "GetUserInfo should return an error") + return + } + require.NoError(t, err, "GetUserInfo should not return an error") + + golden.CheckOrUpdateYAML(t, got) + }) + } +} + +func TestGetGroups(t *testing.T) { + t.Parallel() + + accessToken := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{}) + accessTokenStr, err := accessToken.SignedString(testutils.MockKey) + require.NoError(t, err, "Failed to sign access token") + token := &oauth2.Token{ + AccessToken: accessTokenStr, + RefreshToken: "refreshtoken", + Expiry: time.Now().Add(1000 * time.Hour), + } + + tests := map[string]struct { + tokenScopes []string + providerMetadata map[string]any + acquireAccessToken bool + + groupEndpointHandler http.HandlerFunc + + wantErr bool + }{ + "Successfully_get_groups": {}, + "Successfully_get_groups_with_local_groups": {groupEndpointHandler: localGroupHandler}, + "Successfully_get_groups_with_mixed_groups": {groupEndpointHandler: mixedGroupHandler}, + "Successfully_get_groups_filtering_non_security_groups": {groupEndpointHandler: nonSecurityGroupHandler}, + "Successfully_get_groups_with_acquired_access_token": {acquireAccessToken: true}, + + "Error_when_msgraph_host_is_invalid": {providerMetadata: map[string]any{"msgraph_host": "invalid"}, wantErr: true}, + "Error_when_token_does_not_have_required_scopes": {tokenScopes: []string{"not the required scopes"}, wantErr: true}, + "Error_when_getting_user_groups_fails": {groupEndpointHandler: errorGroupHandler, wantErr: true}, + "Error_when_group_is_missing_id": {groupEndpointHandler: missingIDGroupHandler, wantErr: true}, + "Error_when_group_is_missing_display_name": {groupEndpointHandler: missingDisplayNameGroupHandler, wantErr: true}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + if tc.tokenScopes == nil { + tc.tokenScopes = strings.Split(msentraid.AllExpectedScopes(), " ") + } + + if tc.providerMetadata == nil { + mockServer, cleanup := startMockMSServer(t, &mockMSServerConfig{ + GroupEndpointHandler: tc.groupEndpointHandler, + }) + t.Cleanup(cleanup) + tc.providerMetadata = map[string]any{"msgraph_host": mockServer.URL} + } + + var deviceRegistrationData []byte + if tc.acquireAccessToken { + var cleanup func() + deviceRegistrationData, cleanup, err = maybeRegisterDevice(t, nil) + t.Cleanup(cleanup) + require.NoError(t, err, "maybeRegisterDevice should not return an error") + } + + p := msentraid.New() + p.SetNeedsAccessTokenForGraphAPI(tc.acquireAccessToken) + p.SetTokenScopesForGraphAPI(tc.tokenScopes) + + got, err := p.GetGroups( + context.Background(), + "", + "", + token, + tc.providerMetadata, + deviceRegistrationData, + ) + if tc.wantErr { + require.Error(t, err, "GetUserInfo should return an error") + return + } + require.NoError(t, err, "GetUserInfo should not return an error") + + golden.CheckOrUpdateYAML(t, got) + }) + } +} + +func TestIsTokenForDeviceRegistration(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + appID string + invalidToken bool + + want bool + wantErr bool + }{ + "Success_when_token_has_microsoft_broker_app_ID": {appID: consts.MicrosoftBrokerAppID, want: true}, + "Success_when_token_has_other_app_ID": {appID: "some-other-app-id", want: false}, + "Success_when_token_has_empty_app_ID": {appID: "", want: false}, + + "Error_when_token_has_no_app_ID": {appID: "-", wantErr: true}, + "Error_when_token_is_invalid": {invalidToken: true, wantErr: true}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + claims := jwt.MapClaims{"appid": tc.appID} + if tc.appID == "-" { + claims = jwt.MapClaims{} + } + + accessToken := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + accessTokenString, err := accessToken.SignedString(testutils.MockKey) + require.NoError(t, err, "Failed to sign access token") + + if tc.invalidToken { + accessTokenString = "invalid-token" + } + + token := &oauth2.Token{AccessToken: accessTokenString} + + p := msentraid.New() + got, err := p.IsTokenForDeviceRegistration(token) + + if tc.wantErr { + require.Error(t, err, "IsTokenForDeviceRegistration should return an error") + return + } + require.NoError(t, err, "IsTokenForDeviceRegistration should not return an error") + require.Equal(t, tc.want, got, "IsTokenForDeviceRegistration should return the expected value") + }) + } +} + +func TestMaybeRegisterDevice(t *testing.T) { + t.Parallel() + + registrationData, err := json.Marshal(&himmelblau.DeviceRegistrationData{ + DeviceID: "test-device-id", + CertKey: []byte("test-cert-key"), + TransportKey: []byte("test-transport-key"), + AuthValue: "test-auth-value", + TPMMachineKey: []byte("test-tpm-machine-key"), + }) + require.NoError(t, err, "Failed to marshal device registration data") + + type args = maybeRegisterDeviceArgs + + tests := map[string]struct { + args + + wantErr bool + }{ + "Successfully_registers_device": {}, + "Reuses_existing_device_registration": {args: args{oldData: registrationData}}, + + "Error_when_username_does_not_have_a_domain": {args: args{username: "userwithoutdomain"}, wantErr: true}, + "Error_when_discover_url_is_invalid_format": {args: args{discoveryURL: "invalid-url"}, wantErr: true}, + "Error_when_discover_url_is_unreachable": {args: args{discoveryURL: "http://invalid-url"}, wantErr: true}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + registrationData, cleanup, err := maybeRegisterDevice(t, &tc.args) + t.Cleanup(cleanup) + if tc.wantErr { + require.Error(t, err, "MaybeRegisterDevice should return an error") + return + } + require.NoError(t, err, "MaybeRegisterDevice should not return an error") + + if tc.oldData != nil { + require.Equal(t, tc.oldData, registrationData, "MaybeRegisterDevice should return the existing registration data") + } + + // We don't compare the registration data with a golden file, because it differs every time due to the + // generated keys. Instead, we just check that it's not empty. + require.NotEmpty(t, registrationData, "MaybeRegisterDevice should return non-empty registration data") + }) + } +} + +type maybeRegisterDeviceArgs struct { + username string + oldData []byte + discoveryURL string +} + +func maybeRegisterDevice( + t *testing.T, + args *maybeRegisterDeviceArgs, +) ([]byte, func(), error) { + // Start the mock MS server (or reuse the existing one) + ensureMockMSServerForDeviceRegistration(t) + mockServer := mockMSServerForDeviceRegistration + + if args == nil { + args = &maybeRegisterDeviceArgs{} + } + + if args.discoveryURL == "" { + args.discoveryURL = mockServer.URL + } + + if args.username == "" { + args.username = "user@example.com" + } + + // Make libhimmelblau use the mock MS server. These settings are global, + // so test case which need different settings must not run in parallel. + if args.discoveryURL == "" { + // We don't need to set the environment variable, just ensure no other test is modifying it while we run. + discoveryURLMu.RLock() + defer discoveryURLMu.RUnlock() + } else { + // Set the environment variable for the duration of the test. + discoveryURLMu.Lock() + oldValue := os.Getenv("HIMMELBLAU_DISCOVERY_URL") + err := os.Setenv("HIMMELBLAU_DISCOVERY_URL", args.discoveryURL) + require.NoError(t, err, "Failed to set HIMMELBLAU_DISCOVERY_URL environment variable") + defer func() { + err := os.Setenv("HIMMELBLAU_DISCOVERY_URL", oldValue) + discoveryURLMu.Unlock() + require.NoError(t, err, "Failed to unset HIMMELBLAU_DISCOVERY_URL environment variable") + }() + } + + accessToken := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{}) + accessTokenStr, err := accessToken.SignedString(testutils.MockKey) + require.NoError(t, err, "Failed to sign access token") + token := &oauth2.Token{ + AccessToken: accessTokenStr, + RefreshToken: "refreshtoken", + Expiry: time.Now().Add(1000 * time.Hour), + } + + tenantID := "8de88d99-6d0f-44d7-a8a5-925b012e5940" + issuerURL := fmt.Sprintf("https://login.microsoftonline.com/%s/v2.0", tenantID) + + p := msentraid.New() + + return p.MaybeRegisterDevice( + context.Background(), + token, + args.username, + issuerURL, + args.oldData, + ) +} + +func TestMain(m *testing.M) { + log.SetLevel(log.DebugLevel) + + m.Run() +} diff --git a/authd-oidc-brokers/internal/providers/msentraid/msmock_test.go b/authd-oidc-brokers/internal/providers/msentraid/msmock_test.go new file mode 100644 index 0000000000..c597db3589 --- /dev/null +++ b/authd-oidc-brokers/internal/providers/msentraid/msmock_test.go @@ -0,0 +1,519 @@ +//go:build withmsentraid + +package msentraid_test + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "html" + "io" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/canonical/authd/authd-oidc-brokers/internal/providers/msentraid/himmelblau" + "github.com/go-jose/go-jose/v4" + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/require" +) + +var mockMSServerForDeviceRegistration *mockMSServer +var mockMSServerForDeviceRegistrationOnce sync.Once + +func ensureMockMSServerForDeviceRegistration(t *testing.T) { + mockMSServerForDeviceRegistrationOnce.Do(func() { + mockMSServerForDeviceRegistration, _ = startMockMSServer(t, &mockMSServerConfig{ + GroupEndpointHandler: simpleGroupHandler, + }) + + himmelblau.SetAuthorityBaseURL(t, mockMSServerForDeviceRegistration.URL) + err := os.Setenv("HIMMELBLAU_DISCOVERY_URL", mockMSServerForDeviceRegistration.URL) + require.NoError(t, err, "failed to set HIMMELBLAU_DISCOVERY_URL") + }) +} + +type mockMSServer struct { + *httptest.Server + + rsaPrivateKey *rsa.PrivateKey + transportKeyBySPKI map[string]*rsa.PublicKey + transportKeyMu sync.RWMutex +} + +type mockMSServerConfig struct { + // TenantID is the tenant ID to use in the token endpoint URL. + // If empty, requests to the token endpoint will be accepted for any tenant. + TenantID string + GroupEndpointHandler http.HandlerFunc +} + +func startMockMSServer(t *testing.T, config *mockMSServerConfig) (mockServer *mockMSServer, cleanup func()) { + if config == nil { + config = &mockMSServerConfig{} + } + + if config.GroupEndpointHandler == nil { + config.GroupEndpointHandler = simpleGroupHandler + } + + rsaPrivateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err, "failed to generate RSA private key") + + m := &mockMSServer{ + rsaPrivateKey: rsaPrivateKey, + transportKeyBySPKI: make(map[string]*rsa.PublicKey), + } + + m.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(os.Stderr, "Mock MS server received request: %s %s\n", r.Method, r.URL.Path) + + switch { + // ===== login.microsoftonline.com ===== + case r.Method == http.MethodPost && isTokenEndpoint(r.URL.Path, config.TenantID): + m.handleTokenRequest(t, w, r) + + case r.Method == http.MethodGet && isAuthorizeEndpoint(r.URL.Path, config.TenantID): + m.handleAuthorizeRequest(t, w, r) + + // ===== enterpriseregistration.windows.net ===== + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/Discover"): + m.handleDiscoverRequest(t, w, r) + + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/EnrollmentServer/device/"): + m.handleDeviceEnrollmentRequest(t, w, r) + + // ===== graph.microsoft.com ===== + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/me/transitiveMemberOf/graph.group"): + config.GroupEndpointHandler(w, r) + + default: + require.Fail(t, "unexpected request", "path=%s, method=%s", r.URL.Path, r.Method) + } + })) + + cleanup = func() { m.Close() } + + return m, cleanup +} + +func (m *mockMSServer) Close() { + m.Server.Close() +} + +// isTokenEndpoint returns true if the given path is the token endpoint for the given tenant. +// Both the v2.0 and v1.0 endpoints are supported. +// If tenantID is empty, requests for any tenant will be accepted. +func isTokenEndpoint(path, tenantID string) bool { + path = strings.ToLower(path) + + if tenantID != "" { + return path == "/"+tenantID+"/oauth2/v2.0/token" || path == "/"+tenantID+"/oauth2/token" + } + return strings.HasSuffix(path, "/oauth2/v2.0/token") || strings.HasSuffix(path, "/oauth2/token") +} + +func isAuthorizeEndpoint(path, tenantID string) bool { + path = strings.ToLower(path) + + if tenantID != "" { + return path == "/"+tenantID+"/oauth2/v2.0/authorize" + } + return strings.HasSuffix(path, "/oauth2/v2.0/authorize") +} + +// ----- handlers ----- + +func (m *mockMSServer) handleTokenRequest(t *testing.T, w http.ResponseWriter, r *http.Request) { + //nolint:gosec // G120 - test mock server, no untrusted input. + err := r.ParseForm() + require.NoError(t, err, "failed to parse form") + + body, err := io.ReadAll(r.Body) + require.NoError(t, err, "failed to read request body") + fmt.Fprintf(os.Stderr, "Mock MS server received token request - form: %s, body: %s\n", r.Form, string(body)) + + // The grant_type can be passed as form data or as the body + grantType := r.Form.Get("grant_type") + if grantType == "" && strings.HasPrefix(string(body), "grant_type=") { + grantType = strings.TrimPrefix(string(body), "grant_type=") + } + + switch grantType { + case "refresh_token": + m.handleRefreshTokenRequest(t, w, r) + + case "srv_challenge": + m.handleNonceRequest(t, w, r) + + case "urn:ietf:params:oauth:grant-type:jwt-bearer": + m.handlePRTRequest(t, w, r) + + case "authorization_code": + m.handleAuthorizationCodeRequest(t, w, r) + + default: + t.Fatalf("unexpected grant_type in token request: %s", grantType) + } +} + +func (m *mockMSServer) handleAuthorizeRequest(t *testing.T, w http.ResponseWriter, r *http.Request) { + // Example path: //oAuth2/v2.0/authorize + // Example query: client_id=...&response_type=code&redirect_uri=...&client-request-id=...&scope=... + + q := r.URL.Query() + clientID := q.Get("client_id") + redirectURI := q.Get("redirect_uri") + respType := q.Get("response_type") + scope := q.Get("scope") + reqID := q.Get("client-request-id") + + fmt.Fprintf(os.Stderr, "Mock MS server authorize request: client_id=%s, response_type=%s, redirect_uri=%s, scope=%s, client-request-id=%s\n", + clientID, respType, redirectURI, scope, reqID) + + require.Equal(t, "code", respType, "unexpected response_type") + require.NotEmpty(t, redirectURI, "missing redirect_uri") + + // Construct the final redirect URL: {redirect_uri}?code=... + redir, err := url.Parse(redirectURI) + require.NoError(t, err, "failed to parse redirect_uri") + params := redir.Query() + params.Set("code", "mock-code") + redir.RawQuery = params.Encode() + redirectStr := redir.String() + + jsURL, err := json.Marshal(redirectStr) + require.NoError(t, err, "failed to encode redirect URI for javascript") + + htmlBody := fmt.Sprintf(` + + Working… + + + + +`, jsURL, html.EscapeString(redirectStr)) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(htmlBody)) +} + +func (m *mockMSServer) handlePRTRequest(t *testing.T, w http.ResponseWriter, r *http.Request) { + fmt.Fprint(os.Stderr, "Mock MS server responding with PRT\n") + + reqJWT := r.Form.Get("request") + spki := spkiFromJWTx5c(t, reqJWT) + m.transportKeyMu.Lock() + transportKey, ok := m.transportKeyBySPKI[spki] + m.transportKeyMu.Unlock() + require.True(t, ok, "no transport key for SPKI %s, transport key map: %v", spki, m.transportKeyBySPKI) + + resp := map[string]any{ + "token_type": "Bearer", + "expires_in": "3600", + "ext_expires_in": "3600", + "expires_on": "9999999999", + "refresh_token": "mock-refresh-token", + "refresh_token_expires_in": 7200, + "session_key_jwe": m.generateMockJWE(t, transportKey), + "id_token": map[string]string{ + "name": "Mock User", + "oid": "00000000-0000-0000-0000-000000000000", + "tid": "11111111-1111-1111-1111-111111111111", + }, + "client_info": map[string]string{ + "uid": "11111111-1111-1111-1111-111111111111", + "utid": "22222222-2222-2222-2222-222222222222", + }, + } + fmt.Fprintf(os.Stderr, "Mock MS server responding with PRT: %+v\n", resp) + + w.Header().Set("Content-Type", "application/json") + err := json.NewEncoder(w).Encode(resp) + require.NoError(t, err, "failed to encode response") +} + +func (m *mockMSServer) handleAuthorizationCodeRequest(t *testing.T, w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(os.Stderr, "Mock MS server handling authorization code request\n") + + accessToken := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{}) + accessTokenStr, err := accessToken.SignedString(m.rsaPrivateKey) + require.NoError(t, err, "failed to sign access token") + + resp := map[string]interface{}{ + "token_type": "Bearer", + "expires_in": 3600, + "ext_expires_in": 3600, + "access_token": accessTokenStr, + "refresh_token": "mock-refresh-token", + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + err = json.NewEncoder(w).Encode(resp) + require.NoError(t, err, "failed to encode response") +} + +func (m *mockMSServer) handleNonceRequest(t *testing.T, w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(os.Stderr, "Mock MS server responding with nonce\n") + resp := map[string]string{ + "Nonce": "mock-nonce-1234", + } + w.Header().Set("Content-Type", "application/json") + err := json.NewEncoder(w).Encode(resp) + require.NoError(t, err, "failed to encode response") +} + +func (m *mockMSServer) handleRefreshTokenRequest(t *testing.T, w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(os.Stderr, "Mock MS server responding with access token from refresh\n") + resp := map[string]any{ + "token_type": "Bearer", + "expires_in": 3600, + "ext_expires_in": 3600, + "access_token": "mock_access_token", + "refresh_token": "mock_refresh_token", + "id_token": map[string]string{ + "name": "Mock User", + "oid": "00000000-0000-0000-0000-000000000000", + "tid": "11111111-1111-1111-1111-111111111111", + }, + "client_info": map[string]string{ + "uid": "11111111-1111-1111-1111-111111111111", + "utid": "22222222-2222-2222-2222-222222222222", + }, + } + w.Header().Set("Content-Type", "application/json") + err := json.NewEncoder(w).Encode(resp) + require.NoError(t, err, "failed to encode response") +} + +func (m *mockMSServer) handleDiscoverRequest(t *testing.T, w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(os.Stderr, "Mock MS server responding with enrollment discovery\n") + resp := map[string]any{ + "DeviceJoinService": map[string]string{ + "JoinEndpoint": m.URL + "/EnrollmentServer/device/", + }, + } + w.Header().Set("Content-Type", "application/json") + err := json.NewEncoder(w).Encode(resp) + require.NoError(t, err, "failed to encode response") +} + +func (m *mockMSServer) handleDeviceEnrollmentRequest(t *testing.T, w http.ResponseWriter, r *http.Request) { + fmt.Fprint(os.Stderr, "Mock MS server responding with device enrollment\n") + + var req struct { + CertificateRequest struct { + Data string `json:"Data"` + Type string `json:"Type"` + } `json:"CertificateRequest"` + TransportKey string `json:"TransportKey"` + } + err := json.NewDecoder(r.Body).Decode(&req) + require.NoError(t, err, "failed to parse request") + + csrDER, err := base64.StdEncoding.DecodeString(req.CertificateRequest.Data) + require.NoError(t, err, "failed to decode CSR data") + + csr, err := x509.ParseCertificateRequest(csrDER) + require.NoError(t, err, "failed to parse CSR") + + m.transportKeyMu.RLock() + m.transportKeyBySPKI[spkiFingerprint(csr.RawSubjectPublicKeyInfo)] = parseBcryptRSAPublicBlob(t, req.TransportKey) + m.transportKeyMu.RUnlock() + + // Create a self-signed cert using the CSR's public key + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: csr.Subject, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + BasicConstraintsValid: true, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, csr.PublicKey, m.rsaPrivateKey) + require.NoError(t, err, "failed to create certificate") + + resp := map[string]any{ + "Certificate": map[string]string{ + "RawBody": base64.StdEncoding.EncodeToString(certDER), + }, + } + w.Header().Set("Content-Type", "application/json") + err = json.NewEncoder(w).Encode(resp) + require.NoError(t, err, "failed to encode response") +} + +// ----- group endpoint handlers ----- + +// simpleGroupHandler simulates a successful response with a list of groups. +func simpleGroupHandler(w http.ResponseWriter, r *http.Request) { + response := map[string]any{ + "value": []map[string]any{ + {"id": "id1", "displayName": "Group1", "securityEnabled": true}, + {"id": "id2", "displayName": "Group2", "securityEnabled": true}, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) +} + +// localGroupHandler simulates a successful response with a list of local groups. +func localGroupHandler(w http.ResponseWriter, r *http.Request) { + response := map[string]any{ + "value": []map[string]any{ + {"id": "local-id1", "displayName": "linux-local1", "securityEnabled": true}, + {"id": "local-id2", "displayName": "linux-local2", "securityEnabled": true}, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) +} + +// mixedGroupHandler simulates a successful response with a list of mixed remote and local groups. +func mixedGroupHandler(w http.ResponseWriter, r *http.Request) { + response := map[string]any{ + "value": []map[string]any{ + {"id": "id1", "displayName": "Group1", "securityEnabled": true}, + {"id": "local-id1", "displayName": "linux-local1", "securityEnabled": true}, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) +} + +// nonSecurityGroupHandler simulates a successful response with a list of groups including non-security groups. +func nonSecurityGroupHandler(w http.ResponseWriter, r *http.Request) { + response := map[string]any{ + "value": []map[string]any{ + {"id": "id1", "displayName": "Group1", "securityEnabled": true}, + {"id": "non-security-id", "displayName": "non-security"}, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) +} + +// missingIDGroupHandler simulates a successful response with a list of groups missing the ID field. +func missingIDGroupHandler(w http.ResponseWriter, r *http.Request) { + response := map[string]any{ + "value": []map[string]any{ + {"displayName": "Group1", "securityEnabled": true}, + {"id": "id2", "displayName": "Group2", "securityEnabled": true}, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) +} + +// missingDisplayNameGroupHandler simulates a successful response with a list of groups missing the displayName field. +func missingDisplayNameGroupHandler(w http.ResponseWriter, r *http.Request) { + response := map[string]any{ + "value": []map[string]any{ + {"id": "id1", "securityEnabled": true}, + {"id": "id2", "displayName": "Group2", "securityEnabled": true}, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) +} + +// errorGroupHandler simulates an error response from the server. +func errorGroupHandler(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) +} + +// ----- helpers ----- + +// parseBcryptRSAPublicBlob decodes a BCRYPT_RSAPUBLIC_BLOB ("RSA1" magic, little-endian header) as used by the Windows CryptoAPI. +// This format is not supported by the Go standard library. +func parseBcryptRSAPublicBlob(t *testing.T, b64 string) *rsa.PublicKey { + raw, err := base64.StdEncoding.DecodeString(b64) + require.NoError(t, err, "failed to base64-decode transport key") + + require.Equal(t, "RSA1", string(raw[:4]), "invalid RSA1 blob") + + cbExp := binary.LittleEndian.Uint32(raw[8:12]) + cbMod := binary.LittleEndian.Uint32(raw[12:16]) + + offset := 24 + eBytes := raw[offset : offset+int(cbExp)] + offset += int(cbExp) + mBytes := raw[offset : offset+int(cbMod)] + + // Convert exponent bytes (big-endian) to int + e := 0 + for _, b := range eBytes { + e = (e << 8) | int(b) + } + + n := new(big.Int).SetBytes(mBytes) // modulus is big-endian in this blob + + return &rsa.PublicKey{N: n, E: e} +} + +func (m *mockMSServer) generateMockJWE(t *testing.T, transportKey *rsa.PublicKey) string { + opts := &jose.EncrypterOptions{} + + encrypter, err := jose.NewEncrypter( + jose.A256GCM, + jose.Recipient{Algorithm: jose.RSA_OAEP, Key: transportKey}, + opts, + ) + require.NoError(t, err, "failed to create encrypter") + + // Payload is ignored by the client, keep it simple + payload := []byte(`{}`) + + obj, err := encrypter.Encrypt(payload) + require.NoError(t, err, "failed to encrypt payload") + + jwe, err := obj.CompactSerialize() + require.NoError(t, err, "failed to serialize JWE") + + return jwe +} + +// Extract SPKI fingerprint from the request JWT's header x5c. +func spkiFromJWTx5c(t *testing.T, reqJWT string) string { + parts := strings.Split(reqJWT, ".") + require.GreaterOrEqual(t, len(parts), 2, "invalid JWT") + + hdrJSON, err := base64.RawURLEncoding.DecodeString(parts[0]) + require.NoError(t, err, "decode header") + + var hdr struct { + X5c []string `json:"x5c"` + } + err = json.Unmarshal(hdrJSON, &hdr) + require.NoError(t, err, "unmarshal header") + require.NotEmpty(t, hdr.X5c, "x5c missing") + + der, err := base64.StdEncoding.DecodeString(hdr.X5c[0]) + require.NoError(t, err, "x5c b64") + + cert, err := x509.ParseCertificate(der) + require.NoError(t, err, "x5c parse cert") + + return spkiFingerprint(cert.RawSubjectPublicKeyInfo) +} + +func spkiFingerprint(spkiDER []byte) string { + sum := sha256.Sum256(spkiDER) + return hex.EncodeToString(sum[:]) +} diff --git a/authd-oidc-brokers/internal/providers/msentraid/testdata/golden/TestGetGroups/Successfully_get_groups b/authd-oidc-brokers/internal/providers/msentraid/testdata/golden/TestGetGroups/Successfully_get_groups new file mode 100644 index 0000000000..781c44561e --- /dev/null +++ b/authd-oidc-brokers/internal/providers/msentraid/testdata/golden/TestGetGroups/Successfully_get_groups @@ -0,0 +1,4 @@ +- name: group1 + ugid: id1 +- name: group2 + ugid: id2 diff --git a/authd-oidc-brokers/internal/providers/msentraid/testdata/golden/TestGetGroups/Successfully_get_groups_filtering_non_security_groups b/authd-oidc-brokers/internal/providers/msentraid/testdata/golden/TestGetGroups/Successfully_get_groups_filtering_non_security_groups new file mode 100644 index 0000000000..368fbd4757 --- /dev/null +++ b/authd-oidc-brokers/internal/providers/msentraid/testdata/golden/TestGetGroups/Successfully_get_groups_filtering_non_security_groups @@ -0,0 +1,2 @@ +- name: group1 + ugid: id1 diff --git a/authd-oidc-brokers/internal/providers/msentraid/testdata/golden/TestGetGroups/Successfully_get_groups_with_acquired_access_token b/authd-oidc-brokers/internal/providers/msentraid/testdata/golden/TestGetGroups/Successfully_get_groups_with_acquired_access_token new file mode 100644 index 0000000000..781c44561e --- /dev/null +++ b/authd-oidc-brokers/internal/providers/msentraid/testdata/golden/TestGetGroups/Successfully_get_groups_with_acquired_access_token @@ -0,0 +1,4 @@ +- name: group1 + ugid: id1 +- name: group2 + ugid: id2 diff --git a/authd-oidc-brokers/internal/providers/msentraid/testdata/golden/TestGetGroups/Successfully_get_groups_with_local_groups b/authd-oidc-brokers/internal/providers/msentraid/testdata/golden/TestGetGroups/Successfully_get_groups_with_local_groups new file mode 100644 index 0000000000..20d38a219a --- /dev/null +++ b/authd-oidc-brokers/internal/providers/msentraid/testdata/golden/TestGetGroups/Successfully_get_groups_with_local_groups @@ -0,0 +1,4 @@ +- name: local1 + ugid: "" +- name: local2 + ugid: "" diff --git a/authd-oidc-brokers/internal/providers/msentraid/testdata/golden/TestGetGroups/Successfully_get_groups_with_mixed_groups b/authd-oidc-brokers/internal/providers/msentraid/testdata/golden/TestGetGroups/Successfully_get_groups_with_mixed_groups new file mode 100644 index 0000000000..a6c80f7619 --- /dev/null +++ b/authd-oidc-brokers/internal/providers/msentraid/testdata/golden/TestGetGroups/Successfully_get_groups_with_mixed_groups @@ -0,0 +1,4 @@ +- name: group1 + ugid: id1 +- name: local1 + ugid: "" diff --git a/authd-oidc-brokers/internal/providers/msentraid/testdata/golden/TestGetUserInfo/Successfully_get_user_info b/authd-oidc-brokers/internal/providers/msentraid/testdata/golden/TestGetUserInfo/Successfully_get_user_info new file mode 100644 index 0000000000..b8c7d0c961 --- /dev/null +++ b/authd-oidc-brokers/internal/providers/msentraid/testdata/golden/TestGetUserInfo/Successfully_get_user_info @@ -0,0 +1,6 @@ +name: valid-user +uuid: valid-sub +home: /home/valid-user +shell: /bin/bash +gecos: Valid User +groups: [] diff --git a/authd-oidc-brokers/internal/providers/providers.go b/authd-oidc-brokers/internal/providers/providers.go new file mode 100644 index 0000000000..8831acf7c6 --- /dev/null +++ b/authd-oidc-brokers/internal/providers/providers.go @@ -0,0 +1,46 @@ +// Package providers define provider-specific configurations and functions to be used by the OIDC broker. +package providers + +import ( + "context" + + "github.com/canonical/authd/authd-oidc-brokers/internal/providers/info" + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +// Provider defines provider-specific methods to be used by the broker. +type Provider interface { + AdditionalScopes() []string + AuthOptions() []oauth2.AuthCodeOption + GetExtraFields(token *oauth2.Token) map[string]interface{} + GetMetadata(provider *oidc.Provider) (map[string]interface{}, error) + + GetUserInfo(idToken info.Claimer) (info.User, error) + + GetGroups( + ctx context.Context, + clientID string, + issuerURL string, + token *oauth2.Token, + providerMetadata map[string]interface{}, + deviceRegistrationData []byte, + ) ([]info.Group, error) + + IsTokenExpiredError(err *oauth2.RetrieveError) bool + IsUserDisabledError(err *oauth2.RetrieveError) bool + IsTokenForDeviceRegistration(token *oauth2.Token) (bool, error) + + MaybeRegisterDevice( + ctx context.Context, + token *oauth2.Token, + username string, + issuerURL string, + deviceRegistrationData []byte, + ) ([]byte, func(), error) + + NormalizeUsername(username string) string + SupportedOIDCAuthModes() []string + VerifyUsername(requestedUsername, authenticatedUsername string) error + SupportsDeviceRegistration() bool +} diff --git a/authd-oidc-brokers/internal/providers/withgoogle.go b/authd-oidc-brokers/internal/providers/withgoogle.go new file mode 100644 index 0000000000..912decfe6e --- /dev/null +++ b/authd-oidc-brokers/internal/providers/withgoogle.go @@ -0,0 +1,10 @@ +//go:build withgoogle + +package providers + +import "github.com/canonical/authd/authd-oidc-brokers/internal/providers/google" + +// CurrentProvider returns a Google provider implementation. +func CurrentProvider() Provider { + return google.New() +} diff --git a/authd-oidc-brokers/internal/providers/withmsentraid.go b/authd-oidc-brokers/internal/providers/withmsentraid.go new file mode 100644 index 0000000000..b246f00c43 --- /dev/null +++ b/authd-oidc-brokers/internal/providers/withmsentraid.go @@ -0,0 +1,12 @@ +//go:build withmsentraid + +package providers + +import ( + "github.com/canonical/authd/authd-oidc-brokers/internal/providers/msentraid" +) + +// CurrentProvider returns a Microsoft Entra ID provider implementation. +func CurrentProvider() Provider { + return msentraid.New() +} diff --git a/authd-oidc-brokers/internal/testutils/dbus.go b/authd-oidc-brokers/internal/testutils/dbus.go new file mode 100644 index 0000000000..1537ad0b7a --- /dev/null +++ b/authd-oidc-brokers/internal/testutils/dbus.go @@ -0,0 +1,102 @@ +// Package testutils provides utility functions and behaviors for testing. +package testutils + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/godbus/dbus/v5" +) + +const defaultSystemBusAddress = "unix:path=/var/run/dbus/system_bus_socket" + +var systemBusMockCfg = ` + + system + + unix:path=%s + + + + + + + +` + +// StartSystemBusMock starts a mock dbus daemon and returns a cancel function to stop it. +// +// This function uses os.Setenv to set the DBUS_SYSTEM_BUS_ADDRESS environment, so it shouldn't be used in parallel tests +// that rely on the mentioned variable. +func StartSystemBusMock() (func(), error) { + if isRunning() { + return nil, errors.New("system bus mock is already running") + } + + tmp, err := os.MkdirTemp(os.TempDir(), "authd-system-bus-mock") + if err != nil { + return nil, err + } + + cfgPath := filepath.Join(tmp, "bus.conf") + listenPath := filepath.Join(tmp, "bus.sock") + + err = os.WriteFile(cfgPath, []byte(fmt.Sprintf(systemBusMockCfg, listenPath)), 0600) + if err != nil { + err = errors.Join(err, os.RemoveAll(tmp)) + return nil, err + } + + busCtx, busCancel := context.WithCancel(context.Background()) + //#nosec:G204 // This is a test helper and we are in control of the arguments. + cmd := exec.CommandContext(busCtx, "dbus-daemon", "--config-file="+cfgPath) + if err := cmd.Start(); err != nil { + busCancel() + err = errors.Join(err, os.RemoveAll(tmp)) + return nil, err + } + // Give some time for the daemon to start. + time.Sleep(500 * time.Millisecond) + + prev, set := os.LookupEnv("DBUS_SYSTEM_BUS_ADDRESS") + os.Setenv("DBUS_SYSTEM_BUS_ADDRESS", "unix:path="+listenPath) + + return func() { + busCancel() + _ = cmd.Wait() + _ = os.RemoveAll(tmp) + + if !set { + _ = os.Unsetenv("DBUS_SYSTEM_BUS_ADDRESS") + } else { + _ = os.Setenv("DBUS_SYSTEM_BUS_ADDRESS", prev) + } + }, nil +} + +// GetSystemBusConnection returns a connection to the system bus with a safety check to avoid mistakenly connecting to the +// actual system bus. +func GetSystemBusConnection(t *testing.T) (*dbus.Conn, error) { + t.Helper() + if !isRunning() { + return nil, errors.New("system bus mock is not running. If that's intended, manually connect to the system bus instead of using this function") + } + conn, err := dbus.ConnectSystemBus() + if err != nil { + return nil, err + } + return conn, nil +} + +// isRunning checks if the system bus mock is running. +func isRunning() bool { + busAddr := os.Getenv("DBUS_SYSTEM_BUS_ADDRESS") + return busAddr != "" && busAddr != defaultSystemBusAddress +} diff --git a/authd-oidc-brokers/internal/testutils/golden/golden.go b/authd-oidc-brokers/internal/testutils/golden/golden.go new file mode 100644 index 0000000000..a9b1706d11 --- /dev/null +++ b/authd-oidc-brokers/internal/testutils/golden/golden.go @@ -0,0 +1,381 @@ +// Package golden provides utilities to compare and update golden files in tests. +package golden + +import ( + "bytes" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/otiai10/copy" + "github.com/pmezard/go-difflib/difflib" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +var update bool + +const ( + // UpdateGoldenFilesEnv is the environment variable used to indicate go test that + // the golden files should be overwritten with the current test results. + UpdateGoldenFilesEnv = `TESTS_UPDATE_GOLDEN` +) + +func init() { + if os.Getenv(UpdateGoldenFilesEnv) != "" { + update = true + } +} + +type config struct { + path string +} + +// Option is a supported option reference to change the golden files comparison. +type Option func(*config) + +// WithPath overrides the default path for golden files used. +func WithPath(path string) Option { + return func(cfg *config) { + if path != "" { + cfg.path = path + } + } +} + +func updateGoldenFile(t *testing.T, path string, data []byte) { + t.Helper() + + t.Logf("updating golden file %s", path) + err := os.MkdirAll(filepath.Dir(path), 0750) + require.NoError(t, err, "Cannot create directory for updating golden files") + err = os.WriteFile(path, data, 0600) + require.NoError(t, err, "Cannot write golden file") +} + +// CheckOrUpdate compares the provided string with the content of the golden file. If the update environment +// variable is set, the golden file is updated with the provided string. +func CheckOrUpdate(t *testing.T, got string, options ...Option) { + t.Helper() + + cfg := config{} + for _, f := range options { + f(&cfg) + } + if !filepath.IsAbs(cfg.path) { + cfg.path = filepath.Join(Path(t), cfg.path) + } + + if update { + updateGoldenFile(t, cfg.path, []byte(got)) + } + + checkGoldenFileEqualsString(t, got, cfg.path) +} + +// CheckOrUpdateYAML compares the provided object with the content of the golden file. If the update environment +// variable is set, the golden file is updated with the provided object serialized as YAML. +func CheckOrUpdateYAML[E any](t *testing.T, got E, options ...Option) { + t.Helper() + + data, err := yaml.Marshal(got) + require.NoError(t, err, "Cannot serialize provided object") + + CheckOrUpdate(t, string(data), options...) +} + +// LoadWithUpdate loads the element from a plaintext golden file. +// It will update the file if the update flag is used prior to loading it. +func LoadWithUpdate(t *testing.T, data string, options ...Option) string { + t.Helper() + + cfg := config{} + for _, f := range options { + f(&cfg) + } + if !filepath.IsAbs(cfg.path) { + cfg.path = filepath.Join(Path(t), cfg.path) + } + + if update { + updateGoldenFile(t, cfg.path, []byte(data)) + } + + want, err := os.ReadFile(cfg.path) + require.NoError(t, err, "Cannot load golden file") + + return string(want) +} + +// LoadWithUpdateYAML load the generic element from a YAML serialized golden file. +// It will update the file if the update flag is used prior to deserializing it. +func LoadWithUpdateYAML[E any](t *testing.T, got E, options ...Option) E { + t.Helper() + + t.Logf("Serializing object for golden file") + data, err := yaml.Marshal(got) + require.NoError(t, err, "Cannot serialize provided object") + want := LoadWithUpdate(t, string(data), options...) + + var wantDeserialized E + err = yaml.Unmarshal([]byte(want), &wantDeserialized) + require.NoError(t, err, "Cannot create expanded policy objects from golden file") + + return wantDeserialized +} + +// CheckValidGoldenFileName checks if the provided name is a valid golden file name. +func CheckValidGoldenFileName(t *testing.T, name string) { + t.Helper() + + // A valid golden file contains only alphanumeric characters, underscores, dashes, and dots. + require.Regexp(t, `^[\w\-.]+$`, name, + "Invalid golden file name %q. Only alphanumeric characters, underscores, dashes, and dots are allowed", name) +} + +// Path returns the golden path for the provided test. +func Path(t *testing.T) string { + t.Helper() + + cwd, err := os.Getwd() + require.NoError(t, err, "Cannot get current working directory") + + topLevelTest, subtest, subtestFound := strings.Cut(t.Name(), "/") + CheckValidGoldenFileName(t, topLevelTest) + + path := filepath.Join(cwd, "testdata", "golden", topLevelTest) + + if !subtestFound { + return path + } + + CheckValidGoldenFileName(t, subtest) + return filepath.Join(path, subtest) +} + +// runDelta pipes the unified diff through the `delta` command for word-level diff and coloring. +func runDelta(diff string) (string, error) { + cmd := exec.Command("delta", "--diff-so-fancy", "--hunk-header-style", "omit") + cmd.Stdin = strings.NewReader(diff) + + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + + err := cmd.Run() + if err != nil { + return "", fmt.Errorf("failed to run delta: %w", err) + } + return out.String(), nil +} + +// checkFileContent compares the content of the actual and golden files and reports any differences. +func checkFileContent(t *testing.T, actual, expected, actualPath, expectedPath string) { + t.Helper() + + if actual == expected { + return + } + + diff := difflib.UnifiedDiff{ + A: difflib.SplitLines(expected), + B: difflib.SplitLines(actual), + FromFile: "Expected (golden)", + ToFile: "Actual", + Context: 3, + } + diffStr, err := difflib.GetUnifiedDiffString(diff) + require.NoError(t, err, "Cannot get unified diff") + + // Check if the `delta` command is available and use it to colorize the diff. + _, err = exec.LookPath("delta") + if err == nil { + diffStr, err = runDelta(diffStr) + require.NoError(t, err, "Cannot run delta") + } else { + diffStr = "\nDiff:\n" + diffStr + } + + msg := fmt.Sprintf("Golden file: %s", expectedPath) + if actualPath != "Actual" { + msg += fmt.Sprintf("\nFile: %s", actualPath) + } + + require.Failf(t, strings.Join([]string{ + "Golden file content mismatch", + "\nExpected (golden):", + strings.Repeat("-", 50), + strings.TrimSuffix(expected, "\n"), + strings.Repeat("-", 50), + "\nActual: ", + strings.Repeat("-", 50), + strings.TrimSuffix(actual, "\n"), + strings.Repeat("-", 50), + diffStr, + }, "\n"), msg) +} + +func checkGoldenFileEqualsFile(t *testing.T, path, goldenPath string) { + t.Helper() + + fileContent, err := os.ReadFile(path) + require.NoError(t, err, "Cannot read file %s", path) + goldenContent, err := os.ReadFile(goldenPath) + require.NoError(t, err, "Cannot read golden file %s", goldenPath) + + checkFileContent(t, string(fileContent), string(goldenContent), path, goldenPath) +} + +func checkGoldenFileEqualsString(t *testing.T, got, goldenPath string) { + t.Helper() + + goldenContent, err := os.ReadFile(goldenPath) + require.NoError(t, err, "Cannot read golden file %s", goldenPath) + + checkFileContent(t, got, string(goldenContent), "Actual", goldenPath) +} + +// CheckOrUpdateFileTree allows comparing a goldPath directory to p. Those can be updated via the dedicated flag. +func CheckOrUpdateFileTree(t *testing.T, path string, options ...Option) { + t.Helper() + + cfg := config{} + for _, f := range options { + f(&cfg) + } + if !filepath.IsAbs(cfg.path) { + cfg.path = filepath.Join(Path(t), cfg.path) + } + + if update { + t.Logf("updating golden path %s", cfg.path) + err := os.RemoveAll(cfg.path) + require.NoError(t, err, "Cannot remove golden path %s", cfg.path) + + // check the source directory exists before trying to copy it + info, err := os.Stat(path) + if errors.Is(err, fs.ErrNotExist) { + return + } + require.NoErrorf(t, err, "Error on checking %q", path) + + if !info.IsDir() { + // copy file + data, err := os.ReadFile(path) + require.NoError(t, err, "Cannot read file %s", path) + err = os.WriteFile(cfg.path, data, info.Mode()) + require.NoError(t, err, "Cannot write golden file") + } else { + err := addEmptyMarker(path) + require.NoError(t, err, "Cannot add empty marker to directory %s", path) + + err = copy.Copy(path, cfg.path) + require.NoError(t, err, "Can’t update golden directory") + } + } + + // Compare the content and attributes of the files in the directories. + err := filepath.WalkDir(path, func(p string, de fs.DirEntry, err error) error { + if err != nil { + return err + } + + relPath, err := filepath.Rel(path, p) + require.NoError(t, err, "Cannot get relative path for %s", p) + goldenFilePath := filepath.Join(cfg.path, relPath) + + if de.IsDir() { + return nil + } + + goldenFile, err := os.Stat(goldenFilePath) + if errors.Is(err, fs.ErrNotExist) { + require.Failf(t, fmt.Sprintf("Missing golden file %s", goldenFilePath), "File: %s", p) + } + require.NoError(t, err, "Cannot get golden file %s", goldenFilePath) + + file, err := os.Stat(p) + require.NoError(t, err, "Cannot get file %s", p) + + // Compare executable bit + a := strconv.FormatInt(int64(goldenFile.Mode().Perm()&0o111), 8) + b := strconv.FormatInt(int64(file.Mode().Perm()&0o111), 8) + require.Equal(t, a, b, "Executable bit does not match.\nFile: %s\nGolden file: %s", p, goldenFilePath) + + // Compare content + checkGoldenFileEqualsFile(t, p, goldenFilePath) + + return nil + }) + require.NoError(t, err, "Cannot walk through directory %s", path) + + // Check if there are files in the golden directory that are not in the source directory. + err = filepath.WalkDir(cfg.path, func(p string, de fs.DirEntry, err error) error { + if err != nil { + return err + } + + // Ignore the ".empty" file + if de.Name() == fileForEmptyDir { + return nil + } + + relPath, err := filepath.Rel(cfg.path, p) + require.NoError(t, err, "Cannot get relative path for %s", p) + filePath := filepath.Join(path, relPath) + + if de.IsDir() { + return nil + } + + _, err = os.Stat(filePath) + require.NoError(t, err, "Missing expected file %s", filePath) + + return nil + }) + require.NoError(t, err, "Cannot walk through directory %s", cfg.path) +} + +const fileForEmptyDir = ".empty" + +// addEmptyMarker adds to any empty directory, fileForEmptyDir to it. +// That allows git to commit it. +func addEmptyMarker(p string) error { + err := filepath.WalkDir(p, func(path string, de fs.DirEntry, err error) error { + if err != nil { + return err + } + + if !de.IsDir() { + return nil + } + + entries, err := os.ReadDir(path) + if err != nil { + return err + } + if len(entries) == 0 { + f, err := os.Create(filepath.Join(path, fileForEmptyDir)) + if err != nil { + return err + } + if err = f.Close(); err != nil { + return err + } + } + return nil + }) + + return err +} + +// UpdateEnabled returns true if the update flag was set, false otherwise. +func UpdateEnabled() bool { + return update +} diff --git a/authd-oidc-brokers/internal/testutils/path.go b/authd-oidc-brokers/internal/testutils/path.go new file mode 100644 index 0000000000..d6d2cfd09c --- /dev/null +++ b/authd-oidc-brokers/internal/testutils/path.go @@ -0,0 +1,37 @@ +package testutils + +import ( + "errors" + "io/fs" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +// MakeReadOnly makes dest read only and restore permission on cleanup. +func MakeReadOnly(t *testing.T, dest string) func() { + t.Helper() + + // Get current dest permissions + fi, err := os.Stat(dest) + require.NoError(t, err, "Cannot stat %s", dest) + mode := fi.Mode() + + var perms fs.FileMode = 0444 + if fi.IsDir() { + perms = 0555 + } + err = os.Chmod(dest, perms) + require.NoError(t, err) + + return func() { + _, err := os.Stat(dest) + if errors.Is(err, os.ErrNotExist) { + return + } + + err = os.Chmod(dest, mode) + require.NoError(t, err) + } +} diff --git a/authd-oidc-brokers/internal/testutils/provider.go b/authd-oidc-brokers/internal/testutils/provider.go new file mode 100644 index 0000000000..31c8a5558b --- /dev/null +++ b/authd-oidc-brokers/internal/testutils/provider.go @@ -0,0 +1,477 @@ +package testutils + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "errors" + "fmt" + "math/big" + "net" + "net/http" + "net/http/httptest" + "net/http/httputil" + "strings" + "sync" + "time" + + "github.com/canonical/authd/authd-oidc-brokers/internal/consts" + "github.com/canonical/authd/authd-oidc-brokers/internal/providers/genericprovider" + "github.com/canonical/authd/authd-oidc-brokers/internal/providers/info" + "github.com/coreos/go-oidc/v3/oidc" + "github.com/go-jose/go-jose/v4" + "github.com/golang-jwt/jwt/v5" + "github.com/ubuntu/authd/log" + "golang.org/x/oauth2" +) + +const ( + // ExpiredRefreshToken is used to test the expired refresh token error. + ExpiredRefreshToken = "expired-refresh-token" + // IsForDeviceRegistrationClaim is the claim used to indicate to the mock provider if the token is for device registration. + IsForDeviceRegistrationClaim = "is_for_device_registration" +) + +// MockKey is the RSA key used to sign the JWTs for the mock provider. +var MockKey *rsa.PrivateKey + +var mockCertificate *x509.Certificate + +func init() { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + panic(fmt.Sprintf("Setup: Could not generate RSA key for the Mock: %v", err)) + } + MockKey = key + + certTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(2024), + Subject: pkix.Name{ + Organization: []string{"Mocks ltd."}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(0, 0, 1), + SubjectKeyId: []byte{1, 2, 3, 4, 5}, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, + KeyUsage: x509.KeyUsageDigitalSignature, + IsCA: true, + BasicConstraintsValid: true, + } + + c, err := x509.CreateCertificate(rand.Reader, certTemplate, certTemplate, &MockKey.PublicKey, MockKey) + if err != nil { + panic("Setup: Could not create certificate for the Mock") + } + + cert, err := x509.ParseCertificate(c) + if err != nil { + panic("Setup: Could not parse certificate for the Mock") + } + mockCertificate = cert +} + +// EndpointHandler is a function that handles a request to an OIDC provider endpoint. +type EndpointHandler func(http.ResponseWriter, *http.Request) + +type providerServerOption struct { + handlers map[string]EndpointHandler +} + +// ProviderServerOption is a function that allows to override default options of the mock provider. +type ProviderServerOption func(*providerServerOption) + +// WithHandler returns a ProviderServerOption that adds a handler for a provider endpoint specified by path. +func WithHandler(path string, handler func(http.ResponseWriter, *http.Request)) ProviderServerOption { + return func(o *providerServerOption) { + o.handlers[path] = handler + } +} + +// StartMockProviderServer starts a new HTTP server to be used as an OIDC provider for tests. +func StartMockProviderServer(address string, tokenHandlerOpts *TokenHandlerOptions, args ...ProviderServerOption) (string, func()) { + servMux := http.NewServeMux() + server := httptest.NewUnstartedServer(servMux) + + if address != "" { + l, err := net.Listen("tcp", address) + if err != nil { + panic(fmt.Sprintf("error starting listener: %v", err)) + } + server.Listener = l + } + server.Start() + + opts := providerServerOption{ + handlers: map[string]EndpointHandler{ + "/.well-known/openid-configuration": DefaultOpenIDHandler(server.URL), + "/device_auth": DefaultDeviceAuthHandler(), + "/token": TokenHandler(server.URL, tokenHandlerOpts), + "/keys": DefaultJWKHandler(), + }, + } + for _, arg := range args { + arg(&opts) + } + + for path, handler := range opts.handlers { + if handler == nil { + continue + } + servMux.HandleFunc(path, handler) + } + + return server.URL, func() { + server.Close() + } +} + +// DefaultOpenIDHandler returns a handler that returns a default OIDC configuration. +func DefaultOpenIDHandler(serverURL string) EndpointHandler { + return func(w http.ResponseWriter, _ *http.Request) { + wellKnown := fmt.Sprintf(`{ + "issuer": "%[1]s", + "authorization_endpoint": "%[1]s/auth", + "device_authorization_endpoint": "%[1]s/device_auth", + "token_endpoint": "%[1]s/token", + "jwks_uri": "%[1]s/keys", + "id_token_signing_alg_values_supported": ["RS256"] + }`, serverURL) + + w.Header().Add("Content-Type", "application/json") + _, err := w.Write([]byte(wellKnown)) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + } + } +} + +// OpenIDHandlerWithNoDeviceEndpoint returns a handler that returns an OIDC configuration without device endpoint. +func OpenIDHandlerWithNoDeviceEndpoint(serverURL string) EndpointHandler { + return func(w http.ResponseWriter, _ *http.Request) { + wellKnown := fmt.Sprintf(`{ + "issuer": "%[1]s", + "authorization_endpoint": "%[1]s/auth", + "token_endpoint": "%[1]s/token", + "jwks_uri": "%[1]s/keys", + "id_token_signing_alg_values_supported": ["RS256"] + }`, serverURL) + + w.Header().Add("Content-Type", "application/json") + _, err := w.Write([]byte(wellKnown)) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + } + } +} + +// DefaultDeviceAuthHandler returns a handler that returns a default device auth response. +func DefaultDeviceAuthHandler() EndpointHandler { + return func(w http.ResponseWriter, _ *http.Request) { + response := `{ + "device_code": "device_code", + "user_code": "user_code", + "verification_uri": "https://verification_uri.com" + }` + + w.Header().Add("Content-Type", "application/json") + _, err := w.Write([]byte(response)) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + } + } +} + +// TokenHandlerOptions contains options for the token handler. +type TokenHandlerOptions struct { + Scopes []string + // A list of custom claims to be added to the ID token. Each time the + // handler returns a token, the claims from the first element of the list + // will be added to the token, and then that element will be removed from + // the list. + IDTokenClaims []map[string]interface{} +} + +var idTokenClaimsMutex sync.Mutex + +// TokenHandler returns a handler that returns a default token response. +func TokenHandler(serverURL string, opts *TokenHandlerOptions) EndpointHandler { + if opts == nil { + opts = &TokenHandlerOptions{} + } + if opts.Scopes == nil { + opts.Scopes = consts.DefaultScopes + } + + return func(w http.ResponseWriter, r *http.Request) { + s, err := httputil.DumpRequest(r, true) + if err != nil { + log.Errorf(context.Background(), "could not dump request: %v", err) + } + log.Debugf(context.Background(), "/token endpoint request:\n%s", s) + + // Handle expired refresh token + //nolint:gosec // G120 - test-only mock handler; request bodies are controlled by tests. + refreshToken := r.FormValue("refresh_token") + if refreshToken == ExpiredRefreshToken { + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + // This is an msentraid specific error code and description. + _, _ = w.Write([]byte(`{"error": "invalid_grant", "error_description": "AADSTS50173: The refresh token has expired."}`)) + return + } + + // Mimics user going through auth process + time.Sleep(2 * time.Second) + + claims := jwt.MapClaims{ + "iss": serverURL, + "sub": "test-user-id", + "aud": "test-client-id", + "exp": 9999999999, + "name": "test-user", + "preferred_username": "test-user-preferred-username@email.com", + "email": "test-user@email.com", + "email_verified": true, + } + + idTokenClaimsMutex.Lock() + // Override the default claims with the custom claims + if len(opts.IDTokenClaims) > 0 { + for k, v := range opts.IDTokenClaims[0] { + claims[k] = v + } + opts.IDTokenClaims = opts.IDTokenClaims[1:] + } + idTokenClaimsMutex.Unlock() + + idToken := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + + rawToken, err := idToken.SignedString(MockKey) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + } + + response := fmt.Sprintf(`{ + "access_token": "accesstoken", + "refresh_token": "refreshtoken", + "token_type": "Bearer", + "scope": "%s", + "expires_in": 3600, + "id_token": "%s" + }`, strings.Join(opts.Scopes, " "), rawToken) + + w.Header().Add("Content-Type", "application/json") + if _, err := w.Write([]byte(response)); err != nil { + w.WriteHeader(http.StatusInternalServerError) + } + } +} + +// DefaultJWKHandler returns a handler that provides the signing keys from the broker. +// +// Meant to be used an the endpoint for /keys. +func DefaultJWKHandler() EndpointHandler { + return func(w http.ResponseWriter, r *http.Request) { + jwk := jose.JSONWebKey{ + Key: &MockKey.PublicKey, + KeyID: "fa834459-66c6-475a-852f-444262a07c13_sig_rs256", + Algorithm: "RS256", + Use: "sig", + Certificates: []*x509.Certificate{mockCertificate}, + } + + encodedJWK, err := jwk.MarshalJSON() + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + } + + response := fmt.Sprintf(`{"keys": [%s]}`, encodedJWK) + w.Header().Add("Content-Type", "application/json") + if _, err := w.Write([]byte(response)); err != nil { + w.WriteHeader(http.StatusInternalServerError) + } + } +} + +// UnavailableHandler returns a handler that returns a 503 Service Unavailable response. +func UnavailableHandler() EndpointHandler { + return func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + } +} + +// BadRequestHandler returns a handler that returns a 400 Bad Request response. +func BadRequestHandler() EndpointHandler { + return func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + } +} + +// CustomResponseHandler returns a handler that returns a custom token response. +func CustomResponseHandler(response string) EndpointHandler { + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Add("Content-Type", "application/json") + _, err := w.Write([]byte(response)) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + } + } +} + +// HangingHandler returns a handler that hangs the request until the duration has elapsed. +func HangingHandler(d time.Duration) EndpointHandler { + return func(w http.ResponseWriter, r *http.Request) { + time.Sleep(d) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusRequestTimeout) + } +} + +// ExpiryDeviceAuthHandler returns a handler that returns a device auth response with a short expiry time. +func ExpiryDeviceAuthHandler() EndpointHandler { + return func(w http.ResponseWriter, _ *http.Request) { + response := `{ + "device_code": "device_code", + "user_code": "user_code", + "verification_uri": "https://verification_uri.com", + "expires_in": 1 + }` + + w.Header().Add("Content-Type", "application/json") + _, err := w.Write([]byte(response)) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + } + } +} + +// MockProvider is a mock that implements the Provider interface. +type MockProvider struct { + genericprovider.GenericProvider + Scopes []string + Options []oauth2.AuthCodeOption + GetGroupsFunc func() ([]info.Group, error) + FirstCallDelay int + SecondCallDelay int + GetGroupsFails bool + ProviderSupportsDeviceRegistration bool + + numCalls int + numCallsLock sync.Mutex +} + +// AdditionalScopes returns the additional scopes required by the provider. +func (p *MockProvider) AdditionalScopes() []string { + if p.Scopes != nil { + return p.Scopes + } + return p.GenericProvider.AdditionalScopes() +} + +// AuthOptions returns the additional options required by the provider. +func (p *MockProvider) AuthOptions() []oauth2.AuthCodeOption { + if p.Options != nil { + return p.Options + } + return p.GenericProvider.AuthOptions() +} + +// NormalizeUsername parses a username into a normalized version. +func (p *MockProvider) NormalizeUsername(username string) string { + return strings.ToLower(username) +} + +// GetMetadata is a no-op when no specific provider is in use. +func (p *MockProvider) GetMetadata(provider *oidc.Provider) (map[string]interface{}, error) { + return nil, nil +} + +// GetUserInfo returns the user info parsed from the ID token. +func (p *MockProvider) GetUserInfo(idToken info.Claimer) (info.User, error) { + userClaims, err := p.userClaims(idToken) + if err != nil { + return info.User{}, err + } + + p.numCallsLock.Lock() + numCalls := p.numCalls + p.numCalls++ + p.numCallsLock.Unlock() + + if numCalls == 0 && p.FirstCallDelay > 0 { + time.Sleep(time.Duration(p.FirstCallDelay) * time.Second) + } + if numCalls == 1 && p.SecondCallDelay > 0 { + time.Sleep(time.Duration(p.SecondCallDelay) * time.Second) + } + + return info.NewUser( + userClaims.Email, + userClaims.Home, + userClaims.Sub, + userClaims.Shell, + userClaims.Gecos, + nil, + ), nil +} + +// GetGroups returns the groups the user is a member of. +func (p *MockProvider) GetGroups(ctx context.Context, clientID string, issuerURL string, token *oauth2.Token, providerMetadata map[string]interface{}, deviceRegistrationData []byte) ([]info.Group, error) { + if p.GetGroupsFails { + return nil, errors.New("error requested in the mock") + } + + userGroups := []info.Group{ + {Name: "remote-test-group", UGID: "12345"}, + {Name: "local-test-group", UGID: ""}, + } + + var err error + if p.GetGroupsFunc != nil { + userGroups, err = p.GetGroupsFunc() + if err != nil { + return nil, err + } + } + + return userGroups, nil +} + +// IsTokenForDeviceRegistration checks if the token is for device registration. +func (p *MockProvider) IsTokenForDeviceRegistration(token *oauth2.Token) (bool, error) { + if token == nil { + return false, errors.New("token is nil") + } + + isForDeviceRegistration, ok := token.Extra(IsForDeviceRegistrationClaim).(bool) + if !ok { + return false, fmt.Errorf("token does not contain %q claim", IsForDeviceRegistrationClaim) + } + + return isForDeviceRegistration, nil +} + +// SupportsDeviceRegistration checks if the provider supports device registration. +func (p *MockProvider) SupportsDeviceRegistration() bool { + return p.ProviderSupportsDeviceRegistration +} + +type claims struct { + Email string `json:"email"` + Sub string `json:"sub"` + Home string `json:"home"` + Shell string `json:"shell"` + Gecos string `json:"gecos"` +} + +// userClaims returns the user claims parsed from the ID token. +func (p *MockProvider) userClaims(idToken info.Claimer) (claims, error) { + var userClaims claims + if err := idToken.Claims(&userClaims); err != nil { + return claims{}, fmt.Errorf("failed to get ID token claims: %v", err) + } + return userClaims, nil +} diff --git a/authd-oidc-brokers/internal/token/token.go b/authd-oidc-brokers/internal/token/token.go new file mode 100644 index 0000000000..ffb4cf8090 --- /dev/null +++ b/authd-oidc-brokers/internal/token/token.go @@ -0,0 +1,73 @@ +// Package token provides functions to save and load tokens from disk. +package token + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/canonical/authd/authd-oidc-brokers/internal/providers" + "github.com/canonical/authd/authd-oidc-brokers/internal/providers/info" + "golang.org/x/oauth2" +) + +// AuthCachedInfo represents the token that will be saved on disk for offline authentication. +type AuthCachedInfo struct { + Token *oauth2.Token + ExtraFields map[string]interface{} + RawIDToken string + ProviderMetadata map[string]interface{} + UserInfo info.User + DeviceRegistrationData []byte + DeviceIsDisabled bool + UserIsDisabled bool +} + +// NewAuthCachedInfo creates a new AuthCachedInfo. It sets the provided token and rawIDToken and the provider-specific +// extra fields which should be stored persistently. +func NewAuthCachedInfo(token *oauth2.Token, rawIDToken string, provider providers.Provider) *AuthCachedInfo { + return &AuthCachedInfo{ + Token: token, + RawIDToken: rawIDToken, + ExtraFields: provider.GetExtraFields(token), + } +} + +// CacheAuthInfo saves the token to the given path. +func CacheAuthInfo(path string, token *AuthCachedInfo) (err error) { + jsonData, err := json.Marshal(token) + if err != nil { + return fmt.Errorf("could not marshal token: %v", err) + } + + // Create issuer specific cache directory if it doesn't exist. + if err = os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return fmt.Errorf("could not create token directory: %v", err) + } + + if err = os.WriteFile(path, jsonData, 0600); err != nil { + return fmt.Errorf("could not save token: %v", err) + } + + return nil +} + +// LoadAuthInfo reads the token from the given path. +func LoadAuthInfo(path string) (*AuthCachedInfo, error) { + jsonData, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("could not read token: %v", err) + } + + var cachedInfo AuthCachedInfo + if err := json.Unmarshal(jsonData, &cachedInfo); err != nil { + return nil, fmt.Errorf("could not unmarshal token: %v", err) + } + // Set the extra fields of the token. + if cachedInfo.ExtraFields != nil { + cachedInfo.Token = cachedInfo.Token.WithExtra(cachedInfo.ExtraFields) + } + + return &cachedInfo, nil +} diff --git a/authd-oidc-brokers/internal/token/token_test.go b/authd-oidc-brokers/internal/token/token_test.go new file mode 100644 index 0000000000..0a7b8fbb74 --- /dev/null +++ b/authd-oidc-brokers/internal/token/token_test.go @@ -0,0 +1,127 @@ +package token_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/canonical/authd/authd-oidc-brokers/internal/providers/info" + "github.com/canonical/authd/authd-oidc-brokers/internal/token" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" +) + +var testToken = &token.AuthCachedInfo{ + Token: &oauth2.Token{ + AccessToken: "accesstoken", + RefreshToken: "refreshtoken", + }, + RawIDToken: "rawidtoken", + UserInfo: info.User{ + Name: "foo", + UUID: "saved-user-id", + Home: "/home/foo", + Gecos: "foo", + Shell: "/usr/bin/bash", + Groups: []info.Group{ + {Name: "token-test-group", UGID: "12345"}, + }, + }, +} + +func TestCacheAuthInfo(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + existingParentDir bool + existingFile bool + fileIsDir bool + parentIsFile bool + + wantError bool + }{ + "Successfully_store_token_with_non_existing_parent_directory": {}, + "Successfully_store_token_with_existing_parent_directory": {existingParentDir: true}, + "Successfully_store_token_with_existing_file": {existingParentDir: true, existingFile: true}, + + "Error_when_file_exists_and_is_a_directory": {existingParentDir: true, existingFile: true, fileIsDir: true, wantError: true}, + "Error_when_parent_directory_is_a_file": {existingParentDir: true, parentIsFile: true, wantError: true}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + tokenPath := filepath.Join(t.TempDir(), "parent", "token.json") + + if tc.existingParentDir && !tc.parentIsFile { + err := os.MkdirAll(filepath.Dir(tokenPath), 0700) + require.NoError(t, err, "MkdirAll should not return an error") + } + if tc.existingFile && !tc.fileIsDir { + err := os.WriteFile(tokenPath, []byte("existing file"), 0600) + require.NoError(t, err, "WriteFile should not return an error") + } + if tc.fileIsDir { + err := os.MkdirAll(tokenPath, 0700) + require.NoError(t, err, "MkdirAll should not return an error") + } + if tc.parentIsFile { + parentPath := filepath.Dir(tokenPath) + err := os.WriteFile(parentPath, []byte("existing file"), 0600) + require.NoError(t, err, "WriteFile should not return an error") + } + + err := token.CacheAuthInfo(tokenPath, testToken) + if tc.wantError { + require.Error(t, err, "CacheAuthInfo should return an error") + return + } + require.NoError(t, err, "CacheAuthInfo should not return an error") + }) + } +} + +func TestLoadAuthInfo(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + expectedRet *token.AuthCachedInfo + fileExists bool + invalidJSON bool + + wantError bool + }{ + "Successfully_load_token_from_existing_file": {fileExists: true, expectedRet: testToken}, + "Error_when_file_does_not_exist": {wantError: true}, + "Error_when_file_contains_invalid_JSON": {fileExists: true, invalidJSON: true, wantError: true}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + tokenPath := filepath.Join(t.TempDir(), "parent", "token.json") + if tc.fileExists { + err := os.MkdirAll(filepath.Dir(tokenPath), 0700) + require.NoError(t, err, "MkdirAll should not return an error") + + if tc.invalidJSON { + err = os.WriteFile(tokenPath, []byte("invalid json"), 0600) + require.NoError(t, err, "WriteFile should not return an error") + } else { + err = token.CacheAuthInfo(tokenPath, testToken) + require.NoError(t, err, "CacheAuthInfo should not return an error") + } + } + + got, err := token.LoadAuthInfo(tokenPath) + if tc.wantError { + require.Error(t, err, "LoadAuthInfo should return an error") + return + } + require.NoError(t, err, "LoadAuthInfo should not return an error") + require.Equal(t, tc.expectedRet, got, "LoadAuthInfo should return the expected value") + }) + } +} diff --git a/authd-oidc-brokers/po/embedder.go b/authd-oidc-brokers/po/embedder.go new file mode 100644 index 0000000000..734c4dd21c --- /dev/null +++ b/authd-oidc-brokers/po/embedder.go @@ -0,0 +1,11 @@ +//go:build !withmo || windows + +// Package po allows embedding po files in project in development mode or windows. +package po + +import "embed" + +// Files containing po files +// +//go:embed *.po +var Files embed.FS diff --git a/authd-oidc-brokers/po/fr.po b/authd-oidc-brokers/po/fr.po new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authd-oidc-brokers/po/mofiles.go b/authd-oidc-brokers/po/mofiles.go new file mode 100644 index 0000000000..5609fa7d6b --- /dev/null +++ b/authd-oidc-brokers/po/mofiles.go @@ -0,0 +1,9 @@ +// TiCS: disabled // This is a helper file. + +//go:build withmo && !windows + +package po + +import "embed" + +var Files embed.FS diff --git a/authd-oidc-brokers/rust-toolchain.toml b/authd-oidc-brokers/rust-toolchain.toml new file mode 100644 index 0000000000..4f0b659d63 --- /dev/null +++ b/authd-oidc-brokers/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.94.0" +profile = "minimal" +components = ["clippy"] diff --git a/authd-oidc-brokers/third_party/libhimmelblau b/authd-oidc-brokers/third_party/libhimmelblau new file mode 160000 index 0000000000..11bd85bd8b --- /dev/null +++ b/authd-oidc-brokers/third_party/libhimmelblau @@ -0,0 +1 @@ +Subproject commit 11bd85bd8bf2de0d645cbeead1292bcbc72b6f66 diff --git a/authd-oidc-brokers/tools/go.mod b/authd-oidc-brokers/tools/go.mod new file mode 100644 index 0000000000..f4dd562017 --- /dev/null +++ b/authd-oidc-brokers/tools/go.mod @@ -0,0 +1,218 @@ +module github.com/canonical/authd/authd-oidc-brokers/tools + +go 1.25.0 + +toolchain go1.25.8 + +require ( + github.com/golangci/golangci-lint/v2 v2.11.3 + golang.org/x/mod v0.33.0 +) + +require ( + 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect + 4d63.com/gochecknoglobals v0.2.2 // indirect + codeberg.org/chavacava/garif v0.2.0 // indirect + codeberg.org/polyfloyd/go-errorlint v1.9.0 // indirect + dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect + dev.gaijin.team/go/golib v0.6.0 // indirect + github.com/4meepo/tagalign v1.4.3 // indirect + github.com/Abirdcfly/dupword v0.1.7 // indirect + github.com/AdminBenni/iota-mixing v1.0.0 // indirect + github.com/AlwxSin/noinlineerr v1.0.5 // indirect + github.com/Antonboom/errname v1.1.1 // indirect + github.com/Antonboom/nilnil v1.1.1 // indirect + github.com/Antonboom/testifylint v1.6.4 // indirect + github.com/BurntSushi/toml v1.6.0 // indirect + github.com/Djarvur/go-err113 v0.1.1 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/MirrexOne/unqueryvet v1.5.4 // indirect + github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect + github.com/alecthomas/chroma/v2 v2.23.1 // indirect + github.com/alecthomas/go-check-sumtype v0.3.1 // indirect + github.com/alexkohler/nakedret/v2 v2.0.6 // indirect + github.com/alexkohler/prealloc v1.1.0 // indirect + github.com/alfatraining/structtag v1.0.0 // indirect + github.com/alingse/asasalint v0.0.11 // indirect + github.com/alingse/nilnesserr v0.2.0 // indirect + github.com/ashanbrown/forbidigo/v2 v2.3.0 // indirect + github.com/ashanbrown/makezero/v2 v2.1.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/bkielbasa/cyclop v1.2.3 // indirect + github.com/blizzy78/varnamelen v0.8.0 // indirect + github.com/bombsimon/wsl/v4 v4.7.0 // indirect + github.com/bombsimon/wsl/v5 v5.6.0 // indirect + github.com/breml/bidichk v0.3.3 // indirect + github.com/breml/errchkjson v0.4.1 // indirect + github.com/butuzov/ireturn v0.4.0 // indirect + github.com/butuzov/mirror v1.3.0 // indirect + github.com/catenacyber/perfsprint v0.10.1 // indirect + github.com/ccojocar/zxcvbn-go v1.0.4 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charithe/durationcheck v0.0.11 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/ckaznocha/intrange v0.3.1 // indirect + github.com/curioswitch/go-reassign v0.3.0 // indirect + github.com/daixiang0/gci v0.13.7 // indirect + github.com/dave/dst v0.27.3 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/denis-tingaikin/go-header v0.5.0 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/ettle/strcase v0.2.0 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/fatih/structtag v1.2.0 // indirect + github.com/firefart/nonamedreturns v1.0.6 // indirect + github.com/fsnotify/fsnotify v1.5.4 // indirect + github.com/fzipp/gocyclo v0.6.0 // indirect + github.com/ghostiam/protogetter v0.3.20 // indirect + github.com/go-critic/go-critic v0.14.3 // indirect + github.com/go-toolsmith/astcast v1.1.0 // indirect + github.com/go-toolsmith/astcopy v1.1.0 // indirect + github.com/go-toolsmith/astequal v1.2.0 // indirect + github.com/go-toolsmith/astfmt v1.1.0 // indirect + github.com/go-toolsmith/astp v1.1.0 // indirect + github.com/go-toolsmith/strparse v1.1.0 // indirect + github.com/go-toolsmith/typep v1.1.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/godoc-lint/godoc-lint v0.11.2 // indirect + github.com/gofrs/flock v0.13.0 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/golangci/asciicheck v0.5.0 // indirect + github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect + github.com/golangci/go-printf-func-name v0.1.1 // indirect + github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect + github.com/golangci/golines v0.15.0 // indirect + github.com/golangci/misspell v0.8.0 // indirect + github.com/golangci/plugin-module-register v0.1.2 // indirect + github.com/golangci/revgrep v0.8.0 // indirect + github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect + github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/gordonklaus/ineffassign v0.2.0 // indirect + github.com/gostaticanalysis/analysisutil v0.7.1 // indirect + github.com/gostaticanalysis/comment v1.5.0 // indirect + github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect + github.com/gostaticanalysis/nilerr v0.1.2 // indirect + github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect + github.com/hashicorp/go-version v1.8.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/hexops/gotextdiff v1.0.3 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jgautheron/goconst v1.8.2 // indirect + github.com/jingyugao/rowserrcheck v1.1.1 // indirect + github.com/jjti/go-spancheck v0.6.5 // indirect + github.com/julz/importas v0.2.0 // indirect + github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect + github.com/kisielk/errcheck v1.10.0 // indirect + github.com/kkHAIKE/contextcheck v1.1.6 // indirect + github.com/kulti/thelper v0.7.1 // indirect + github.com/kunwardeep/paralleltest v1.0.15 // indirect + github.com/lasiar/canonicalheader v1.1.2 // indirect + github.com/ldez/exptostd v0.4.5 // indirect + github.com/ldez/gomoddirectives v0.8.0 // indirect + github.com/ldez/grignotin v0.10.1 // indirect + github.com/ldez/structtags v0.6.1 // indirect + github.com/ldez/tagliatelle v0.7.2 // indirect + github.com/ldez/usetesting v0.5.0 // indirect + github.com/leonklingele/grouper v1.1.2 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/macabu/inamedparam v0.2.0 // indirect + github.com/magiconair/properties v1.8.6 // indirect + github.com/manuelarte/embeddedstructfieldcheck v0.4.0 // indirect + github.com/manuelarte/funcorder v0.5.0 // indirect + github.com/maratori/testableexamples v1.0.1 // indirect + github.com/maratori/testpackage v1.1.2 // indirect + github.com/matoous/godox v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect + github.com/mgechev/revive v1.15.0 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/moricho/tparallel v0.3.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/nakabonne/nestif v0.3.1 // indirect + github.com/nishanths/exhaustive v0.12.0 // indirect + github.com/nishanths/predeclared v0.2.2 // indirect + github.com/nunnatsa/ginkgolinter v0.23.0 // indirect + github.com/pelletier/go-toml v1.9.5 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_golang v1.12.1 // indirect + github.com/prometheus/client_model v0.2.0 // indirect + github.com/prometheus/common v0.32.1 // indirect + github.com/prometheus/procfs v0.7.3 // indirect + github.com/quasilyte/go-ruleguard v0.4.5 // indirect + github.com/quasilyte/go-ruleguard/dsl v0.3.23 // indirect + github.com/quasilyte/gogrep v0.5.0 // indirect + github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect + github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect + github.com/raeperd/recvcheck v0.2.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/ryancurrah/gomodguard v1.4.1 // indirect + github.com/ryanrolds/sqlclosecheck v0.5.1 // indirect + github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/sashamelentyev/interfacebloat v1.1.0 // indirect + github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect + github.com/securego/gosec/v2 v2.24.8-0.20260309165252-619ce2117e08 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/sivchari/containedctx v1.0.3 // indirect + github.com/sonatard/noctx v0.5.0 // indirect + github.com/sourcegraph/go-diff v0.7.0 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.5.0 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/jwalterweatherman v1.1.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/spf13/viper v1.12.0 // indirect + github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect + github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/subosito/gotenv v1.4.1 // indirect + github.com/tetafro/godot v1.5.4 // indirect + github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 // indirect + github.com/timonwong/loggercheck v0.11.0 // indirect + github.com/tomarrell/wrapcheck/v2 v2.12.0 // indirect + github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect + github.com/ultraware/funlen v0.2.0 // indirect + github.com/ultraware/whitespace v0.2.0 // indirect + github.com/uudashr/gocognit v1.2.1 // indirect + github.com/uudashr/iface v1.4.1 // indirect + github.com/xen0n/gosmopolitan v1.3.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yagipy/maintidx v1.0.0 // indirect + github.com/yeya24/promlinter v0.3.0 // indirect + github.com/ykadowak/zerologlint v0.1.5 // indirect + gitlab.com/bosi/decorder v0.4.2 // indirect + go-simpler.org/musttag v0.14.0 // indirect + go-simpler.org/sloglint v0.11.1 // indirect + go.augendre.info/arangolint v0.4.0 // indirect + go.augendre.info/fatcontext v0.9.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + go.uber.org/zap v1.27.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/tools v0.42.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + honnef.co/go/tools v0.7.0 // indirect + mvdan.cc/gofumpt v0.9.2 // indirect + mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 // indirect +) diff --git a/authd-oidc-brokers/tools/go.sum b/authd-oidc-brokers/tools/go.sum new file mode 100644 index 0000000000..316eef9859 --- /dev/null +++ b/authd-oidc-brokers/tools/go.sum @@ -0,0 +1,994 @@ +4d63.com/gocheckcompilerdirectives v1.3.0 h1:Ew5y5CtcAAQeTVKUVFrE7EwHMrTO6BggtEj8BZSjZ3A= +4d63.com/gocheckcompilerdirectives v1.3.0/go.mod h1:ofsJ4zx2QAuIP/NO/NAh1ig6R1Fb18/GI7RVMwz7kAY= +4d63.com/gochecknoglobals v0.2.2 h1:H1vdnwnMaZdQW/N+NrkT1SZMTBmcwHe9Vq8lJcYYTtU= +4d63.com/gochecknoglobals v0.2.2/go.mod h1:lLxwTQjL5eIesRbvnzIP3jZtG140FnTdz+AlMa+ogt0= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= +cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= +cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= +cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= +cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= +cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= +cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= +cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= +cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= +cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= +cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +codeberg.org/chavacava/garif v0.2.0 h1:F0tVjhYbuOCnvNcU3YSpO6b3Waw6Bimy4K0mM8y6MfY= +codeberg.org/chavacava/garif v0.2.0/go.mod h1:P2BPbVbT4QcvLZrORc2T29szK3xEOlnl0GiPTJmEqBQ= +codeberg.org/polyfloyd/go-errorlint v1.9.0 h1:VkdEEmA1VBpH6ecQoMR4LdphVI3fA4RrCh2an7YmodI= +codeberg.org/polyfloyd/go-errorlint v1.9.0/go.mod h1:GPRRu2LzVijNn4YkrZYJfatQIdS+TrcK8rL5Xs24qw8= +dev.gaijin.team/go/exhaustruct/v4 v4.0.0 h1:873r7aNneqoBB3IaFIzhvt2RFYTuHgmMjoKfwODoI1Y= +dev.gaijin.team/go/exhaustruct/v4 v4.0.0/go.mod h1:aZ/k2o4Y05aMJtiux15x8iXaumE88YdiB0Ai4fXOzPI= +dev.gaijin.team/go/golib v0.6.0 h1:v6nnznFTs4bppib/NyU1PQxobwDHwCXXl15P7DV5Zgo= +dev.gaijin.team/go/golib v0.6.0/go.mod h1:uY1mShx8Z/aNHWDyAkZTkX+uCi5PdX7KsG1eDQa2AVE= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/4meepo/tagalign v1.4.3 h1:Bnu7jGWwbfpAie2vyl63Zup5KuRv21olsPIha53BJr8= +github.com/4meepo/tagalign v1.4.3/go.mod h1:00WwRjiuSbrRJnSVeGWPLp2epS5Q/l4UEy0apLLS37c= +github.com/Abirdcfly/dupword v0.1.7 h1:2j8sInznrje4I0CMisSL6ipEBkeJUJAmK1/lfoNGWrQ= +github.com/Abirdcfly/dupword v0.1.7/go.mod h1:K0DkBeOebJ4VyOICFdppB23Q0YMOgVafM0zYW0n9lF4= +github.com/AdminBenni/iota-mixing v1.0.0 h1:Os6lpjG2dp/AE5fYBPAA1zfa2qMdCAWwPMCgpwKq7wo= +github.com/AdminBenni/iota-mixing v1.0.0/go.mod h1:i4+tpAaB+qMVIV9OK3m4/DAynOd5bQFaOu+2AhtBCNY= +github.com/AlwxSin/noinlineerr v1.0.5 h1:RUjt63wk1AYWTXtVXbSqemlbVTb23JOSRiNsshj7TbY= +github.com/AlwxSin/noinlineerr v1.0.5/go.mod h1:+QgkkoYrMH7RHvcdxdlI7vYYEdgeoFOVjU9sUhw/rQc= +github.com/Antonboom/errname v1.1.1 h1:bllB7mlIbTVzO9jmSWVWLjxTEbGBVQ1Ff/ClQgtPw9Q= +github.com/Antonboom/errname v1.1.1/go.mod h1:gjhe24xoxXp0ScLtHzjiXp0Exi1RFLKJb0bVBtWKCWQ= +github.com/Antonboom/nilnil v1.1.1 h1:9Mdr6BYd8WHCDngQnNVV0b554xyisFioEKi30sksufQ= +github.com/Antonboom/nilnil v1.1.1/go.mod h1:yCyAmSw3doopbOWhJlVci+HuyNRuHJKIv6V2oYQa8II= +github.com/Antonboom/testifylint v1.6.4 h1:gs9fUEy+egzxkEbq9P4cpcMB6/G0DYdMeiFS87UiqmQ= +github.com/Antonboom/testifylint v1.6.4/go.mod h1:YO33FROXX2OoUfwjz8g+gUxQXio5i9qpVy7nXGbxDD4= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/Djarvur/go-err113 v0.1.1 h1:eHfopDqXRwAi+YmCUas75ZE0+hoBHJ2GQNLYRSxao4g= +github.com/Djarvur/go-err113 v0.1.1/go.mod h1:IaWJdYFLg76t2ihfflPZnM1LIQszWOsFDh2hhhAVF6k= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/MirrexOne/unqueryvet v1.5.4 h1:38QOxShO7JmMWT+eCdDMbcUgGCOeJphVkzzRgyLJgsQ= +github.com/MirrexOne/unqueryvet v1.5.4/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU= +github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4= +github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= +github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= +github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU= +github.com/alecthomas/go-check-sumtype v0.3.1/go.mod h1:A8TSiN3UPRw3laIgWEUOHHLPa6/r9MtoigdlP5h3K/E= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= +github.com/alexkohler/nakedret/v2 v2.0.6 h1:ME3Qef1/KIKr3kWX3nti3hhgNxw6aqN5pZmQiFSsuzQ= +github.com/alexkohler/nakedret/v2 v2.0.6/go.mod h1:l3RKju/IzOMQHmsEvXwkqMDzHHvurNQfAgE1eVmT40Q= +github.com/alexkohler/prealloc v1.1.0 h1:cKGRBqlXw5iyQGLYhrXrDlcHxugXpTq4tQ5c91wkf8M= +github.com/alexkohler/prealloc v1.1.0/go.mod h1:fT39Jge3bQrfA7nPMDngUfvUbQGQeJyGQnR+913SCig= +github.com/alfatraining/structtag v1.0.0 h1:2qmcUqNcCoyVJ0up879K614L9PazjBSFruTB0GOFjCc= +github.com/alfatraining/structtag v1.0.0/go.mod h1:p3Xi5SwzTi+Ryj64DqjLWz7XurHxbGsq6y3ubePJPus= +github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQRnw= +github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I= +github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEWd/w= +github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg= +github.com/ashanbrown/forbidigo/v2 v2.3.0 h1:OZZDOchCgsX5gvToVtEBoV2UWbFfI6RKQTir2UZzSxo= +github.com/ashanbrown/forbidigo/v2 v2.3.0/go.mod h1:5p6VmsG5/1xx3E785W9fouMxIOkvY2rRV9nMdWadd6c= +github.com/ashanbrown/makezero/v2 v2.1.0 h1:snuKYMbqosNokUKm+R6/+vOPs8yVAi46La7Ck6QYSaE= +github.com/ashanbrown/makezero/v2 v2.1.0/go.mod h1:aEGT/9q3S8DHeE57C88z2a6xydvgx8J5hgXIGWgo0MY= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bkielbasa/cyclop v1.2.3 h1:faIVMIGDIANuGPWH031CZJTi2ymOQBULs9H21HSMa5w= +github.com/bkielbasa/cyclop v1.2.3/go.mod h1:kHTwA9Q0uZqOADdupvcFJQtp/ksSnytRMe8ztxG8Fuo= +github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ089M= +github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k= +github.com/bombsimon/wsl/v4 v4.7.0 h1:1Ilm9JBPRczjyUs6hvOPKvd7VL1Q++PL8M0SXBDf+jQ= +github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF/8xTiapg= +github.com/bombsimon/wsl/v5 v5.6.0 h1:4z+/sBqC5vUmSp1O0mS+czxwH9+LKXtCWtHH9rZGQL8= +github.com/bombsimon/wsl/v5 v5.6.0/go.mod h1:Uqt2EfrMj2NV8UGoN1f1Y3m0NpUVCsUdrNCdet+8LvU= +github.com/breml/bidichk v0.3.3 h1:WSM67ztRusf1sMoqH6/c4OBCUlRVTKq+CbSeo0R17sE= +github.com/breml/bidichk v0.3.3/go.mod h1:ISbsut8OnjB367j5NseXEGGgO/th206dVa427kR8YTE= +github.com/breml/errchkjson v0.4.1 h1:keFSS8D7A2T0haP9kzZTi7o26r7kE3vymjZNeNDRDwg= +github.com/breml/errchkjson v0.4.1/go.mod h1:a23OvR6Qvcl7DG/Z4o0el6BRAjKnaReoPQFciAl9U3s= +github.com/butuzov/ireturn v0.4.0 h1:+s76bF/PfeKEdbG8b54aCocxXmi0wvYdOVsWxVO7n8E= +github.com/butuzov/ireturn v0.4.0/go.mod h1:ghI0FrCmap8pDWZwfPisFD1vEc56VKH4NpQUxDHta70= +github.com/butuzov/mirror v1.3.0 h1:HdWCXzmwlQHdVhwvsfBb2Au0r3HyINry3bDWLYXiKoc= +github.com/butuzov/mirror v1.3.0/go.mod h1:AEij0Z8YMALaq4yQj9CPPVYOyJQyiexpQEQgihajRfI= +github.com/catenacyber/perfsprint v0.10.1 h1:u7Riei30bk46XsG8nknMhKLXG9BcXz3+3tl/WpKm0PQ= +github.com/catenacyber/perfsprint v0.10.1/go.mod h1:DJTGsi/Zufpuus6XPGJyKOTMELe347o6akPvWG9Zcsc= +github.com/ccojocar/zxcvbn-go v1.0.4 h1:FWnCIRMXPj43ukfX000kvBZvV6raSxakYr1nzyNrUcc= +github.com/ccojocar/zxcvbn-go v1.0.4/go.mod h1:3GxGX+rHmueTUMvm5ium7irpyjmm7ikxYFOSJB21Das= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charithe/durationcheck v0.0.11 h1:g1/EX1eIiKS57NTWsYtHDZ/APfeXKhye1DidBcABctk= +github.com/charithe/durationcheck v0.0.11/go.mod h1:x5iZaixRNl8ctbM+3B2RrPG5t856TxRyVQEnbIEM2X4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/ckaznocha/intrange v0.3.1 h1:j1onQyXvHUsPWujDH6WIjhyH26gkRt/txNlV7LspvJs= +github.com/ckaznocha/intrange v0.3.1/go.mod h1:QVepyz1AkUoFQkpEqksSYpNpUo3c5W7nWh/s6SHIJJk= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/curioswitch/go-reassign v0.3.0 h1:dh3kpQHuADL3cobV/sSGETA8DOv457dwl+fbBAhrQPs= +github.com/curioswitch/go-reassign v0.3.0/go.mod h1:nApPCCTtqLJN/s8HfItCcKV0jIPwluBOvZP+dsJGA88= +github.com/daixiang0/gci v0.13.7 h1:+0bG5eK9vlI08J+J/NWGbWPTNiXPG4WhNLJOkSxWITQ= +github.com/daixiang0/gci v0.13.7/go.mod h1:812WVN6JLFY9S6Tv76twqmNqevN0pa3SX3nih0brVzQ= +github.com/dave/dst v0.27.3 h1:P1HPoMza3cMEquVf9kKy8yXsFirry4zEnWOdYPOoIzY= +github.com/dave/dst v0.27.3/go.mod h1:jHh6EOibnHgcUW3WjKHisiooEkYwqpHLBSX1iOBhEyc= +github.com/dave/jennifer v1.7.1 h1:B4jJJDHelWcDhlRQxWeo0Npa/pYKBLrirAQoTN45txo= +github.com/dave/jennifer v1.7.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42t4429eC9k8= +github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q= +github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= +github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= +github.com/firefart/nonamedreturns v1.0.6 h1:vmiBcKV/3EqKY3ZiPxCINmpS431OcE1S47AQUwhrg8E= +github.com/firefart/nonamedreturns v1.0.6/go.mod h1:R8NisJnSIpvPWheCq0mNRXJok6D8h7fagJTF8EMEwCo= +github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= +github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= +github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI= +github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= +github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo= +github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA= +github.com/ghostiam/protogetter v0.3.20 h1:oW7OPFit2FxZOpmMRPP9FffU4uUpfeE/rEdE1f+MzD0= +github.com/ghostiam/protogetter v0.3.20/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI= +github.com/go-critic/go-critic v0.14.3 h1:5R1qH2iFeo4I/RJU8vTezdqs08Egi4u5p6vOESA0pog= +github.com/go-critic/go-critic v0.14.3/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= +github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-toolsmith/astcast v1.1.0 h1:+JN9xZV1A+Re+95pgnMgDboWNVnIMMQXwfBwLRPgSC8= +github.com/go-toolsmith/astcast v1.1.0/go.mod h1:qdcuFWeGGS2xX5bLM/c3U9lewg7+Zu4mr+xPwZIB4ZU= +github.com/go-toolsmith/astcopy v1.1.0 h1:YGwBN0WM+ekI/6SS6+52zLDEf8Yvp3n2seZITCUBt5s= +github.com/go-toolsmith/astcopy v1.1.0/go.mod h1:hXM6gan18VA1T/daUEHCFcYiW8Ai1tIwIzHY6srfEAw= +github.com/go-toolsmith/astequal v1.0.3/go.mod h1:9Ai4UglvtR+4up+bAD4+hCj7iTo4m/OXVTSLnCyTAx4= +github.com/go-toolsmith/astequal v1.1.0/go.mod h1:sedf7VIdCL22LD8qIvv7Nn9MuWJruQA/ysswh64lffQ= +github.com/go-toolsmith/astequal v1.2.0 h1:3Fs3CYZ1k9Vo4FzFhwwewC3CHISHDnVUPC4x0bI2+Cw= +github.com/go-toolsmith/astequal v1.2.0/go.mod h1:c8NZ3+kSFtFY/8lPso4v8LuJjdJiUFVnSuU3s0qrrDY= +github.com/go-toolsmith/astfmt v1.1.0 h1:iJVPDPp6/7AaeLJEruMsBUlOYCmvg0MoCfJprsOmcco= +github.com/go-toolsmith/astfmt v1.1.0/go.mod h1:OrcLlRwu0CuiIBp/8b5PYF9ktGVZUjlNMV634mhwuQ4= +github.com/go-toolsmith/astp v1.1.0 h1:dXPuCl6u2llURjdPLLDxJeZInAeZ0/eZwFJmqZMnpQA= +github.com/go-toolsmith/astp v1.1.0/go.mod h1:0T1xFGz9hicKs8Z5MfAqSUitoUYS30pDMsRVIDHs8CA= +github.com/go-toolsmith/pkgload v1.2.2 h1:0CtmHq/02QhxcF7E9N5LIFcYFsMR5rdovfqTtRKkgIk= +github.com/go-toolsmith/pkgload v1.2.2/go.mod h1:R2hxLNRKuAsiXCo2i5J6ZQPhnPMOVtU+f0arbFPWCus= +github.com/go-toolsmith/strparse v1.0.0/go.mod h1:YI2nUKP9YGZnL/L1/DLFBfixrcjslWct4wyljWhSRy8= +github.com/go-toolsmith/strparse v1.1.0 h1:GAioeZUK9TGxnLS+qfdqNbA4z0SSm5zVNtCQiyP2Bvw= +github.com/go-toolsmith/strparse v1.1.0/go.mod h1:7ksGy58fsaQkGQlY8WVoBFNyEPMGuJin1rfoPS4lBSQ= +github.com/go-toolsmith/typep v1.1.0 h1:fIRYDyF+JywLfqzyhdiHzRop/GQDxxNhLGQ6gFUNHus= +github.com/go-toolsmith/typep v1.1.0/go.mod h1:fVIw+7zjdsMxDA3ITWnH1yOiw1rnTQKCsF/sk2H/qig= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY= +github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/godoc-lint/godoc-lint v0.11.2 h1:Bp0FkJWoSdNsBikdNgIcgtaoo+xz6I/Y9s5WSBQUeeM= +github.com/godoc-lint/godoc-lint v0.11.2/go.mod h1:iVpGdL1JCikNH2gGeAn3Hh+AgN5Gx/I/cxV+91L41jo= +github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= +github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golangci/asciicheck v0.5.0 h1:jczN/BorERZwK8oiFBOGvlGPknhvq0bjnysTj4nUfo0= +github.com/golangci/asciicheck v0.5.0/go.mod h1:5RMNAInbNFw2krqN6ibBxN/zfRFa9S6tA1nPdM0l8qQ= +github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 h1:WUvBfQL6EW/40l6OmeSBYQJNSif4O11+bmWEz+C7FYw= +github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32/go.mod h1:NUw9Zr2Sy7+HxzdjIULge71wI6yEg1lWQr7Evcu8K0E= +github.com/golangci/go-printf-func-name v0.1.1 h1:hIYTFJqAGp1iwoIfsNTpoq1xZAarogrvjO9AfiW3B4U= +github.com/golangci/go-printf-func-name v0.1.1/go.mod h1:Es64MpWEZbh0UBtTAICOZiB+miW53w/K9Or/4QogJss= +github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d h1:viFft9sS/dxoYY0aiOTsLKO2aZQAPT4nlQCsimGcSGE= +github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY= +github.com/golangci/golangci-lint/v2 v2.11.3 h1:ySX1GtLwlwOEzcLKJifI/aIVesrcHDno+5mrro8rWes= +github.com/golangci/golangci-lint/v2 v2.11.3/go.mod h1:HmDEVZuxz77cNLumPfNNHAFyMX/b7IbA0tpmAbwiVfo= +github.com/golangci/golines v0.15.0 h1:Qnph25g8Y1c5fdo1X7GaRDGgnMHgnxh4Gk4VfPTtRx0= +github.com/golangci/golines v0.15.0/go.mod h1:AZjXd23tbHMpowhtnGlj9KCNsysj72aeZVVHnVcZx10= +github.com/golangci/misspell v0.8.0 h1:qvxQhiE2/5z+BVRo1kwYA8yGz+lOlu5Jfvtx2b04Jbg= +github.com/golangci/misspell v0.8.0/go.mod h1:WZyyI2P3hxPY2UVHs3cS8YcllAeyfquQcKfdeE9AFVg= +github.com/golangci/plugin-module-register v0.1.2 h1:e5WM6PO6NIAEcij3B053CohVp3HIYbzSuP53UAYgOpg= +github.com/golangci/plugin-module-register v0.1.2/go.mod h1:1+QGTsKBvAIvPvoY/os+G5eoqxWn70HYDm2uvUyGuVw= +github.com/golangci/revgrep v0.8.0 h1:EZBctwbVd0aMeRnNUsFogoyayvKHyxlV3CdUA46FX2s= +github.com/golangci/revgrep v0.8.0/go.mod h1:U4R/s9dlXZsg8uJmaR1GrloUr14D7qDl8gi2iPXJH8k= +github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e h1:ai0EfmVYE2bRA5htgAG9r7s3tHsfjIhN98WshBTJ9jM= +github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e/go.mod h1:Vrn4B5oR9qRwM+f54koyeH3yzphlecwERs0el27Fr/s= +github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e h1:gD6P7NEo7Eqtt0ssnqSJNNndxe69DOQ24A5h7+i3KpM= +github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e/go.mod h1:h+wZwLjUTJnm/P2rwlbJdRPZXOzaT36/FwnPnY2inzc= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= +github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/gordonklaus/ineffassign v0.2.0 h1:Uths4KnmwxNJNzq87fwQQDDnbNb7De00VOk9Nu0TySs= +github.com/gordonklaus/ineffassign v0.2.0/go.mod h1:TIpymnagPSexySzs7F9FnO1XFTy8IT3a59vmZp5Y9Lw= +github.com/gostaticanalysis/analysisutil v0.7.1 h1:ZMCjoue3DtDWQ5WyU16YbjbQEQ3VuzwxALrpYd+HeKk= +github.com/gostaticanalysis/analysisutil v0.7.1/go.mod h1:v21E3hY37WKMGSnbsw2S/ojApNWb6C1//mXO48CXbVc= +github.com/gostaticanalysis/comment v1.4.2/go.mod h1:KLUTGDv6HOCotCH8h2erHKmpci2ZoR8VPu34YA2uzdM= +github.com/gostaticanalysis/comment v1.5.0 h1:X82FLl+TswsUMpMh17srGRuKaaXprTaytmEpgnKIDu8= +github.com/gostaticanalysis/comment v1.5.0/go.mod h1:V6eb3gpCv9GNVqb6amXzEUX3jXLVK/AdA+IrAMSqvEc= +github.com/gostaticanalysis/forcetypeassert v0.2.0 h1:uSnWrrUEYDr86OCxWa4/Tp2jeYDlogZiZHzGkWFefTk= +github.com/gostaticanalysis/forcetypeassert v0.2.0/go.mod h1:M5iPavzE9pPqWyeiVXSFghQjljW1+l/Uke3PXHS6ILY= +github.com/gostaticanalysis/nilerr v0.1.2 h1:S6nk8a9N8g062nsx63kUkF6AzbHGw7zzyHMcpu52xQU= +github.com/gostaticanalysis/nilerr v0.1.2/go.mod h1:A19UHhoY3y8ahoL7YKz6sdjDtduwTSI4CsymaC2htPA= +github.com/gostaticanalysis/testutil v0.3.1-0.20210208050101-bfb5c8eec0e4/go.mod h1:D+FIZ+7OahH3ePw/izIEeH5I06eKs1IKI4Xr64/Am3M= +github.com/gostaticanalysis/testutil v0.5.0 h1:Dq4wT1DdTwTGCQQv3rl3IvD5Ld0E6HiY+3Zh0sUGqw8= +github.com/gostaticanalysis/testutil v0.5.0/go.mod h1:OLQSbuM6zw2EvCcXTz1lVq5unyoNft372msDY0nY5Hs= +github.com/hashicorp/go-immutable-radix/v2 v2.1.0 h1:CUW5RYIcysz+D3B+l1mDeXrQ7fUvGGCwJfdASSzbrfo= +github.com/hashicorp/go-immutable-radix/v2 v2.1.0/go.mod h1:hgdqLXA4f6NIjRVisM1TJ9aOJVNRqKZj+xDGF6m7PBw= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= +github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jgautheron/goconst v1.8.2 h1:y0XF7X8CikZ93fSNT6WBTb/NElBu9IjaY7CCYQrCMX4= +github.com/jgautheron/goconst v1.8.2/go.mod h1:A0oxgBCHy55NQn6sYpO7UdnA9p+h7cPtoOZUmvNIako= +github.com/jingyugao/rowserrcheck v1.1.1 h1:zibz55j/MJtLsjP1OF4bSdgXxwL1b+Vn7Tjzq7gFzUs= +github.com/jingyugao/rowserrcheck v1.1.1/go.mod h1:4yvlZSDb3IyDTUZJUmpZfm2Hwok+Dtp+nu2qOq+er9c= +github.com/jjti/go-spancheck v0.6.5 h1:lmi7pKxa37oKYIMScialXUK6hP3iY5F1gu+mLBPgYB8= +github.com/jjti/go-spancheck v0.6.5/go.mod h1:aEogkeatBrbYsyW6y5TgDfihCulDYciL1B7rG2vSsrU= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ= +github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY= +github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhExWKxD/fP6q0= +github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY= +github.com/kisielk/errcheck v1.10.0 h1:Lvs/YAHP24YKg08LA8oDw2z9fJVme090RAXd90S+rrw= +github.com/kisielk/errcheck v1.10.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE= +github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kulti/thelper v0.7.1 h1:fI8QITAoFVLx+y+vSyuLBP+rcVIB8jKooNSCT2EiI98= +github.com/kulti/thelper v0.7.1/go.mod h1:NsMjfQEy6sd+9Kfw8kCP61W1I0nerGSYSFnGaxQkcbs= +github.com/kunwardeep/paralleltest v1.0.15 h1:ZMk4Qt306tHIgKISHWFJAO1IDQJLc6uDyJMLyncOb6w= +github.com/kunwardeep/paralleltest v1.0.15/go.mod h1:di4moFqtfz3ToSKxhNjhOZL+696QtJGCFe132CbBLGk= +github.com/lasiar/canonicalheader v1.1.2 h1:vZ5uqwvDbyJCnMhmFYimgMZnJMjwljN5VGY0VKbMXb4= +github.com/lasiar/canonicalheader v1.1.2/go.mod h1:qJCeLFS0G/QlLQ506T+Fk/fWMa2VmBUiEI2cuMK4djI= +github.com/ldez/exptostd v0.4.5 h1:kv2ZGUVI6VwRfp/+bcQ6Nbx0ghFWcGIKInkG/oFn1aQ= +github.com/ldez/exptostd v0.4.5/go.mod h1:QRjHRMXJrCTIm9WxVNH6VW7oN7KrGSht69bIRwvdFsM= +github.com/ldez/gomoddirectives v0.8.0 h1:JqIuTtgvFC2RdH1s357vrE23WJF2cpDCPFgA/TWDGpk= +github.com/ldez/gomoddirectives v0.8.0/go.mod h1:jutzamvZR4XYJLr0d5Honycp4Gy6GEg2mS9+2YX3F1Q= +github.com/ldez/grignotin v0.10.1 h1:keYi9rYsgbvqAZGI1liek5c+jv9UUjbvdj3Tbn5fn4o= +github.com/ldez/grignotin v0.10.1/go.mod h1:UlDbXFCARrXbWGNGP3S5vsysNXAPhnSuBufpTEbwOas= +github.com/ldez/structtags v0.6.1 h1:bUooFLbXx41tW8SvkfwfFkkjPYvFFs59AAMgVg6DUBk= +github.com/ldez/structtags v0.6.1/go.mod h1:YDxVSgDy/MON6ariaxLF2X09bh19qL7MtGBN5MrvbdY= +github.com/ldez/tagliatelle v0.7.2 h1:KuOlL70/fu9paxuxbeqlicJnCspCRjH0x8FW+NfgYUk= +github.com/ldez/tagliatelle v0.7.2/go.mod h1:PtGgm163ZplJfZMZ2sf5nhUT170rSuPgBimoyYtdaSI= +github.com/ldez/usetesting v0.5.0 h1:3/QtzZObBKLy1F4F8jLuKJiKBjjVFi1IavpoWbmqLwc= +github.com/ldez/usetesting v0.5.0/go.mod h1:Spnb4Qppf8JTuRgblLrEWb7IE6rDmUpGvxY3iRrzvDQ= +github.com/leonklingele/grouper v1.1.2 h1:o1ARBDLOmmasUaNDesWqWCIFH3u7hoFlM84YrjT3mIY= +github.com/leonklingele/grouper v1.1.2/go.mod h1:6D0M/HVkhs2yRKRFZUoGjeDy7EZTfFBE9gl4kjmIGkA= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/macabu/inamedparam v0.2.0 h1:VyPYpOc10nkhI2qeNUdh3Zket4fcZjEWe35poddBCpE= +github.com/macabu/inamedparam v0.2.0/go.mod h1:+Pee9/YfGe5LJ62pYXqB89lJ+0k5bsR8Wgz/C0Zlq3U= +github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo= +github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= +github.com/manuelarte/embeddedstructfieldcheck v0.4.0 h1:3mAIyaGRtjK6EO9E73JlXLtiy7ha80b2ZVGyacxgfww= +github.com/manuelarte/embeddedstructfieldcheck v0.4.0/go.mod h1:z8dFSyXqp+fC6NLDSljRJeNQJJDWnY7RoWFzV3PC6UM= +github.com/manuelarte/funcorder v0.5.0 h1:llMuHXXbg7tD0i/LNw8vGnkDTHFpTnWqKPI85Rknc+8= +github.com/manuelarte/funcorder v0.5.0/go.mod h1:Yt3CiUQthSBMBxjShjdXMexmzpP8YGvGLjrxJNkO2hA= +github.com/maratori/testableexamples v1.0.1 h1:HfOQXs+XgfeRBJ+Wz0XfH+FHnoY9TVqL6Fcevpzy4q8= +github.com/maratori/testableexamples v1.0.1/go.mod h1:XE2F/nQs7B9N08JgyRmdGjYVGqxWwClLPCGSQhXQSrQ= +github.com/maratori/testpackage v1.1.2 h1:ffDSh+AgqluCLMXhM19f/cpvQAKygKAJXFl9aUjmbqs= +github.com/maratori/testpackage v1.1.2/go.mod h1:8F24GdVDFW5Ew43Et02jamrVMNXLUNaOynhDssITGfc= +github.com/matoous/godox v1.1.0 h1:W5mqwbyWrwZv6OQ5Z1a/DHGMOvXYCBP3+Ht7KMoJhq4= +github.com/matoous/godox v1.1.0/go.mod h1:jgE/3fUXiTurkdHOLT5WEkThTSuE7yxHv5iWPa80afs= +github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mgechev/revive v1.15.0 h1:vJ0HzSBzfNyPbHKolgiFjHxLek9KUijhqh42yGoqZ8Q= +github.com/mgechev/revive v1.15.0/go.mod h1:LlAKO3QQe9OJ0pVZzI2GPa8CbXGZ/9lNpCGvK4T/a8A= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKHTI= +github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U= +github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE= +github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhKRf3Swg= +github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs= +github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk= +github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c= +github.com/nunnatsa/ginkgolinter v0.23.0 h1:x3o4DGYOWbBMP/VdNQKgSj+25aJKx2Pe6lHr8gBcgf8= +github.com/nunnatsa/ginkgolinter v0.23.0/go.mod h1:9qN1+0akwXEccwV1CAcCDfcoBlWXHB+ML9884pL4SZ4= +github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= +github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= +github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= +github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= +github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw= +github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU= +github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w= +github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE= +github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs= +github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo= +github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc= +github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= +github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= +github.com/prometheus/client_golang v1.12.1 h1:ZiaPsmm9uiBeaSMRznKsCDNtPCS0T3JVDGF+06gjBzk= +github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= +github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= +github.com/prometheus/common v0.32.1 h1:hWIdL3N2HoUx3B8j3YN9mWor0qhY/NlEKZEaXxuIRh4= +github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= +github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU= +github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= +github.com/quasilyte/go-ruleguard v0.4.5 h1:AGY0tiOT5hJX9BTdx/xBdoCubQUAE2grkqY2lSwvZcA= +github.com/quasilyte/go-ruleguard v0.4.5/go.mod h1:Vl05zJ538vcEEwu16V/Hdu7IYZWyKSwIy4c88Ro1kRE= +github.com/quasilyte/go-ruleguard/dsl v0.3.23 h1:lxjt5B6ZCiBeeNO8/oQsegE6fLeCzuMRoVWSkXC4uvY= +github.com/quasilyte/go-ruleguard/dsl v0.3.23/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU= +github.com/quasilyte/gogrep v0.5.0 h1:eTKODPXbI8ffJMN+W2aE0+oL0z/nh8/5eNdiO34SOAo= +github.com/quasilyte/gogrep v0.5.0/go.mod h1:Cm9lpz9NZjEoL1tgZ2OgeUKPIxL1meE7eo60Z6Sk+Ng= +github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 h1:TCg2WBOl980XxGFEZSS6KlBGIV0diGdySzxATTWoqaU= +github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727/go.mod h1:rlzQ04UMyJXu/aOvhd8qT+hvDrFpiwqp8MRXDY9szc0= +github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 h1:M8mH9eK4OUR4lu7Gd+PU1fV2/qnDNfzT635KRSObncs= +github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567/go.mod h1:DWNGW8A4Y+GyBgPuaQJuWiy0XYftx4Xm/y5Jqk9I6VQ= +github.com/raeperd/recvcheck v0.2.0 h1:GnU+NsbiCqdC2XX5+vMZzP+jAJC5fht7rcVTAhX74UI= +github.com/raeperd/recvcheck v0.2.0/go.mod h1:n04eYkwIR0JbgD73wT8wL4JjPC3wm0nFtzBnWNocnYU= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryancurrah/gomodguard v1.4.1 h1:eWC8eUMNZ/wM/PWuZBv7JxxqT5fiIKSIyTvjb7Elr+g= +github.com/ryancurrah/gomodguard v1.4.1/go.mod h1:qnMJwV1hX9m+YJseXEBhd2s90+1Xn6x9dLz11ualI1I= +github.com/ryanrolds/sqlclosecheck v0.5.1 h1:dibWW826u0P8jNLsLN+En7+RqWWTYrjCB9fJfSfdyCU= +github.com/ryanrolds/sqlclosecheck v0.5.1/go.mod h1:2g3dUjoS6AL4huFdv6wn55WpLIDjY7ZgUR4J8HOO/XQ= +github.com/sanposhiho/wastedassign/v2 v2.1.0 h1:crurBF7fJKIORrV85u9UUpePDYGWnwvv3+A96WvwXT0= +github.com/sanposhiho/wastedassign/v2 v2.1.0/go.mod h1:+oSmSC+9bQ+VUAxA66nBb0Z7N8CK7mscKTDYC6aIek4= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tMEOsumirXcOJqAw= +github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ= +github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iMf7Knkq057v4XOQ= +github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8= +github.com/securego/gosec/v2 v2.24.8-0.20260309165252-619ce2117e08 h1:AoLtJX4WUtZkhhUUMFy3GgecAALp/Mb4S1iyQOA2s0U= +github.com/securego/gosec/v2 v2.24.8-0.20260309165252-619ce2117e08/go.mod h1:+XLCJiRE95ga77XInNELh2M6zQP+PdqiT9Zpm0D9Wpk= +github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= +github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= +github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+Wwfd0XE= +github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4= +github.com/sonatard/noctx v0.5.0 h1:e/jdaqAsuWVOKQ0P6NWiIdDNHmHT5SwuuSfojFjzwrw= +github.com/sonatard/noctx v0.5.0/go.mod h1:64XdbzFb18XL4LporKXp8poqZtPKbCrqQ402CV+kJas= +github.com/sourcegraph/go-diff v0.7.0 h1:9uLlrd5T46OXs5qpp8L/MTltk0zikUGi0sNNyCpA8G0= +github.com/sourcegraph/go-diff v0.7.0/go.mod h1:iBszgVvyxdc8SFZ7gm69go2KDdt3ag071iBaWPF6cjs= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= +github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= +github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.12.0 h1:CZ7eSOd3kZoaYDLbXnmzgQI5RlciuXBMA+18HwHRfZQ= +github.com/spf13/viper v1.12.0/go.mod h1:b6COn30jlNxbm/V2IqWiNWkJ+vZNiMNksliPCiuKtSI= +github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YEwQ0= +github.com/ssgreg/nlreturn/v2 v2.2.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I= +github.com/stbenjam/no-sprintf-host-port v0.3.1 h1:AyX7+dxI4IdLBPtDbsGAyqiTSLpCP9hWRrXQDU4Cm/g= +github.com/stbenjam/no-sprintf-host-port v0.3.1/go.mod h1:ODbZesTCHMVKthBHskvUUexdcNHAQRXk9NpSsL8p/HQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs= +github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= +github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA= +github.com/tenntenn/modver v1.0.1/go.mod h1:bePIyQPb7UeioSRkw3Q0XeMhYZSMx9B8ePqg6SAMGH0= +github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpRQGxTSkNYKJ51yaw6ChIqO+Je8UqsTKN/cDag= +github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY= +github.com/tetafro/godot v1.5.4 h1:u1ww+gqpRLiIA16yF2PV1CV1n/X3zhyezbNXC3E14Sg= +github.com/tetafro/godot v1.5.4/go.mod h1:eOkMrVQurDui411nBY2FA05EYH01r14LuWY/NrVDVcU= +github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 h1:9LPGD+jzxMlnk5r6+hJnar67cgpDIz/iyD+rfl5r2Vk= +github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67/go.mod h1:mkjARE7Yr8qU23YcGMSALbIxTQ9r9QBVahQOBRfU460= +github.com/timonwong/loggercheck v0.11.0 h1:jdaMpYBl+Uq9mWPXv1r8jc5fC3gyXx4/WGwTnnNKn4M= +github.com/timonwong/loggercheck v0.11.0/go.mod h1:HEAWU8djynujaAVX7QI65Myb8qgfcZ1uKbdpg3ZzKl8= +github.com/tomarrell/wrapcheck/v2 v2.12.0 h1:H/qQ1aNWz/eeIhxKAFvkfIA+N7YDvq6TWVFL27Of9is= +github.com/tomarrell/wrapcheck/v2 v2.12.0/go.mod h1:AQhQuZd0p7b6rfW+vUwHm5OMCGgp63moQ9Qr/0BpIWo= +github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw= +github.com/tommy-muehle/go-mnd/v2 v2.5.1/go.mod h1:WsUAkMJMYww6l/ufffCD3m+P7LEvr8TnZn9lwVDlgzw= +github.com/ultraware/funlen v0.2.0 h1:gCHmCn+d2/1SemTdYMiKLAHFYxTYz7z9VIDRaTGyLkI= +github.com/ultraware/funlen v0.2.0/go.mod h1:ZE0q4TsJ8T1SQcjmkhN/w+MceuatI6pBFSxxyteHIJA= +github.com/ultraware/whitespace v0.2.0 h1:TYowo2m9Nfj1baEQBjuHzvMRbp19i+RCcRYrSWoFa+g= +github.com/ultraware/whitespace v0.2.0/go.mod h1:XcP1RLD81eV4BW8UhQlpaR+SDc2givTvyI8a586WjW8= +github.com/uudashr/gocognit v1.2.1 h1:CSJynt5txTnORn/DkhiB4mZjwPuifyASC8/6Q0I/QS4= +github.com/uudashr/gocognit v1.2.1/go.mod h1:acaubQc6xYlXFEMb9nWX2dYBzJ/bIjEkc1zzvyIZg5Q= +github.com/uudashr/iface v1.4.1 h1:J16Xl1wyNX9ofhpHmQ9h9gk5rnv2A6lX/2+APLTo0zU= +github.com/uudashr/iface v1.4.1/go.mod h1:pbeBPlbuU2qkNDn0mmfrxP2X+wjPMIQAy+r1MBXSXtg= +github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pHqQM= +github.com/xen0n/gosmopolitan v1.3.0/go.mod h1:rckfr5T6o4lBtM1ga7mLGKZmLxswUoH1zxHgNXOsEt4= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yagipy/maintidx v1.0.0 h1:h5NvIsCz+nRDapQ0exNv4aJ0yXSI0420omVANTv3GJM= +github.com/yagipy/maintidx v1.0.0/go.mod h1:0qNf/I/CCZXSMhsRsrEPDZ+DkekpKLXAJfsTACwgXLk= +github.com/yeya24/promlinter v0.3.0 h1:JVDbMp08lVCP7Y6NP3qHroGAO6z2yGKQtS5JsjqtoFs= +github.com/yeya24/promlinter v0.3.0/go.mod h1:cDfJQQYv9uYciW60QT0eeHlFodotkYZlL+YcPQN+mW4= +github.com/ykadowak/zerologlint v0.1.5 h1:Gy/fMz1dFQN9JZTPjv1hxEk+sRWm05row04Yoolgdiw= +github.com/ykadowak/zerologlint v0.1.5/go.mod h1:KaUskqF3e/v59oPmdq1U1DnKcuHokl2/K1U4pmIELKg= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo= +gitlab.com/bosi/decorder v0.4.2/go.mod h1:muuhHoaJkA9QLcYHq4Mj8FJUwDZ+EirSHRiaTcTf6T8= +go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ= +go-simpler.org/assert v0.9.0/go.mod h1:74Eqh5eI6vCK6Y5l3PI8ZYFXG4Sa+tkr70OIPJAUr28= +go-simpler.org/musttag v0.14.0 h1:XGySZATqQYSEV3/YTy+iX+aofbZZllJaqwFWs+RTtSo= +go-simpler.org/musttag v0.14.0/go.mod h1:uP8EymctQjJ4Z1kUnjX0u2l60WfUdQxCwSNKzE1JEOE= +go-simpler.org/sloglint v0.11.1 h1:xRbPepLT/MHPTCA6TS/wNfZrDzkGvCCqUv4Bdwc3H7s= +go-simpler.org/sloglint v0.11.1/go.mod h1:2PowwiCOK8mjiF+0KGifVOT8ZsCNiFzvfyJeJOIt8MQ= +go.augendre.info/arangolint v0.4.0 h1:xSCZjRoS93nXazBSg5d0OGCi9APPLNMmmLrC995tR50= +go.augendre.info/arangolint v0.4.0/go.mod h1:l+f/b4plABuFISuKnTGD4RioXiCCgghv2xqst/xOvAA= +go.augendre.info/fatcontext v0.9.0 h1:Gt5jGD4Zcj8CDMVzjOJITlSb9cEch54hjRRlN3qDojE= +go.augendre.info/fatcontext v0.9.0/go.mod h1:L94brOAT1OOUNue6ph/2HnwxoNlds9aXDF2FcUntbNw= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 h1:qWFG1Dj7TBjOjOvhEOkmyGPVoquqUKnIU0lEVLp8xyk= +golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200329025819-fd4102a86c65/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200724022722-7017fd6b1305/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.1.1-0.20210205202024-ef80cdb6ec6d/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU= +golang.org/x/tools v0.1.1-0.20210302220138-2ac05c832e1a/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= +google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= +google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU= +honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc= +mvdan.cc/gofumpt v0.9.2 h1:zsEMWL8SVKGHNztrx6uZrXdp7AX8r421Vvp23sz7ik4= +mvdan.cc/gofumpt v0.9.2/go.mod h1:iB7Hn+ai8lPvofHd9ZFGVg2GOr8sBUw1QUWjNbmIL/s= +mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 h1:ssMzja7PDPJV8FStj7hq9IKiuiKhgz9ErWw+m68e7DI= +mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15/go.mod h1:4M5MMXl2kW6fivUT6yRGpLLPNfuGtU2Z0cPvFquGDYU= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/authd-oidc-brokers/tools/semver/semver.go b/authd-oidc-brokers/tools/semver/semver.go new file mode 100644 index 0000000000..78cdf44791 --- /dev/null +++ b/authd-oidc-brokers/tools/semver/semver.go @@ -0,0 +1,89 @@ +// Package semver implements comparison of semantic version strings. +package main + +import ( + "fmt" + "os" + "strings" + + "golang.org/x/mod/semver" +) + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(1) + } + + switch os.Args[1] { + case "check": + if len(os.Args) != 3 { + fmt.Fprintf(os.Stderr, "Error: 'check' requires exactly one version argument\n") + usage() + os.Exit(1) + } + checkVersion(os.Args[2]) + + case "compare": + if len(os.Args) != 4 { + fmt.Fprintf(os.Stderr, "Error: 'compare' requires exactly two version arguments\n") + usage() + os.Exit(1) + } + compareVersions(os.Args[2], os.Args[3]) + + default: + fmt.Fprintf(os.Stderr, "Error: unknown command %q\n", os.Args[1]) + usage() + os.Exit(1) + } +} + +func usage() { + fmt.Fprintf(os.Stderr, `Usage: %[1]s [arguments] + +Commands: + check Check if a version is valid + compare Compare two versions + +Examples: + %[1]s check 1.2.3 # Prints "valid" or "invalid" + %[1]s compare 1.2.3 2.0.0 # Prints "less", "equal", or "greater" +`, os.Args[0]) +} + +func addVPrefix(version string) string { + if !strings.HasPrefix(version, "v") { + return "v" + version + } + return version +} + +func checkVersion(version string) { + v := addVPrefix(version) + if semver.IsValid(v) { + fmt.Println("valid") + return + } + fmt.Println("invalid") + os.Exit(1) +} + +func compareVersions(ver1, ver2 string) { + v1 := addVPrefix(ver1) + v2 := addVPrefix(ver2) + + if !semver.IsValid(v1) || !semver.IsValid(v2) { + fmt.Fprintf(os.Stderr, "Error: invalid semantic version format\n") + os.Exit(1) + } + + switch semver.Compare(v1, v2) { + case -1: + fmt.Println("less") + case 0: + fmt.Println("equal") + case 1: + fmt.Println("greater") + } +} diff --git a/authd-oidc-brokers/tools/tools.go b/authd-oidc-brokers/tools/tools.go new file mode 100644 index 0000000000..fe7e655d74 --- /dev/null +++ b/authd-oidc-brokers/tools/tools.go @@ -0,0 +1,9 @@ +// TiCS: disabled // This is a helper file to pin the tools versions that we use. + +//go:build tools + +package tools + +import ( + _ "github.com/golangci/golangci-lint/v2/cmd/golangci-lint" +) diff --git a/cmd/authctl/group/group.go b/cmd/authctl/group/group.go new file mode 100644 index 0000000000..01a7c2b88b --- /dev/null +++ b/cmd/authctl/group/group.go @@ -0,0 +1,18 @@ +// Package group provides utilities for managing group operations. +package group + +import ( + "github.com/spf13/cobra" +) + +// GroupCmd is a command to perform group-related operations. +var GroupCmd = &cobra.Command{ + Use: "group", + Short: "Commands related to groups", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { return cmd.Usage() }, +} + +func init() { + GroupCmd.AddCommand(setGIDCmd) +} diff --git a/cmd/authctl/group/group_test.go b/cmd/authctl/group/group_test.go new file mode 100644 index 0000000000..34d3093521 --- /dev/null +++ b/cmd/authctl/group/group_test.go @@ -0,0 +1,59 @@ +package group_test + +import ( + "fmt" + "os" + "os/exec" + "testing" + + "github.com/canonical/authd/internal/testutils" +) + +var authctlPath string +var daemonPath string + +func TestGroupCommand(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + args []string + expectedExitCode int + }{ + "Usage_message_when_no_args": {expectedExitCode: 0}, + "Help_flag": {args: []string{"--help"}, expectedExitCode: 0}, + + "Error_on_invalid_command": {args: []string{"invalid-command"}, expectedExitCode: 1}, + "Error_on_invalid_flag": {args: []string{"--invalid-flag"}, expectedExitCode: 1}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + //nolint:gosec // G204 it's safe to use exec.Command with a variable here + cmd := exec.Command(authctlPath, append([]string{"group"}, tc.args...)...) + testutils.CheckCommand(t, cmd, tc.expectedExitCode) + }) + } +} + +func TestMain(m *testing.M) { + var authctlCleanup func() + var err error + authctlPath, authctlCleanup, err = testutils.BuildAuthctl() + if err != nil { + fmt.Fprintf(os.Stderr, "Setup: %v\n", err) + os.Exit(1) + } + defer authctlCleanup() + + var daemonCleanup func() + daemonPath, daemonCleanup, err = testutils.BuildAuthdWithExampleBroker() + if err != nil { + fmt.Fprintf(os.Stderr, "Setup: %v\n", err) + os.Exit(1) + } + defer daemonCleanup() + + m.Run() +} diff --git a/cmd/authctl/group/set-gid.go b/cmd/authctl/group/set-gid.go new file mode 100644 index 0000000000..069570c51a --- /dev/null +++ b/cmd/authctl/group/set-gid.go @@ -0,0 +1,80 @@ +package group + +import ( + "context" + "errors" + "fmt" + "os" + "strconv" + + "github.com/canonical/authd/cmd/authctl/internal/client" + "github.com/canonical/authd/cmd/authctl/internal/completion" + "github.com/canonical/authd/cmd/authctl/internal/log" + "github.com/canonical/authd/internal/proto/authd" + "github.com/spf13/cobra" +) + +// setGIDCmd is a command to set the GID of a group managed by authd. +var setGIDCmd = &cobra.Command{ + Use: "set-gid ", + Short: "Set the GID of a group managed by authd", + Long: `Set the GID of a group managed by authd to the specified value. + +The new GID must be unique and non-negative. The command must be run as root. + +When a group's GID is changed, any users whose primary group is set to this +group will have the GID of their primary group updated. The home directories of +these users, and any files within these directories that are owned by the group, +will be updated to the new GID. + +Files outside users' home directories are not updated and must be changed +manually. Note that changing a GID can be unsafe if files on the system are +still owned by the original GID: those files may become accessible to a +different group that is later assigned that GID.`, + Example: ` # Set the GID of group "staff" to 30000 + authctl group set-gid staff 30000`, + Args: cobra.ExactArgs(2), + ValidArgsFunction: completion.Groups, + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + gidStr := args[1] + gid, err := strconv.ParseUint(gidStr, 10, 32) + if err != nil { + // Remove the "strconv.ParseUint: parsing ..." part from the error message + // because it doesn't add any useful information. + if unwrappedErr := errors.Unwrap(err); unwrappedErr != nil { + err = unwrappedErr + } + return fmt.Errorf("failed to parse GID %q: %w", gidStr, err) + } + + client, err := client.NewUserServiceClient() + if err != nil { + return err + } + + resp, err := client.SetGroupID(context.Background(), &authd.SetGroupIDRequest{ + Name: name, + Id: uint32(gid), + Lang: os.Getenv("LANG"), + }) + if resp == nil { + return err + } + + if resp.IdChanged { + log.Infof("GID of group '%s' set to %d.", name, gid) + if resp.HomeDirOwnerChanged { + log.Info("Updated ownership of the user's home directory.") + } + log.Info("Note: Ownership of files outside the user's home directory are not updated and must be changed manually.") + } + + // Print any warnings returned by the server. + for _, warning := range resp.Warnings { + log.Warning(warning) + } + + return err + }, +} diff --git a/cmd/authctl/group/set-gid_test.go b/cmd/authctl/group/set-gid_test.go new file mode 100644 index 0000000000..d26382f1df --- /dev/null +++ b/cmd/authctl/group/set-gid_test.go @@ -0,0 +1,87 @@ +package group_test + +import ( + "math" + "os" + "os/exec" + "path/filepath" + "strconv" + "testing" + + "github.com/canonical/authd/internal/testutils" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" +) + +func TestSetGIDCommand(t *testing.T) { + // We can't run these tests in parallel because the daemon with the example + // broker which we're using here uses userslocking.Z_ForTests_OverrideLocking() + // which makes userslocking.WriteLock() return an error immediately when the lock + // is already held - unlike the normal behavior which tries to acquire the lock + // for 15 seconds before returning an error. + + daemonSocket := testutils.StartAuthd(t, daemonPath, + testutils.WithGroupFile(filepath.Join("testdata", "empty.group")), + testutils.WithPreviousDBState("one_user_and_group"), + testutils.WithCurrentUserAsRoot, + ) + + err := os.Setenv("AUTHD_SOCKET", daemonSocket) + require.NoError(t, err, "Failed to set AUTHD_SOCKET environment variable") + + tests := map[string]struct { + args []string + authdUnavailable bool + + expectedExitCode int + }{ + "Set_group_gid_success": { + args: []string{"set-gid", "group1", "123456"}, + expectedExitCode: 0, + }, + + "Error_when_group_does_not_exist": { + args: []string{"set-gid", "invalidgroup", "123456"}, + expectedExitCode: int(codes.NotFound), + }, + "Error_when_gid_is_invalid": { + args: []string{"set-gid", "group1", "invalidgid"}, + expectedExitCode: 1, + }, + "Error_when_gid_is_too_large": { + args: []string{"set-gid", "group1", strconv.Itoa(math.MaxInt32 + 1)}, + expectedExitCode: int(codes.Unknown), + }, + "Error_when_gid_is_already_taken": { + args: []string{"set-gid", "group1", "0"}, + expectedExitCode: int(codes.Unknown), + }, + "Error_when_gid_is_negative": { + args: []string{"set-gid", "group1", "--", "-1000"}, + expectedExitCode: 1, + }, + "Error_when_authd_is_unavailable": { + args: []string{"set-gid", "group1", "123456"}, + authdUnavailable: true, + expectedExitCode: int(codes.Unavailable), + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + if tc.authdUnavailable { + origValue := os.Getenv("AUTHD_SOCKET") + err := os.Setenv("AUTHD_SOCKET", "/non-existent") + require.NoError(t, err, "Failed to set AUTHD_SOCKET environment variable") + t.Cleanup(func() { + err := os.Setenv("AUTHD_SOCKET", origValue) + require.NoError(t, err, "Failed to restore AUTHD_SOCKET environment variable") + }) + } + + //nolint:gosec // G204 it's safe to use exec.Command with a variable here + cmd := exec.Command(authctlPath, append([]string{"group"}, tc.args...)...) + testutils.CheckCommand(t, cmd, tc.expectedExitCode) + }) + } +} diff --git a/internal/users/db/testdata/golden/TestNew/New_migrates_database_to_lowercase_user_and_group_names b/cmd/authctl/group/testdata/db/one_user_and_group.db.yaml similarity index 94% rename from internal/users/db/testdata/golden/TestNew/New_migrates_database_to_lowercase_user_and_group_names rename to cmd/authctl/group/testdata/db/one_user_and_group.db.yaml index 0f479a482b..77567897ae 100644 --- a/internal/users/db/testdata/golden/TestNew/New_migrates_database_to_lowercase_user_and_group_names +++ b/cmd/authctl/group/testdata/db/one_user_and_group.db.yaml @@ -15,4 +15,3 @@ groups: users_to_groups: - uid: 1111 gid: 11111 -schema_version: 1 diff --git a/cmd/authctl/group/testdata/empty.group b/cmd/authctl/group/testdata/empty.group new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cmd/authctl/group/testdata/golden/TestGroupCommand/Error_on_invalid_command b/cmd/authctl/group/testdata/golden/TestGroupCommand/Error_on_invalid_command new file mode 100644 index 0000000000..ecd4f9d8a8 --- /dev/null +++ b/cmd/authctl/group/testdata/golden/TestGroupCommand/Error_on_invalid_command @@ -0,0 +1,13 @@ +Usage: + authctl group [flags] + authctl group [command] + +Available Commands: + set-gid Set the GID of a group managed by authd + +Flags: + -h, --help help for group + +Use "authctl group [command] --help" for more information about a command. + +unknown command "invalid-command" for "authctl group" diff --git a/cmd/authctl/group/testdata/golden/TestGroupCommand/Error_on_invalid_flag b/cmd/authctl/group/testdata/golden/TestGroupCommand/Error_on_invalid_flag new file mode 100644 index 0000000000..7ca5da1f79 --- /dev/null +++ b/cmd/authctl/group/testdata/golden/TestGroupCommand/Error_on_invalid_flag @@ -0,0 +1,13 @@ +Usage: + authctl group [flags] + authctl group [command] + +Available Commands: + set-gid Set the GID of a group managed by authd + +Flags: + -h, --help help for group + +Use "authctl group [command] --help" for more information about a command. + +unknown flag: --invalid-flag diff --git a/cmd/authctl/group/testdata/golden/TestGroupCommand/Help_flag b/cmd/authctl/group/testdata/golden/TestGroupCommand/Help_flag new file mode 100644 index 0000000000..ad64bb0b01 --- /dev/null +++ b/cmd/authctl/group/testdata/golden/TestGroupCommand/Help_flag @@ -0,0 +1,13 @@ +Commands related to groups + +Usage: + authctl group [flags] + authctl group [command] + +Available Commands: + set-gid Set the GID of a group managed by authd + +Flags: + -h, --help help for group + +Use "authctl group [command] --help" for more information about a command. diff --git a/cmd/authctl/group/testdata/golden/TestGroupCommand/Usage_message_when_no_args b/cmd/authctl/group/testdata/golden/TestGroupCommand/Usage_message_when_no_args new file mode 100644 index 0000000000..1c53f30766 --- /dev/null +++ b/cmd/authctl/group/testdata/golden/TestGroupCommand/Usage_message_when_no_args @@ -0,0 +1,11 @@ +Usage: + authctl group [flags] + authctl group [command] + +Available Commands: + set-gid Set the GID of a group managed by authd + +Flags: + -h, --help help for group + +Use "authctl group [command] --help" for more information about a command. diff --git a/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_authd_is_unavailable b/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_authd_is_unavailable new file mode 100644 index 0000000000..ba5b5abcba --- /dev/null +++ b/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_authd_is_unavailable @@ -0,0 +1 @@ +Error: connection error: desc = "transport: Error while dialing: dial unix /non-existent: connect: no such file or directory" diff --git a/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_gid_is_already_taken b/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_gid_is_already_taken new file mode 100644 index 0000000000..673356ab64 --- /dev/null +++ b/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_gid_is_already_taken @@ -0,0 +1 @@ +Error: GID 0 already exists diff --git a/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_gid_is_invalid b/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_gid_is_invalid new file mode 100644 index 0000000000..9fb3f91660 --- /dev/null +++ b/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_gid_is_invalid @@ -0,0 +1 @@ +failed to parse GID "invalidgid": invalid syntax diff --git a/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_gid_is_negative b/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_gid_is_negative new file mode 100644 index 0000000000..91f9e8c76a --- /dev/null +++ b/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_gid_is_negative @@ -0,0 +1 @@ +failed to parse GID "-1000": invalid syntax diff --git a/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_gid_is_too_large b/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_gid_is_too_large new file mode 100644 index 0000000000..be456ffb84 --- /dev/null +++ b/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_gid_is_too_large @@ -0,0 +1 @@ +Error: GID 2147483648 is too large to convert to int32 diff --git a/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_group_does_not_exist b/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_group_does_not_exist new file mode 100644 index 0000000000..bcbc1916c2 --- /dev/null +++ b/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_group_does_not_exist @@ -0,0 +1 @@ +Error: group "invalidgroup" not found diff --git a/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Set_group_gid_success b/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Set_group_gid_success new file mode 100644 index 0000000000..19d12d3e82 --- /dev/null +++ b/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Set_group_gid_success @@ -0,0 +1,2 @@ +GID of group 'group1' set to 123456. +Note: Ownership of files outside the user's home directory are not updated and must be changed manually. diff --git a/cmd/authctl/internal/client/client.go b/cmd/authctl/internal/client/client.go new file mode 100644 index 0000000000..fff6efe7ff --- /dev/null +++ b/cmd/authctl/internal/client/client.go @@ -0,0 +1,35 @@ +// Package client provides a utility function to create a gRPC client for the authd service. +package client + +import ( + "fmt" + "os" + "regexp" + + "github.com/canonical/authd/internal/consts" + "github.com/canonical/authd/internal/proto/authd" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +// NewUserServiceClient creates and returns a new [authd.UserServiceClient]. +func NewUserServiceClient() (authd.UserServiceClient, error) { + authdSocket := os.Getenv("AUTHD_SOCKET") + if authdSocket == "" { + authdSocket = "unix://" + consts.DefaultSocketPath + } + + // Check if the socket has a scheme, else default to "unix://" + schemeRegex := regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.-]*:`) + if !schemeRegex.MatchString(authdSocket) { + authdSocket = "unix://" + authdSocket + } + + conn, err := grpc.NewClient(authdSocket, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return nil, fmt.Errorf("failed to connect to authd: %w", err) + } + + client := authd.NewUserServiceClient(conn) + return client, nil +} diff --git a/cmd/authctl/internal/completion/completion.go b/cmd/authctl/internal/completion/completion.go new file mode 100644 index 0000000000..0070afd743 --- /dev/null +++ b/cmd/authctl/internal/completion/completion.go @@ -0,0 +1,77 @@ +// Package completion provides completion functions for authctl. +package completion + +import ( + "context" + "time" + + "github.com/canonical/authd/cmd/authctl/internal/client" + "github.com/canonical/authd/internal/proto/authd" + "github.com/spf13/cobra" + "google.golang.org/grpc/status" +) + +const timeout = 5 * time.Second + +// Users returns the list of authd users for shell completion. +func Users(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + svc, err := client.NewUserServiceClient() + if err != nil { + return showError(err) + } + + ctx, cancel := context.WithTimeout(cmd.Context(), timeout) + defer cancel() + + resp, err := svc.ListUsers(ctx, &authd.Empty{}) + if err != nil { + return showError(err) + } + + var userNames []string + for _, user := range resp.Users { + userNames = append(userNames, user.Name) + } + + return userNames, cobra.ShellCompDirectiveNoFileComp +} + +// Groups returns the list of authd groups for shell completion. +func Groups(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + c, err := client.NewUserServiceClient() + if err != nil { + return showError(err) + } + + ctx, cancel := context.WithTimeout(cmd.Context(), timeout) + defer cancel() + + resp, err := c.ListGroups(ctx, &authd.Empty{}) + if err != nil { + return showError(err) + } + + var groupNames []string + for _, group := range resp.Groups { + groupNames = append(groupNames, group.Name) + } + + return groupNames, cobra.ShellCompDirectiveNoFileComp +} + +// NoArgs returns no arguments and disables file completion. +func NoArgs(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return nil, cobra.ShellCompDirectiveNoFileComp +} + +func showError(err error) ([]string, cobra.ShellCompDirective) { + if s, ok := status.FromError(err); ok { + return showMessage(s.Message()) + } + + return showMessage(err.Error()) +} + +func showMessage(msg string) ([]string, cobra.ShellCompDirective) { + return cobra.AppendActiveHelp(nil, msg), cobra.ShellCompDirectiveNoFileComp +} diff --git a/cmd/authctl/internal/docgen/main.go b/cmd/authctl/internal/docgen/main.go new file mode 100644 index 0000000000..a479c15060 --- /dev/null +++ b/cmd/authctl/internal/docgen/main.go @@ -0,0 +1,84 @@ +// Package main generates CLI reference documentation. +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/canonical/authd/cmd/authctl/root" + "github.com/spf13/cobra/doc" +) + +func logf(format string, v ...any) { + fmt.Fprintf(os.Stderr, format+"\n", v...) +} + +func log(v ...any) { + logf("%v", v...) +} + +func fatalf(format string, v ...any) { + logf(format, v...) + os.Exit(1) +} + +func fatal(v ...any) { + fatalf("%v", v...) +} + +func main() { + out := flag.String("out", "", "output path (directory for markdown/rest, file for man)") + format := flag.String("format", "markdown", "markdown|man|rest") + front := flag.Bool("frontmatter", false, "prepend simple YAML front matter to markdown") + flag.Parse() + + if *out == "" { + fatal("-out is required") + } + + rootCmd := root.RootCmd + rootCmd.DisableAutoGenTag = true // stable, reproducible files (no timestamp footer) + + switch *format { + case "markdown": + logf("Generating markdown documentation in %s", *out) + if err := os.MkdirAll(*out, 0o750); err != nil { + fatal(err) + } + + if *front { + prep := func(filename string) string { + base := filepath.Base(filename) + name := strings.TrimSuffix(base, filepath.Ext(base)) + title := strings.ReplaceAll(name, "_", " ") + return fmt.Sprintf("---\ntitle: %q\nslug: %q\ndescription: \"CLI reference for %s\"\n---\n\n", title, name, title) + } + link := func(name string) string { return strings.ToLower(name) } + if err := doc.GenMarkdownTreeCustom(rootCmd, *out, prep, link); err != nil { + fatal(err) + } + } else { + if err := doc.GenMarkdownTree(rootCmd, *out); err != nil { + fatal(err) + } + } + case "rest": + logf("Generating reStructuredText documentation in %s", *out) + if err := os.MkdirAll(*out, 0o750); err != nil { + fatal(err) + } + if err := doc.GenReSTTree(rootCmd, *out); err != nil { + fatal(err) + } + case "man": + logf("Generating man page in %s", *out) + if err := genManPage(rootCmd, *out); err != nil { + fatal(err) + } + default: + fatalf("unknown format: %s", *format) + } +} diff --git a/cmd/authctl/internal/docgen/manpage.go b/cmd/authctl/internal/docgen/manpage.go new file mode 100644 index 0000000000..2e2bf186ac --- /dev/null +++ b/cmd/authctl/internal/docgen/manpage.go @@ -0,0 +1,284 @@ +package main + +import ( + "bytes" + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/spf13/cobra/doc" + "github.com/spf13/pflag" +) + +// genManPage generates a single man page for authctl and all its subcommands. +// We can't use cobra's doc.GenManTree because it generates separate man +// pages for each command, and we want a single page with all commands. +func genManPage(cmd *cobra.Command, path string) error { + cmd.InitDefaultHelpFlag() + cmd.InitDefaultVersionFlag() + + header := &doc.GenManHeader{Title: strings.ToUpper(cmd.Name()), Section: "1"} + fillHeader(header, cmd.CommandPath()) + + buf := new(bytes.Buffer) + + // Header + fmt.Fprintf(buf, ".\\\" Generated from authctl man page generator\n") + fmt.Fprintf(buf, ".\\\" Do not edit manually\n") + fmt.Fprintf(buf, ".nh\n") + fmt.Fprintf(buf, ".TH \"%s\" \"%s\" \"%s\" \"%s\"\n", + header.Title, header.Section, header.Date.Format("2006-01-02"), header.Source) + + // NAME + fmt.Fprintf(buf, ".SH NAME\n") + fmt.Fprintf(buf, "%s \\- %s\n", cmd.Name(), escapeRoff(cmd.Short)) + + // SYNOPSIS + fmt.Fprintf(buf, ".SH SYNOPSIS\n") + fmt.Fprintf(buf, "\\fB%s\\fP [\\fIoptions\\fP] \\fI\\fP [\\fIargs\\fP]\n", cmd.Name()) + + // DESCRIPTION + desc := cmd.Long + if desc == "" { + desc = cmd.Short + } + desc = escapeRoff(desc) + // Make occurrences of the command name bold in the description + desc = strings.ReplaceAll(desc, cmd.Name(), "\\fB"+cmd.Name()+"\\fP") + fmt.Fprintf(buf, ".SH DESCRIPTION\n") + fmt.Fprintf(buf, "%s\n", desc) + + // COMMANDS + fmt.Fprintf(buf, ".SH COMMANDS\n") + genCommandList(buf, cmd) + + // OPTIONS + globalFlags := cmd.PersistentFlags() + if globalFlags.HasAvailableFlags() { + fmt.Fprintf(buf, ".SH OPTIONS\n") + fmt.Fprintf(buf, "The following options are understood:\n") + manPrintFlags(buf, globalFlags) + } + + // SEE ALSO + fmt.Fprintf(buf, ".SH SEE ALSO\n") + fmt.Fprintf(buf, "For more information, please refer to the \\m[blue]\\fBauthd documentation\\fP\\m[][1]\\&.\n") + + // NOTES + fmt.Fprintf(buf, ".SH NOTES\n") + fmt.Fprintf(buf, ".IP \" 1.\" 4\n") + fmt.Fprintf(buf, "authd documentation\n") + fmt.Fprintf(buf, ".RS 4\n") + fmt.Fprintf(buf, "\\%%https://documentation.ubuntu.com/authd\n") + fmt.Fprintf(buf, ".RE\n") + + if !shouldWriteManPage(path, buf.Bytes()) { + return nil + } + + return os.WriteFile(path, buf.Bytes(), 0600) +} + +func genCommandList(buf *bytes.Buffer, cmd *cobra.Command) { + var commands []*cobra.Command + collectCommands(cmd, &commands) + + for _, c := range commands { + // Calculate command name relative to root + // e.g. "user lock" + name := c.UseLine() + rootName := c.Root().Name() + if strings.HasPrefix(name, rootName+" ") { + // +1 for space + name = name[len(rootName)+1:] + } + + // Split command and arguments + // Format: "command " -> "\fBcommand\fP \fI\fP \fI\fP" + parts := strings.Fields(name) + var formattedParts []string + + for _, part := range parts { + if strings.HasPrefix(part, "<") && strings.HasSuffix(part, ">") { + // This is an argument - make it italic, keep angle brackets and lowercase + formattedParts = append(formattedParts, "\\fI"+part+"\\fP") + } else { + // This is part of the command - make it bold + formattedParts = append(formattedParts, "\\fB"+part+"\\fP") + } + } + + formattedName := strings.Join(formattedParts, " ") + + // Write command with proper roff formatting + fmt.Fprintf(buf, ".PP\n") + fmt.Fprintf(buf, "%s\n", formattedName) + fmt.Fprintf(buf, ".RS 4\n") + + // Write description + desc := "" + if c.Long != "" { + desc = c.Long + } else if c.Short != "" { + desc = c.Short + } + + if desc != "" { + // Escape special characters in description + desc = escapeRoff(desc) + // Write paragraphs + paragraphs := strings.Split(desc, "\n\n") + for i, para := range paragraphs { + para = strings.TrimSpace(para) + if para == "" { + continue + } + // Replace newlines within paragraph with spaces + para = strings.ReplaceAll(para, "\n", " ") + fmt.Fprintf(buf, "%s\n", para) + if i < len(paragraphs)-1 { + fmt.Fprintf(buf, ".sp\n") // Add spacing between paragraphs + } + } + } + + // Options + flags := c.NonInheritedFlags() + if flags.HasAvailableFlags() { + fmt.Fprintf(buf, ".sp\n") + fmt.Fprintf(buf, "\\fBOptions:\\fP\n") + fmt.Fprintf(buf, ".sp\n") + manPrintFlags(buf, flags) + } + + // .RE ends indented block + fmt.Fprintf(buf, ".RE\n") + } +} +func collectCommands(cmd *cobra.Command, res *[]*cobra.Command) { + for _, c := range cmd.Commands() { + if !c.IsAvailableCommand() || c.IsAdditionalHelpTopicCommand() { + continue + } + if len(c.Commands()) > 0 { + collectCommands(c, res) + } else { + *res = append(*res, c) + } + } +} + +func fillHeader(header *doc.GenManHeader, name string) { + if header.Title == "" { + header.Title = strings.ToUpper(strings.ReplaceAll(name, " ", "\\-")) + } + if header.Section == "" { + header.Section = "1" + } + if header.Source == "" { + header.Source = "authd" + } + if header.Date == nil { + now := time.Now() + if epoch := os.Getenv("SOURCE_DATE_EPOCH"); epoch != "" { + unixEpoch, err := strconv.ParseInt(epoch, 10, 64) + if err == nil { + now = time.Unix(unixEpoch, 0) + } + } + header.Date = &now + } +} + +func escapeRoff(s string) string { + // Escape backslashes - this is the main special character that needs escaping + s = strings.ReplaceAll(s, "\\", "\\\\") + return s +} + +func manPrintFlags(buf *bytes.Buffer, flags *pflag.FlagSet) { + flags.VisitAll(func(flag *pflag.Flag) { + if len(flag.Deprecated) > 0 || flag.Hidden { + return + } + + // Build flag name + fmt.Fprintf(buf, ".PP\n") + + var flagStr string + if len(flag.Shorthand) > 0 && len(flag.ShorthandDeprecated) == 0 { + flagStr = fmt.Sprintf("\\fB\\-%s\\fP, \\fB\\-\\-%s\\fP", flag.Shorthand, flag.Name) + } else { + flagStr = fmt.Sprintf("\\fB\\-\\-%s\\fP", flag.Name) + } + + // Add value specification for non-boolean flags + if flag.Value.Type() != "bool" { + if len(flag.NoOptDefVal) > 0 { + flagStr += " [" + } else { + flagStr += " " + } + + // Format value based on type + valName := strings.ToUpper(flag.Name) + if flag.Value.Type() == "string" { + flagStr += fmt.Sprintf("\\fI%s\\fP", valName) + } else { + flagStr += fmt.Sprintf("\\fI%s\\fP", valName) + } + + if len(flag.NoOptDefVal) > 0 { + flagStr += "]" + } + } + + fmt.Fprintf(buf, "%s\n", flagStr) + fmt.Fprintf(buf, ".RS 4\n") + fmt.Fprintf(buf, "%s\n", escapeRoff(flag.Usage)) + + // Show default value if not empty and not boolean + if flag.Value.Type() != "bool" && flag.DefValue != "" { + fmt.Fprintf(buf, ".sp\n") + fmt.Fprintf(buf, "Defaults to \\fI%s\\fP\\&.\n", escapeRoff(flag.DefValue)) + } + + fmt.Fprintf(buf, ".RE\n") + }) +} + +// stripDate returns the given man page data with the date removed. +func stripDate(data []byte) []byte { + lines := strings.Split(string(data), "\n") + var out []string + for _, line := range lines { + if strings.HasPrefix(line, ".TH ") { + parts := strings.Fields(line) + if len(parts) >= 5 { + parts[3] = "" // Remove date + line = strings.Join(parts, " ") + } + } + out = append(out, line) + } + return []byte(strings.Join(out, "\n")) +} + +func shouldWriteManPage(path string, content []byte) bool { + existing, err := os.ReadFile(path) + if err != nil { + // The man page doesn't exist yet, so we should write it + return true + } + + if bytes.Equal(stripDate(existing), stripDate(content)) { + // The man page is unchanged (except for the date), so don't write it + log("Man page is up-to-date") + return false + } + + return true +} diff --git a/cmd/authctl/internal/log/log.go b/cmd/authctl/internal/log/log.go new file mode 100644 index 0000000000..fda728ce1c --- /dev/null +++ b/cmd/authctl/internal/log/log.go @@ -0,0 +1,70 @@ +// Package log provides logging functions for authctl. +package log + +import ( + "fmt" + "os" + "sync" + + "golang.org/x/term" +) + +var useColor = sync.OnceValue(func() bool { + if os.Getenv("NO_COLOR") != "" { + return false + } + + return term.IsTerminal(int(os.Stderr.Fd())) +}) + +// Info prints a message to stderr. +func Info(a ...any) { + fmt.Fprintln(os.Stderr, fmt.Sprint(a...)) +} + +// Infof prints a formatted message to stderr. +func Infof(format string, args ...any) { + Info(fmt.Sprintf(format, args...)) +} + +// Notice prints a message to stderr in bold. +func Notice(a ...any) { + if !useColor() { + fmt.Fprintln(os.Stderr, fmt.Sprint(a...)) + return + } + fmt.Fprintln(os.Stderr, "\033[0;1;39m"+fmt.Sprint(a...)+"\033[0m") +} + +// Noticef prints a formatted message to stderr in bold. +func Noticef(format string, args ...any) { + Notice(fmt.Sprintf(format, args...)) +} + +// Warning prints a message to stderr in yellow. +func Warning(a ...any) { + if !useColor() { + fmt.Fprintln(os.Stderr, fmt.Sprint(a...)) + return + } + fmt.Fprintln(os.Stderr, "\033[0;1;38:5:185m"+fmt.Sprint(a...)+"\033[0m") +} + +// Warningf prints a formatted message to stderr in yellow. +func Warningf(format string, args ...any) { + Warning(fmt.Sprintf(format, args...)) +} + +// Error prints a message to stderr in red. +func Error(a ...any) { + if !useColor() { + fmt.Fprintln(os.Stderr, fmt.Sprint(a...)) + return + } + fmt.Fprintln(os.Stderr, "\033[1;31m"+fmt.Sprint(a...)+"\033[0m") +} + +// Errorf prints a formatted message to stderr in red. +func Errorf(format string, args ...any) { + Error(fmt.Sprintf(format, args...)) +} diff --git a/cmd/authctl/main.go b/cmd/authctl/main.go new file mode 100644 index 0000000000..ea154b7ce9 --- /dev/null +++ b/cmd/authctl/main.go @@ -0,0 +1,38 @@ +// Package main implements Cobra commands for management operations on authd. +package main + +import ( + "os" + + "github.com/canonical/authd/cmd/authctl/internal/log" + "github.com/canonical/authd/cmd/authctl/root" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func main() { + if err := root.RootCmd.Execute(); err != nil { + s, ok := status.FromError(err) + if !ok { + // If the error is not a gRPC status, we print it as is. + log.Error(err.Error()) + os.Exit(1) + } + + // If the error is a gRPC status, we print the message and exit with the gRPC status code. + switch s.Code() { + case codes.PermissionDenied: + log.Errorf("Permission denied: %s", s.Message()) + default: + log.Errorf("Error: %s", s.Message()) + } + code := int(s.Code()) + if code < 0 || code > 255 { + // We cannot exit with a negative code or a code greater than 255, + // so we map it to 1 in that case. + code = 1 + } + + os.Exit(code) + } +} diff --git a/cmd/authctl/main_test.go b/cmd/authctl/main_test.go new file mode 100644 index 0000000000..52c2a0ee62 --- /dev/null +++ b/cmd/authctl/main_test.go @@ -0,0 +1,52 @@ +package main_test + +import ( + "fmt" + "os" + "os/exec" + "testing" + + "github.com/canonical/authd/internal/testutils" +) + +var authctlPath string + +func TestRootCommand(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + args []string + expectedExitCode int + }{ + "Usage_message_when_no_args": {expectedExitCode: 0}, + "Help_command": {args: []string{"help"}, expectedExitCode: 0}, + "Help_flag": {args: []string{"--help"}, expectedExitCode: 0}, + "Completion_command": {args: []string{"completion"}, expectedExitCode: 0}, + + "Error_on_invalid_command": {args: []string{"invalid-command"}, expectedExitCode: 1}, + "Error_on_invalid_flag": {args: []string{"--invalid-flag"}, expectedExitCode: 1}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + //nolint:gosec // G204 it's safe to use exec.Command with a variable here + cmd := exec.Command(authctlPath, tc.args...) + testutils.CheckCommand(t, cmd, tc.expectedExitCode) + }) + } +} + +func TestMain(m *testing.M) { + var cleanup func() + var err error + authctlPath, cleanup, err = testutils.BuildAuthctl() + if err != nil { + fmt.Fprintf(os.Stderr, "Setup: %v\n", err) + os.Exit(1) + } + defer cleanup() + + m.Run() +} diff --git a/cmd/authctl/root/root.go b/cmd/authctl/root/root.go new file mode 100644 index 0000000000..591eee4d18 --- /dev/null +++ b/cmd/authctl/root/root.go @@ -0,0 +1,38 @@ +// Package root contains the root command for authctl. +package root + +import ( + "github.com/canonical/authd/cmd/authctl/group" + "github.com/canonical/authd/cmd/authctl/user" + "github.com/spf13/cobra" +) + +// RootCmd is the root command for authctl. +var RootCmd = &cobra.Command{ + Use: "authctl", + Short: "Manage authd users and groups", + Long: "authctl is a command-line tool for managing users and groups handled by authd.", + PersistentPreRun: func(cmd *cobra.Command, args []string) { + // The command was successfully parsed, so we don't want cobra to print usage information on error. + cmd.SilenceUsage = true + }, + CompletionOptions: cobra.CompletionOptions{ + HiddenDefaultCmd: true, + }, + // Avoid the "Auto generated by spf13/cobra" line in the generated markdown docs + DisableAutoGenTag: true, + // We handle errors ourselves + SilenceErrors: true, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { return cmd.Usage() }, +} + +func init() { + // Disable command sorting by name. This makes cobra print the commands in the + // order they are added to the root command and adds the `help` and `completion` + // commands at the end. + cobra.EnableCommandSorting = false + + RootCmd.AddCommand(user.UserCmd) + RootCmd.AddCommand(group.GroupCmd) +} diff --git a/cmd/authctl/testdata/golden/TestRootCommand/Completion_command b/cmd/authctl/testdata/golden/TestRootCommand/Completion_command new file mode 100644 index 0000000000..fb3a14a225 --- /dev/null +++ b/cmd/authctl/testdata/golden/TestRootCommand/Completion_command @@ -0,0 +1,16 @@ +Generate the autocompletion script for authctl for the specified shell. +See each sub-command's help for details on how to use the generated script. + +Usage: + authctl completion [command] + +Available Commands: + bash Generate the autocompletion script for bash + zsh Generate the autocompletion script for zsh + fish Generate the autocompletion script for fish + powershell Generate the autocompletion script for powershell + +Flags: + -h, --help help for completion + +Use "authctl completion [command] --help" for more information about a command. diff --git a/cmd/authctl/testdata/golden/TestRootCommand/Error_on_invalid_command b/cmd/authctl/testdata/golden/TestRootCommand/Error_on_invalid_command new file mode 100644 index 0000000000..ffcf63f66e --- /dev/null +++ b/cmd/authctl/testdata/golden/TestRootCommand/Error_on_invalid_command @@ -0,0 +1,15 @@ +Usage: + authctl [flags] + authctl [command] + +Available Commands: + user Commands related to users + group Commands related to groups + help Help about any command + +Flags: + -h, --help help for authctl + +Use "authctl [command] --help" for more information about a command. + +unknown command "invalid-command" for "authctl" diff --git a/cmd/authctl/testdata/golden/TestRootCommand/Error_on_invalid_flag b/cmd/authctl/testdata/golden/TestRootCommand/Error_on_invalid_flag new file mode 100644 index 0000000000..427c0c5bf3 --- /dev/null +++ b/cmd/authctl/testdata/golden/TestRootCommand/Error_on_invalid_flag @@ -0,0 +1,15 @@ +Usage: + authctl [flags] + authctl [command] + +Available Commands: + user Commands related to users + group Commands related to groups + help Help about any command + +Flags: + -h, --help help for authctl + +Use "authctl [command] --help" for more information about a command. + +unknown flag: --invalid-flag diff --git a/cmd/authctl/testdata/golden/TestRootCommand/Help_command b/cmd/authctl/testdata/golden/TestRootCommand/Help_command new file mode 100644 index 0000000000..cbd8f4dc4a --- /dev/null +++ b/cmd/authctl/testdata/golden/TestRootCommand/Help_command @@ -0,0 +1,15 @@ +authctl is a command-line tool for managing users and groups handled by authd. + +Usage: + authctl [flags] + authctl [command] + +Available Commands: + user Commands related to users + group Commands related to groups + help Help about any command + +Flags: + -h, --help help for authctl + +Use "authctl [command] --help" for more information about a command. diff --git a/cmd/authctl/testdata/golden/TestRootCommand/Help_flag b/cmd/authctl/testdata/golden/TestRootCommand/Help_flag new file mode 100644 index 0000000000..cbd8f4dc4a --- /dev/null +++ b/cmd/authctl/testdata/golden/TestRootCommand/Help_flag @@ -0,0 +1,15 @@ +authctl is a command-line tool for managing users and groups handled by authd. + +Usage: + authctl [flags] + authctl [command] + +Available Commands: + user Commands related to users + group Commands related to groups + help Help about any command + +Flags: + -h, --help help for authctl + +Use "authctl [command] --help" for more information about a command. diff --git a/cmd/authctl/testdata/golden/TestRootCommand/Usage_message_when_no_args b/cmd/authctl/testdata/golden/TestRootCommand/Usage_message_when_no_args new file mode 100644 index 0000000000..d2577969fd --- /dev/null +++ b/cmd/authctl/testdata/golden/TestRootCommand/Usage_message_when_no_args @@ -0,0 +1,13 @@ +Usage: + authctl [flags] + authctl [command] + +Available Commands: + user Commands related to users + group Commands related to groups + help Help about any command + +Flags: + -h, --help help for authctl + +Use "authctl [command] --help" for more information about a command. diff --git a/cmd/authctl/user/lock.go b/cmd/authctl/user/lock.go new file mode 100644 index 0000000000..c0d3caa23e --- /dev/null +++ b/cmd/authctl/user/lock.go @@ -0,0 +1,32 @@ +package user + +import ( + "context" + + "github.com/canonical/authd/cmd/authctl/internal/client" + "github.com/canonical/authd/cmd/authctl/internal/completion" + "github.com/canonical/authd/internal/proto/authd" + "github.com/spf13/cobra" +) + +// lockCmd is a command to lock (disable) a user. +var lockCmd = &cobra.Command{ + Use: "lock ", + Short: "Lock (disable) a user managed by authd", + Long: `Lock a user so that they cannot log in.`, + Args: cobra.ExactArgs(1), + ValidArgsFunction: completion.Users, + RunE: func(cmd *cobra.Command, args []string) error { + client, err := client.NewUserServiceClient() + if err != nil { + return err + } + + _, err = client.LockUser(context.Background(), &authd.LockUserRequest{Name: args[0]}) + if err != nil { + return err + } + + return nil + }, +} diff --git a/cmd/authctl/user/lock_test.go b/cmd/authctl/user/lock_test.go new file mode 100644 index 0000000000..cbc13c203f --- /dev/null +++ b/cmd/authctl/user/lock_test.go @@ -0,0 +1,44 @@ +package user_test + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/canonical/authd/internal/testutils" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" +) + +func TestUserLockCommand(t *testing.T) { + t.Parallel() + + daemonSocket := testutils.StartAuthd(t, daemonPath, + testutils.WithGroupFile(filepath.Join("testdata", "empty.group")), + testutils.WithPreviousDBState("one_user_and_group"), + testutils.WithCurrentUserAsRoot, + ) + + err := os.Setenv("AUTHD_SOCKET", daemonSocket) + require.NoError(t, err, "Failed to set AUTHD_SOCKET environment variable") + + tests := map[string]struct { + args []string + expectedExitCode int + }{ + "Lock_user_success": {args: []string{"lock", "user1@example.com"}, expectedExitCode: 0}, + + "Error_locking_invalid_user": {args: []string{"lock", "invaliduser"}, expectedExitCode: int(codes.NotFound)}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + //nolint:gosec // G204 it's safe to use exec.Command with a variable here + cmd := exec.Command(authctlPath, append([]string{"user"}, tc.args...)...) + testutils.CheckCommand(t, cmd, tc.expectedExitCode) + }) + } +} diff --git a/cmd/authctl/user/set-uid.go b/cmd/authctl/user/set-uid.go new file mode 100644 index 0000000000..f87cdcc60f --- /dev/null +++ b/cmd/authctl/user/set-uid.go @@ -0,0 +1,86 @@ +package user + +import ( + "context" + "errors" + "fmt" + "os" + "strconv" + + "github.com/canonical/authd/cmd/authctl/internal/client" + "github.com/canonical/authd/cmd/authctl/internal/completion" + "github.com/canonical/authd/cmd/authctl/internal/log" + "github.com/canonical/authd/internal/proto/authd" + "github.com/spf13/cobra" +) + +// setUIDCmd is a command to set the UID of a user managed by authd. +var setUIDCmd = &cobra.Command{ + Use: "set-uid ", + Short: "Set the UID of a user managed by authd", + Long: `Set the UID of a user managed by authd to the specified value. + +The new UID must be unique and non-negative. The command must be run as root. + +The ownership of the user's home directory, and any files within the directory +that the user owns, will automatically be updated to the new UID. + +Files outside the user's home directory are not updated and must be changed +manually. Note that changing a UID can be unsafe if files on the system are +still owned by the original UID: those files may become accessible to a +different account that is later assigned that UID.`, + Example: ` # Set the UID of user "alice" to 15000 + authctl user set-uid alice 15000`, + Args: cobra.ExactArgs(2), + ValidArgsFunction: setUIDCompletionFunc, + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + uidStr := args[1] + uid, err := strconv.ParseUint(uidStr, 10, 32) + if err != nil { + // Remove the "strconv.ParseUint: parsing ..." part from the error message + // because it doesn't add any useful information. + if unwrappedErr := errors.Unwrap(err); unwrappedErr != nil { + err = unwrappedErr + } + return fmt.Errorf("failed to parse UID %q: %w", uidStr, err) + } + + client, err := client.NewUserServiceClient() + if err != nil { + return err + } + + resp, err := client.SetUserID(context.Background(), &authd.SetUserIDRequest{ + Name: name, + Id: uint32(uid), + Lang: os.Getenv("LANG"), + }) + if resp == nil { + return err + } + + if resp.IdChanged { + log.Infof("UID of user '%s' set to %d.", name, uid) + if resp.HomeDirOwnerChanged { + log.Info("Updated ownership of the user's home directory.") + } + log.Info("Note: Ownership of files outside the user's home directory are not updated and must be changed manually.") + } + + // Print any warnings returned by the server. + for _, warning := range resp.Warnings { + log.Warning(warning) + } + + return err + }, +} + +func setUIDCompletionFunc(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return completion.Users(cmd, args, toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp +} diff --git a/cmd/authctl/user/set-uid_test.go b/cmd/authctl/user/set-uid_test.go new file mode 100644 index 0000000000..276169b4f2 --- /dev/null +++ b/cmd/authctl/user/set-uid_test.go @@ -0,0 +1,87 @@ +package user_test + +import ( + "math" + "os" + "os/exec" + "path/filepath" + "strconv" + "testing" + + "github.com/canonical/authd/internal/testutils" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" +) + +func TestSetUIDCommand(t *testing.T) { + // We can't run these tests in parallel because the daemon with the example + // broker which we're using here uses userslocking.Z_ForTests_OverrideLocking() + // which makes userslocking.WriteLock() return an error immediately when the lock + // is already held - unlike the normal behavior which tries to acquire the lock + // for 15 seconds before returning an error. + + daemonSocket := testutils.StartAuthd(t, daemonPath, + testutils.WithGroupFile(filepath.Join("testdata", "empty.group")), + testutils.WithPreviousDBState("one_user_and_group"), + testutils.WithCurrentUserAsRoot, + ) + + err := os.Setenv("AUTHD_SOCKET", daemonSocket) + require.NoError(t, err, "Failed to set AUTHD_SOCKET environment variable") + + tests := map[string]struct { + args []string + authdUnavailable bool + + expectedExitCode int + }{ + "Set_user_uid_success": { + args: []string{"set-uid", "user1@example.com", "123456"}, + expectedExitCode: 0, + }, + + "Error_when_user_does_not_exist": { + args: []string{"set-uid", "invaliduser", "123456"}, + expectedExitCode: int(codes.NotFound), + }, + "Error_when_uid_is_invalid": { + args: []string{"set-uid", "user1@example.com", "invaliduid"}, + expectedExitCode: 1, + }, + "Error_when_uid_is_too_large": { + args: []string{"set-uid", "user1@example.com", strconv.Itoa(math.MaxInt32 + 1)}, + expectedExitCode: int(codes.Unknown), + }, + "Error_when_uid_is_already_taken": { + args: []string{"set-uid", "user1@example.com", "0"}, + expectedExitCode: int(codes.Unknown), + }, + "Error_when_uid_is_negative": { + args: []string{"set-uid", "user1@example.com", "--", "-1000"}, + expectedExitCode: 1, + }, + "Error_when_authd_is_unavailable": { + args: []string{"set-uid", "user1@example.com", "123456"}, + authdUnavailable: true, + expectedExitCode: int(codes.Unavailable), + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + if tc.authdUnavailable { + origValue := os.Getenv("AUTHD_SOCKET") + err := os.Setenv("AUTHD_SOCKET", "/non-existent") + require.NoError(t, err, "Failed to set AUTHD_SOCKET environment variable") + t.Cleanup(func() { + err := os.Setenv("AUTHD_SOCKET", origValue) + require.NoError(t, err, "Failed to restore AUTHD_SOCKET environment variable") + }) + } + + //nolint:gosec // G204 it's safe to use exec.Command with a variable here + cmd := exec.Command(authctlPath, append([]string{"user"}, tc.args...)...) + testutils.CheckCommand(t, cmd, tc.expectedExitCode) + }) + } +} diff --git a/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_last_login_time_for_user b/cmd/authctl/user/testdata/db/one_user_and_group.db.yaml similarity index 72% rename from internal/users/db/testdata/golden/TestUpdateUserEntry/Update_last_login_time_for_user rename to cmd/authctl/user/testdata/db/one_user_and_group.db.yaml index 5d9b092038..39af2b587b 100644 --- a/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_last_login_time_for_user +++ b/cmd/authctl/user/testdata/db/one_user_and_group.db.yaml @@ -1,13 +1,13 @@ users: - - name: user1 + - name: user1@example.com uid: 1111 gid: 11111 gecos: |- User1 gecos On multiple lines - dir: /home/user1 + dir: /home/user1@example.com shell: /bin/bash - last_login: 2020-01-01T00:00:00Z + broker_id: broker-id groups: - name: group1 gid: 11111 diff --git a/cmd/authctl/user/testdata/empty.group b/cmd/authctl/user/testdata/empty.group new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_authd_is_unavailable b/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_authd_is_unavailable new file mode 100644 index 0000000000..ba5b5abcba --- /dev/null +++ b/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_authd_is_unavailable @@ -0,0 +1 @@ +Error: connection error: desc = "transport: Error while dialing: dial unix /non-existent: connect: no such file or directory" diff --git a/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_uid_is_already_taken b/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_uid_is_already_taken new file mode 100644 index 0000000000..ed14c2f9ad --- /dev/null +++ b/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_uid_is_already_taken @@ -0,0 +1 @@ +Error: UID 0 already exists diff --git a/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_uid_is_invalid b/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_uid_is_invalid new file mode 100644 index 0000000000..acf1ab1bfd --- /dev/null +++ b/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_uid_is_invalid @@ -0,0 +1 @@ +failed to parse UID "invaliduid": invalid syntax diff --git a/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_uid_is_negative b/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_uid_is_negative new file mode 100644 index 0000000000..6345a0adf3 --- /dev/null +++ b/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_uid_is_negative @@ -0,0 +1 @@ +failed to parse UID "-1000": invalid syntax diff --git a/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_uid_is_too_large b/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_uid_is_too_large new file mode 100644 index 0000000000..e89c9a4386 --- /dev/null +++ b/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_uid_is_too_large @@ -0,0 +1 @@ +Error: UID 2147483648 is too large to convert to int32 diff --git a/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_user_does_not_exist b/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_user_does_not_exist new file mode 100644 index 0000000000..93dd7dd5ff --- /dev/null +++ b/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_user_does_not_exist @@ -0,0 +1 @@ +Error: user "invaliduser" not found diff --git a/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Set_user_uid_success b/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Set_user_uid_success new file mode 100644 index 0000000000..45b83a8c2e --- /dev/null +++ b/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Set_user_uid_success @@ -0,0 +1,2 @@ +UID of user 'user1@example.com' set to 123456. +Note: Ownership of files outside the user's home directory are not updated and must be changed manually. diff --git a/cmd/authctl/user/testdata/golden/TestUserCommand/Error_on_invalid_command b/cmd/authctl/user/testdata/golden/TestUserCommand/Error_on_invalid_command new file mode 100644 index 0000000000..a66b03e2c9 --- /dev/null +++ b/cmd/authctl/user/testdata/golden/TestUserCommand/Error_on_invalid_command @@ -0,0 +1,15 @@ +Usage: + authctl user [flags] + authctl user [command] + +Available Commands: + lock Lock (disable) a user managed by authd + unlock Unlock (enable) a user managed by authd + set-uid Set the UID of a user managed by authd + +Flags: + -h, --help help for user + +Use "authctl user [command] --help" for more information about a command. + +unknown command "invalid-command" for "authctl user" diff --git a/cmd/authctl/user/testdata/golden/TestUserCommand/Error_on_invalid_flag b/cmd/authctl/user/testdata/golden/TestUserCommand/Error_on_invalid_flag new file mode 100644 index 0000000000..3408cec6d8 --- /dev/null +++ b/cmd/authctl/user/testdata/golden/TestUserCommand/Error_on_invalid_flag @@ -0,0 +1,15 @@ +Usage: + authctl user [flags] + authctl user [command] + +Available Commands: + lock Lock (disable) a user managed by authd + unlock Unlock (enable) a user managed by authd + set-uid Set the UID of a user managed by authd + +Flags: + -h, --help help for user + +Use "authctl user [command] --help" for more information about a command. + +unknown flag: --invalid-flag diff --git a/cmd/authctl/user/testdata/golden/TestUserCommand/Help_flag b/cmd/authctl/user/testdata/golden/TestUserCommand/Help_flag new file mode 100644 index 0000000000..ee4765c1cc --- /dev/null +++ b/cmd/authctl/user/testdata/golden/TestUserCommand/Help_flag @@ -0,0 +1,15 @@ +Commands related to users + +Usage: + authctl user [flags] + authctl user [command] + +Available Commands: + lock Lock (disable) a user managed by authd + unlock Unlock (enable) a user managed by authd + set-uid Set the UID of a user managed by authd + +Flags: + -h, --help help for user + +Use "authctl user [command] --help" for more information about a command. diff --git a/cmd/authctl/user/testdata/golden/TestUserCommand/Usage_message_when_no_args b/cmd/authctl/user/testdata/golden/TestUserCommand/Usage_message_when_no_args new file mode 100644 index 0000000000..d83ced5684 --- /dev/null +++ b/cmd/authctl/user/testdata/golden/TestUserCommand/Usage_message_when_no_args @@ -0,0 +1,13 @@ +Usage: + authctl user [flags] + authctl user [command] + +Available Commands: + lock Lock (disable) a user managed by authd + unlock Unlock (enable) a user managed by authd + set-uid Set the UID of a user managed by authd + +Flags: + -h, --help help for user + +Use "authctl user [command] --help" for more information about a command. diff --git a/cmd/authctl/user/testdata/golden/TestUserLockCommand/Error_locking_invalid_user b/cmd/authctl/user/testdata/golden/TestUserLockCommand/Error_locking_invalid_user new file mode 100644 index 0000000000..93dd7dd5ff --- /dev/null +++ b/cmd/authctl/user/testdata/golden/TestUserLockCommand/Error_locking_invalid_user @@ -0,0 +1 @@ +Error: user "invaliduser" not found diff --git a/cmd/authctl/user/testdata/golden/TestUserLockCommand/Lock_user_success b/cmd/authctl/user/testdata/golden/TestUserLockCommand/Lock_user_success new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cmd/authctl/user/unlock.go b/cmd/authctl/user/unlock.go new file mode 100644 index 0000000000..66811a0a0f --- /dev/null +++ b/cmd/authctl/user/unlock.go @@ -0,0 +1,32 @@ +package user + +import ( + "context" + + "github.com/canonical/authd/cmd/authctl/internal/client" + "github.com/canonical/authd/cmd/authctl/internal/completion" + "github.com/canonical/authd/internal/proto/authd" + "github.com/spf13/cobra" +) + +// unlockCmd is a command to unlock (enable) a user. +var unlockCmd = &cobra.Command{ + Use: "unlock ", + Short: "Unlock (enable) a user managed by authd", + Long: `Unlock a locked user so that they can log in again.`, + Args: cobra.ExactArgs(1), + ValidArgsFunction: completion.Users, + RunE: func(cmd *cobra.Command, args []string) error { + client, err := client.NewUserServiceClient() + if err != nil { + return err + } + + _, err = client.UnlockUser(context.Background(), &authd.UnlockUserRequest{Name: args[0]}) + if err != nil { + return err + } + + return nil + }, +} diff --git a/cmd/authctl/user/user.go b/cmd/authctl/user/user.go new file mode 100644 index 0000000000..109743aa17 --- /dev/null +++ b/cmd/authctl/user/user.go @@ -0,0 +1,20 @@ +// Package user provides utilities for managing user operations. +package user + +import ( + "github.com/spf13/cobra" +) + +// UserCmd is a command to perform user-related operations. +var UserCmd = &cobra.Command{ + Use: "user", + Short: "Commands related to users", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { return cmd.Usage() }, +} + +func init() { + UserCmd.AddCommand(lockCmd) + UserCmd.AddCommand(unlockCmd) + UserCmd.AddCommand(setUIDCmd) +} diff --git a/cmd/authctl/user/user_test.go b/cmd/authctl/user/user_test.go new file mode 100644 index 0000000000..5010e8cc74 --- /dev/null +++ b/cmd/authctl/user/user_test.go @@ -0,0 +1,59 @@ +package user_test + +import ( + "fmt" + "os" + "os/exec" + "testing" + + "github.com/canonical/authd/internal/testutils" +) + +var authctlPath string +var daemonPath string + +func TestUserCommand(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + args []string + expectedExitCode int + }{ + "Usage_message_when_no_args": {expectedExitCode: 0}, + "Help_flag": {args: []string{"--help"}, expectedExitCode: 0}, + + "Error_on_invalid_command": {args: []string{"invalid-command"}, expectedExitCode: 1}, + "Error_on_invalid_flag": {args: []string{"--invalid-flag"}, expectedExitCode: 1}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + //nolint:gosec // G204 it's safe to use exec.Command with a variable here + cmd := exec.Command(authctlPath, append([]string{"user"}, tc.args...)...) + testutils.CheckCommand(t, cmd, tc.expectedExitCode) + }) + } +} + +func TestMain(m *testing.M) { + var authctlCleanup func() + var err error + authctlPath, authctlCleanup, err = testutils.BuildAuthctl() + if err != nil { + fmt.Fprintf(os.Stderr, "Setup: %v\n", err) + os.Exit(1) + } + defer authctlCleanup() + + var daemonCleanup func() + daemonPath, daemonCleanup, err = testutils.BuildAuthdWithExampleBroker() + if err != nil { + fmt.Fprintf(os.Stderr, "Setup: %v\n", err) + os.Exit(1) + } + defer daemonCleanup() + + m.Run() +} diff --git a/cmd/authd/daemon/config.go b/cmd/authd/daemon/config.go index 883bdf7001..3a2e7aa0ee 100644 --- a/cmd/authd/daemon/config.go +++ b/cmd/authd/daemon/config.go @@ -4,20 +4,19 @@ import ( "context" "errors" "fmt" - "log/slog" "os" "path/filepath" "strings" + "github.com/canonical/authd/internal/consts" + "github.com/canonical/authd/internal/decorate" + "github.com/canonical/authd/log" "github.com/spf13/cobra" "github.com/spf13/viper" - "github.com/ubuntu/authd/internal/consts" - "github.com/ubuntu/authd/log" - "github.com/ubuntu/decorate" ) // initViperConfig sets verbosity level and add config env variables and file support based on name prefix. -func initViperConfig(name string, cmd *cobra.Command, vip *viper.Viper) (err error) { +func initViperConfig(name string, cmd *cobra.Command, vip *viper.Viper, configDir string) (err error) { defer decorate.OnError(&err, "can't load configuration") // Force a visit of the local flags so persistent flags for all parents are merged. @@ -37,7 +36,7 @@ func initViperConfig(name string, cmd *cobra.Command, vip *viper.Viper) (err err vip.SetConfigName(name) vip.AddConfigPath("./") vip.AddConfigPath("$HOME/") - vip.AddConfigPath("/etc/authd/") + vip.AddConfigPath(configDir) // Add the executable path to the config search path. if binPath, err := os.Executable(); err != nil { log.Warningf(context.Background(), "Failed to get current executable path, not adding it as a config dir: %v", err) @@ -94,6 +93,5 @@ func setVerboseMode(level int) { log.SetLevel(log.InfoLevel) default: log.SetLevel(log.DebugLevel) - slog.SetLogLoggerLevel(slog.LevelDebug) } } diff --git a/cmd/authd/daemon/daemon.go b/cmd/authd/daemon/daemon.go index e5afc2980e..966de7d3bf 100644 --- a/cmd/authd/daemon/daemon.go +++ b/cmd/authd/daemon/daemon.go @@ -6,14 +6,14 @@ import ( "fmt" "runtime" + "github.com/canonical/authd/internal/consts" + "github.com/canonical/authd/internal/daemon" + "github.com/canonical/authd/internal/decorate" + "github.com/canonical/authd/internal/services" + "github.com/canonical/authd/internal/users" + "github.com/canonical/authd/log" "github.com/spf13/cobra" "github.com/spf13/viper" - "github.com/ubuntu/authd/internal/consts" - "github.com/ubuntu/authd/internal/daemon" - "github.com/ubuntu/authd/internal/services" - "github.com/ubuntu/authd/internal/users" - "github.com/ubuntu/authd/log" - "github.com/ubuntu/decorate" ) // cmdName is the binary name for the agent. @@ -48,22 +48,41 @@ type daemonConfig struct { UsersConfig *users.Config `mapstructure:",squash" yaml:",inline"` } +type options struct { + configDir string +} + +// Option is a function that modifies configuration options for the daemon. +type Option func(*options) + +// WithConfigDir sets the configuration directory for the daemon. +func WithConfigDir(configDir string) Option { + return func(o *options) { + o.configDir = configDir + } +} + // New registers commands and return a new App. -func New() *App { +func New(args ...Option) *App { + opts := &options{configDir: consts.DefaultConfigDir} + for _, arg := range args { + arg(opts) + } + a := App{ready: make(chan struct{})} a.rootCmd = cobra.Command{ Use: fmt.Sprintf("%s COMMAND", cmdName), Short:/*i18n.G(*/ "Authentication daemon", /*)*/ Long:/*i18n.G(*/ "Authentication daemon bridging the system with external brokers.", /*)*/ Args: cobra.NoArgs, - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + PersistentPreRun: func(cmd *cobra.Command, args []string) { // First thing, initialize the journal handler log.InitJournalHandler(false) // Command parsing has been successful. Returns to not print usage anymore. a.rootCmd.SilenceUsage = true - // TODO: before or after? cmd.LocalFlags() - + }, + RunE: func(cmd *cobra.Command, args []string) error { // Set config defaults a.config = daemonConfig{ Paths: systemPaths{ @@ -75,7 +94,7 @@ func New() *App { } // Install and unmarshall configuration - if err := initViperConfig(cmdName, &a.rootCmd, a.viper); err != nil { + if err := initViperConfig(cmdName, &a.rootCmd, a.viper, opts.configDir); err != nil { return err } if err := a.viper.Unmarshal(&a.config); err != nil { @@ -85,6 +104,11 @@ func New() *App { setVerboseMode(a.config.Verbosity) log.Debugf(context.Background(), "Verbosity: %d", a.config.Verbosity) + // If we are only checking the configuration, we exit now. + if check, _ := cmd.Flags().GetBool("check-config"); check { + return nil + } + if err := maybeMigrateOldDBDir(oldDBDir, a.config.Paths.Database); err != nil { return err } @@ -93,13 +117,14 @@ func New() *App { return err } - return nil - }, - RunE: func(cmd *cobra.Command, args []string) error { return a.serve(a.config) }, // We display usage error ourselves SilenceErrors: true, + // Don't add a completion subcommand, authd is not a CLI tool. + CompletionOptions: cobra.CompletionOptions{ + DisableDefaultCmd: true, + }, } viper := viper.New() @@ -107,6 +132,8 @@ func New() *App { installVerbosityFlag(&a.rootCmd, a.viper) installConfigFlag(&a.rootCmd) + // Install the --check-config flag to check the configuration and exit. + a.rootCmd.Flags().Bool("check-config", false /*i18n.G(*/, "check configuration and exit" /*)*/) // subcommands a.installVersion() diff --git a/cmd/authd/daemon/daemon_test.go b/cmd/authd/daemon/daemon_test.go index 7dc47b279b..9e6b5afd8a 100644 --- a/cmd/authd/daemon/daemon_test.go +++ b/cmd/authd/daemon/daemon_test.go @@ -11,13 +11,14 @@ import ( "testing" "time" + "github.com/canonical/authd/cmd/authd/daemon" + "github.com/canonical/authd/internal/consts" + "github.com/canonical/authd/internal/fileutils" + "github.com/canonical/authd/internal/testutils" + "github.com/canonical/authd/internal/users" + userslocking "github.com/canonical/authd/internal/users/locking" + "github.com/canonical/authd/log" "github.com/stretchr/testify/require" - "github.com/ubuntu/authd/cmd/authd/daemon" - "github.com/ubuntu/authd/internal/consts" - "github.com/ubuntu/authd/internal/fileutils" - "github.com/ubuntu/authd/internal/testutils" - "github.com/ubuntu/authd/internal/users" - "github.com/ubuntu/authd/log" ) func TestHelp(t *testing.T) { @@ -29,15 +30,6 @@ func TestHelp(t *testing.T) { require.NoErrorf(t, err, "Run should not return an error with argument --help. Stdout: %v", getStdout()) } -func TestCompletion(t *testing.T) { - a := daemon.NewForTests(t, nil, "completion", "bash") - - getStdout := captureStdout(t) - - err := a.Run() - require.NoError(t, err, "Completion should not start the daemon. Stdout: %v", getStdout()) -} - func TestVersion(t *testing.T) { a := daemon.NewForTests(t, nil, "version") @@ -58,7 +50,7 @@ func TestVersion(t *testing.T) { } func TestNoUsageError(t *testing.T) { - a := daemon.NewForTests(t, nil, "completion", "bash") + a := daemon.NewForTests(t, nil, "version") getStdout := captureStdout(t) err := a.Run() @@ -284,10 +276,8 @@ func TestConfigLoad(t *testing.T) { } func TestAutoDetectConfig(t *testing.T) { - customizedSocketPath := filepath.Join(t.TempDir(), "mysocket") var config daemon.DaemonConfig config.Verbosity = 1 - config.Paths.Socket = customizedSocketPath configPath := daemon.GenerateTestConfig(t, &config) configNextToBinaryPath := filepath.Join(filepath.Dir(os.Args[0]), "authd.yaml") @@ -296,31 +286,23 @@ func TestAutoDetectConfig(t *testing.T) { // Remove configuration next binary for other tests to not pick it up. defer os.Remove(configNextToBinaryPath) - a := daemon.New() + // Avoid that an existing config file in the config directory is picked up. + opt := daemon.WithConfigDir(t.TempDir()) - wg := sync.WaitGroup{} - wg.Add(1) - go func() { - defer wg.Done() - err := a.Run() - require.NoError(t, err, "Run should exits without any error") - }() - a.WaitReady() - time.Sleep(50 * time.Millisecond) + a := daemon.New(opt) + a.SetArgs("--check-config") - defer wg.Wait() - defer a.Quit() + err = a.Run() + require.NoError(t, err, "Run should not return an error") - _, err = os.Stat(customizedSocketPath) - require.NoError(t, err, "Socket should exist") require.Equal(t, 1, a.Config().Verbosity, "Verbosity is set from config") require.Equal(t, &users.DefaultConfig, a.Config().UsersConfig, "Default Users Config") } func TestNoConfigSetDefaults(t *testing.T) { a := daemon.New() - // Use version to still run preExec to load no config but without running server - a.SetArgs("version") + + a.SetArgs("--check-config") err := a.Run() require.NoError(t, err, "Run should not return an error") @@ -334,8 +316,7 @@ func TestNoConfigSetDefaults(t *testing.T) { func TestBadConfigReturnsError(t *testing.T) { a := daemon.New() - // Use version to still run preExec to load no config but without running server - a.SetArgs("version", "--config", "/does/not/exist.yaml") + a.SetArgs("--check-config", "--config", "/does/not/exist.yaml") err := a.Run() require.Error(t, err, "Run should return an error on config file") @@ -420,5 +401,8 @@ func TestMain(m *testing.M) { } defer cleanup() + userslocking.Z_ForTests_OverrideLocking() + defer userslocking.Z_ForTests_RestoreLocking() + m.Run() } diff --git a/cmd/authd/daemon/integrationtests.go b/cmd/authd/daemon/integrationtests.go index 3e79123fd3..07f5b605cd 100644 --- a/cmd/authd/daemon/integrationtests.go +++ b/cmd/authd/daemon/integrationtests.go @@ -6,7 +6,7 @@ import ( "fmt" "os" - "github.com/ubuntu/authd/internal/testsdetection" + "github.com/canonical/authd/internal/testsdetection" ) // load any behaviour modifiers from env variable. diff --git a/cmd/authd/daemon/migration.go b/cmd/authd/daemon/migration.go index 6b56f41415..ea4ae7374b 100644 --- a/cmd/authd/daemon/migration.go +++ b/cmd/authd/daemon/migration.go @@ -6,11 +6,11 @@ import ( "os" "path/filepath" - "github.com/ubuntu/authd/internal/consts" - "github.com/ubuntu/authd/internal/fileutils" - "github.com/ubuntu/authd/internal/users/db" - "github.com/ubuntu/authd/internal/users/db/bbolt" - "github.com/ubuntu/authd/log" + "github.com/canonical/authd/internal/consts" + "github.com/canonical/authd/internal/fileutils" + "github.com/canonical/authd/internal/users/db" + "github.com/canonical/authd/internal/users/db/bbolt" + "github.com/canonical/authd/log" ) func maybeMigrateOldDBDir(oldPath, newPath string) error { diff --git a/cmd/authd/daemon/migration_test.go b/cmd/authd/daemon/migration_test.go index 4677b0da49..9bef906265 100644 --- a/cmd/authd/daemon/migration_test.go +++ b/cmd/authd/daemon/migration_test.go @@ -5,13 +5,14 @@ import ( "path/filepath" "testing" + "github.com/canonical/authd/internal/consts" + "github.com/canonical/authd/internal/fileutils" + "github.com/canonical/authd/internal/testutils/golden" + "github.com/canonical/authd/internal/users/db" + "github.com/canonical/authd/internal/users/db/bbolt" + localgrouptestutils "github.com/canonical/authd/internal/users/localentries/testutils" + userslocking "github.com/canonical/authd/internal/users/locking" "github.com/stretchr/testify/require" - "github.com/ubuntu/authd/internal/consts" - "github.com/ubuntu/authd/internal/fileutils" - "github.com/ubuntu/authd/internal/testutils/golden" - "github.com/ubuntu/authd/internal/users/db" - "github.com/ubuntu/authd/internal/users/db/bbolt" - userslocking "github.com/ubuntu/authd/internal/users/locking" ) func TestMaybeMigrateOldDBDir(t *testing.T) { @@ -127,8 +128,7 @@ func TestMaybeMigrateBBoltToSQLite(t *testing.T) { // Make the userslocking package use a locking mechanism which doesn't // require root privileges. - userslocking.Z_ForTests_OverrideLocking() - t.Cleanup(userslocking.Z_ForTests_RestoreLocking) + userslocking.Z_ForTests_OverrideLockingWithCleanup(t) testCases := map[string]struct { bboltExists bool @@ -187,10 +187,8 @@ func TestMaybeMigrateBBoltToSQLite(t *testing.T) { err := fileutils.CopyFile(groupFile, tempGroupFile) require.NoError(t, err, "failed to copy group file for testing") - // Make the db package use the temporary group file - origGroupFile := db.Z_ForTests_GetGroupFile() - db.Z_ForTests_SetGroupFile(tempGroupFile) - t.Cleanup(func() { db.Z_ForTests_SetGroupFile(origGroupFile) }) + // Make the localentries package use the test group file. + tempGroupFile = localgrouptestutils.SetupGroupMock(t, tempGroupFile) migrated, err := maybeMigrateBBoltToSQLite(dbDir) if tc.wantError { diff --git a/cmd/authd/daemon/testdata/golden/TestMaybeMigrateBBoltToSQLite/Migration_if_bbolt_exists_and_sqlite_does_not_exist/db b/cmd/authd/daemon/testdata/golden/TestMaybeMigrateBBoltToSQLite/Migration_if_bbolt_exists_and_sqlite_does_not_exist/db index 9a59a4ead0..064aef046d 100644 --- a/cmd/authd/daemon/testdata/golden/TestMaybeMigrateBBoltToSQLite/Migration_if_bbolt_exists_and_sqlite_does_not_exist/db +++ b/cmd/authd/daemon/testdata/golden/TestMaybeMigrateBBoltToSQLite/Migration_if_bbolt_exists_and_sqlite_does_not_exist/db @@ -1,32 +1,32 @@ users: - - name: user1 + - name: user1@example.com uid: 1111 gid: 11111 gecos: |- User1 gecos On multiple lines - dir: /home/user1 + dir: /home/user1@example.com shell: /bin/bash broker_id: broker-id - - name: user2 + - name: user2@example.com uid: 2222 gid: 22222 gecos: User2 - dir: /home/user2 + dir: /home/user2@example.com shell: /bin/dash broker_id: broker-id - - name: user3 + - name: user3@example.com uid: 3333 gid: 33333 gecos: User3 - dir: /home/user3 + dir: /home/user3@example.com shell: /bin/zsh broker_id: broker-id - - name: userwithoutbroker + - name: userwithoutbroker@example.com uid: 4444 gid: 44444 gecos: userwithoutbroker - dir: /home/userwithoutbroker + dir: /home/userwithoutbroker@example.com shell: /bin/sh groups: - name: group1 @@ -61,4 +61,4 @@ users_to_groups: gid: 44444 - uid: 4444 gid: 99999 -schema_version: 1 +schema_version: 2 diff --git a/cmd/authd/daemon/testdata/golden/TestMaybeMigrateBBoltToSQLite/Migration_if_bbolt_exists_and_sqlite_does_not_exist/group b/cmd/authd/daemon/testdata/golden/TestMaybeMigrateBBoltToSQLite/Migration_if_bbolt_exists_and_sqlite_does_not_exist/group index d284122dc2..1a1e08e363 100644 --- a/cmd/authd/daemon/testdata/golden/TestMaybeMigrateBBoltToSQLite/Migration_if_bbolt_exists_and_sqlite_does_not_exist/group +++ b/cmd/authd/daemon/testdata/golden/TestMaybeMigrateBBoltToSQLite/Migration_if_bbolt_exists_and_sqlite_does_not_exist/group @@ -1,5 +1,5 @@ root:x:0: other-local-group:x:1234: -other-local-group-with-users:x:4321:user-foo,user-bar -group1:x:11111:user1 -UpperCaseGroup:x:11111:user1,user3 +other-local-group-with-users:x:4321:user-foo@example.com,user-bar@example.com +group1:x:11111:user1@example.com +UpperCaseGroup:x:11112:user1@example.com,user3@example.com diff --git a/cmd/authd/daemon/testdata/group b/cmd/authd/daemon/testdata/group index 121f89c7e0..4b212d8698 100644 --- a/cmd/authd/daemon/testdata/group +++ b/cmd/authd/daemon/testdata/group @@ -1,5 +1,5 @@ root:x:0: other-local-group:x:1234: -other-local-group-with-users:x:4321:user-foo,user-bar -group1:x:11111:user1 -UpperCaseGroup:x:11111:user1,User3 +other-local-group-with-users:x:4321:user-foo@example.com,user-bar@example.com +group1:x:11111:user1@example.com +UpperCaseGroup:x:11112:user1@example.com,User3@example.com diff --git a/cmd/authd/daemon/testdata/multiple_users_and_groups.db.yaml b/cmd/authd/daemon/testdata/multiple_users_and_groups.db.yaml index 6bee451a8e..297031d118 100644 --- a/cmd/authd/daemon/testdata/multiple_users_and_groups.db.yaml +++ b/cmd/authd/daemon/testdata/multiple_users_and_groups.db.yaml @@ -23,15 +23,15 @@ GroupToUsers: "44444": '{"GID":33333,"UIDs":[4444]}' "99999": '{"GID":99999,"UIDs":[1111,2222,3333,4444]}' UserByID: - "1111": '{"Name":"user1","UID":1111,"GID":11111,"Gecos":"User1 gecos\nOn multiple lines","Dir":"/home/user1","Shell":"/bin/bash","LastPwdChange":-1,"MaxPwdAge":-1,"PwdWarnPeriod":-1,"PwdInactivity":-1,"MinPwdAge":-1,"ExpirationDate":-1}' - "2222": '{"Name":"user2","UID":2222,"GID":22222,"Gecos":"User2","Dir":"/home/user2","Shell":"/bin/dash","LastPwdChange":-1,"MaxPwdAge":-1,"PwdWarnPeriod":-1,"PwdInactivity":-1,"MinPwdAge":-1,"ExpirationDate":-1}' - "3333": '{"Name":"User3","UID":3333,"GID":33333,"Gecos":"User3","Dir":"/home/user3","Shell":"/bin/zsh","LastPwdChange":-1,"MaxPwdAge":-1,"PwdWarnPeriod":-1,"PwdInactivity":-1,"MinPwdAge":-1,"ExpirationDate":-1}' - "4444": '{"Name":"userwithoutbroker","UID":4444,"GID":44444,"Gecos":"userwithoutbroker","Dir":"/home/userwithoutbroker","Shell":"/bin/sh","LastPwdChange":-1,"MaxPwdAge":-1,"PwdWarnPeriod":-1,"PwdInactivity":-1,"MinPwdAge":-1,"ExpirationDate":-1}' + "1111": '{"Name":"user1@example.com","UID":1111,"GID":11111,"Gecos":"User1 gecos\nOn multiple lines","Dir":"/home/user1@example.com","Shell":"/bin/bash","LastPwdChange":-1,"MaxPwdAge":-1,"PwdWarnPeriod":-1,"PwdInactivity":-1,"MinPwdAge":-1,"ExpirationDate":-1}' + "2222": '{"Name":"user2@example.com","UID":2222,"GID":22222,"Gecos":"User2","Dir":"/home/user2@example.com","Shell":"/bin/dash","LastPwdChange":-1,"MaxPwdAge":-1,"PwdWarnPeriod":-1,"PwdInactivity":-1,"MinPwdAge":-1,"ExpirationDate":-1}' + "3333": '{"Name":"User3@example.com","UID":3333,"GID":33333,"Gecos":"User3","Dir":"/home/user3@example.com","Shell":"/bin/zsh","LastPwdChange":-1,"MaxPwdAge":-1,"PwdWarnPeriod":-1,"PwdInactivity":-1,"MinPwdAge":-1,"ExpirationDate":-1}' + "4444": '{"Name":"userwithoutbroker@example.com","UID":4444,"GID":44444,"Gecos":"userwithoutbroker","Dir":"/home/userwithoutbroker@example.com","Shell":"/bin/sh","LastPwdChange":-1,"MaxPwdAge":-1,"PwdWarnPeriod":-1,"PwdInactivity":-1,"MinPwdAge":-1,"ExpirationDate":-1}' UserByName: - user1: '{"Name":"user1","UID":1111,"GID":11111,"Gecos":"User1 gecos\nOn multiple lines","Dir":"/home/user1","Shell":"/bin/bash","LastPwdChange":-1,"MaxPwdAge":-1,"PwdWarnPeriod":-1,"PwdInactivity":-1,"MinPwdAge":-1,"ExpirationDate":-1}' - user2: '{"Name":"user2","UID":2222,"GID":22222,"Gecos":"User2","Dir":"/home/user2","Shell":"/bin/dash","LastPwdChange":-1,"MaxPwdAge":-1,"PwdWarnPeriod":-1,"PwdInactivity":-1,"MinPwdAge":-1,"ExpirationDate":-1}' - User3: '{"Name":"User3","UID":3333,"GID":33333,"Gecos":"User3","Dir":"/home/user3","Shell":"/bin/zsh","LastPwdChange":-1,"MaxPwdAge":-1,"PwdWarnPeriod":-1,"PwdInactivity":-1,"MinPwdAge":-1,"ExpirationDate":-1}' - userwithoutbroker: '{"Name":"userwithoutbroker","UID":4444,"GID":44444,"Gecos":"userwithoutbroker","Dir":"/home/userwithoutbroker","Shell":"/bin/sh","LastPwdChange":-1,"MaxPwdAge":-1,"PwdWarnPeriod":-1,"PwdInactivity":-1,"MinPwdAge":-1,"ExpirationDate":-1}' + user1@example.com: '{"Name":"user1@example.com","UID":1111,"GID":11111,"Gecos":"User1 gecos\nOn multiple lines","Dir":"/home/user1@example.com","Shell":"/bin/bash","LastPwdChange":-1,"MaxPwdAge":-1,"PwdWarnPeriod":-1,"PwdInactivity":-1,"MinPwdAge":-1,"ExpirationDate":-1}' + user2@example.com: '{"Name":"user2@example.com","UID":2222,"GID":22222,"Gecos":"User2","Dir":"/home/user2@example.com","Shell":"/bin/dash","LastPwdChange":-1,"MaxPwdAge":-1,"PwdWarnPeriod":-1,"PwdInactivity":-1,"MinPwdAge":-1,"ExpirationDate":-1}' + User3@example.com: '{"Name":"User3@example.com","UID":3333,"GID":33333,"Gecos":"User3","Dir":"/home/user3@example.com","Shell":"/bin/zsh","LastPwdChange":-1,"MaxPwdAge":-1,"PwdWarnPeriod":-1,"PwdInactivity":-1,"MinPwdAge":-1,"ExpirationDate":-1}' + userwithoutbroker@example.com: '{"Name":"userwithoutbroker@example.com","UID":4444,"GID":44444,"Gecos":"userwithoutbroker","Dir":"/home/userwithoutbroker@example.com","Shell":"/bin/sh","LastPwdChange":-1,"MaxPwdAge":-1,"PwdWarnPeriod":-1,"PwdInactivity":-1,"MinPwdAge":-1,"ExpirationDate":-1}' UserToGroups: "1111": '{"UID":1111,"GIDs":[11111,99999]}' "2222": '{"UID":2222,"GIDs":[22222,99999]}' diff --git a/cmd/authd/daemon/version.go b/cmd/authd/daemon/version.go index 60507ff37a..ff57a743b5 100644 --- a/cmd/authd/daemon/version.go +++ b/cmd/authd/daemon/version.go @@ -3,8 +3,8 @@ package daemon import ( "fmt" + "github.com/canonical/authd/internal/consts" "github.com/spf13/cobra" - "github.com/ubuntu/authd/internal/consts" ) func (a *App) installVersion() { diff --git a/cmd/authd/integrationtests.go b/cmd/authd/integrationtests.go index 85c1e86ff0..0ddac28156 100644 --- a/cmd/authd/integrationtests.go +++ b/cmd/authd/integrationtests.go @@ -5,14 +5,13 @@ package main import ( + "fmt" "os" - "strings" - "github.com/ubuntu/authd/internal/services/permissions" - "github.com/ubuntu/authd/internal/testsdetection" - "github.com/ubuntu/authd/internal/users/db" - "github.com/ubuntu/authd/internal/users/localentries" - userslocking "github.com/ubuntu/authd/internal/users/locking" + "github.com/canonical/authd/internal/services/permissions" + "github.com/canonical/authd/internal/testsdetection" + "github.com/canonical/authd/internal/users/localentries" + userslocking "github.com/canonical/authd/internal/users/locking" ) // load any behaviour modifiers from env variable. @@ -23,14 +22,15 @@ func init() { permissions.Z_ForTests_DefaultCurrentUserAsRoot() } - gpasswdArgs := os.Getenv("AUTHD_INTEGRATIONTESTS_GPASSWD_ARGS") - grpFilePath := os.Getenv("AUTHD_INTEGRATIONTESTS_GPASSWD_GRP_FILE_PATH") - if gpasswdArgs == "" || grpFilePath == "" { - panic("AUTHD_INTEGRATIONTESTS_GPASSWD_ARGS and AUTHD_INTEGRATIONTESTS_GPASSWD_GRP_FILE_PATH must be set") + grpFilePath := os.Getenv(localentries.Z_ForTests_GroupFilePathEnv) + if grpFilePath == "" { + panic(fmt.Sprintf("%q must be set", localentries.Z_ForTests_GroupFilePathEnv)) } - localentries.Z_ForTests_SetGpasswdCmd(strings.Split(gpasswdArgs, " ")) - localentries.Z_ForTests_SetGroupPath(grpFilePath) - db.Z_ForTests_SetGroupFile(grpFilePath) + grpFileOutputPath := os.Getenv(localentries.Z_ForTests_GroupFileOutputPathEnv) + if grpFileOutputPath == "" { + grpFileOutputPath = grpFilePath + } + localentries.Z_ForTests_SetGroupPath(grpFilePath, grpFileOutputPath) userslocking.Z_ForTests_OverrideLocking() } diff --git a/cmd/authd/main.go b/cmd/authd/main.go index 8314ec27e3..ee13942b63 100644 --- a/cmd/authd/main.go +++ b/cmd/authd/main.go @@ -8,8 +8,8 @@ import ( "sync" "syscall" - "github.com/ubuntu/authd/cmd/authd/daemon" - "github.com/ubuntu/authd/log" + "github.com/canonical/authd/cmd/authd/daemon" + "github.com/canonical/authd/log" ) //FIXME go:generate go run ../generate_completion_documentation.go completion ../../generated @@ -55,13 +55,16 @@ func installSignalHandler(a app) func() { for { switch v, ok := <-c; v { case syscall.SIGINT, syscall.SIGTERM: + log.Infof(context.Background(), "Received signal %d (%s), exiting...", v, v.String()) a.Quit() return case syscall.SIGHUP: if a.Hup() { + log.Info(context.Background(), "Received SIGHUP, exiting...") a.Quit() return } + log.Info(context.Background(), "Received SIGHUP, but nothing to do") default: // channel was closed: we exited if !ok { diff --git a/debian/authd-config/authd.yaml b/debian/authd-config/authd.yaml index 5dc865272d..42e127ef55 100644 --- a/debian/authd-config/authd.yaml +++ b/debian/authd-config/authd.yaml @@ -11,13 +11,14 @@ ## These define the minimum and maximum UID and GID values assigned ## to users and groups by authd. ## -## Ensure that these ranges do not overlap with other ID ranges in use, -## such as those specified in /etc/subuid or /etc/subgid. +## Ensure these ranges do not overlap with other UID/GID ranges in use. +## For common ranges typically used on Linux, see: +## https://systemd.io/UIDS-GIDS/#summary ## ## Note: The user private group (the group named after the user) is always ## created with the same GID as the user’s UID, regardless of the GID_MIN ## and GID_MAX values. -#UID_MIN: 1000000000 -#UID_MAX: 1999999999 -#GID_MIN: 1000000000 -#GID_MAX: 1999999999 +#UID_MIN: 10000 +#UID_MAX: 60000 +#GID_MIN: 10000 +#GID_MAX: 60000 diff --git a/debian/authd.gdm-authd.pam b/debian/authd.gdm-authd.pam index 32e8bb66ab..2c6000f506 100644 --- a/debian/authd.gdm-authd.pam +++ b/debian/authd.gdm-authd.pam @@ -1,6 +1,6 @@ #%PAM-1.0 auth [success=ok user_unknown=ignore default=bad] pam_succeed_if.so user != root quiet_success -auth [success=1 ignore=ignore default=die authinfo_unavail=ignore] pam_authd.so +auth [success=1 ignore=ignore default=die authinfo_unavail=ignore module_unknown=ignore] pam_authd.so # If authd ignored the request => local broker is selected, # then we continue with normal stack auth substack common-auth diff --git a/debian/authd.service.in b/debian/authd.service.in index dc4041f8d3..b3729ad741 100644 --- a/debian/authd.service.in +++ b/debian/authd.service.in @@ -1,5 +1,5 @@ [Unit] -Description=Authd daemon service +Description=authd daemon service After=authd.socket Requires=authd.socket PartOf=authd.socket @@ -34,7 +34,6 @@ StateDirectoryMode=0700 # This always corresponds to /etc/authd ConfigurationDirectory=authd -ConfigurationDirectoryMode=0700 # Prevent writing to /usr and bootloader paths. # We don't use "full" or "strict", because home paths can be anywhere and so we need @@ -87,5 +86,11 @@ SystemCallFilter=@system-service # This makes all files and directories not associated with process management invisible in /proc ProcSubset=pid -# gpasswd requires this specific capability to alter the shadow files -CapabilityBoundingSet=CAP_CHOWN +# CAP_CHOWN: Required by gpasswd to alter the shadow files. +# CAP_DAC_READ_SEARCH: Required by the chown system call to change ownership of +# files not owned by the user. We need this to change the +# ownership of the user's home directory when changing the +# user's UID. +# CAP_SYS_PTRACE: Required by CheckUserBusy used by SetUserID to check if any +# running processes are owned by the UID being modified. +CapabilityBoundingSet=CAP_CHOWN CAP_DAC_READ_SEARCH CAP_SYS_PTRACE diff --git a/debian/authd.socket b/debian/authd.socket index 6dc1ec94b6..9461261261 100644 --- a/debian/authd.socket +++ b/debian/authd.socket @@ -1,5 +1,5 @@ [Unit] -Description=Socket activation for Authd daemon +Description=Socket activation for authd daemon # Ensure there's no ordering cycle since authd needs to start after dbus DefaultDependencies=No Wants=dbus.service diff --git a/debian/changelog b/debian/changelog index 9a95c8a15b..b4490b3170 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,20 @@ +authd (0.6.0~pre2) UNRELEASED; urgency=medium + + * Change default UID/GID range to 10000:60000. + * Add authctl, a command-line tool to manage authd users and groups. + * Deny login if session is offline and user or device is disabled. + * Allow setting the same local password when redoing device authentication. + * Make errors when checking home dir owner during startup non-fatal. + * Fix authentication behaviour when refresh token is missing. + * Fix GDM broken when authd is uninstalled. + * Avoid races when getting user and group entries. + * Use lckpwdf to lock the database when manipulating users or groups. + * Improve error messages. + * debian/control: Install missing build dependency systemd-dev. + * debian/postrm: Remove /etc/pam.d/gdm-authd. + + -- Adrian Dombeck Thu, 19 Mar 2026 13:04:09 +0100 + authd (0.5.11) resolute; urgency=medium * Skip flaky tests in autopkgtests and during deb build. @@ -37,7 +54,7 @@ authd (0.5.8) resolute; urgency=medium many of our vendored dependencies do not declare a copyright holder. * needs-packaging: LP: #2136731 - -- Adrian Dombeck Tue, 23 Feb 2026 11:58:00 +0100 + -- Adrian Dombeck Mon, 23 Feb 2026 11:58:00 +0100 authd (0.5.7) resolute; urgency=medium diff --git a/debian/control b/debian/control index c4cfd7ee59..16a917b89b 100644 --- a/debian/control +++ b/debian/control @@ -11,28 +11,31 @@ Build-Depends: debhelper-compat (= 13), dh-exec, dh-golang, dctrl-tools, + cargo (>= 1.82) | cargo-1.82, # FIXME: We need cargo-vendor-filterer starting from plucky, but noble isn't ready yet # so workaround it, making it kind of optional, and requiring it only on versions after # noble (controlled via base-files version that matches the one in noble). cargo-vendor-filterer | base-files (<< 13.5), - golang-go (>= 2:1.23~) | golang-1.23-go, + golang-go (>= 2:1.25~) | golang-1.25-go, libc6-dev (>= 2.35), libglib2.0-dev, libpam0g-dev, libpwquality-dev, pkgconf, protobuf-compiler, + systemd-dev, + uidmap , Standards-Version: 4.6.2 -XS-Go-Import-Path: github.com/ubuntu/authd -XS-Vendored-Sources-Rust: adler2@2.0.0, aho-corasick@1.1.3, anyhow@1.0.98, async-trait@0.1.88, atomic-waker@1.1.2, autocfg@1.4.0, axum-core@0.5.2, axum@0.8.4, base64@0.22.1, bitflags@2.9.0, bytes@1.10.1, cc@1.2.21, cfg-if@1.0.0, chrono@0.4.41, colored@2.2.0, crc32fast@1.4.2, ctor-proc-macro@0.0.5, ctor@0.4.2, deranged@0.4.0, dtor-proc-macro@0.0.5, dtor@0.0.6, either@1.15.0, equivalent@1.0.2, errno@0.3.11, fastrand@2.3.0, fixedbitset@0.5.7, flate2@1.1.1, fnv@1.0.7, futures-channel@0.3.31, futures-core@0.3.31, futures-sink@0.3.31, futures-task@0.3.31, futures-util@0.3.31, getrandom@0.3.2, h2@0.4.10, hashbrown@0.15.3, heck@0.5.0, hex@0.4.3, hostname@0.4.1, http-body-util@0.1.3, http-body@1.0.1, http@1.3.1, httparse@1.10.1, httpdate@1.0.3, hyper-timeout@0.5.2, hyper-util@0.1.11, hyper@1.6.0, iana-time-zone@0.1.63, indexmap@2.9.0, itertools@0.14.0, itoa@1.0.15, lazy_static@1.5.0, libc@0.2.172, libnss@0.9.0, linux-raw-sys@0.4.15, linux-raw-sys@0.9.4, log@0.4.27, matchit@0.8.4, memchr@2.7.4, mime@0.3.17, miniz_oxide@0.8.8, mio@1.0.3, multimap@0.10.0, num-conv@0.1.0, num-traits@0.2.19, num_threads@0.1.7, once_cell@1.21.3, paste@1.0.15, percent-encoding@2.3.1, petgraph@0.7.1, pin-project-internal@1.1.10, pin-project-lite@0.2.16, pin-project@1.1.10, pin-utils@0.1.0, powerfmt@0.2.0, prettyplease@0.2.32, proc-macro2@1.0.95, procfs-core@0.17.0, procfs@0.17.0, prost-build@0.13.5, prost-derive@0.13.5, prost-types@0.13.5, prost@0.13.5, quote@1.0.40, regex-automata@0.4.9, regex-syntax@0.8.5, regex@1.11.1, rustix@0.38.44, rustix@1.0.7, rustversion@1.0.20, serde@1.0.219, shlex@1.3.0, simple_logger@5.0.0, slab@0.4.9, smallvec@1.15.0, socket2@0.5.9, syn@2.0.101, sync_wrapper@1.0.2, syslog@7.0.0, tempfile@3.19.1, time-core@0.1.4, time-macros@0.2.22, time@0.3.41, tokio-macros@2.5.0, tokio-stream@0.1.17, tokio-util@0.7.15, tokio@1.45.0, tonic-build@0.13.1, tonic@0.13.1, tower-layer@0.3.3, tower-service@0.3.3, tower@0.4.13, tower@0.5.2, tracing-attributes@0.1.28, tracing-core@0.1.33, tracing@0.1.41, try-lock@0.2.5, unicode-ident@1.0.18, want@0.3.1 -Homepage: https://github.com/ubuntu/authd -Vcs-Browser: https://github.com/ubuntu/authd -Vcs-Git: https://github.com/ubuntu/authd.git +XS-Go-Import-Path: github.com/canonical/authd +XS-Vendored-Sources-Rust: adler2@2.0.1, aho-corasick@1.1.3, anyhow@1.0.99, async-trait@0.1.89, atomic-waker@1.1.2, autocfg@1.5.0, axum-core@0.5.2, axum@0.8.4, base64@0.22.1, bitflags@2.9.3, bytes@1.11.1, cc@1.2.34, cfg-if@1.0.3, chrono@0.4.41, colored@2.2.0, crc32fast@1.5.0, ctor-proc-macro@0.0.6, ctor@0.5.0, deranged@0.4.0, dtor-proc-macro@0.0.6, dtor@0.1.0, either@1.15.0, equivalent@1.0.2, errno@0.3.13, fastrand@2.3.0, fixedbitset@0.5.7, flate2@1.1.2, fnv@1.0.7, futures-channel@0.3.31, futures-core@0.3.31, futures-sink@0.3.31, futures-task@0.3.31, futures-util@0.3.31, getrandom@0.3.3, h2@0.4.12, hashbrown@0.15.5, heck@0.5.0, hex@0.4.3, hostname@0.4.1, http-body-util@0.1.3, http-body@1.0.1, http@1.3.1, httparse@1.10.1, httpdate@1.0.3, hyper-timeout@0.5.2, hyper-util@0.1.16, hyper@1.7.0, iana-time-zone@0.1.63, indexmap@2.11.0, itertools@0.14.0, itoa@1.0.15, lazy_static@1.5.0, libc@0.2.175, libnss@0.9.0, linux-raw-sys@0.4.15, linux-raw-sys@0.9.4, log@0.4.27, matchit@0.8.4, memchr@2.7.5, mime@0.3.17, miniz_oxide@0.8.9, mio@1.0.4, multimap@0.10.1, num-conv@0.1.0, num-traits@0.2.19, num_threads@0.1.7, once_cell@1.21.3, paste@1.0.15, percent-encoding@2.3.2, petgraph@0.7.1, pin-project-internal@1.1.10, pin-project-lite@0.2.16, pin-project@1.1.10, pin-utils@0.1.0, powerfmt@0.2.0, prettyplease@0.2.37, proc-macro2@1.0.101, procfs-core@0.17.0, procfs@0.17.0, prost-build@0.14.1, prost-derive@0.14.1, prost-types@0.14.1, prost@0.14.1, pulldown-cmark-to-cmark@21.0.0, pulldown-cmark@0.13.0, quote@1.0.40, regex-automata@0.4.10, regex-syntax@0.8.6, regex@1.11.2, rustix@0.38.44, rustix@1.0.8, rustversion@1.0.22, serde@1.0.219, shlex@1.3.0, simple_logger@5.0.0, slab@0.4.11, smallvec@1.15.1, socket2@0.6.0, syn@2.0.106, sync_wrapper@1.0.2, syslog@7.0.0, tempfile@3.21.0, time-core@0.1.4, time-macros@0.2.22, time@0.3.41, tokio-macros@2.5.0, tokio-stream@0.1.17, tokio-util@0.7.16, tokio@1.47.1, tonic-build@0.14.2, tonic-prost-build@0.14.2, tonic-prost@0.14.2, tonic@0.14.2, tower-layer@0.3.3, tower-service@0.3.3, tower@0.4.13, tower@0.5.2, tracing-attributes@0.1.30, tracing-core@0.1.34, tracing@0.1.41, try-lock@0.2.5, unicase@2.8.1, unicode-ident@1.0.18, want@0.3.1 +Homepage: https://github.com/canonical/authd +Vcs-Browser: https://github.com/canonical/authd +Vcs-Git: https://github.com/canonical/authd.git Description: Authentication daemon for cloud-based identity provider - Authd is a versatile authentication service designed to seamlessly integrate + authd is a versatile authentication service designed to seamlessly integrate with cloud identity providers like OpenID Connect and Entra ID. It offers a secure interface for system authentication, supporting cloud-based identity - management. Authd features a modular structure, facilitating straightforward + management. authd features a modular structure, facilitating straightforward integration with different cloud services maintaining strong security and effective user authentication. diff --git a/debian/copyright b/debian/copyright index 3fa1764cde..778f1c07ba 100644 --- a/debian/copyright +++ b/debian/copyright @@ -55,11 +55,19 @@ Files: vendor/github.com/coreos/* Copyright: 2015-2018 CoreOS, Inc. License: Apache-2.0 +Files: vendor/github.com/coreos/go-systemd/v22/activation/files.go +Copyright: 2026 RedHat, Inc. +License: Apache-2.0 + Files: vendor/github.com/coreos/go-systemd/v22/daemon/sdnotify.go Copyright: 2014 Docker, Inc. 2015-2018 CoreOS, Inc. License: Apache-2.0 +Files: vendor/github.com/cpuguy83/* +Copyright: 2014 Brian Goff +License: Expat + Files: vendor/github.com/davecgh/* Copyright: 2012-2016 Dave Collins License: ISC @@ -165,6 +173,10 @@ Files: vendor/github.com/rivo/* Copyright: 2019 Oliver Kuederle License: Expat +Files: vendor/github.com/russross/* +Copyright: 2011 Russ Ross +License: BSD-2-clause + Files: vendor/github.com/sagikazarmark/* Copyright: 2023 Márk Sági-Kazár License: Expat @@ -173,14 +185,6 @@ Files: vendor/github.com/sahilm/* Copyright: 2017 Sahil Muthoo License: Expat -Files: vendor/github.com/sirupsen/* -Copyright: 2014 Simon Eskildsen -License: Expat - -Files: vendor/github.com/sirupsen/logrus/alt_exit.go -Copyright: 2012 Miki Tebeka -License: Expat - Files: vendor/github.com/skip2/* Copyright: 2014 Tom Harwood License: Expat @@ -246,10 +250,6 @@ Files: vendor/github.com/subosito/* Copyright: 2013 Alif Rachmawadi License: Expat -Files: vendor/github.com/ubuntu/* -Copyright: 2022-2023 Canonical Ltd. -License: Expat - Files: vendor/github.com/xo/* Copyright: 2016 Anmol Sethi License: Expat @@ -258,28 +258,56 @@ Files: vendor/go.etcd.io/* Copyright: 2013 Ben Johnson License: Expat -Files: vendor/go.uber.org/* -Copyright: 2016-2021 Uber Technologies, Inc. +Files: vendor/go.yaml.in/* +Copyright: 2006-2011 Kirill Simonov + 2011 staring in when the project was ported over: + 2011-2016 Canonical Ltd. +License: Apache-2.0 or Expat + +Files: vendor/go.yaml.in/yaml/v3/README.md +Copyright: 2006-2011 Kirill Simonov + 2011 staring in when the project was ported over: + 2011-2016 Canonical Ltd. License: Expat +Files: vendor/go.yaml.in/yaml/v3/apic.go + vendor/go.yaml.in/yaml/v3/emitterc.go + vendor/go.yaml.in/yaml/v3/parserc.go + vendor/go.yaml.in/yaml/v3/readerc.go + vendor/go.yaml.in/yaml/v3/scannerc.go + vendor/go.yaml.in/yaml/v3/writerc.go + vendor/go.yaml.in/yaml/v3/yamlh.go + vendor/go.yaml.in/yaml/v3/yamlprivateh.go +Copyright: 2006-2010 Kirill Simonov + 2011-2019 Canonical Ltd +License: Expat + +Files: vendor/go.yaml.in/yaml/v3/decode.go + vendor/go.yaml.in/yaml/v3/encode.go + vendor/go.yaml.in/yaml/v3/resolve.go + vendor/go.yaml.in/yaml/v3/sorter.go + vendor/go.yaml.in/yaml/v3/yaml.go +Copyright: 2011-2019 Canonical Ltd +License: Apache-2.0 + Files: vendor/golang.org/* Copyright: 2009-2025 The Go Authors. License: BSD-3-clause Files: vendor/google.golang.org/genproto/* -Copyright: 2024 Google LLC +Copyright: 2025 Google LLC License: Apache-2.0 Files: vendor/google.golang.org/grpc/* -Copyright: 2014-2024 The gRPC Authors +Copyright: 2014-2025 The gRPC Authors License: Apache-2.0 Files: vendor/google.golang.org/grpc/CONTRIBUTING.md -Copyright: message template +Copyright: message* License: Apache-2.0 Files: vendor/google.golang.org/protobuf/* -Copyright: 2018-2024 The Go Authors. +Copyright: 2018-2025 The Go Authors. License: BSD-3-clause Files: vendor/google.golang.org/protobuf/types/* @@ -410,10 +438,19 @@ License: Apache-2.0 or Expat Files: vendor_rust/ctor/* License: Apache-2.0 or Expat +Files: vendor_rust/ctor-proc-macro/* +License: Apache-2.0 or Expat + Files: vendor_rust/deranged/* Copyright: 2024 Jacob Pratt et al. License: Apache-2.0 or Expat +Files: vendor_rust/dtor/* +License: Apache-2.0 or Expat + +Files: vendor_rust/dtor-proc-macro/* +License: Apache-2.0 or Expat + Files: vendor_rust/either/* License: Apache-2.0 or Expat @@ -629,7 +666,7 @@ Files: vendor_rust/paste/* License: Apache-2.0 or Expat Files: vendor_rust/percent-encoding/* -Copyright: 2013-2022 The rust-url developers. +Copyright: 2013-2025 The rust-url developers. License: Apache-2.0 or Expat Files: vendor_rust/petgraph/* @@ -684,6 +721,18 @@ Files: vendor_rust/prost-types/* Copyright: 2017 Dan Burkert License: Apache-2.0 +Files: vendor_rust/pulldown-cmark/* +Copyright: 2015-2017 Google Inc. +License: Expat + +Files: vendor_rust/pulldown-cmark-to-cmark/* +License: Apache-2.0 + +Files: vendor_rust/pulldown-cmark/src/linklabel.rs + vendor_rust/pulldown-cmark/src/tree.rs +Copyright: 2018 Google LLC +License: Expat + Files: vendor_rust/quote/* License: Apache-2.0 or Expat @@ -842,6 +891,10 @@ Copyright: 2016 Alex Crichton 2018-2023 Sean McArthur License: Expat +Files: vendor_rust/unicase/* +Copyright: 2014-2017 Sean McArthur +License: Apache-2.0 or Expat + Files: vendor_rust/unicode-ident/* Copyright: 1991-2023 Unicode, Inc. License: Apache-2.0 or Expat or Unicode-DFS-2016 diff --git a/debian/dirs b/debian/dirs new file mode 100644 index 0000000000..842bc5b918 --- /dev/null +++ b/debian/dirs @@ -0,0 +1 @@ +etc/authd/brokers.d diff --git a/debian/install b/debian/install index 094a6ed57d..b5b56345ae 100755 --- a/debian/install +++ b/debian/install @@ -3,6 +3,9 @@ # Install daemon usr/bin/authd ${env:AUTHD_DAEMONS_PATH} +# Install CLI tool +usr/bin/authctl /usr/bin/ + # Install authd config file debian/authd-config/authd.yaml /etc/authd/ @@ -18,3 +21,8 @@ ${env:BUILT_PAM_LIBS_PATH}/go-exec/pam_authd_exec.so ${env:AUTHD_PAM_MODULES_PAT # Install NSS library with right soname target/${DEB_HOST_RUST_TYPE}/release/libnss_authd.so => /usr/lib/${DEB_TARGET_GNU_TYPE}/libnss_authd.so.2 + +# Shell completion scripts +shell-completion/bash/authctl /usr/share/bash-completion/completions/ +shell-completion/zsh/_authctl /usr/share/zsh/vendor-completions/ +shell-completion/fish/authctl.fish /usr/share/fish/vendor_completions.d/ diff --git a/debian/manpages b/debian/manpages new file mode 100644 index 0000000000..70602a5c77 --- /dev/null +++ b/debian/manpages @@ -0,0 +1,2 @@ +man/authctl.1 + diff --git a/debian/pam-configs/authd.in b/debian/pam-configs/authd.in index 09e6421435..ef0ea83159 100644 --- a/debian/pam-configs/authd.in +++ b/debian/pam-configs/authd.in @@ -1,4 +1,4 @@ -Name: Authd authentication +Name: authd authentication Default: yes Priority: 1050 diff --git a/debian/rules b/debian/rules index 3459460e09..0539b8f255 100755 --- a/debian/rules +++ b/debian/rules @@ -44,7 +44,10 @@ export AUTHD_SKIP_EXTERNAL_DEPENDENT_TESTS=1 export AUTHD_SKIP_ROOT_TESTS := 1 # Defines the targets to be built as part of dh_auto_build -export DH_GOLANG_BUILDPKG := $(AUTHD_GO_PACKAGE)/... \ +export DH_GOLANG_BUILDPKG := \ + $(AUTHD_GO_PACKAGE)/cmd/authctl \ + $(AUTHD_GO_PACKAGE)/cmd/authd \ + $(AUTHD_GO_PACKAGE)/pam \ $(NULL) # We add the required backported version to the $PATH so that if it exists, then @@ -88,6 +91,8 @@ override_dh_auto_configure: env DEB_CARGO_CRATE="$(DEB_SOURCE)_$(DEB_VERSION_UPSTREAM)" \ $(CARGO_PATH) prepare-debian "$(CARGO_VENDOR_DIR)"; \ else \ + echo "cargo version: $$(cargo --version)"; \ + echo "cargo-vendor-filterer version: $$(cargo-vendor-filterer --version)"; \ dh_auto_configure --buildsystem=cargo; \ fi diff --git a/debian/source/options b/debian/source/options index fea6b8c873..09cd43aebf 100644 --- a/debian/source/options +++ b/debian/source/options @@ -1,9 +1,17 @@ -tar-ignore = */.git* -tar-ignore = */.go* -tar-ignore = */.editor* -tar-ignore = */.mailmap -tar-ignore = */.vscode -tar-ignore = *.so -tar-ignore = *.o +tar-ignore = authd/.git* +tar-ignore = authd/.go* +tar-ignore = authd/.editor* +tar-ignore = authd/.vscode +tar-ignore = authd/authd-oidc-brokers tar-ignore = authd/docs -tar-ignore = vendor_rust/*.a +tar-ignore = authd/e2e-tests +tar-ignore = authd/snap + +tar-ignore = authd/vendor/*/.git* +tar-ignore = authd/vendor/*/.go* +tar-ignore = authd/vendor/*/.editor* +tar-ignore = authd/vendor/*/.mailmap + +tar-ignore = *.a +tar-ignore = *.o +tar-ignore = *.so diff --git a/debian/tests/run-tests.sh b/debian/tests/run-tests.sh index 0b82478d99..abfa1bb857 100755 --- a/debian/tests/run-tests.sh +++ b/debian/tests/run-tests.sh @@ -2,6 +2,7 @@ set -exuo pipefail +# Skip tests which depend on vhs which is not available in the build environment. export AUTHD_SKIP_EXTERNAL_DEPENDENT_TESTS=1 # Skip flaky tests because we don't want autopkgtests to fail, which would cause @@ -14,4 +15,4 @@ export GOTOOLCHAIN=local PATH=$PATH:$("$(dirname "$0")"/../get-depends-go-bin-path.sh) export PATH -go test -v ./... +go test ./... diff --git a/debian/vendor-rust.sh b/debian/vendor-rust.sh index a0c899b449..1fedf6e1d1 100755 --- a/debian/vendor-rust.sh +++ b/debian/vendor-rust.sh @@ -12,6 +12,10 @@ if ! command -v cargo-vendor-filterer 2>/dev/null; then exit 3 fi +# Print the versions of cargo and cargo-vendor-filterer for debugging purposes. +echo "Using cargo version: $(${CARGO_PATH} --version)" +echo "Using cargo-vendor-filterer version: $(cargo-vendor-filterer --version)" + # Some crates are shipped with .a files, which get removed by the helpers during the package build as a safety measure. # This results in cargo failing to compile, since the files (which are listed in the checksums) are not there anymore. # For those crates, we need to replace their checksum with a more general one that only lists the crate checksum, instead of each file. diff --git a/docs/.custom_wordlist.txt b/docs/.custom_wordlist.txt index 6798e78afb..b3a7856c4b 100644 --- a/docs/.custom_wordlist.txt +++ b/docs/.custom_wordlist.txt @@ -1,15 +1,21 @@ alice amd auth +authctl authd +authd's biometric +cleartext Center +config DBus entra filesystem +Fosstodon fstab ESC GDM +GID GIDs gRPC github @@ -17,29 +23,41 @@ grpc hostname https IDMAP +IdP +init io +jinja KDC Kerberos +Keycloak +keycloak keytab Keytab Keytabs +libpwquality linux +makefile +MDM mountpoint msentraid NFS nss OAuth OIDC +Okta OpenID ppa PR protoc PRs repo +sandboxing smartcard sshd styleguide sudo +systemd +templating TPM ubuntu UEFI diff --git a/docs/.sphinx/.markdownlint.json b/docs/.sphinx/.markdownlint.json deleted file mode 100644 index 536f9ea929..0000000000 --- a/docs/.sphinx/.markdownlint.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "default": false, - "MD003": { - "style": "atx" - }, - "MD014": true, - "MD018": true, - "MD022": true, - "MD023": true, - "MD026": { - "punctuation": ".,;。,;" - }, - "MD031": { - "list_items": false - }, - "MD032": true, - "MD035": true, - "MD042": true, - "MD045": true, - "MD052": true -} \ No newline at end of file diff --git a/docs/.sphinx/.pymarkdown.json b/docs/.sphinx/.pymarkdown.json new file mode 100644 index 0000000000..2c4c669dbf --- /dev/null +++ b/docs/.sphinx/.pymarkdown.json @@ -0,0 +1,46 @@ +{ + "plugins": { + "selectively_enable_rules": true, + "heading-style": { + "enabled": true, + "style": "atx" + }, + "commands-show-output": { + "enabled": true + }, + "no-missing-space-atx": { + "enabled": true + }, + "blanks-around-headings": { + "enabled": true + }, + "heading-start-left": { + "enabled": true + }, + "no-trailing-punctuation": { + "enabled": true, + "punctuation": ".,;。,;" + }, + "blanks-around-fences": { + "enabled": true, + "list_items": false + }, + "blanks-around-lists": { + "enabled": true + }, + "hr-style": { + "enabled": true + }, + "no-empty-links": { + "enabled": true + }, + "no-alt-text": { + "enabled": true + } + }, + "extensions": { + "front-matter" : { + "enabled" : true + } + } +} diff --git a/docs/.sphinx/.wordlist.txt b/docs/.sphinx/.wordlist.txt deleted file mode 100644 index be5021a1f6..0000000000 --- a/docs/.sphinx/.wordlist.txt +++ /dev/null @@ -1,64 +0,0 @@ -ACME -ACME's -addons -AGPLv -API -APIs -balancer -Charmhub -CLI -DCO -Diátaxis -Dqlite -dropdown -EBS -EKS -enablement -favicon -Furo -Git -GitHub -Grafana -IAM -installable -JSON -Juju -Kubeflow -Kubernetes -Launchpad -linter -LTS -LXD -Makefile -Makefiles -Matrix -Mattermost -MicroCeph -MicroCloud -MicroOVN -MyST -namespace -namespaces -NodePort -Numbat -observability -OEM -OLM -Permalink -pre -Quickstart -ReadMe -reST -reStructuredText -roadmap -RTD -subdirectories -subfolders -subtree -TODO -Ubuntu -UI -UUID -VM -webhook -YAML diff --git a/docs/.sphinx/_static/cookie-banner.css b/docs/.sphinx/_static/cookie-banner.css new file mode 100644 index 0000000000..afcf7922a5 --- /dev/null +++ b/docs/.sphinx/_static/cookie-banner.css @@ -0,0 +1,82 @@ +/* Cookie policy styling WILL BE REMOVED when implementation of new theme with vanilla is implemented */ +.cookie-policy { + overflow: auto; + top: 35%; + z-index: 50; + position: fixed; +} + +dialog.cookie-policy { + background-color: var(--color-code-background); + color: var(--color-code-foreground); + height: auto; + max-height: 60vh; + max-width: 40rem; + padding: 0 1rem 0 1rem; + width: auto; +} + +header.p-modal__header { + margin-bottom: .5rem; +} + +header.p-modal__header::after { + background-color: #d9d9d9; + content: ""; + height: 1px; + left: 0; + margin-left: 1rem; + margin-right: 1rem; + position: absolute; + right: 0; +} + +h2#cookie-policy-title.p-modal__title { + align-self: flex-end; + font-size: 1.5rem; + font-style: normal; + font-weight: 275; + line-height: 2rem; + margin: 0 0 1.05rem 0; + padding: 0.45rem 0 0 0; +} + +.cookie-policy p { + font-size: 1rem; + line-height: 1.5rem; + margin-top: 0; + padding-top: .4rem; +} + +.cookie-policy p a { + text-decoration: none; + color: var(--color-link); +} +.cookie-policy button { + border-style: solid; + border-width: 1.5px; + cursor: pointer; + display: inline-block; + font-size: 1rem; + font-weight: 400; + justify-content: center; + line-height: 1.5rem; + padding: calc(.4rem - 1px) 1rem; + text-align: center; + text-decoration: none; + transition-duration: .1s; + transition-property: background-color,border-color; + transition-timing-function: cubic-bezier(0.55,0.055,0.675,0.19); +} + +.cookie-policy button { + background-color: #fff; + border-color: rgba(0,0,0,0.56); + color: #000; +} + +.cookie-policy .p-button--positive { + background-color: #0e8420; + border-color: #0e8420; + color: #fff; +} diff --git a/docs/.sphinx/_static/js/bundle.js b/docs/.sphinx/_static/js/bundle.js new file mode 100644 index 0000000000..a79e20ca87 --- /dev/null +++ b/docs/.sphinx/_static/js/bundle.js @@ -0,0 +1 @@ +(()=>{"use strict";function e(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var o=Object.getOwnPropertySymbols(e);t&&(o=o.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,o)}return n}function t(t){for(var n=1;ncookie policy.',buttonAccept:"Accept all and visit site",buttonManage:"Manage your tracker settings"},manager:{title:"Tracking choices",body1:"We use cookies to recognise visitors and remember your preferences.",body2:"They enhance user experience, personalise content and ads, provide social media features, measure campaign effectiveness, and analyse site traffic.",body3:"Select the types of trackers you consent to, both by us, and third parties.",body4:'Learn more at data privacy: cookie policy - you can change your choices at any time from the footer of the site.',acceptAll:"Accept all",acceptAllHelp:'This will switch all toggles "ON".',SavePreferences:"Save preferences"}},zh:{notification:{title:"您的追踪器设置",body1:"我们使用cookie和相似的方法来识别访问者和记住偏好设置。我们也用它们来衡量活动的效果和网站流量分析。",body2:"选择”接受“,您同意我们和受信的第三方来使用这些方式。",body3:'更多内容或者随时地变更您的同意选择,请点击我们的 cookie策略.',buttonAccept:"接受全部和访问网站",buttonManage:"管理您的追踪器设置"},manager:{title:"追踪选项",body1:"我们使用cookie来识别访问者和记住您的偏好设置",body2:"它们增强用户体验,使内容和广告个性化,提供社交媒体功能,衡量活动效果和网站流量分析。",body3:"选择您同意授予我们和受信的第三方的追踪类型。",body4:'点击数据隐私:cookie策略了解更多,您可以在网站底部随时更改您的选择。',acceptAll:"接受全部",acceptAllHelp:"这将把全部开关变为”开启“。",SavePreferences:"保存偏好设置"}},ja:{notification:{title:"トラッキング機能の設定",body1:"当社は、当社のウェブサイトを訪問された方の識別や傾向の記録を行うために、クッキーおよび類似の手法を利用します。また、キャンペーンの効果の測定やサイトのトラフィックの分析にもクッキーを利用します。",body2:"「同意」を選択すると、当社および信頼できる第三者による上記の手法の利用に同意したものとみなされます。",body3:'詳細または同意の変更については、いつでも当社のクッキーに関するポリシーをご覧になることができます。',buttonAccept:"すべて同意してサイトにアクセス",buttonManage:"トラッキング機能の設定の管理"},manager:{title:"トラッキング機能の選択",body1:"当社は、当社のウェブサイトを訪問された方の識別や傾向の記録を行うために、クッキーを利用します。",body2:"クッキーは、お客様の利便性の向上、お客様に合わせたコンテンツや広告の表示、ソーシャルメディア機能の提供、キャンペーンの効果の測定、サイトのトラフィックの分析に役立ちます。",body3:"当社および第三者によるトラッキング機能のタイプから、お客様が同意されるものをお選びください。",body4:'詳細は、データプライバシー:クッキーに関するポリシーをご覧ください。お客様が選んだ設定は、本サイトの下部からいつでも変更できます。',acceptAll:"すべて同意",acceptAllHelp:"同意されるとすべての設定が「ON」に切り替わります。",SavePreferences:"設定を保存"}}},d={ad_storage:"denied",ad_user_data:"denied",ad_personalization:"denied",analytics_storage:"denied",functionality_storage:"denied",personalization_storage:"denied",security_storage:"denied"},u=["security_storage"],p=["ad_storage","ad_user_data","ad_personalization","analytics_storage"],f=["functionality_storage","personalization_storage"],h=["ad_storage","ad_user_data","ad_personalization","analytics_storage","functionality_storage","personalization_storage"],y=function(e){var t=new Date;t.setTime(t.getTime()+31536e6);var n="expires="+t.toUTCString();document.cookie="_cookies_accepted="+e+"; "+n+"; samesite=lax;path=/;",S(e)&&_()},b=function(){for(var e=document.cookie.split(";"),t="",n="",o=0;o\n ")}},{key:"render",value:function(e){this.container.innerHTML=this.getNotificationMarkup(e),this.initaliseListeners()}},{key:"initaliseListeners",value:function(){var e=this;this.container.querySelector(".js-close").addEventListener("click",(function(t){y("all"),v("all"),e.destroyComponent()})),this.container.querySelector(".js-manage").addEventListener("click",(function(t){e.renderManager()}))}}]),e}(),L=function(){function e(t,n,i){o(this,e),this.language=i,this.id=t.id,this.title=m(t,i).title,this.description=m(t,i).description,this.enableSwitcher=t.enableSwitcher,this.container=n,this.element,this.render()}return a(e,[{key:"render",value:function(){var e=this.cookieIsTrue(),t=document.createElement("div");t.classList.add("u-sv3"),t.innerHTML="\n ".concat(''),"\n

",this.title,"

\n

").concat(this.description,"

"),this.container.appendChild(t),this.element=t.querySelector(".js-".concat(this.id,"-switch"))}},{key:"cookieIsTrue",value:function(){var e=b();return!(!e||e!==this.id&&"all"!==e)||e&&e===this.id}},{key:"isChecked",value:function(){return!!this.element&&this.element.checked}},{key:"getId",value:function(){return this.id}}]),e}(),E=function(){function e(t,n){o(this,e),this.container=t,this.controlsStore=[],this.destroyComponent=n}return a(e,[{key:"getManagerMarkup",value:function(e){var t=g(e).manager;return'\n ")}},{key:"render",value:function(e){var t=this;this.container.innerHTML=this.getManagerMarkup(e);var n=this.container.querySelector(".controls");s.forEach((function(o){var i=new L(o,n,e);t.controlsStore.push(i)})),this.initaliseListeners()}},{key:"initaliseListeners",value:function(){var e=this;this.container.querySelector(".js-close").addEventListener("click",(function(){y("all"),v("all"),e.destroyComponent()})),this.container.querySelector(".js-save-preferences").addEventListener("click",(function(){e.savePreferences(),e.destroyComponent()}))}},{key:"savePreferences",value:function(){var e=this.controlsStore.filter((function(e){return e.isChecked()}));this.controlsStore.length===e.length?y("all"):this.controlsStore.forEach((function(e){e.isChecked()&&y(e.getId())})),function(e){var n=e.filter((function(e){return e.isChecked()})),o=t({},d);n.forEach((function(e){o=k(o,e.id)})),w(o)}(this.controlsStore)}}]),e}();window.gtag||(window.dataLayer=window.dataLayer||[],window.gtag=function(){dataLayer.push(arguments)},window.gtag("consent","default",d));var O=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:null,t=null,n=document.documentElement.lang,o=!1,i=function(e){e&&e.preventDefault(),null===t&&((t=document.createElement("dialog")).classList.add("cookie-policy"),t.setAttribute("open",!0),document.body.appendChild(t),new j(t,a,r).render(n),document.getElementById("cookie-policy-button-accept").focus())},a=function(){new E(t,r).render(n)},r=function(){"function"==typeof e&&e(),document.body.removeChild(t),t=null},c=function(){if(!o){var e;o=!0,(e=b())&&v(e);var t=document.querySelector(".js-revoke-cookie-manager");t&&t.addEventListener("click",i),function(){var e=b();return!e||"true"==e}()&&"hide"!==new URLSearchParams(window.location.search).get("cp")&&i()}};"loading"===document.readyState?document.addEventListener("DOMContentLoaded",c,!1):c()},P=function(e){document.cookie.match(new RegExp("(^| )"+e+"=([^;]+)"))},A=P("_cookies_accepted");function M(){var e,t;if(("all"===(null===(e=A=P("_cookies_accepted"))||void 0===e?void 0:e[2])||"performance"===(null===(t=A)||void 0===t?void 0:t[2]))&&!P("user_id")){var n=([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,(function(e){return(e^crypto.getRandomValues(new Uint8Array(1))[0]&15>>e/4).toString(16)}));document.cookie="user_id="+n+";max-age=31536000;",dataLayer.push({user_id:n})}}A?(M(),O()):O(M)})(); diff --git a/docs/.sphinx/_static/version-warning.css b/docs/.sphinx/_static/version-warning.css new file mode 100644 index 0000000000..c52703b15f --- /dev/null +++ b/docs/.sphinx/_static/version-warning.css @@ -0,0 +1,17 @@ +/* Use subtle bg with strongly contrasting fg */ +.version-warning { + padding: 5px; + width: 100%; + text-align: center; + font-size: var(--font-size--small); + background-color: #EAE9E7; + color: #111; +} + +/* Fix one fg color as only one bg color */ +.version-warning a { + color: #06C; +} +.version-warning a:visited { + color: #872EE0; +} diff --git a/docs/.sphinx/_templates/footer.html b/docs/.sphinx/_templates/footer.html new file mode 100644 index 0000000000..9eab890443 --- /dev/null +++ b/docs/.sphinx/_templates/footer.html @@ -0,0 +1,105 @@ +{# ru-fu: copied from Furo, with modifications as stated below. Modifications are marked 'mod:'. #} + + +
+
+ {%- if show_copyright %} + + {%- endif %} + {%- if license and license.name -%} + {%- if license.url -%} +
+ This page is licensed under {{ license.name }} +
+ {%- else -%} +
+ This page is licensed under {{ license.name }} +
+ {%- endif -%} + {%- endif -%} + + {# mod: removed "Made with" #} + + {%- if last_updated -%} +
+ {% trans last_updated=last_updated|e -%} + Last updated on {{ last_updated }} + {%- endtrans -%} +
+ {%- endif %} + + {%- if show_source and has_source and sourcename %} + + {%- endif %} +
+
+ {% if has_contributor_listing and display_contributors and pagename and page_source_suffix %} + {% set contributors = get_contributors_for_file(pagename, page_source_suffix) %} + {% if contributors %} + {% if contributors | length > 1 %} + Thanks to the {{ contributors |length }} contributors! + {% else %} + Thanks to our contributor! + {% endif %} +
+ + {% endif %} + {% endif %} +
+ +
diff --git a/docs/.sphinx/_templates/header.html b/docs/.sphinx/_templates/header.html index 30c52c5c8b..c42be925e5 100644 --- a/docs/.sphinx/_templates/header.html +++ b/docs/.sphinx/_templates/header.html @@ -1,43 +1,77 @@