diff --git a/.github/scripts/commit-and-tag.cjs b/.github/scripts/commit-and-tag.cjs index 086e1b83e..405ebe719 100644 --- a/.github/scripts/commit-and-tag.cjs +++ b/.github/scripts/commit-and-tag.cjs @@ -13,7 +13,7 @@ module.exports = async ({ github, context }) => { const parent = context.sha; if (!version) throw new Error("VERSION is not defined"); - const files = ["Cargo.toml", "Cargo.lock"]; + const files = ["LETRO_VERSION"]; /** @type {{path: string, mode: "100644", type: "blob", sha: string}[]} */ const tree = []; @@ -55,7 +55,7 @@ module.exports = async ({ github, context }) => { const tag = await github.rest.git.createTag({ owner, repo, - tag: `v${version}`, + tag: `letro-v${version}`, message: version, type: "commit", object: commit.data.sha, diff --git a/.github/scripts/create-version-tag.cjs b/.github/scripts/create-version-tag.cjs index 47e00ecb1..3d78b3467 100644 --- a/.github/scripts/create-version-tag.cjs +++ b/.github/scripts/create-version-tag.cjs @@ -17,7 +17,7 @@ module.exports = async ({ github, context }) => { const tag = await github.rest.git.createRef({ owner, repo, - ref: `refs/tags/v${version}`, + ref: `refs/tags/letro-v${version}`, sha: tagSha, }); console.log("Created tag ref:", tag.data.url); diff --git a/.github/scripts/merge-back.cjs b/.github/scripts/merge-back.cjs index d3948398e..eca79f156 100644 --- a/.github/scripts/merge-back.cjs +++ b/.github/scripts/merge-back.cjs @@ -43,6 +43,9 @@ module.exports = async ({ github, context }) => { labels: ["T-Task"], }); + // Wait for GitHub to register the pending checks before enabling auto-merge + await new Promise((resolve) => setTimeout(resolve, 30000)); + // Enable auto-merge on the PR await github.graphql( ` diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index bf23e0ef8..cecafa139 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -9,26 +9,21 @@ on: push: branches: - main - - "release/**" tags: - - "v*" + - "letro-v*" # Run when there is a label change on the pull request # This runs only if the 'Z-Build-Workflow' is added to the pull request pull_request: types: [labeled] -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - env: CARGO_TERM_COLOR: always CARGO_NET_GIT_FETCH_WITH_CLI: "true" SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" - IMAGE: ghcr.io/element-hq/matrix-authentication-service - BUILDCACHE: ghcr.io/element-hq/matrix-authentication-service/buildcache + IMAGE: ghcr.io/p-num/matrix-authentication-service + BUILDCACHE: ghcr.io/p-num/matrix-authentication-service/buildcache DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index jobs: @@ -54,12 +49,13 @@ jobs: - name: Compute version and timestamp out of git history id: git run: | - echo "describe=$(git describe --tags --match 'v*.*.*' --always)" >> $GITHUB_OUTPUT + echo "describe=$(git describe --tags --match 'letro-v*.*.*' --always)" >> $GITHUB_OUTPUT echo "timestamp=$(git log -1 --format=%ct)" >> $GITHUB_OUTPUT build-assets: name: Build assets - if: github.event_name == 'push' || github.event.label.name == 'Z-Build-Workflow' + if: false + # if: github.event_name == 'push' || github.event.label.name == 'Z-Build-Workflow' runs-on: ubuntu-24.04 permissions: @@ -91,7 +87,8 @@ jobs: build-binaries: name: Build binaries - if: github.event_name == 'push' || github.event.label.name == 'Z-Build-Workflow' + if: false + # if: github.event_name == 'push' || github.event.label.name == 'Z-Build-Workflow' runs-on: ubuntu-24.04 needs: @@ -101,7 +98,6 @@ jobs: matrix: include: - target: x86_64-unknown-linux-gnu - - target: aarch64-unknown-linux-gnu env: VERGEN_GIT_DESCRIBE: ${{ needs.compute-version.outputs.describe }} @@ -150,7 +146,8 @@ jobs: assemble-archives: name: Assemble release archives - if: github.event_name == 'push' || github.event.label.name == 'Z-Build-Workflow' + if: false + # if: github.event_name == 'push' || github.event.label.name == 'Z-Build-Workflow' runs-on: ubuntu-24.04 needs: @@ -173,29 +170,15 @@ jobs: name: binary-x86_64-unknown-linux-gnu path: binary-x86_64 - - name: Download binary aarch64 - uses: actions/download-artifact@v8 - with: - name: binary-aarch64-unknown-linux-gnu - path: binary-aarch64 - - name: Create final archives run: | - for arch in x86_64 aarch64; do - mkdir -p dist/${arch}/share - cp -r assets-dist/share/* dist/${arch}/share/ - cp assets-dist/LICENSE dist/${arch}/LICENSE - cp binary-$arch/mas-cli dist/${arch}/mas-cli - chmod -R u=rwX,go=rX dist/${arch}/ - chmod u=rwx,go=rx dist/${arch}/mas-cli - tar -czvf mas-cli-${arch}-linux.tar.gz --owner=0 --group=0 -C dist/${arch}/ . - done - - - name: Upload aarch64 archive - uses: actions/upload-artifact@v7.0.0 - with: - name: mas-cli-aarch64-linux - path: mas-cli-aarch64-linux.tar.gz + mkdir -p dist/x86_64/share + cp -r assets-dist/share/* dist/x86_64/share/ + cp assets-dist/LICENSE dist/x86_64/LICENSE + cp binary-x86_64/mas-cli dist/x86_64/mas-cli + chmod -R u=rwX,go=rX dist/x86_64/ + chmod u=rwx,go=rx dist/x86_64/mas-cli + tar -czvf mas-cli-x86_64-linux.tar.gz --owner=0 --group=0 -C dist/x86_64/ . - name: Upload x86_64 archive uses: actions/upload-artifact@v7.0.0 @@ -224,6 +207,16 @@ jobs: SOURCE_DATE_EPOCH: ${{ needs.compute-version.outputs.timestamp }} steps: + - name: Extract semver version from letro tag + id: tag-semver + run: | + tag="${{ github.ref_name }}" + if [[ "$tag" == letro-v* ]]; then + echo "version=${tag#letro-v}" >> "$GITHUB_OUTPUT" + else + echo "version=" >> "$GITHUB_OUTPUT" + fi + - name: Docker meta id: meta uses: docker/metadata-action@v6.0.0 @@ -235,9 +228,9 @@ jobs: tags: | type=ref,event=branch type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} + type=semver,pattern={{version}},value=${{ steps.tag-semver.outputs.version }},enable=${{ steps.tag-semver.outputs.version != '' }} + type=semver,pattern={{major}}.{{minor}},value=${{ steps.tag-semver.outputs.version }},enable=${{ steps.tag-semver.outputs.version != '' }} + type=semver,pattern={{major}},value=${{ steps.tag-semver.outputs.version }},enable=${{ steps.tag-semver.outputs.version != '' }} type=sha - name: Docker meta (debug variant) @@ -252,9 +245,9 @@ jobs: tags: | type=ref,event=branch type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} + type=semver,pattern={{version}},value=${{ steps.tag-semver.outputs.version }},enable=${{ steps.tag-semver.outputs.version != '' }} + type=semver,pattern={{major}}.{{minor}},value=${{ steps.tag-semver.outputs.version }},enable=${{ steps.tag-semver.outputs.version != '' }} + type=semver,pattern={{major}},value=${{ steps.tag-semver.outputs.version }},enable=${{ steps.tag-semver.outputs.version != '' }} type=sha - name: Setup Cosign @@ -268,7 +261,7 @@ jobs: mirrors = ["mirror.gcr.io"] - name: Login to GitHub Container Registry - uses: docker/login-action@v4.0.0 + uses: docker/login-action@v4.1.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -276,7 +269,7 @@ jobs: - name: Build and push id: bake - uses: docker/bake-action@v7.0.0 + uses: docker/bake-action@v7.1.0 with: files: | ./docker-bake.hcl @@ -291,16 +284,18 @@ jobs: # This transforms the ouput to an object which looks like this: # { reguar: { digest: "…", tags: ["…", "…"] }, debug: { digest: "…", tags: ["…"] }, … } id: output + env: + BAKE_METADATA: ${{ steps.bake.outputs.metadata }} run: | echo 'metadata<> $GITHUB_OUTPUT - echo '${{ steps.bake.outputs.metadata }}' | jq -c 'with_entries(select(.value | (type == "object" and has("containerimage.digest")))) | map_values({ digest: .["containerimage.digest"], tags: (.["image.name"] | split(",")) })' >> $GITHUB_OUTPUT + echo "$BAKE_METADATA" | jq -c 'with_entries(select(.value | (type == "object" and has("containerimage.digest")))) | map_values({ digest: .["containerimage.digest"], tags: (.["image.name"] | split(",")) })' >> $GITHUB_OUTPUT echo 'EOF' >> $GITHUB_OUTPUT - name: Sign the images with GitHub Actions provided token # Only sign on tags and on commits on main branch if: | github.event_name != 'pull_request' - && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') + && (startsWith(github.ref, 'refs/tags/letro-v') || github.ref == 'refs/heads/main') env: REGULAR_DIGEST: ${{ steps.output.outputs.metadata && fromJSON(steps.output.outputs.metadata).regular.digest }} @@ -313,23 +308,50 @@ jobs: release: name: Release - if: startsWith(github.ref, 'refs/tags/') + if: startsWith(github.ref, 'refs/tags/letro-v') runs-on: ubuntu-24.04 needs: - - assemble-archives - build-image + + permissions: + contents: write + steps: - - name: Download the artifacts from the previous job - uses: actions/download-artifact@v8 + - name: Checkout the code (full history for diff) + uses: actions/checkout@v6 with: - pattern: mas-cli-* - path: artifacts - merge-multiple: true + fetch-depth: 0 + + - name: Read upstream commit from UPSTREAM_VERSION + id: upstream + run: | + echo "commit=$(grep '^commit:' UPSTREAM_VERSION | awk '{print $2}')" >> "$GITHUB_OUTPUT" + + - name: Fetch upstream commit + run: git fetch origin "${{ steps.upstream.outputs.commit }}" || git fetch https://github.com/element-hq/matrix-authentication-service.git "${{ steps.upstream.outputs.commit }}" + + - name: Generate git diff from upstream + run: | + git diff "${{ steps.upstream.outputs.commit }}" HEAD -- \ + ':!LETRO_VERSION' ':!FORK_DIVERGENCE.md' ':!UPSTREAM_VERSION' \ + > fork-changes.diff + - name: Install Trivy + run: | + curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin + + - name: Generate repository SBOM with Trivy + run: | + trivy fs \ + --format cyclonedx \ + --output sbom.json \ + . + - name: Prepare a release uses: softprops/action-gh-release@v2.6.1 with: generate_release_notes: true + prerelease: ${{ contains(github.ref_name, '-rc.') }} body: | ### Docker image @@ -358,9 +380,9 @@ jobs: ``` files: | - artifacts/mas-cli-aarch64-linux.tar.gz - artifacts/mas-cli-x86_64-linux.tar.gz - draft: true + fork-changes.diff + FORK_DIVERGENCE.md + sbom.json unstable: name: Update the unstable release @@ -368,7 +390,6 @@ jobs: runs-on: ubuntu-24.04 needs: - - assemble-archives - build-image permissions: @@ -381,13 +402,6 @@ jobs: sparse-checkout: | .github/scripts - - name: Download the artifacts from the previous job - uses: actions/download-artifact@v8 - with: - pattern: mas-cli-* - path: artifacts - merge-multiple: true - - name: Update unstable git tag uses: actions/github-script@v8.0.0 with: @@ -434,9 +448,6 @@ jobs: ') }} ``` - files: | - artifacts/mas-cli-aarch64-linux.tar.gz - artifacts/mas-cli-x86_64-linux.tar.gz prerelease: true make_latest: false diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1fe4f1a8d..89da690f4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,7 +11,7 @@ on: - main - "release/**" tags: - - "v*" + - "letro-v*" pull_request: concurrency: @@ -159,7 +159,7 @@ jobs: uses: actions/checkout@v6 - name: Run `cargo-deny` - uses: EmbarkStudios/cargo-deny-action@v2.0.15 + uses: EmbarkStudios/cargo-deny-action@v2.0.16 with: rust-version: stable diff --git a/.github/workflows/release-branch.yaml b/.github/workflows/release-branch.yaml index f0843f717..c0ec8b117 100644 --- a/.github/workflows/release-branch.yaml +++ b/.github/workflows/release-branch.yaml @@ -36,15 +36,12 @@ jobs: - name: Checkout the code uses: actions/checkout@v6 - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - name: Compute the new minor RC id: next env: BUMP: pre${{ inputs.kind }} run: | - CURRENT_VERSION="$(cargo metadata --format-version 1 --no-deps | jq -r '.packages[] | select(.name == "mas-cli") | .version')" + CURRENT_VERSION="$(cat LETRO_VERSION | tr -d '[:space:]')" NEXT_VERSION="$(npx --yes semver@7.5.4 -i "$BUMP" --preid rc "${CURRENT_VERSION}")" # compute the short minor version, e.g. 0.1.0-rc.1 -> 0.1 SHORT_VERSION="$(echo "${NEXT_VERSION}" | cut -d. -f1-2)" @@ -53,6 +50,7 @@ jobs: localazy: name: Create a new branch in Localazy + if: false runs-on: ubuntu-24.04 needs: [compute-version] @@ -103,11 +101,12 @@ jobs: contents: write pull-requests: write - needs: [tag, compute-version, localazy] + needs: [tag, compute-version] steps: - name: Checkout the code uses: actions/checkout@v6 with: + token: ${{ secrets.BOT_GITHUB_TOKEN }} sparse-checkout: | .github/scripts diff --git a/.github/workflows/release-bump.yaml b/.github/workflows/release-bump.yaml index a2a20791a..01f164785 100644 --- a/.github/workflows/release-bump.yaml +++ b/.github/workflows/release-bump.yaml @@ -35,12 +35,9 @@ jobs: - name: Checkout the code uses: actions/checkout@v6 - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - name: Extract the current version id: current - run: echo "version=$(cargo metadata --format-version 1 --no-deps | jq -r '.packages[] | select(.name == "mas-cli") | .version')" >> "$GITHUB_OUTPUT" + run: echo "version=$(cat LETRO_VERSION | tr -d '[:space:]')" >> "$GITHUB_OUTPUT" - name: Compute the new minor RC id: next @@ -78,6 +75,7 @@ jobs: - name: Checkout the code uses: actions/checkout@v6 with: + token: ${{ secrets.BOT_GITHUB_TOKEN }} sparse-checkout: | .github/scripts diff --git a/.github/workflows/tag.yaml b/.github/workflows/tag.yaml index c6c394c81..00257a9ef 100644 --- a/.github/workflows/tag.yaml +++ b/.github/workflows/tag.yaml @@ -32,18 +32,10 @@ jobs: - name: Checkout the code uses: actions/checkout@v6 - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Set the crates version + - name: Set the fork version env: VERSION: ${{ inputs.version }} - run: | - sed -i "s/^package.version = .*/package.version = \"$VERSION\"/" Cargo.toml - sed -i "/path = \".\/crates\//s/version = \".*\"/version = \"=$VERSION\"/" Cargo.toml - - - name: Run `cargo metadata` to make sure the lockfile is up to date - run: cargo metadata --format-version 1 + run: echo "$VERSION" > LETRO_VERSION - name: Commit and tag using the GitHub API uses: actions/github-script@v8.0.0 diff --git a/.gitignore b/.gitignore index d98402c23..984909b26 100644 --- a/.gitignore +++ b/.gitignore @@ -10,5 +10,9 @@ target .idea .nova +# Local config (may contain secrets) +config.yaml +config.local.yaml + # OS garbage .DS_Store diff --git a/Cargo.lock b/Cargo.lock index f78a22e6d..7af27529d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,7 +18,16 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" dependencies = [ - "gimli", + "gimli 0.32.3", +] + +[[package]] +name = "addr2line" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a" +dependencies = [ + "gimli 0.33.0", ] [[package]] @@ -95,7 +104,7 @@ dependencies = [ "bytes", "cfg-if", "http", - "indexmap 2.11.4", + "indexmap 2.14.0", "schemars 0.9.0", "serde", "serde_json", @@ -314,7 +323,7 @@ dependencies = [ "futures-timer", "futures-util", "http", - "indexmap 2.11.4", + "indexmap 2.14.0", "mime", "multer", "num-traits", @@ -366,7 +375,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34ecdaff7c9cffa3614a9f9999bf9ee4c3078fe3ce4d6a6e161736b56febf2de" dependencies = [ "bytes", - "indexmap 2.11.4", + "indexmap 2.14.0", "serde", "serde_json", ] @@ -650,11 +659,11 @@ version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" dependencies = [ - "addr2line", + "addr2line 0.25.1", "cfg-if", "libc", "miniz_oxide", - "object", + "object 0.37.3", "rustc-demangle", "windows-link 0.2.1", ] @@ -766,9 +775,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" dependencies = [ "allocator-api2", ] @@ -1144,46 +1153,48 @@ dependencies = [ [[package]] name = "cranelift-assembler-x64" -version = "0.127.4" +version = "0.130.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6abf69c884fde2d9d4cc232a585fb18f16af3ae04c634315c84ebe158ded92d" +checksum = "046d4b584c3bb9b5eb500c8f29549bec36be11000f1ba2a927cef3d1a9875691" dependencies = [ "cranelift-assembler-x64-meta", ] [[package]] name = "cranelift-assembler-x64-meta" -version = "0.127.4" +version = "0.130.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "263d31fcdf83a10267e8c38b53bc8f7688dfbc331267fd8fdf5b22e0dc47a55b" +checksum = "b9b194a7870becb1490366fc0ae392ccd188065ff35f8391e77ac659db6fb977" dependencies = [ "cranelift-srcgen", ] [[package]] name = "cranelift-bforest" -version = "0.127.4" +version = "0.130.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d459d5377c01c4472b71029caa2df41afaf47711676aa9b12d7414f15104637b" +checksum = "bb6a4ab44c6b371e661846b97dab687387a60ac4e2f864e2d4257284aad9e889" dependencies = [ "cranelift-entity", + "wasmtime-internal-core", ] [[package]] name = "cranelift-bitset" -version = "0.127.4" +version = "0.130.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8283088d5823ba7856ab8d531b7c3654b24984748f9fd99dcf3210701fd1d065" +checksum = "b8b7a44150c2f471a94023482bda1902710746e4bed9f9973d60c5a94319b06d" dependencies = [ "serde", "serde_derive", + "wasmtime-internal-core", ] [[package]] name = "cranelift-codegen" -version = "0.127.4" +version = "0.130.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d3138316d8dd341d725d6ab1598750545c76ad32892837fde558edd68a01b43" +checksum = "01b06598133b1dd76758b8b95f8d6747c124124aade50cea96a3d88b962da9fa" dependencies = [ "bumpalo", "cranelift-assembler-x64", @@ -1194,8 +1205,9 @@ dependencies = [ "cranelift-control", "cranelift-entity", "cranelift-isle", - "gimli", - "hashbrown 0.15.5", + "gimli 0.33.0", + "hashbrown 0.16.1", + "libm", "log", "pulley-interpreter", "regalloc2", @@ -1203,14 +1215,14 @@ dependencies = [ "serde", "smallvec", "target-lexicon", - "wasmtime-internal-math", + "wasmtime-internal-core", ] [[package]] name = "cranelift-codegen-meta" -version = "0.127.4" +version = "0.130.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "505cead19304a8dc8689e31b29038775c3f73f9d5ea7a5e33864437a1f46c6b6" +checksum = "6190e2e7bcf0a678da2f715363d34ed530fedf7a2f0ab75edaefef72a70465ff" dependencies = [ "cranelift-assembler-x64-meta", "cranelift-codegen-shared", @@ -1221,35 +1233,36 @@ dependencies = [ [[package]] name = "cranelift-codegen-shared" -version = "0.127.4" +version = "0.130.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce62ba94f570644ce7de6ed05bd39ca28936665dec10a2a1f6f2c531d6add45c" +checksum = "f583cf203d1aa8b79560e3b01f929bdacf9070b015eec4ea9c46e22a3f83e4a0" [[package]] name = "cranelift-control" -version = "0.127.4" +version = "0.130.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6cfe339689c3926412a7060ab00ef3b2b43d936b537e7a3f696121be9d0eaa" +checksum = "803159df35cc398ae54473c150b16d6c77e92ab2948be638488de126a3328fbc" dependencies = [ "arbitrary", ] [[package]] name = "cranelift-entity" -version = "0.127.4" +version = "0.130.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625518090e912bdfe3c41464bf97ae421f6044d4ca0f5c3267dcacdb352b033d" +checksum = "3109e417257082d88087f5bcce677525bdaa8322b88dd7f175ed1a1fd41d546c" dependencies = [ "cranelift-bitset", "serde", "serde_derive", + "wasmtime-internal-core", ] [[package]] name = "cranelift-frontend" -version = "0.127.4" +version = "0.130.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17f652d40ddf3afb55be64d8979d312b52b384a8cebbcde1dd1c2e32ebcd4466" +checksum = "14db6b0e0e4994c581092df78d837be2072578f7cb2528f96a6cf895e56dee63" dependencies = [ "cranelift-codegen", "log", @@ -1259,15 +1272,15 @@ dependencies = [ [[package]] name = "cranelift-isle" -version = "0.127.4" +version = "0.130.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f512767e83015f4baf6e732cabca93cea82907e3ab237f826ef64f7ece75eb6" +checksum = "ec66ea5025c7317383699778282ac98741d68444f956e3b1d7b62f12b7216e67" [[package]] name = "cranelift-native" -version = "0.127.4" +version = "0.130.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb1ca6e4dca568ff988d367e4707be2362cee9782265b0a501eaf467ffd550a8" +checksum = "373ade56438e6232619d85678477d0a88a31b3581936e0503e61e96b546b0800" dependencies = [ "cranelift-codegen", "libc", @@ -1276,9 +1289,9 @@ dependencies = [ [[package]] name = "cranelift-srcgen" -version = "0.127.4" +version = "0.130.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97400ad8fbd3a434092fc0b486fa7784150b53187941d818b1087f3ac0a547f0" +checksum = "ef53619d3cd5c78fd998c6d9420547af26b72e6456f94c2a8a2334cb76b42baa" [[package]] name = "crc" @@ -1755,12 +1768,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "fallible-iterator" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - [[package]] name = "fancy-regex" version = "0.13.0" @@ -1844,6 +1851,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -2036,9 +2049,16 @@ name = "gimli" version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "gimli" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c" dependencies = [ - "fallible-iterator", - "indexmap 2.11.4", + "fnv", + "hashbrown 0.16.1", + "indexmap 2.14.0", "stable_deref_trait", ] @@ -2098,7 +2118,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.11.4", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -2129,10 +2149,26 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", "serde", + "serde_core", ] +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + [[package]] name = "hashlink" version = "0.10.0" @@ -2664,9 +2700,9 @@ checksum = "1adcf7b613a268af025bc2a2532b4b9ee294e6051c5c0832d8bff20ac0232e68" [[package]] name = "id-arena" -version = "2.2.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25a2bc672d1148e28034f176e01fffebb08b35768468cc954630da77a1449005" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[package]] name = "ident_case" @@ -2708,12 +2744,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.11.4" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.15.5", + "hashbrown 0.17.0", "serde", "serde_core", ] @@ -2978,9 +3014,9 @@ checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] name = "libm" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" @@ -3062,7 +3098,7 @@ dependencies = [ [[package]] name = "mas-axum-utils" -version = "1.15.0-rc.0" +version = "1.16.0-rc.0" dependencies = [ "anyhow", "axum", @@ -3096,7 +3132,7 @@ dependencies = [ [[package]] name = "mas-cli" -version = "1.15.0-rc.0" +version = "1.16.0-rc.0" dependencies = [ "anyhow", "axum", @@ -3171,7 +3207,7 @@ dependencies = [ [[package]] name = "mas-config" -version = "1.15.0-rc.0" +version = "1.16.0-rc.0" dependencies = [ "anyhow", "camino", @@ -3202,7 +3238,7 @@ dependencies = [ [[package]] name = "mas-context" -version = "1.15.0-rc.0" +version = "1.16.0-rc.0" dependencies = [ "console", "opentelemetry", @@ -3218,7 +3254,7 @@ dependencies = [ [[package]] name = "mas-data-model" -version = "1.15.0-rc.0" +version = "1.16.0-rc.0" dependencies = [ "base64ct", "chrono", @@ -3239,7 +3275,7 @@ dependencies = [ [[package]] name = "mas-email" -version = "1.15.0-rc.0" +version = "1.16.0-rc.0" dependencies = [ "async-trait", "lettre", @@ -3250,7 +3286,7 @@ dependencies = [ [[package]] name = "mas-handlers" -version = "1.15.0-rc.0" +version = "1.16.0-rc.0" dependencies = [ "aide", "anyhow", @@ -3272,7 +3308,7 @@ dependencies = [ "hex", "hyper", "icu_normalizer", - "indexmap 2.11.4", + "indexmap 2.14.0", "insta", "lettre", "mas-axum-utils", @@ -3331,7 +3367,7 @@ dependencies = [ [[package]] name = "mas-http" -version = "1.15.0-rc.0" +version = "1.16.0-rc.0" dependencies = [ "futures-util", "headers", @@ -3352,7 +3388,7 @@ dependencies = [ [[package]] name = "mas-i18n" -version = "1.15.0-rc.0" +version = "1.16.0-rc.0" dependencies = [ "camino", "icu_calendar", @@ -3374,7 +3410,7 @@ dependencies = [ [[package]] name = "mas-i18n-scan" -version = "1.15.0-rc.0" +version = "1.16.0-rc.0" dependencies = [ "camino", "clap", @@ -3388,7 +3424,7 @@ dependencies = [ [[package]] name = "mas-iana" -version = "1.15.0-rc.0" +version = "1.16.0-rc.0" dependencies = [ "schemars 0.9.0", "serde", @@ -3396,7 +3432,7 @@ dependencies = [ [[package]] name = "mas-iana-codegen" -version = "1.15.0-rc.0" +version = "1.16.0-rc.0" dependencies = [ "anyhow", "async-trait", @@ -3413,7 +3449,7 @@ dependencies = [ [[package]] name = "mas-jose" -version = "1.15.0-rc.0" +version = "1.16.0-rc.0" dependencies = [ "base64ct", "chrono", @@ -3443,7 +3479,7 @@ dependencies = [ [[package]] name = "mas-keystore" -version = "1.15.0-rc.0" +version = "1.16.0-rc.0" dependencies = [ "aead", "base64ct", @@ -3471,7 +3507,7 @@ dependencies = [ [[package]] name = "mas-listener" -version = "1.15.0-rc.0" +version = "1.16.0-rc.0" dependencies = [ "anyhow", "bytes", @@ -3495,7 +3531,7 @@ dependencies = [ [[package]] name = "mas-matrix" -version = "1.15.0-rc.0" +version = "1.16.0-rc.0" dependencies = [ "anyhow", "async-trait", @@ -3505,7 +3541,7 @@ dependencies = [ [[package]] name = "mas-matrix-synapse" -version = "1.15.0-rc.0" +version = "1.16.0-rc.0" dependencies = [ "anyhow", "async-trait", @@ -3522,7 +3558,7 @@ dependencies = [ [[package]] name = "mas-oidc-client" -version = "1.15.0-rc.0" +version = "1.16.0-rc.0" dependencies = [ "assert_matches", "async-trait", @@ -3558,7 +3594,7 @@ dependencies = [ [[package]] name = "mas-policy" -version = "1.15.0-rc.0" +version = "1.16.0-rc.0" dependencies = [ "anyhow", "arc-swap", @@ -3575,7 +3611,7 @@ dependencies = [ [[package]] name = "mas-router" -version = "1.15.0-rc.0" +version = "1.16.0-rc.0" dependencies = [ "axum", "serde", @@ -3586,7 +3622,7 @@ dependencies = [ [[package]] name = "mas-spa" -version = "1.15.0-rc.0" +version = "1.16.0-rc.0" dependencies = [ "camino", "serde", @@ -3595,7 +3631,7 @@ dependencies = [ [[package]] name = "mas-storage" -version = "1.15.0-rc.0" +version = "1.16.0-rc.0" dependencies = [ "async-trait", "chrono", @@ -3617,7 +3653,7 @@ dependencies = [ [[package]] name = "mas-storage-pg" -version = "1.15.0-rc.0" +version = "1.16.0-rc.0" dependencies = [ "async-trait", "chrono", @@ -3647,7 +3683,7 @@ dependencies = [ [[package]] name = "mas-tasks" -version = "1.15.0-rc.0" +version = "1.16.0-rc.0" dependencies = [ "anyhow", "async-trait", @@ -3679,7 +3715,7 @@ dependencies = [ [[package]] name = "mas-templates" -version = "1.15.0-rc.0" +version = "1.16.0-rc.0" dependencies = [ "anyhow", "arc-swap", @@ -3711,7 +3747,7 @@ dependencies = [ [[package]] name = "mas-tower" -version = "1.15.0-rc.0" +version = "1.16.0-rc.0" dependencies = [ "http", "opentelemetry", @@ -3964,12 +4000,12 @@ dependencies = [ [[package]] name = "oauth2-types" -version = "1.15.0-rc.0" +version = "1.16.0-rc.0" dependencies = [ "assert_matches", "base64ct", "chrono", - "indexmap 2.11.4", + "indexmap 2.14.0", "insta", "language-tags", "mas-iana", @@ -3987,10 +4023,19 @@ name = "object" version = "0.37.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "object" +version = "0.38.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271638cd5fa9cca89c4c304675ca658efc4e64a66c716b7cfe1afb4b9611dbbc" dependencies = [ "crc32fast", - "hashbrown 0.15.5", - "indexmap 2.11.4", + "hashbrown 0.16.1", + "indexmap 2.14.0", "memchr", ] @@ -4008,9 +4053,9 @@ checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" [[package]] name = "opa-wasm" -version = "0.1.9" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "360f7106079ceb334afe98119289eec2ae5c0865f270a04f550e7f56a9bd507c" +checksum = "7a9d1cb0566372e6dadfe69b407c3cc39505fe5ab49e97f749e9c42d65a92aa1" dependencies = [ "anyhow", "base64", @@ -4190,7 +4235,7 @@ dependencies = [ "futures-util", "opentelemetry", "percent-encoding", - "rand 0.9.2", + "rand 0.9.4", "thiserror 2.0.17", "tokio", "tokio-stream", @@ -4493,7 +4538,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64", - "indexmap 2.11.4", + "indexmap 2.14.0", "quick-xml", "serde", "time", @@ -4679,21 +4724,21 @@ dependencies = [ [[package]] name = "pulley-interpreter" -version = "40.0.4" +version = "43.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5de307c194cf6310d486dd5595ffc329c53b4acafd54e214752c1eb2e68be3a9" +checksum = "010dec3755eb61b2f1051ecb3611b718460b7a74c131e474de2af20a845938af" dependencies = [ "cranelift-bitset", "log", "pulley-macros", - "wasmtime-internal-math", + "wasmtime-internal-core", ] [[package]] name = "pulley-macros" -version = "40.0.4" +version = "43.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99dca2747e910d10bafe911e172a1b35860268421c3ee5ddb7e16c35e0288b4a" +checksum = "ad360c32e85ca4b083ac0e2b6856e8f11c3d5060dafa7d5dc57b370857fa3018" dependencies = [ "proc-macro2", "quote", @@ -4758,9 +4803,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.3", @@ -4864,9 +4909,9 @@ dependencies = [ [[package]] name = "regalloc2" -version = "0.13.3" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e249c660440317032a71ddac302f25f1d5dff387667bcc3978d1f77aa31ac34" +checksum = "952ddbfc6f9f64d006c3efd8c9851a6ba2f2b944ba94730db255d55006e0ffda" dependencies = [ "allocator-api2", "bumpalo", @@ -5000,7 +5045,7 @@ dependencies = [ "base64", "bytes", "form_urlencoded", - "indexmap 2.11.4", + "indexmap 2.14.0", "js_int", "percent-encoding", "regex", @@ -5153,9 +5198,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", @@ -5210,7 +5255,7 @@ checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" dependencies = [ "chrono", "dyn-clone", - "indexmap 2.11.4", + "indexmap 2.14.0", "ref-cast", "schemars_derive", "serde", @@ -5404,7 +5449,7 @@ version = "0.46.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b0b1e7ca40f965db239da279bf278d87b7407469b98835f27f0c8e59ed189b06" dependencies = [ - "rand 0.9.2", + "rand 0.9.4", "sentry-types", "serde", "serde_json", @@ -5457,7 +5502,7 @@ checksum = "567711f01f86a842057e1fc17779eba33a336004227e1a1e7e6cc2599e22e259" dependencies = [ "debugid", "hex", - "rand 0.9.2", + "rand 0.9.4", "serde", "serde_json", "thiserror 2.0.17", @@ -5514,7 +5559,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f" dependencies = [ "form_urlencoded", - "indexmap 2.11.4", + "indexmap 2.14.0", "itoa", "ryu", "serde_core", @@ -5526,7 +5571,7 @@ version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.14.0", "itoa", "memchr", "ryu", @@ -5589,7 +5634,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.11.4", + "indexmap 2.14.0", "schemars 0.9.0", "schemars 1.1.0", "serde", @@ -5617,7 +5662,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.14.0", "itoa", "ryu", "serde", @@ -5803,7 +5848,7 @@ dependencies = [ "futures-util", "hashbrown 0.15.5", "hashlink", - "indexmap 2.11.4", + "indexmap 2.14.0", "ipnetwork", "log", "memchr", @@ -6060,7 +6105,7 @@ dependencies = [ [[package]] name = "syn2mas" -version = "1.15.0-rc.0" +version = "1.16.0-rc.0" dependencies = [ "anyhow", "arc-swap", @@ -6396,7 +6441,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.14.0", "serde", "serde_spanned", "toml_datetime", @@ -6958,9 +7003,9 @@ dependencies = [ [[package]] name = "wasm-encoder" -version = "0.243.0" +version = "0.245.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c55db9c896d70bd9fa535ce83cd4e1f2ec3726b0edd2142079f594fc3be1cb35" +checksum = "3f9dca005e69bf015e45577e415b9af8c67e8ee3c0e38b5b0add5aa92581ed5c" dependencies = [ "leb128fmt", "wasmparser", @@ -6968,22 +7013,22 @@ dependencies = [ [[package]] name = "wasmparser" -version = "0.243.0" +version = "0.245.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6d8db401b0528ec316dfbe579e6ab4152d61739cfe076706d2009127970159d" +checksum = "4f08c9adee0428b7bddf3890fc27e015ac4b761cc608c822667102b8bfd6995e" dependencies = [ "bitflags", - "hashbrown 0.15.5", - "indexmap 2.11.4", + "hashbrown 0.16.1", + "indexmap 2.14.0", "semver", "serde", ] [[package]] name = "wasmprinter" -version = "0.243.0" +version = "0.245.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2b6035559e146114c29a909a3232928ee488d6507a1504d8934e8607b36d7b" +checksum = "5f41517a3716fbb8ccf46daa9c1325f760fcbff5168e75c7392288e410b91ac8" dependencies = [ "anyhow", "termcolor", @@ -6992,24 +7037,21 @@ dependencies = [ [[package]] name = "wasmtime" -version = "40.0.4" +version = "43.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0702b64d4c3fe43ae4ce229e06af06a27783e48c519e68586d180717cdd24314" +checksum = "ce205cd643d661b5ba5ba4717e13730262e8cdbc8f2eacbc7b906d45c1a74026" dependencies = [ - "addr2line", - "anyhow", + "addr2line 0.26.1", "async-trait", "bitflags", "bumpalo", "cc", "cfg-if", - "hashbrown 0.15.5", - "indexmap 2.11.4", "libc", "log", "mach2", "memfd", - "object", + "object 0.38.1", "once_cell", "postcard", "pulley-interpreter", @@ -7022,12 +7064,11 @@ dependencies = [ "wasmparser", "wasmtime-environ", "wasmtime-internal-component-macro", + "wasmtime-internal-core", "wasmtime-internal-cranelift", "wasmtime-internal-fiber", "wasmtime-internal-jit-debug", "wasmtime-internal-jit-icache-coherence", - "wasmtime-internal-math", - "wasmtime-internal-slab", "wasmtime-internal-unwinder", "wasmtime-internal-versioned-export-macros", "windows-sys 0.61.2", @@ -7035,32 +7076,36 @@ dependencies = [ [[package]] name = "wasmtime-environ" -version = "40.0.4" +version = "43.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ffeb777a21965a85e4b1ce7b308c63ba130df91912096b49b95523bf3bdd2c7" +checksum = "0b8b78abf3677d4a0a5db82e5015b4d085ff3a1b8b472cbb8c70d4b769f019ce" dependencies = [ "anyhow", + "cranelift-bforest", "cranelift-bitset", "cranelift-entity", - "gimli", - "indexmap 2.11.4", + "gimli 0.33.0", + "hashbrown 0.16.1", + "indexmap 2.14.0", "log", - "object", + "object 0.38.1", "postcard", "serde", "serde_derive", + "sha2", "smallvec", "target-lexicon", "wasm-encoder", "wasmparser", "wasmprinter", + "wasmtime-internal-core", ] [[package]] name = "wasmtime-internal-component-macro" -version = "40.0.4" +version = "43.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "935bb8db1e2829bf26b80ed3daeed6cf9fc804f7002c90a8d17dbd5e93c69e0b" +checksum = "0d3d6914f34be2f9d78d8ee9f422e834dfc204e71ccce697205fae95fed87892" dependencies = [ "anyhow", "proc-macro2", @@ -7073,58 +7118,69 @@ dependencies = [ [[package]] name = "wasmtime-internal-component-util" -version = "40.0.4" +version = "43.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52625d0c8fe2df1d7dd96d45d37dae8818c183c183a82b2368e3741ee5253859" +checksum = "3751b0616b914fdd87fe1bf804694a078f321b000338e6476bc48a4d6e454f21" [[package]] -name = "wasmtime-internal-cranelift" -version = "40.0.4" +name = "wasmtime-internal-core" +version = "43.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85da1ba5fee01a3ee21c4d0c8052cc9035388639fa091a969b534d4c6f8449d4" +checksum = "22632b187e1b0716f1b9ac57ad29013bed33175fcb19e10bb6896126f82fac67" dependencies = [ "anyhow", + "hashbrown 0.16.1", + "libm", + "serde", +] + +[[package]] +name = "wasmtime-internal-cranelift" +version = "43.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b3ca07b3e0bb3429674b173b5800577719d600774dd81bff58f775c0aaa64ee" +dependencies = [ "cfg-if", "cranelift-codegen", "cranelift-control", "cranelift-entity", "cranelift-frontend", "cranelift-native", - "gimli", + "gimli 0.33.0", "itertools 0.14.0", "log", - "object", + "object 0.38.1", "pulley-interpreter", "smallvec", "target-lexicon", "thiserror 2.0.17", "wasmparser", "wasmtime-environ", - "wasmtime-internal-math", + "wasmtime-internal-core", "wasmtime-internal-unwinder", "wasmtime-internal-versioned-export-macros", ] [[package]] name = "wasmtime-internal-fiber" -version = "40.0.4" +version = "43.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4c7de5a0872764c1ca640886af10a70cf7f8526386906245b43cdb345ece0e6" +checksum = "20c8b2c9704eb1f33ead025ec16038277ccb63d0a14c31e99d5b765d7c36da55" dependencies = [ - "anyhow", "cc", "cfg-if", "libc", "rustix", + "wasmtime-environ", "wasmtime-internal-versioned-export-macros", "windows-sys 0.61.2", ] [[package]] name = "wasmtime-internal-jit-debug" -version = "40.0.4" +version = "43.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "160acd973d770d62bef1b2697d7fac83a8fe63ef966215e624382b2a9532bd58" +checksum = "d950310d07391d34369f62c48336ebb14eacbd4d6f772bb5f349c24e838e0664" dependencies = [ "cc", "wasmtime-internal-versioned-export-macros", @@ -7132,49 +7188,34 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-icache-coherence" -version = "40.0.4" +version = "43.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc57f590ba7ea967ea9e8c8560175c6926e5b15d11c29bbde3ad0013a29470eb" +checksum = "3606662c156962d096be3127b8b8ae8ee2f8be3f896dad29259ff01ddb64abfd" dependencies = [ - "anyhow", "cfg-if", "libc", + "wasmtime-internal-core", "windows-sys 0.61.2", ] -[[package]] -name = "wasmtime-internal-math" -version = "40.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07612904518d47b677e8db67ca47c16d8c8cefb0099020729f886776950cb58b" -dependencies = [ - "libm", -] - -[[package]] -name = "wasmtime-internal-slab" -version = "40.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc83ff16531e1e1537e0de2b630af56a0a9e1fab864130c5b7e213da71783a0f" - [[package]] name = "wasmtime-internal-unwinder" -version = "40.0.4" +version = "43.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47371d697244785e4bbb371229f9a2daa8628d1e03368ec895cf658370e1bf38" +checksum = "75eef0747e52dc545b075f64fd0e0cc237ae738e641266b1970e07e2d744bc32" dependencies = [ - "anyhow", "cfg-if", "cranelift-codegen", "log", - "object", + "object 0.38.1", + "wasmtime-environ", ] [[package]] name = "wasmtime-internal-versioned-export-macros" -version = "40.0.4" +version = "43.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad3cd4aff8f2e7ec658c7a0424b74ad88a6940505303fb0616323592a1c400a6" +checksum = "d8b0a5dab02a8fb527f547855ecc0e05f9fdc3d5bd57b8b080349408f9a6cece" dependencies = [ "proc-macro2", "quote", @@ -7183,14 +7224,14 @@ dependencies = [ [[package]] name = "wasmtime-internal-wit-bindgen" -version = "40.0.4" +version = "43.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73a5774737acccdc70ff27bbe9e09c45b156bd7792bb83906735a572ce122247" +checksum = "7900c3e3c1d6e475bc225d73b02d6d5484815f260022e6964dca9558e50dd01a" dependencies = [ "anyhow", "bitflags", "heck 0.5.0", - "indexmap 2.11.4", + "indexmap 2.14.0", "wit-parser", ] @@ -7710,13 +7751,14 @@ checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "wit-parser" -version = "0.243.0" +version = "0.245.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df983a8608e513d8997f435bb74207bf0933d0e49ca97aa9d8a6157164b9b7fc" +checksum = "330698718e82983499419494dd1e3d7811a457a9bf9f69734e8c5f07a2547929" dependencies = [ "anyhow", + "hashbrown 0.16.1", "id-arena", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "semver", "serde", diff --git a/Cargo.toml b/Cargo.toml index f723022dd..37b542a8e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = ["crates/*"] resolver = "2" # Updated in the CI with a `sed` command -package.version = "1.15.0-rc.0" +package.version = "1.16.0-rc.0" package.license = "AGPL-3.0-only OR LicenseRef-Element-Commercial" package.authors = ["Element Backend Team"] package.edition = "2024" @@ -34,35 +34,35 @@ broken_intra_doc_links = "deny" [workspace.dependencies] # Workspace crates -mas-axum-utils = { path = "./crates/axum-utils/", version = "=1.15.0-rc.0" } -mas-cli = { path = "./crates/cli/", version = "=1.15.0-rc.0" } -mas-config = { path = "./crates/config/", version = "=1.15.0-rc.0" } -mas-context = { path = "./crates/context/", version = "=1.15.0-rc.0" } -mas-data-model = { path = "./crates/data-model/", version = "=1.15.0-rc.0" } -mas-email = { path = "./crates/email/", version = "=1.15.0-rc.0" } -mas-graphql = { path = "./crates/graphql/", version = "=1.15.0-rc.0" } -mas-handlers = { path = "./crates/handlers/", version = "=1.15.0-rc.0" } -mas-http = { path = "./crates/http/", version = "=1.15.0-rc.0" } -mas-i18n = { path = "./crates/i18n/", version = "=1.15.0-rc.0" } -mas-i18n-scan = { path = "./crates/i18n-scan/", version = "=1.15.0-rc.0" } -mas-iana = { path = "./crates/iana/", version = "=1.15.0-rc.0" } -mas-iana-codegen = { path = "./crates/iana-codegen/", version = "=1.15.0-rc.0" } -mas-jose = { path = "./crates/jose/", version = "=1.15.0-rc.0" } -mas-keystore = { path = "./crates/keystore/", version = "=1.15.0-rc.0" } -mas-listener = { path = "./crates/listener/", version = "=1.15.0-rc.0" } -mas-matrix = { path = "./crates/matrix/", version = "=1.15.0-rc.0" } -mas-matrix-synapse = { path = "./crates/matrix-synapse/", version = "=1.15.0-rc.0" } -mas-oidc-client = { path = "./crates/oidc-client/", version = "=1.15.0-rc.0" } -mas-policy = { path = "./crates/policy/", version = "=1.15.0-rc.0" } -mas-router = { path = "./crates/router/", version = "=1.15.0-rc.0" } -mas-spa = { path = "./crates/spa/", version = "=1.15.0-rc.0" } -mas-storage = { path = "./crates/storage/", version = "=1.15.0-rc.0" } -mas-storage-pg = { path = "./crates/storage-pg/", version = "=1.15.0-rc.0" } -mas-tasks = { path = "./crates/tasks/", version = "=1.15.0-rc.0" } -mas-templates = { path = "./crates/templates/", version = "=1.15.0-rc.0" } -mas-tower = { path = "./crates/tower/", version = "=1.15.0-rc.0" } -oauth2-types = { path = "./crates/oauth2-types/", version = "=1.15.0-rc.0" } -syn2mas = { path = "./crates/syn2mas", version = "=1.15.0-rc.0" } +mas-axum-utils = { path = "./crates/axum-utils/", version = "=1.16.0-rc.0" } +mas-cli = { path = "./crates/cli/", version = "=1.16.0-rc.0" } +mas-config = { path = "./crates/config/", version = "=1.16.0-rc.0" } +mas-context = { path = "./crates/context/", version = "=1.16.0-rc.0" } +mas-data-model = { path = "./crates/data-model/", version = "=1.16.0-rc.0" } +mas-email = { path = "./crates/email/", version = "=1.16.0-rc.0" } +mas-graphql = { path = "./crates/graphql/", version = "=1.16.0-rc.0" } +mas-handlers = { path = "./crates/handlers/", version = "=1.16.0-rc.0" } +mas-http = { path = "./crates/http/", version = "=1.16.0-rc.0" } +mas-i18n = { path = "./crates/i18n/", version = "=1.16.0-rc.0" } +mas-i18n-scan = { path = "./crates/i18n-scan/", version = "=1.16.0-rc.0" } +mas-iana = { path = "./crates/iana/", version = "=1.16.0-rc.0" } +mas-iana-codegen = { path = "./crates/iana-codegen/", version = "=1.16.0-rc.0" } +mas-jose = { path = "./crates/jose/", version = "=1.16.0-rc.0" } +mas-keystore = { path = "./crates/keystore/", version = "=1.16.0-rc.0" } +mas-listener = { path = "./crates/listener/", version = "=1.16.0-rc.0" } +mas-matrix = { path = "./crates/matrix/", version = "=1.16.0-rc.0" } +mas-matrix-synapse = { path = "./crates/matrix-synapse/", version = "=1.16.0-rc.0" } +mas-oidc-client = { path = "./crates/oidc-client/", version = "=1.16.0-rc.0" } +mas-policy = { path = "./crates/policy/", version = "=1.16.0-rc.0" } +mas-router = { path = "./crates/router/", version = "=1.16.0-rc.0" } +mas-spa = { path = "./crates/spa/", version = "=1.16.0-rc.0" } +mas-storage = { path = "./crates/storage/", version = "=1.16.0-rc.0" } +mas-storage-pg = { path = "./crates/storage-pg/", version = "=1.16.0-rc.0" } +mas-tasks = { path = "./crates/tasks/", version = "=1.16.0-rc.0" } +mas-templates = { path = "./crates/templates/", version = "=1.16.0-rc.0" } +mas-tower = { path = "./crates/tower/", version = "=1.16.0-rc.0" } +oauth2-types = { path = "./crates/oauth2-types/", version = "=1.16.0-rc.0" } +syn2mas = { path = "./crates/syn2mas", version = "=1.16.0-rc.0" } # OpenAPI schema generation and validation [workspace.dependencies.aide] @@ -392,7 +392,7 @@ version = "0.3.0" # Open Policy Agent support through WASM [workspace.dependencies.opa-wasm] -version = "0.1.9" +version = "0.2.0" # OpenTelemetry [workspace.dependencies.opentelemetry] diff --git a/Dockerfile b/Dockerfile index ba2ed16c8..c4626351e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,10 +5,7 @@ # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial # Please see LICENSE files in the repository root for full details. -# Builds a minimal image with the binary only. It is multi-arch capable, -# cross-building to aarch64 and x86_64. When cross-compiling, Docker sets two -# implicit BUILDARG: BUILDPLATFORM being the host platform and TARGETPLATFORM -# being the platform being built. +# Builds a minimal image with the binary only for amd64. # The Debian version and version name must be in sync ARG DEBIAN_VERSION=13 @@ -76,13 +73,12 @@ RUN --network=default \ cargo install --locked \ cargo-auditable@=${CARGO_AUDITABLE_VERSION} -# Install all cross-compilation targets -# Network access: to download the targets +# Install the cross-compilation target used for the image +# Network access: to download the target RUN --network=default \ rustup target add \ --toolchain "${RUSTC_VERSION}" \ - x86_64-unknown-linux-gnu \ - aarch64-unknown-linux-gnu + x86_64-unknown-linux-gnu RUN --network=none \ dpkg --add-architecture arm64 && \ @@ -90,21 +86,15 @@ RUN --network=none \ ARG BUILDPLATFORM -# Install cross-compilation toolchains for all supported targets +# Install the cross-compilation toolchain for amd64 # Network access: to install apt packages RUN --network=default \ apt-get update && apt-get install -y \ - $(if [ "${BUILDPLATFORM}" != "linux/arm64" ]; then echo "g++-aarch64-linux-gnu"; fi) \ - $(if [ "${BUILDPLATFORM}" != "linux/amd64" ]; then echo "g++-x86-64-linux-gnu"; fi) \ libc6-dev-amd64-cross \ - libc6-dev-arm64-cross \ g++ # Setup the cross-compilation environment ENV \ - CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \ - CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc \ - CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++ \ CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=x86_64-linux-gnu-gcc \ CC_x86_64_unknown_linux_gnu=x86_64-linux-gnu-gcc \ CXX_x86_64_unknown_linux_gnu=x86_64-linux-gnu-g++ @@ -130,9 +120,7 @@ RUN --network=default \ --no-default-features \ --features docker \ --target x86_64-unknown-linux-gnu \ - --target aarch64-unknown-linux-gnu \ - && mv "target/x86_64-unknown-linux-gnu/release/mas-cli" /usr/local/bin/mas-cli-amd64 \ - && mv "target/aarch64-unknown-linux-gnu/release/mas-cli" /usr/local/bin/mas-cli-arm64 + && mv "target/x86_64-unknown-linux-gnu/release/mas-cli" /usr/local/bin/mas-cli ####################################### ## Prepare /usr/local/share/mas-cli/ ## @@ -149,8 +137,7 @@ COPY ./translations/ /share/translations ################################## FROM gcr.io/distroless/cc-debian${DEBIAN_VERSION}:debug-nonroot AS debug -ARG TARGETARCH -COPY --from=builder /usr/local/bin/mas-cli-${TARGETARCH} /usr/local/bin/mas-cli +COPY --from=builder /usr/local/bin/mas-cli /usr/local/bin/mas-cli COPY --from=share /share /usr/local/share/mas-cli WORKDIR / @@ -161,8 +148,7 @@ ENTRYPOINT ["/usr/local/bin/mas-cli"] ################### FROM gcr.io/distroless/cc-debian${DEBIAN_VERSION}:nonroot -ARG TARGETARCH -COPY --from=builder /usr/local/bin/mas-cli-${TARGETARCH} /usr/local/bin/mas-cli +COPY --from=builder /usr/local/bin/mas-cli /usr/local/bin/mas-cli COPY --from=share /share /usr/local/share/mas-cli WORKDIR / diff --git a/README.md b/README.md index 6acc6f6eb..4044f1bd7 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,27 @@ -# Matrix Authentication Service +# Matrix Authentication Service (Letro Fork) -MAS (Matrix Authentication Service) is a user management and authentication service for [Matrix](https://matrix.org/) homeservers, written and maintained by [Element](https://element.io/). You can directly run and manage the source code in this repository, available under an AGPL license (or alternatively under a commercial license from Element). Support is not provided by Element unless you have a subscription. +> **This is a fork of [element-hq/matrix-authentication-service](https://github.com/element-hq/matrix-authentication-service)** +> Maintained by [Letro Tech GmbH](https://letro.com) -It has been created to support the migration of Matrix to a next-generation of auth APIs per [MSC3861](https://github.com/matrix-org/matrix-doc/pull/3861). +MAS (Matrix Authentication Service) is a user management and authentication service for [Matrix](https://matrix.org/) homeservers. This fork contains modifications and customizations specific to Letro's deployment needs. -See the [Documentation](https://element-hq.github.io/matrix-authentication-service/index.html) for information on installation and use. +For the official upstream documentation, see: [element-hq.github.io/matrix-authentication-service](https://element-hq.github.io/matrix-authentication-service/index.html) -You can learn more about Matrix and next-generation auth at [areweoidcyet.com](https://areweoidcyet.com/). +## 📋 About this Fork -## 🚀 Getting started +This repository is a fork of the upstream Matrix Authentication Service with customizations for Letro's infrastructure. -This component is developed and maintained by [Element](https://element.io). It gets shipped as part of the **Element Server Suite (ESS)** which provides the official means of deployment. +**Upstream Repository**: [element-hq/matrix-authentication-service](https://github.com/element-hq/matrix-authentication-service) -ESS is a Matrix distribution from Element with focus on quality and ease of use. It ships a full Matrix stack tailored to the respective use case. +## 💬 Community -There are three editions of ESS: +For general Matrix Authentication Service discussion, join [#matrix-auth:matrix.org](https://matrix.to/#/#matrix-auth:matrix.org) -- [ESS Community](https://github.com/element-hq/ess-helm) - the free Matrix - distribution from Element tailored to small-/mid-scale, non-commercial - community use cases -- [ESS Pro](https://element.io/server-suite) - the commercial Matrix - distribution from Element for professional use -- [ESS TI-M](https://element.io/server-suite/ti-messenger) - a special version - of ESS Pro focused on the requirements of TI-Messenger Pro and ePA as - specified by the German National Digital Health Agency Gematik +## 🛠️ Installation -## 💬 Community room - -Developers and users of Matrix Authentication Service can chat in the [#matrix-auth:matrix.org](https://matrix.to/#/#matrix-auth:matrix.org) room on Matrix. - -## 🛠️ Standalone installation and configuration - -The best way to get a modern Element Matrix stack is through the [Element Server Suite](https://element.io/en/server-suite), which includes MAS. - -The MAS documentation describes [how to install and configure MAS](https://element-hq.github.io/matrix-authentication-service/setup/). -We recommend using the [Docker image](https://element-hq.github.io/matrix-authentication-service/setup/installation.html#using-the-docker-image) or the [pre-built binaries](https://element-hq.github.io/matrix-authentication-service/setup/installation.html#pre-built-binaries). - -## 📖 Translations - -Matrix Authentication Service is available in multiple languages. -Anyone can contribute to translations through [Localazy](https://localazy.com/element-matrix-authentication-service/). - -## 🏗️ Contributing - -See the [contribution guidelines](https://element-hq.github.io/matrix-authentication-service/development/contributing.html) for information on how to contribute to this project. +For installation and configuration, refer to: +- **Upstream documentation**: [element-hq.github.io/matrix-authentication-service/setup](https://element-hq.github.io/matrix-authentication-service/setup/) +- Letro-specific deployment documentation (if applicable) ## ⚖️ Copyright & License @@ -57,5 +35,18 @@ This software is dual-licensed by Element Creations Ltd (Element). It can be use (1) for free under the terms of the GNU Affero General Public License (as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version); OR -(2) under the terms of a paid-for Element Commercial License agreement between you and Element (the terms of which may vary depending on what you and Element have agreed to). -Unless required by applicable law or agreed to in writing, software distributed under the Licenses is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the Licenses for the specific language governing permissions and limitations under the Licenses. +### Original Work +Copyright 2021-2024 The Matrix.org Foundation C.I.C. +Copyright 2024, 2025 New Vector Ltd. +Copyright 2025, 2026 Element Creations Ltd. + +### Modifications +Copyright 2026 Letro Tech GmbH + +This software is based on the Matrix Authentication Service and is licensed under the GNU Affero General Public License version 3 (AGPL-3.0). + +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License along with this program. If not, see \ No newline at end of file diff --git a/crates/cli/src/commands/server.rs b/crates/cli/src/commands/server.rs index b72d48111..462da7528 100644 --- a/crates/cli/src/commands/server.rs +++ b/crates/cli/src/commands/server.rs @@ -231,6 +231,7 @@ impl Options { password_manager.clone(), url_builder.clone(), limiter.clone(), + http_client.clone(), ); let state = { diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index 454276150..224166b53 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -243,6 +243,20 @@ pub fn site_config_from_config( soft_limit: c.soft_limit, hard_limit: c.hard_limit, }), + postnumber_validation: account_config.postnumber_validation.as_ref().map(|c| { + mas_data_model::PostnumberValidationConfig { + endpoint: c.endpoint.clone(), + timeout: c.timeout, + on_unavailable: match c.on_unavailable { + mas_config::PostnumberValidationFailureMode::Open => { + mas_data_model::PostnumberValidationFailureMode::Open + } + mas_config::PostnumberValidationFailureMode::Closed => { + mas_data_model::PostnumberValidationFailureMode::Closed + } + }, + } + }), }) } diff --git a/crates/config/src/sections/account.rs b/crates/config/src/sections/account.rs index 2b6538a2b..4b657b96e 100644 --- a/crates/config/src/sections/account.rs +++ b/crates/config/src/sections/account.rs @@ -4,8 +4,12 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. +use std::time::Duration; + use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use serde_with::serde_as; +use url::Url; use crate::ConfigurationSection; @@ -27,6 +31,54 @@ const fn is_default_false(value: &bool) -> bool { *value == default_false() } +fn default_postnumber_timeout() -> Duration { + Duration::from_secs(2) +} + +fn is_default_postnumber_timeout(value: &Duration) -> bool { + *value == default_postnumber_timeout() +} + +/// How MAS should behave when the postnumber resolver cannot be reached. +#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PostnumberValidationFailureMode { + /// Allow the registration or admin action to proceed without resolver + /// input. + Open, + /// Reject the registration or admin action when the resolver is + /// unavailable. + Closed, +} + +impl Default for PostnumberValidationFailureMode { + fn default() -> Self { + Self::Closed + } +} + +#[serde_as] +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +/// Configuration for the external postnumber resolver integration. +pub struct PostnumberValidationConfig { + /// Resolver endpoint used to validate user-facing postnumbers. + pub endpoint: Url, + + /// Per-request timeout when calling the resolver. + #[serde_as(as = "serde_with::DurationSeconds")] + #[schemars(with = "u64")] + #[serde( + default = "default_postnumber_timeout", + skip_serializing_if = "is_default_postnumber_timeout" + )] + pub timeout: Duration, + + /// Behavior when the resolver cannot be reached or returns an unexpected + /// error. + #[serde(default)] + pub on_unavailable: PostnumberValidationFailureMode, +} + /// Configuration section to configure features related to account management #[allow(clippy::struct_excessive_bools)] #[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] @@ -88,6 +140,11 @@ pub struct AccountConfig { /// is disabled. #[serde(default = "default_false", skip_serializing_if = "is_default_false")] pub registration_token_required: bool, + + /// Optional external resolver for validating user-facing postnumbers in + /// registration flows. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub postnumber_validation: Option, } impl Default for AccountConfig { @@ -102,6 +159,7 @@ impl Default for AccountConfig { account_deactivation_allowed: default_true(), login_with_email_allowed: default_false(), registration_token_required: default_false(), + postnumber_validation: None, } } } @@ -117,6 +175,7 @@ impl AccountConfig { && is_default_true(&self.account_deactivation_allowed) && is_default_false(&self.login_with_email_allowed) && is_default_false(&self.registration_token_required) + && self.postnumber_validation.is_none() } } diff --git a/crates/config/src/sections/mod.rs b/crates/config/src/sections/mod.rs index eb4ff2a44..e2ae0c1ec 100644 --- a/crates/config/src/sections/mod.rs +++ b/crates/config/src/sections/mod.rs @@ -28,7 +28,7 @@ mod templates; mod upstream_oauth2; pub use self::{ - account::AccountConfig, + account::{AccountConfig, PostnumberValidationConfig, PostnumberValidationFailureMode}, branding::BrandingConfig, captcha::{CaptchaConfig, CaptchaServiceKind}, clients::{ClientAuthMethodConfig, ClientConfig, ClientsConfig}, diff --git a/crates/data-model/src/lib.rs b/crates/data-model/src/lib.rs index 05b2466b9..6854157fc 100644 --- a/crates/data-model/src/lib.rs +++ b/crates/data-model/src/lib.rs @@ -40,7 +40,8 @@ pub use self::{ }, policy_data::PolicyData, site_config::{ - CaptchaConfig, CaptchaService, SessionExpirationConfig, SessionLimitConfig, SiteConfig, + CaptchaConfig, CaptchaService, PostnumberValidationConfig, PostnumberValidationFailureMode, + SessionExpirationConfig, SessionLimitConfig, SiteConfig, }, tokens::{ AccessToken, AccessTokenState, RefreshToken, RefreshTokenState, TokenFormatError, TokenType, diff --git a/crates/data-model/src/site_config.rs b/crates/data-model/src/site_config.rs index bb92dc3e4..20d4a2ba2 100644 --- a/crates/data-model/src/site_config.rs +++ b/crates/data-model/src/site_config.rs @@ -4,7 +4,7 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. -use std::num::NonZeroU64; +use std::{num::NonZeroU64, time::Duration as StdDuration}; use chrono::Duration; use serde::Serialize; @@ -111,4 +111,27 @@ pub struct SiteConfig { /// Limits on the number of application sessions that each user can have pub session_limit: Option, + + /// Postnumber validation configuration + pub postnumber_validation: Option, +} + +/// What to do when the postnumber resolver is unreachable. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PostnumberValidationFailureMode { + /// Allow registration to proceed (fail-open). + Open, + /// Block registration (fail-closed). + Closed, +} + +/// Runtime configuration for the postnumber resolver service. +#[derive(Debug, Clone)] +pub struct PostnumberValidationConfig { + /// Base URL of the resolver (e.g. `http://localhost:3001`). + pub endpoint: Url, + /// Per-request timeout. + pub timeout: StdDuration, + /// Behaviour when the resolver cannot be reached. + pub on_unavailable: PostnumberValidationFailureMode, } diff --git a/crates/handlers/src/admin/mod.rs b/crates/handlers/src/admin/mod.rs index cbb23edbf..a9168b777 100644 --- a/crates/handlers/src/admin/mod.rs +++ b/crates/handlers/src/admin/mod.rs @@ -166,6 +166,7 @@ where Arc: FromRef, SiteConfig: FromRef, AppVersion: FromRef, + reqwest::Client: FromRef, { // We *always* want to explicitly set the possible responses, beacuse the // infered ones are not necessarily correct diff --git a/crates/handlers/src/admin/v1/mod.rs b/crates/handlers/src/admin/v1/mod.rs index 98f1d10e2..769f7f73d 100644 --- a/crates/handlers/src/admin/v1/mod.rs +++ b/crates/handlers/src/admin/v1/mod.rs @@ -39,6 +39,7 @@ where SiteConfig: FromRef, AppVersion: FromRef, Arc: FromRef, + reqwest::Client: FromRef, BoxRng: FromRequestParts, CallContext: FromRequestParts, { diff --git a/crates/handlers/src/admin/v1/users/add.rs b/crates/handlers/src/admin/v1/users/add.rs index 9b3307e8d..78e3f3b2c 100644 --- a/crates/handlers/src/admin/v1/users/add.rs +++ b/crates/handlers/src/admin/v1/users/add.rs @@ -17,6 +17,7 @@ use serde::Deserialize; use tracing::warn; use crate::{ + SiteConfig, admin::{ call_context::CallContext, model::User, @@ -26,14 +27,7 @@ use crate::{ }; fn valid_username_character(c: char) -> bool { - c.is_ascii_lowercase() - || c.is_ascii_digit() - || c == '=' - || c == '_' - || c == '-' - || c == '.' - || c == '/' - || c == '+' + c.is_ascii_lowercase() || c.is_ascii_digit() } // XXX: this should be shared with the graphql handler @@ -42,12 +36,7 @@ fn username_valid(username: &str) -> bool { return false; } - // Should not start with an underscore - if username.starts_with('_') { - return false; - } - - // Should only contain valid characters + // Should only contain lowercase ASCII letters and digits if !username.chars().all(valid_username_character) { return false; } @@ -72,6 +61,12 @@ pub enum RouteError { #[error("Username is reserved by the homeserver")] UsernameReserved, + + #[error("Postnumber is reserved")] + PostnumberReserved, + + #[error("Postnumber resolver unavailable")] + PostnumberResolverUnavailable, } impl_from_error_for_route!(mas_storage::RepositoryError); @@ -81,9 +76,13 @@ impl IntoResponse for RouteError { let error = ErrorResponse::from_error(&self); let sentry_event_id = record_error!(self, Self::Internal(_) | Self::Homeserver(_)); let status = match self { - Self::Internal(_) | Self::Homeserver(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::Internal(_) | Self::Homeserver(_) | Self::PostnumberResolverUnavailable => { + StatusCode::INTERNAL_SERVER_ERROR + } Self::UsernameNotValid => StatusCode::BAD_REQUEST, - Self::UserAlreadyExists | Self::UsernameReserved => StatusCode::CONFLICT, + Self::UserAlreadyExists | Self::UsernameReserved | Self::PostnumberReserved => { + StatusCode::CONFLICT + } }; (status, sentry_event_id, Json(error)).into_response() } @@ -103,6 +102,12 @@ pub struct Request { /// tokens (like with admin access) for them #[serde(default)] skip_homeserver_check: bool, + + /// Explicitly claim a postnumber that is marked as reserved by the + /// postnumber resolver. Has no effect when postnumber validation is + /// not configured. + #[serde(default)] + claim_reserved_postnumber: bool, } pub fn doc(operation: TransformOperation) -> TransformOperation { @@ -137,6 +142,8 @@ pub async fn handler( }: CallContext, NoApi(mut rng): NoApi, State(homeserver): State>, + State(site_config): State, + State(http_client): State, Json(params): Json, ) -> Result<(StatusCode, Json>), RouteError> { if repo.user().exists(¶ms.username).await? { @@ -148,6 +155,35 @@ pub async fn handler( return Err(RouteError::UsernameNotValid); } + // Postnumber validation via external resolver + match crate::postnumber::check( + &http_client, + site_config.postnumber_validation.as_ref(), + ¶ms.username, + ) + .await + { + Ok(Some(crate::postnumber::PostnumberOutcome::Allowed) | None) => { + // Valid – continue + } + Ok(Some(crate::postnumber::PostnumberOutcome::Reserved)) => { + if !params.claim_reserved_postnumber { + return Err(RouteError::PostnumberReserved); + } + tracing::info!( + username = %params.username, + "Admin claiming reserved postnumber" + ); + } + Err(e) => { + tracing::error!( + error = &e as &dyn std::error::Error, + "postnumber resolver error during admin user creation" + ); + return Err(RouteError::PostnumberResolverUnavailable); + } + } + // Ask the homeserver if the username is available let homeserver_available = homeserver .is_localpart_available(¶ms.username) @@ -323,4 +359,104 @@ mod tests { assert_eq!(user.username, "bob"); } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_add_user_postnumber_reserved(pool: PgPool) { + setup(); + + let mock_server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("POST")) + .and(wiremock::matchers::path("/v1/check")) + .respond_with( + wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "normalized": "admin", + "status": "RESERVED", + "match_kind": "exact", + "matched_terms": ["admin"], + "matched_categories": [], + "pattern_family": null + })), + ) + .mount(&mock_server) + .await; + + let mut site_config = crate::test_utils::test_site_config(); + site_config.postnumber_validation = Some(mas_data_model::PostnumberValidationConfig { + endpoint: url::Url::parse(&format!("{}/", mock_server.uri())).unwrap(), + timeout: std::time::Duration::from_secs(5), + on_unavailable: mas_data_model::PostnumberValidationFailureMode::Closed, + }); + + let mut state = TestState::from_pool_with_site_config(pool, site_config) + .await + .unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + // Reserved postnumber without claim flag → rejected + let request = Request::post("/api/admin/v1/users") + .bearer(&token) + .json(serde_json::json!({ + "username": "admin", + })); + let response = state.request(request).await; + response.assert_status(StatusCode::CONFLICT); + let body: serde_json::Value = response.json(); + assert_eq!(body["errors"][0]["title"], "Postnumber is reserved"); + + // Reserved postnumber with claim flag → allowed + let request = Request::post("/api/admin/v1/users") + .bearer(&token) + .json(serde_json::json!({ + "username": "admin", + "claim_reserved_postnumber": true, + })); + let response = state.request(request).await; + response.assert_status(StatusCode::CREATED); + let body: serde_json::Value = response.json(); + assert_eq!(body["data"]["attributes"]["username"], "admin"); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_add_user_postnumber_allowed(pool: PgPool) { + setup(); + + let mock_server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("POST")) + .and(wiremock::matchers::path("/v1/check")) + .respond_with( + wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "normalized": "alice", + "status": "ALLOW", + "match_kind": "allow", + "matched_terms": [], + "matched_categories": [], + "pattern_family": null + })), + ) + .mount(&mock_server) + .await; + + let mut site_config = crate::test_utils::test_site_config(); + site_config.postnumber_validation = Some(mas_data_model::PostnumberValidationConfig { + endpoint: url::Url::parse(&format!("{}/", mock_server.uri())).unwrap(), + timeout: std::time::Duration::from_secs(5), + on_unavailable: mas_data_model::PostnumberValidationFailureMode::Closed, + }); + + let mut state = TestState::from_pool_with_site_config(pool, site_config) + .await + .unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + // Allowed postnumber → success + let request = Request::post("/api/admin/v1/users") + .bearer(&token) + .json(serde_json::json!({ + "username": "alice", + })); + let response = state.request(request).await; + response.assert_status(StatusCode::CREATED); + let body: serde_json::Value = response.json(); + assert_eq!(body["data"]["attributes"]["username"], "alice"); + } } diff --git a/crates/handlers/src/bin/api-schema.rs b/crates/handlers/src/bin/api-schema.rs index 1b73c05c3..da300cf11 100644 --- a/crates/handlers/src/bin/api-schema.rs +++ b/crates/handlers/src/bin/api-schema.rs @@ -61,6 +61,7 @@ impl_from_ref!(mas_handlers::passwords::PasswordManager); impl_from_ref!(Arc); impl_from_ref!(mas_data_model::SiteConfig); impl_from_ref!(mas_data_model::AppVersion); +impl_from_ref!(reqwest::Client); fn main() -> Result<(), Box> { let (mut api, _) = mas_handlers::admin_api_router::(); diff --git a/crates/handlers/src/graphql/mod.rs b/crates/handlers/src/graphql/mod.rs index 7ccf9e51b..e70f72f1d 100644 --- a/crates/handlers/src/graphql/mod.rs +++ b/crates/handlers/src/graphql/mod.rs @@ -76,6 +76,7 @@ struct GraphQLState { password_manager: PasswordManager, url_builder: UrlBuilder, limiter: Limiter, + http_client: reqwest::Client, } #[async_trait::async_trait] @@ -108,6 +109,10 @@ impl state::State for GraphQLState { &self.limiter } + fn http_client(&self) -> &reqwest::Client { + &self.http_client + } + fn clock(&self) -> BoxClock { let clock = SystemClock::default(); Box::new(clock) @@ -131,6 +136,7 @@ pub fn schema( password_manager: PasswordManager, url_builder: UrlBuilder, limiter: Limiter, + http_client: reqwest::Client, ) -> Schema { let state = GraphQLState { repository_factory, @@ -140,6 +146,7 @@ pub fn schema( password_manager, url_builder, limiter, + http_client, }; let state: BoxState = Box::new(state); diff --git a/crates/handlers/src/graphql/mutations/user.rs b/crates/handlers/src/graphql/mutations/user.rs index 376652a46..e4c9d8bf8 100644 --- a/crates/handlers/src/graphql/mutations/user.rs +++ b/crates/handlers/src/graphql/mutations/user.rs @@ -440,14 +440,7 @@ impl DeactivateUserPayload { } fn valid_username_character(c: char) -> bool { - c.is_ascii_lowercase() - || c.is_ascii_digit() - || c == '=' - || c == '_' - || c == '-' - || c == '.' - || c == '/' - || c == '+' + c.is_ascii_lowercase() || c.is_ascii_digit() } // XXX: this should probably be moved somewhere else @@ -456,12 +449,7 @@ fn username_valid(username: &str) -> bool { return false; } - // Should not start with an underscore - if username.starts_with('_') { - return false; - } - - // Should only contain valid characters + // Should only contain lowercase ASCII letters and digits if !username.chars().all(valid_username_character) { return false; } @@ -497,6 +485,29 @@ impl UserMutations { return Ok(AddUserPayload::Invalid); } + // Postnumber (username) validation via external resolver + match crate::postnumber::check( + state.http_client(), + state.site_config().postnumber_validation.as_ref(), + &input.username, + ) + .await + { + Ok(Some(crate::postnumber::PostnumberOutcome::Allowed) | None) => { + // Valid – continue + } + Ok(Some(crate::postnumber::PostnumberOutcome::Reserved)) => { + return Ok(AddUserPayload::Reserved); + } + Err(e) => { + tracing::error!( + error = &e as &dyn std::error::Error, + "postnumber resolver error during addUser" + ); + return Err(async_graphql::Error::new("Postnumber resolver unavailable")); + } + } + // Ask the homeserver if the username is available let homeserver_available = state .homeserver_connection() diff --git a/crates/handlers/src/graphql/state.rs b/crates/handlers/src/graphql/state.rs index 7faf76334..a270d7c0f 100644 --- a/crates/handlers/src/graphql/state.rs +++ b/crates/handlers/src/graphql/state.rs @@ -26,6 +26,7 @@ pub trait State { fn site_config(&self) -> &SiteConfig; fn url_builder(&self) -> &UrlBuilder; fn limiter(&self) -> &Limiter; + fn http_client(&self) -> &reqwest::Client; } pub type BoxState = Box; diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 0cb450f53..ce2be4e95 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -64,6 +64,7 @@ mod activity_tracker; mod captcha; #[cfg(test)] mod cleanup_tests; +pub(crate) mod postnumber; mod preferred_language; mod rate_limit; mod session; @@ -395,7 +396,7 @@ where .route(mas_router::Logout::route(), post(self::views::logout::post)) .route( mas_router::Register::route(), - get(self::views::register::get), + get(self::views::register::get).post(self::views::register::post), ) .route( mas_router::PasswordRegister::route(), diff --git a/crates/handlers/src/postnumber.rs b/crates/handlers/src/postnumber.rs new file mode 100644 index 000000000..9d8029b02 --- /dev/null +++ b/crates/handlers/src/postnumber.rs @@ -0,0 +1,564 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +//! Client for the external postnumber-resolver service. +//! +//! The resolver validates user-facing "postnumbers" (usernames) during +//! registration and returns whether a value is *allowed* or *reserved*. +//! +//! MAS usernames are restricted to `[a-z0-9]` which is the same alphabet +//! as the resolver. The `normalize_for_resolver` helper lowercases the +//! input before sending it to the resolver. + +use std::sync::LazyLock; + +use mas_data_model::{PostnumberValidationConfig, PostnumberValidationFailureMode}; +use mas_http::RequestBuilderExt as _; +use opentelemetry::{Key, KeyValue, metrics::Counter}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use url::Url; + +use crate::METER; + +// --------------------------------------------------------------------------- +// Metrics +// --------------------------------------------------------------------------- + +static OUTCOME: Key = Key::from_static_str("postnumber.outcome"); + +static CHECK_COUNTER: LazyLock> = LazyLock::new(|| { + METER + .u64_counter("mas.postnumber.check") + .with_description("Number of postnumber resolver checks") + .with_unit("{check}") + .build() +}); + +// --------------------------------------------------------------------------- +// Wire types – mirror of the resolver's HTTP contract +// --------------------------------------------------------------------------- + +/// Request payload sent to `POST /v1/check`. +#[derive(Debug, Serialize)] +struct CheckRequest<'a> { + value: &'a str, +} + +/// Status returned by the resolver on a successful check. +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum CheckStatus { + Allow, + Reserved, +} + +/// Full success response from `POST /v1/check`. +#[derive(Debug, Deserialize)] +pub struct CheckResponse { + pub status: CheckStatus, + #[allow(dead_code)] + pub match_kind: serde_json::Value, // we don't need the enum on this side + #[allow(dead_code)] + pub matched_terms: Vec, + #[allow(dead_code)] + pub matched_categories: Vec, + #[allow(dead_code)] + pub pattern_family: Option, +} + +// --------------------------------------------------------------------------- +// Domain result +// --------------------------------------------------------------------------- + +/// Maximum length the resolver accepts. Values longer than this after +/// stripping non-alphanumeric characters are not postnumbers — skip the +/// check and let MAS's own rules decide. +const MAX_RESOLVER_INPUT_LENGTH: usize = 20; + +/// The outcome of a postnumber check that callers should act on. +#[derive(Debug)] +pub enum PostnumberOutcome { + /// The postnumber is valid and available for any user. + Allowed, + /// The postnumber is valid but reserved by policy (admin-only). + Reserved, +} + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +#[derive(Debug, Error)] +pub enum Error { + /// HTTP-level failure (connection refused, timeout, TLS error, …). + #[error("postnumber resolver request failed")] + Request(#[from] reqwest::Error), + + /// The resolver returned an unexpected status code or unparseable body. + #[error("postnumber resolver returned an unexpected response (status {status})")] + UnexpectedResponse { status: reqwest::StatusCode }, + + /// The resolver is temporarily unavailable (rate-limited, service down, …) + /// and the configured failure-mode decides what happens. + #[error("postnumber resolver is unavailable")] + Unavailable, +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Check a postnumber against the resolver service. +/// +/// Returns `Ok(None)` when postnumber validation is not configured (i.e. the +/// feature is disabled). +/// +/// Returns `Ok(Some(outcome))` with the resolver's decision, or `Err` when the +/// resolver is unreachable and the failure mode is `Closed`. +#[tracing::instrument( + name = "postnumber.check", + skip_all, + fields(postnumber.value = %value), +)] +pub async fn check( + http_client: &reqwest::Client, + config: Option<&PostnumberValidationConfig>, + value: &str, +) -> Result, Error> { + let Some(config) = config else { + return Ok(None); + }; + + // Lowercase the input before sending to the resolver. If the + // result is empty or exceeds the resolver's max length, skip. + let sanitized = normalize_for_resolver(value); + if sanitized.is_empty() || sanitized.len() > MAX_RESOLVER_INPUT_LENGTH { + tracing::debug!( + original = %value, + sanitized = %sanitized, + "skipping postnumber check – value not in resolver's domain", + ); + return Ok(None); + } + + match do_check(http_client, &config.endpoint, config.timeout, &sanitized).await { + Ok(outcome) => { + let label = match &outcome { + PostnumberOutcome::Allowed => "allowed", + PostnumberOutcome::Reserved => "reserved", + }; + CHECK_COUNTER.add(1, &[KeyValue::new(OUTCOME.clone(), label)]); + Ok(Some(outcome)) + } + Err(Error::Unavailable) => { + // Single retry with backoff for transient failures before + // triggering the fail-open/fail-closed policy. + tracing::info!("postnumber resolver unavailable – retrying once after 500ms"); + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + + match do_check(http_client, &config.endpoint, config.timeout, &sanitized).await { + Ok(outcome) => { + let label = match &outcome { + PostnumberOutcome::Allowed => "allowed", + PostnumberOutcome::Reserved => "reserved", + }; + CHECK_COUNTER.add(1, &[KeyValue::new(OUTCOME.clone(), label)]); + Ok(Some(outcome)) + } + Err(Error::Unavailable) => match config.on_unavailable { + PostnumberValidationFailureMode::Open => { + tracing::warn!( + "postnumber resolver unavailable after retry – failing open" + ); + CHECK_COUNTER.add(1, &[KeyValue::new(OUTCOME.clone(), "unavailable_open")]); + Ok(None) + } + PostnumberValidationFailureMode::Closed => { + tracing::warn!( + "postnumber resolver unavailable after retry – failing closed" + ); + CHECK_COUNTER + .add(1, &[KeyValue::new(OUTCOME.clone(), "unavailable_closed")]); + Err(Error::Unavailable) + } + }, + Err(e) => { + CHECK_COUNTER.add(1, &[KeyValue::new(OUTCOME.clone(), "error")]); + Err(e) + } + } + } + Err(e) => { + CHECK_COUNTER.add(1, &[KeyValue::new(OUTCOME.clone(), "error")]); + Err(e) + } + } +} + +// --------------------------------------------------------------------------- +// Internal request logic +// --------------------------------------------------------------------------- + +/// Lowercases the input for the resolver. Since MAS now only allows +/// `[a-z0-9]` usernames, this is effectively just a `.to_ascii_lowercase()` +/// pass — no characters need to be stripped. +fn normalize_for_resolver(value: &str) -> String { + value + .chars() + .map(|c| c.to_ascii_lowercase()) + .collect::() +} + +async fn do_check( + http_client: &reqwest::Client, + endpoint: &Url, + timeout: std::time::Duration, + value: &str, +) -> Result { + // Ensure the endpoint has a trailing slash so that `Url::join` + // appends "v1/check" as a sub-path rather than replacing the last + // segment. For example, without this normalization: + // "http://host:3001/api".join("v1/check") → "http://host:3001/v1/check" + // With the trailing slash: + // "http://host:3001/api/".join("v1/check") → "http://host:3001/api/v1/check" + let mut base = endpoint.clone(); + if !base.path().ends_with('/') { + base.set_path(&format!("{}/", base.path())); + } + + let url = base + .join("v1/check") + .expect("endpoint URL should be joinable with v1/check"); + + let response = http_client + .post(url) + .timeout(timeout) + .json(&CheckRequest { value }) + .send_traced() + .await + .map_err(|e| { + if e.is_timeout() || e.is_connect() { + tracing::warn!(error = &e as &dyn std::error::Error, "resolver unreachable"); + Error::Unavailable + } else { + Error::Request(e) + } + })?; + + let status = response.status(); + + if status.is_success() { + let body: CheckResponse = response + .json() + .await + .map_err(|_| Error::UnexpectedResponse { status })?; + + match body.status { + CheckStatus::Allow => Ok(PostnumberOutcome::Allowed), + CheckStatus::Reserved => Ok(PostnumberOutcome::Reserved), + } + } else if status == reqwest::StatusCode::BAD_REQUEST + || status == reqwest::StatusCode::UNPROCESSABLE_ENTITY + { + // The resolver rejected the format. Since MAS is the authority on + // username format (via OPA policy / `username_valid()`), we treat + // this as "not a postnumber" and allow it through. + tracing::debug!( + %status, + %value, + "resolver returned client error – treating as allowed", + ); + Ok(PostnumberOutcome::Allowed) + } else if status == reqwest::StatusCode::TOO_MANY_REQUESTS + || status == reqwest::StatusCode::SERVICE_UNAVAILABLE + || status.is_server_error() + { + Err(Error::Unavailable) + } else { + Err(Error::UnexpectedResponse { status }) + } +} + +#[cfg(test)] +mod tests { + use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{body_json, method, path}, + }; + + use super::*; + + fn test_http_client() -> reqwest::Client { + reqwest::Client::builder() + .no_proxy() + .build() + .expect("failed to create test HTTP client") + } + + fn test_config(server: &MockServer) -> PostnumberValidationConfig { + PostnumberValidationConfig { + endpoint: Url::parse(&format!("{}/", server.uri())).unwrap(), + timeout: std::time::Duration::from_secs(5), + on_unavailable: PostnumberValidationFailureMode::Closed, + } + } + + #[tokio::test] + async fn test_allowed() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/check")) + .and(body_json(serde_json::json!({ "value": "alice" }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "normalized": "alice", + "status": "ALLOW", + "match_kind": "allow", + "matched_terms": [], + "matched_categories": [], + "pattern_family": null + }))) + .expect(1) + .mount(&server) + .await; + + let client = test_http_client(); + let config = test_config(&server); + + let result = check(&client, Some(&config), "alice").await.unwrap(); + match result { + Some(PostnumberOutcome::Allowed) => {} + other => panic!("expected Allowed, got {other:?}"), + } + } + + #[tokio::test] + async fn test_reserved() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/check")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "normalized": "admin", + "status": "RESERVED", + "match_kind": "exact", + "matched_terms": ["admin"], + "matched_categories": [], + "pattern_family": null + }))) + .mount(&server) + .await; + + let client = test_http_client(); + let config = test_config(&server); + + let result = check(&client, Some(&config), "admin").await.unwrap(); + match result { + Some(PostnumberOutcome::Reserved) => {} + other => panic!("expected Reserved, got {other:?}"), + } + } + + #[tokio::test] + async fn test_resolver_400_treated_as_allowed() { + // When the resolver rejects the format, MAS treats it as + // "not a postnumber" and allows it — MAS's own rules handle format. + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/check")) + .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({ + "error": "invalid_postnumber_format", + "message": "postnumber must start with a letter" + }))) + .mount(&server) + .await; + + let client = test_http_client(); + let config = test_config(&server); + + let result = check(&client, Some(&config), "abc123").await.unwrap(); + match result { + Some(PostnumberOutcome::Allowed) => { /* correct */ } + other => panic!("expected Allowed, got {other:?}"), + } + } + + #[tokio::test] + async fn test_uppercase_lowered() { + // normalize_for_resolver lowercases the input before sending. + // Verify the resolver receives "alice", not "Alice". + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/check")) + .and(body_json(serde_json::json!({ "value": "alice" }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "normalized": "alice", + "status": "ALLOW", + "match_kind": "allow", + "matched_terms": [], + "matched_categories": [], + "pattern_family": null + }))) + .expect(1) + .mount(&server) + .await; + + let client = test_http_client(); + let config = test_config(&server); + + // "Alice" → "alice" sent to resolver + let result = check(&client, Some(&config), "Alice").await.unwrap(); + match result { + Some(PostnumberOutcome::Allowed) => {} + other => panic!("expected Allowed, got {other:?}"), + } + } + + #[tokio::test] + async fn test_long_username_skipped() { + // Usernames > 20 alphanumeric chars are outside the resolver's + // domain and should be skipped entirely (returns None). + let server = MockServer::start().await; + // Don't mount any mock — if the resolver is called, the test fails. + + let client = test_http_client(); + let config = test_config(&server); + + let long_name = "a".repeat(21); + let result = check(&client, Some(&config), &long_name).await.unwrap(); + assert!(result.is_none(), "expected None for long username"); + } + + #[tokio::test] + async fn test_unavailable_closed() { + // No server running → connection refused + let config = PostnumberValidationConfig { + endpoint: Url::parse("http://127.0.0.1:1/").unwrap(), + timeout: std::time::Duration::from_millis(200), + on_unavailable: PostnumberValidationFailureMode::Closed, + }; + + let client = test_http_client(); + let result = check(&client, Some(&config), "test").await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_unavailable_open() { + let config = PostnumberValidationConfig { + endpoint: Url::parse("http://127.0.0.1:1/").unwrap(), + timeout: std::time::Duration::from_millis(200), + on_unavailable: PostnumberValidationFailureMode::Open, + }; + + let client = test_http_client(); + let result = check(&client, Some(&config), "test").await.unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_no_config() { + let client = test_http_client(); + let result = check(&client, None, "anything").await.unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_rate_limited() { + let server = MockServer::start().await; + + // With retry, the resolver will be called twice before giving up. + Mock::given(method("POST")) + .and(path("/v1/check")) + .respond_with(ResponseTemplate::new(429).set_body_json(serde_json::json!({ + "error": "rate_limited", + "message": "Too many requests" + }))) + .expect(2) + .mount(&server) + .await; + + let client = test_http_client(); + let config = test_config(&server); + + let result = check(&client, Some(&config), "test").await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_retry_succeeds_on_second_attempt() { + // First call returns 503, second call succeeds. + let server = MockServer::start().await; + + // Mount a 503 response that only fires once… + Mock::given(method("POST")) + .and(path("/v1/check")) + .respond_with(ResponseTemplate::new(503)) + .up_to_n_times(1) + .mount(&server) + .await; + + // …then an ALLOW response for subsequent calls. + Mock::given(method("POST")) + .and(path("/v1/check")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "normalized": "bob", + "status": "ALLOW", + "match_kind": "allow", + "matched_terms": [], + "matched_categories": [], + "pattern_family": null + }))) + .mount(&server) + .await; + + let client = test_http_client(); + let config = test_config(&server); + + let result = check(&client, Some(&config), "bob").await.unwrap(); + match result { + Some(PostnumberOutcome::Allowed) => {} + other => panic!("expected Allowed after retry, got {other:?}"), + } + } + + #[test] + fn test_endpoint_url_normalization() { + // Verify that the URL join works correctly with and without trailing slash. + let with_slash = Url::parse("http://localhost:3001/").unwrap(); + let without_slash = Url::parse("http://localhost:3001").unwrap(); + let with_path = Url::parse("http://localhost:3001/api").unwrap(); + let with_path_slash = Url::parse("http://localhost:3001/api/").unwrap(); + + // Helper that mirrors the normalization in do_check + let normalize_and_join = |endpoint: &Url| -> String { + let mut base = endpoint.clone(); + if !base.path().ends_with('/') { + base.set_path(&format!("{}/", base.path())); + } + base.join("v1/check").unwrap().to_string() + }; + + assert_eq!( + normalize_and_join(&with_slash), + "http://localhost:3001/v1/check" + ); + assert_eq!( + normalize_and_join(&without_slash), + "http://localhost:3001/v1/check" + ); + assert_eq!( + normalize_and_join(&with_path), + "http://localhost:3001/api/v1/check" + ); + assert_eq!( + normalize_and_join(&with_path_slash), + "http://localhost:3001/api/v1/check" + ); + } +} diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs index 521a4848d..4fd4dbb77 100644 --- a/crates/handlers/src/test_utils.rs +++ b/crates/handlers/src/test_utils.rs @@ -68,6 +68,13 @@ pub(crate) fn setup() { .try_init(); } +pub(crate) fn test_http_client() -> reqwest::Client { + reqwest::Client::builder() + .no_proxy() + .build() + .expect("failed to create test HTTP client") +} + pub(crate) async fn policy_factory( server_name: &str, data: serde_json::Value, @@ -150,6 +157,7 @@ pub fn test_site_config() -> SiteConfig { login_with_email_allowed: true, plan_management_iframe_uri: None, session_limit: None, + postnumber_validation: None, } } @@ -183,7 +191,7 @@ impl TestState { ) .await?; - let http_client = mas_http::reqwest_client(); + let http_client = test_http_client(); // TODO: add more test keys to the store let rsa = @@ -228,6 +236,7 @@ impl TestState { password_manager: password_manager.clone(), url_builder: url_builder.clone(), limiter: limiter.clone(), + http_client: http_client.clone(), }; let state: crate::graphql::BoxState = Box::new(graphql_state); @@ -441,6 +450,7 @@ struct TestGraphQLState { password_manager: PasswordManager, url_builder: UrlBuilder, limiter: Limiter, + http_client: reqwest::Client, } #[async_trait::async_trait] @@ -482,6 +492,10 @@ impl graphql::State for TestGraphQLState { let rng = ChaChaRng::from_rng(&mut *parent_rng).expect("Failed to seed RNG"); Box::new(rng) } + + fn http_client(&self) -> &reqwest::Client { + &self.http_client + } } impl FromRef for PgPool { diff --git a/crates/handlers/src/upstream_oauth2/cache.rs b/crates/handlers/src/upstream_oauth2/cache.rs index 0857bc2c1..f5f6ff987 100644 --- a/crates/handlers/src/upstream_oauth2/cache.rs +++ b/crates/handlers/src/upstream_oauth2/cache.rs @@ -314,13 +314,13 @@ mod tests { }; use super::*; - use crate::test_utils::setup; + use crate::test_utils::{setup, test_http_client}; #[tokio::test] async fn test_metadata_cache() { setup(); let mock_server = MockServer::start().await; - let http_client = mas_http::reqwest_client(); + let http_client = test_http_client(); let cache = MetadataCache::new(); @@ -384,7 +384,7 @@ mod tests { setup(); let mock_server = MockServer::start().await; - let http_client = mas_http::reqwest_client(); + let http_client = test_http_client(); let expected_calls = 2; let mut calls = 0; diff --git a/crates/handlers/src/upstream_oauth2/link.rs b/crates/handlers/src/upstream_oauth2/link.rs index e081b0f79..86bc0e419 100644 --- a/crates/handlers/src/upstream_oauth2/link.rs +++ b/crates/handlers/src/upstream_oauth2/link.rs @@ -235,6 +235,8 @@ pub(crate) async fn get( State(templates): State, State(url_builder): State, State(homeserver): State>, + State(site_config): State, + State(http_client): State, cookie_jar: CookieJar, activity_tracker: BoundActivityTracker, user_agent: Option>, @@ -730,6 +732,48 @@ pub(crate) async fn get( )); } + // Postnumber validation via external resolver + match crate::postnumber::check( + &http_client, + site_config.postnumber_validation.as_ref(), + &localpart, + ) + .await + { + Ok(Some(crate::postnumber::PostnumberOutcome::Allowed) | None) => { + // Valid – continue + } + Ok(Some(crate::postnumber::PostnumberOutcome::Reserved)) => { + if !forced_or_required { + tracing::warn!( + upstream_oauth_provider.id = %provider.id, + upstream_oauth_link.id = %link.id, + "Upstream provider returned a localpart {localpart:?} which is reserved. As the username is just a suggestion, it was ignored." + ); + break 'localpart None; + } + + let ctx = ErrorContext::new() + .with_code("postnumber-reserved") + .with_description(format!( + r"Localpart {localpart:?} is reserved and cannot be used for self-service registration" + )) + .with_language(&locale); + + return Ok(( + cookie_jar, + Html(templates.render_error(&ctx)?).into_response(), + )); + } + Err(e) => { + tracing::error!( + error = &e as &dyn std::error::Error, + "postnumber resolver error during upstream registration" + ); + return Err(RouteError::Internal(Box::new(e))); + } + } + // Now let's check if the localpart is allowed by the homeserver. It's possible // that it's plain invalid (although that should have been caught by the // policy), or just reserved by an application service @@ -857,6 +901,7 @@ pub(crate) async fn post( State(homeserver): State>, State(url_builder): State, State(site_config): State, + State(http_client): State, Path(link_id): Path, Form(form): Form>, ) -> Result { @@ -1128,6 +1173,39 @@ pub(crate) async fn post( ); } + // Postnumber (username) validation via external resolver. + // Run after OPA policy so we don't waste HTTP calls on usernames + // that OPA would already reject (all-numeric, banned, etc.). + if !username.is_empty() && form_state.is_valid() { + match crate::postnumber::check( + &http_client, + site_config.postnumber_validation.as_ref(), + &username, + ) + .await + { + Ok(Some(crate::postnumber::PostnumberOutcome::Allowed) | None) => { + // Valid – continue + } + Ok(Some(crate::postnumber::PostnumberOutcome::Reserved)) => { + form_state.add_error_on_field( + mas_templates::UpstreamRegisterFormField::Username, + FieldError::Policy { + code: Some("postnumber-reserved"), + message: "This username is reserved".to_owned(), + }, + ); + } + Err(e) => { + tracing::error!( + error = &e as &dyn std::error::Error, + "postnumber resolver error during upstream registration" + ); + form_state.add_error_on_form(FormError::Internal); + } + } + } + form_state }; diff --git a/crates/handlers/src/views/register/mod.rs b/crates/handlers/src/views/register/mod.rs index 6a51852ae..a04bcc3ca 100644 --- a/crates/handlers/src/views/register/mod.rs +++ b/crates/handlers/src/views/register/mod.rs @@ -3,16 +3,28 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. +use std::{net::IpAddr, sync::Arc}; + use axum::{ - extract::State, + extract::{Form, State}, response::{Html, IntoResponse, Response}, }; use axum_extra::extract::Query; -use mas_axum_utils::{InternalError, SessionInfoExt, cookies::CookieJar, csrf::CsrfExt as _}; +use mas_axum_utils::{ + InternalError, SessionInfoExt, + cookies::CookieJar, + csrf::{CsrfExt as _, ProtectedForm}, +}; use mas_data_model::{BoxClock, BoxRng, SiteConfig}; +use mas_matrix::HomeserverConnection; +use mas_policy::Policy; use mas_router::{PasswordRegister, UpstreamOAuth2Authorize, UrlBuilder}; -use mas_storage::BoxRepository; -use mas_templates::{RegisterContext, TemplateContext, Templates}; +use mas_storage::{BoxRepository, RepositoryAccess}; +use mas_templates::{ + FieldError, FormError, FormState, RegisterContext, RegisterFormField, TemplateContext, + Templates, ToFormState, +}; +use serde::{Deserialize, Serialize}; use super::shared::OptionalPostAuthAction; use crate::{BoundActivityTracker, PreferredLanguage}; @@ -23,6 +35,128 @@ pub(crate) mod steps; pub use self::cookie::UserRegistrationSessions as UserRegistrationSessionsCookie; +#[derive(Debug, Deserialize, Serialize)] +pub(crate) struct UsernameForm { + username: String, + #[serde(flatten)] + action: OptionalPostAuthAction, +} + +impl ToFormState for UsernameForm { + type Field = RegisterFormField; +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn validate_registration_username( + state: &mut FormState, + username: &str, + user_agent: Option, + ip_address: Option, + repo: &mut BoxRepository, + policy: &mut Policy, + homeserver: &Arc, + http_client: &reqwest::Client, + site_config: &SiteConfig, +) -> Result<(), InternalError> { + let mut homeserver_denied_username = false; + if username.is_empty() { + state.add_error_on_field(RegisterFormField::Username, FieldError::Required); + } else if repo.user().exists(username).await? { + state.add_error_on_field(RegisterFormField::Username, FieldError::Exists); + } else if !homeserver + .is_localpart_available(username) + .await + .map_err(InternalError::from_anyhow)? + { + tracing::warn!(username, "Homeserver denied username provided by user"); + homeserver_denied_username = true; + } + + let res = policy + .evaluate_register(mas_policy::RegisterInput { + registration_method: mas_policy::RegistrationMethod::Password, + username, + email: None, + requester: mas_policy::Requester { + ip_address, + user_agent, + }, + }) + .await?; + + for violation in res.violations { + match violation.field.as_deref() { + Some("username") => { + homeserver_denied_username = false; + state.add_error_on_field( + RegisterFormField::Username, + FieldError::Policy { + code: violation.variant.map(|c| c.as_str()), + message: violation.msg, + }, + ); + } + _ => state.add_error_on_form(FormError::Policy { + code: violation.variant.map(|c| c.as_str()), + message: violation.msg, + }), + } + } + + if homeserver_denied_username { + state.add_error_on_field(RegisterFormField::Username, FieldError::Exists); + } + + if !username.is_empty() && state.is_valid() { + match crate::postnumber::check( + http_client, + site_config.postnumber_validation.as_ref(), + username, + ) + .await + { + Ok(Some(crate::postnumber::PostnumberOutcome::Allowed) | None) => {} + Ok(Some(crate::postnumber::PostnumberOutcome::Reserved)) => { + state.add_error_on_field( + RegisterFormField::Username, + FieldError::Policy { + code: Some("postnumber-reserved"), + message: "This username is reserved".to_owned(), + }, + ); + } + Err(e) => { + tracing::error!( + error = &e as &dyn std::error::Error, + "postnumber resolver error" + ); + state.add_error_on_form(FormError::Internal); + } + } + } + + Ok(()) +} + +async fn render( + locale: mas_i18n::DataLocale, + mut ctx: RegisterContext, + action: OptionalPostAuthAction, + csrf_token: impl ToString, + repo: &mut BoxRepository, + templates: &Templates, +) -> Result { + let post_action = action + .load_context(repo) + .await + .map_err(InternalError::from_anyhow)?; + if let Some(action) = post_action { + ctx = ctx.with_post_action(action); + } + + Ok(templates.render_register(&ctx.with_csrf(csrf_token).with_language(locale))?) +} + #[tracing::instrument(name = "handlers.views.register.get", skip_all)] pub(crate) async fn get( mut rng: BoxRng, @@ -78,18 +212,80 @@ pub(crate) async fn get( return Ok((cookie_jar, url_builder.redirect(&destination)).into_response()); } - let mut ctx = RegisterContext::new(providers); - let post_action = query - .load_context(&mut repo) - .await - .map_err(InternalError::from_anyhow)?; - if let Some(action) = post_action { - ctx = ctx.with_post_action(action); + let content = render( + locale, + RegisterContext::new(providers), + query, + csrf_token.form_value(), + &mut repo, + &templates, + ) + .await?; + + Ok((cookie_jar, Html(content)).into_response()) +} + +#[tracing::instrument(name = "handlers.views.register.post", skip_all)] +pub(crate) async fn post( + mut rng: BoxRng, + clock: BoxClock, + PreferredLanguage(locale): PreferredLanguage, + State(templates): State, + State(url_builder): State, + State(site_config): State, + State(homeserver): State>, + State(http_client): State, + mut policy: Policy, + mut repo: BoxRepository, + activity_tracker: BoundActivityTracker, + user_agent: Option>, + cookie_jar: CookieJar, + Form(form): Form>, +) -> Result { + let user_agent = user_agent.map(|ua| ua.as_str().to_owned()); + + let form = cookie_jar.verify_form(&clock, form)?; + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); + + if !site_config.password_registration_enabled { + return Ok(url_builder + .redirect(&mas_router::Login::from(form.action.post_auth_action)) + .into_response()); } - let ctx = ctx.with_csrf(csrf_token.form_value()).with_language(locale); + let providers = repo.upstream_oauth_provider().all_enabled().await?; + let mut state = form.to_form_state(); + validate_registration_username( + &mut state, + &form.username, + user_agent, + activity_tracker.ip(), + &mut repo, + &mut policy, + &homeserver, + &http_client, + &site_config, + ) + .await?; - let content = templates.render_register(&ctx)?; + if !state.is_valid() { + let content = render( + locale, + RegisterContext::new(providers).with_form_state(state), + form.action, + csrf_token.form_value(), + &mut repo, + &templates, + ) + .await?; - Ok((cookie_jar, Html(content)).into_response()) + return Ok((cookie_jar, Html(content)).into_response()); + } + + let mut destination = PasswordRegister::default().with_username(form.username); + if let Some(action) = form.action.post_auth_action { + destination = destination.and_then(action); + } + + Ok((cookie_jar, url_builder.redirect(&destination)).into_response()) } diff --git a/crates/handlers/src/views/register/password.rs b/crates/handlers/src/views/register/password.rs index 39643e972..696af7b4f 100644 --- a/crates/handlers/src/views/register/password.rs +++ b/crates/handlers/src/views/register/password.rs @@ -26,7 +26,7 @@ use mas_router::UrlBuilder; use mas_storage::{ BoxRepository, RepositoryAccess, queue::{QueueJobRepositoryExt as _, SendEmailAuthenticationCodeJob}, - user::{UserEmailRepository, UserRepository}, + user::UserEmailRepository, }; use mas_templates::{ FieldError, FormError, FormState, PasswordRegisterContext, RegisterFormField, TemplateContext, @@ -181,27 +181,18 @@ pub(crate) async fn post( state.add_error_on_form(FormError::Captcha); } - let mut homeserver_denied_username = false; - if form.username.is_empty() { - state.add_error_on_field(RegisterFormField::Username, FieldError::Required); - } else if repo.user().exists(&form.username).await? { - // The user already exists in the database - state.add_error_on_field(RegisterFormField::Username, FieldError::Exists); - } else if !homeserver - .is_localpart_available(&form.username) - .await - .map_err(InternalError::from_anyhow)? - { - // The user already exists on the homeserver - tracing::warn!( - username = &form.username, - "Homeserver denied username provided by user" - ); - - // We defer adding the error on the field, until we know whether we had another - // error from the policy, to avoid showing both - homeserver_denied_username = true; - } + super::validate_registration_username( + &mut state, + &form.username, + user_agent.clone(), + activity_tracker.ip(), + &mut repo, + &mut policy, + &homeserver, + &http_client, + &site_config, + ) + .await?; if let Some(email) = &email { // Note that we don't check here if the email is already taken here, as @@ -267,18 +258,7 @@ pub(crate) async fn post( message: violation.msg, }, ), - Some("username") => { - // If the homeserver denied the username, but we also had an error on the policy - // side, we don't want to show both, so we reset the state here - homeserver_denied_username = false; - state.add_error_on_field( - RegisterFormField::Username, - FieldError::Policy { - code: violation.variant.map(|c| c.as_str()), - message: violation.msg, - }, - ); - } + Some("username") => {} Some("password") => state.add_error_on_field( RegisterFormField::Password, FieldError::Policy { @@ -293,11 +273,6 @@ pub(crate) async fn post( } } - if homeserver_denied_username { - // XXX: we may want to return different errors like "this username is reserved" - state.add_error_on_field(RegisterFormField::Username, FieldError::Exists); - } - if state.is_valid() { // Check the rate limit if we are about to process the form if let Err(e) = limiter.check_registration(requester) { @@ -626,7 +601,7 @@ mod tests { cookies.save_cookies(&response); response.assert_status(StatusCode::OK); assert!( - response.body().contains("Username is too long"), + response.body().contains("PostNumber is too long"), "response body: {}", response.body() ); @@ -680,7 +655,7 @@ mod tests { let response = state.request(request).await; cookies.save_cookies(&response); response.assert_status(StatusCode::OK); - assert!(response.body().contains("This username is already taken")); + assert!(response.body().contains("This postNumber is already taken")); } /// When the username is already reserved on the homeserver, it should give @@ -726,7 +701,7 @@ mod tests { let response = state.request(request).await; cookies.save_cookies(&response); response.assert_status(StatusCode::OK); - assert!(response.body().contains("This username is already taken")); + assert!(response.body().contains("This postNumber is already taken")); } /// Test registration without email when email is not required diff --git a/crates/handlers/src/views/register/steps/finish.rs b/crates/handlers/src/views/register/steps/finish.rs index af0b8ef9f..f10121a22 100644 --- a/crates/handlers/src/views/register/steps/finish.rs +++ b/crates/handlers/src/views/register/steps/finish.rs @@ -51,6 +51,7 @@ pub(crate) async fn get( user_agent: Option>, State(url_builder): State, State(homeserver): State>, + State(http_client): State, State(templates): State, State(site_config): State, PreferredLanguage(lang): PreferredLanguage, @@ -120,6 +121,34 @@ pub(crate) async fn get( ))); } + // Re-check postnumber reservation to close the TOCTOU window between + // the initial form check and this final commit step. + match crate::postnumber::check( + &http_client, + site_config.postnumber_validation.as_ref(), + ®istration.username, + ) + .await + { + Ok(Some(crate::postnumber::PostnumberOutcome::Allowed) | None) => { + // Valid or not configured – continue + } + Ok(Some(crate::postnumber::PostnumberOutcome::Reserved)) => { + return Err(InternalError::from_anyhow(anyhow::anyhow!( + "This PostNumber is reserved and cannot be used for registration" + ))); + } + Err(e) => { + tracing::error!( + error = &e as &dyn std::error::Error, + "postnumber resolver error during registration finalization" + ); + return Err(InternalError::from_anyhow(anyhow::anyhow!( + "Could not verify PostNumber availability" + ))); + } + } + // Check if the registration token is required and was provided let registration_token = if site_config.registration_token_required { if let Some(registration_token_id) = registration.user_registration_token_id { diff --git a/crates/oidc-client/tests/it/main.rs b/crates/oidc-client/tests/it/main.rs index cc8641085..f8f44a7fe 100644 --- a/crates/oidc-client/tests/it/main.rs +++ b/crates/oidc-client/tests/it/main.rs @@ -42,10 +42,17 @@ fn now() -> DateTime { Utc::now() } +fn test_http_client() -> reqwest::Client { + reqwest::Client::builder() + .no_proxy() + .build() + .expect("failed to create test HTTP client") +} + async fn init_test() -> (reqwest::Client, MockServer, Url) { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - let client = mas_http::reqwest_client(); + let client = test_http_client(); let mock_server = MockServer::start().await; let issuer = Url::parse(&mock_server.uri()).expect("Couldn't parse URL"); diff --git a/crates/policy/src/lib.rs b/crates/policy/src/lib.rs index a5d4805ad..3c20eaed0 100644 --- a/crates/policy/src/lib.rs +++ b/crates/policy/src/lib.rs @@ -30,7 +30,7 @@ pub enum LoadError { Read(#[from] tokio::io::Error), #[error("failed to create WASM engine")] - Engine(#[source] anyhow::Error), + Engine(#[source] opa_wasm::wasmtime::Error), #[error("module compilation task crashed")] CompilationTask(#[from] tokio::task::JoinError), @@ -89,6 +89,7 @@ impl Entrypoints { } } +/// Global static data that stays the same for the life of the [`PolicyFactory`] #[derive(Debug)] pub struct Data { base: BaseData, @@ -198,6 +199,10 @@ fn merge_data_rec( Ok(()) } +/// Global dynamic data +/// +/// Hint: there is an admin API to manage this, see +/// `crates/handlers/src/admin/v1/policy_data/set.rs` struct DynamicData { version: Option, merged: serde_json::Value, @@ -224,7 +229,6 @@ impl PolicyFactory { entrypoints: Entrypoints, ) -> Result { let mut config = Config::default(); - config.async_support(true); config.cranelift_opt_level(OptLevel::SpeedAndSize); let engine = Engine::new(&config).map_err(LoadError::Engine)?; @@ -319,6 +323,7 @@ impl PolicyFactory { &self, data: &serde_json::Value, ) -> Result { + tracing::debug!("Instantiating policy with data={}", data); let mut store = Store::new(&self.engine, ()); let runtime = Runtime::new(&mut store, &self.module) .await diff --git a/crates/router/src/endpoints.rs b/crates/router/src/endpoints.rs index 239f24b25..e4b8bda42 100644 --- a/crates/router/src/endpoints.rs +++ b/crates/router/src/endpoints.rs @@ -340,6 +340,12 @@ pub struct PasswordRegister { } impl PasswordRegister { + #[must_use] + pub fn with_username(mut self, username: impl Into) -> Self { + self.username = Some(username.into()); + self + } + #[must_use] pub fn and_then(mut self, action: PostAuthAction) -> Self { self.post_auth_action = Some(action); diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index 25123970b..b433894c1 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -647,6 +647,7 @@ impl FormField for RegisterFormField { /// Context used by the `register.html` template #[derive(Serialize, Default)] pub struct RegisterContext { + form: FormState, providers: Vec, next: Option, } @@ -661,6 +662,7 @@ impl TemplateContext for RegisterContext { Self: Sized, { sample_list(vec![RegisterContext { + form: FormState::default(), providers: Vec::new(), next: None, }]) @@ -672,11 +674,18 @@ impl RegisterContext { #[must_use] pub fn new(providers: Vec) -> Self { Self { + form: FormState::default(), providers, next: None, } } + /// Add an error on the registration form + #[must_use] + pub fn with_form_state(self, form: FormState) -> Self { + Self { form, ..self } + } + /// Add a post authentication action to the context #[must_use] pub fn with_post_action(self, next: PostAuthContext) -> Self { diff --git a/deny.toml b/deny.toml index 47b0bb9ca..672a13e63 100644 --- a/deny.toml +++ b/deny.toml @@ -20,6 +20,9 @@ ignore = [ # RSA key extraction "Marvin Attack". This is only relevant when using # PKCS#1 v1.5 encryption, which we don't "RUSTSEC-2023-0071", + # Rand 0.8.5 unsoundness with custom logger + thread_rng reseeding. + # Only triggers when the `log` feature is enabled, which we don't use. + "RUSTSEC-2026-0097", ] [licenses] @@ -53,7 +56,10 @@ multiple-versions = "deny" skip = [ { name = "itertools", version = "0.13.0" }, # zxcvbn depends on this old version - { name = "hashbrown", version = "0.14.5" }, # a few crates depend on this old version + # a few crates depend on old versions of hashbrown + { name = "hashbrown", version = "0.14.5" }, + { name = "hashbrown", version = "0.15.5" }, + { name = "hashbrown", version = "0.16.1" }, # a few dependencies depend on the 1.x version of thiserror { name = "thiserror", version = "1.0.69" }, { name = "thiserror-impl", version = "1.0.69" }, @@ -63,15 +69,19 @@ skip = [ { name = "unicode-width", version = "0.1.14" }, # cron depends on this old version # https://github.com/zslayton/cron/pull/137 - { name = "winnow", version = "0.6.20" }, + { name = "winnow", version = "0.6.26" }, # hyper-util -> system-configuration depends on this old version { name = "core-foundation", version = "0.9.4" }, + # wasmtime pulls in older versions of these + { name = "foldhash", version = "0.1.5" }, + { name = "gimli", version = "0.32.3" }, + { name = "object", version = "0.37.3" }, # We are still mainly using rand 0.8 { name = "rand", version = "0.8.5" }, { name = "rand_chacha", version = "0.3.1" }, { name = "rand_core", version = "0.6.4" }, - { name = "getrandom", version = "0.2.15" }, + { name = "getrandom", version = "0.2.16" }, ] skip-tree = [] diff --git a/docker-bake.hcl b/docker-bake.hcl index 3c3cac3af..f5e191975 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -29,7 +29,6 @@ target "base" { platforms = [ "linux/amd64", - "linux/arm64", ] } diff --git a/docs/development/contributing.md b/docs/development/contributing.md index 206602784..721aa948a 100644 --- a/docs/development/contributing.md +++ b/docs/development/contributing.md @@ -108,6 +108,34 @@ If you haven't already, install [Cargo-Nextest](https://nexte.st/docs/installati - `make PODMAN=1 test` (runs inside a container; needs Podman installed) - `make DOCKER=1 test` (runs inside a container; needs Docker installed) +### Debug policies + +When in doubt, rebuild the policies (see the *Build and run MAS* section above). It's +really easy to make a change to the policies and forget to rebuild them, which can lead +to maddening debugging sessions. + +The policies get a combination of `data` and `input` when they're evaluated. + + - `data`: App-global data. This is a combination of many things: + - Static fields defined in `mas_policy::Data` + - `mas_policy::DynamicData` is mixed in and is managed with the admin API. + - Arbitrary data can be added via configuration (`policy.data`) ([docs](../reference/configuration.md#policy)) + - `input`: Information passed during evaluation that is derived from each request. + - Each policy has its own input schema defined by the types like `CompatLoginInput`, etc. + +To debug what the policy template sees, you can add a +[`print(...)`](https://www.openpolicyagent.org/docs/cheatsheet#print) statement in the +policy, which will print to the [server +logs](https://github.com/matrix-org/rust-opa-wasm/blob/17cdd1570448da02f9d37bbe4e89ffad2ffc5e3f/src/policy.rs#L276) +(FIXME: this currently doesn't work). + +Since the way `data` is assembled is a bit complex, you can use +`RUST_LOG=info,mas_policy=debug` which will show the `tracing::debug!("Instantiating +policy with data={}", data);` debug logs. + +For `input`, you can just log it more directly where you evaluate the policy. + + ## 8. Submit a pull request Once you've made changes, you're ready to submit a pull request. diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index d2d65a0b4..a4ecc17b4 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -358,6 +358,25 @@ account: # When enabled, users must provide a valid registration token during password # registration. This has no effect if password registration is disabled. registration_token_required: false + + # Optional external resolver for validating postnumbers (usernames) during + # registration. When configured, every self-service and upstream-OAuth + # registration will call this service. The Admin API also calls this, + # but allows explicitly claiming reserved postnumbers via the + # `claim_reserved_postnumber` flag. + # + # Omit this section entirely to disable postnumber validation. + #postnumber_validation: + # # URL of the postnumber-resolver service (required) + # endpoint: "http://localhost:3001/" + # + # # Per-request timeout in seconds (default: 2) + # timeout: 2 + # + # # What to do when the resolver is unreachable: + # # - "closed" (default): reject the registration + # # - "open": allow the registration to proceed + # on_unavailable: closed ``` ## `captcha` diff --git a/frontend/index.html b/frontend/index.html index cdbc8a69d..b18d81d0d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -13,7 +13,7 @@ Please see LICENSE files in the repository root for full details. - matrix-authentication-service + letro-authentication-service