diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d806d8b..45c1243 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,16 +1,16 @@ repos: - repo: git@github.com:Yelp/detect-secrets - rev: v1.2.0 + rev: v1.5.0 hooks: - id: detect-secrets args: ['--baseline', '.secrets.baseline'] - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.4.0 + rev: v6.0.0 hooks: - id: end-of-file-fixer - id: no-commit-to-branch args: [--branch, develop, --branch, master, --pattern, release/.*] - repo: https://github.com/psf/black - rev: 22.3.0 + rev: 26.5.1 hooks: - id: black diff --git a/.secrets.baseline b/.secrets.baseline index 14ac725..893d1a8 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -1,5 +1,5 @@ { - "version": "1.2.0", + "version": "1.5.0", "plugins_used": [ { "name": "ArtifactoryDetector" diff --git a/poetry.lock b/poetry.lock index c0ceadf..7aa847f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.1 and should not be changed by hand. [[package]] name = "annotated-doc" @@ -28,14 +28,14 @@ files = [ [[package]] name = "anyio" -version = "4.14.1" +version = "4.14.2" description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72"}, - {file = "anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e"}, + {file = "anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494"}, + {file = "anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f"}, ] [package.dependencies] @@ -140,6 +140,7 @@ files = [ {file = "certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db"}, {file = "certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432"}, ] +markers = {main = "extra == \"datamodelutils\""} [[package]] name = "cffi" @@ -390,103 +391,103 @@ markers = {main = "extra == \"flask\" and platform_system == \"Windows\"", dev = [[package]] name = "coverage" -version = "7.15.0" +version = "7.15.2" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "coverage-7.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:50913d4bf5ddafa6ca3693da5e4dd833dd1b772e0283c99ca7f7d287db67331a"}, - {file = "coverage-7.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:359e141ccd33893ce3f1ad5525f8b96083003677c82182e5907d62d4ea5799fc"}, - {file = "coverage-7.15.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3200b6204935f928c64b2ca1f923ab8c1acb7c9de45ec61569711b34d25cccaf"}, - {file = "coverage-7.15.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:be616bf61346883b2cfdc5178669647e03531d81ab761a7e378558b7e8bcb628"}, - {file = "coverage-7.15.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc7bafc3fe1059463a8fdd97ca79972d6e2bf819d775c7d54991b5b1971201d6"}, - {file = "coverage-7.15.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b713aa7fcf325a01d4184d848acb46fd84f78fdb0978470c636b23a06a753d91"}, - {file = "coverage-7.15.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e38e6fba2d56652fdfaf0231f8f78aeb805234a912de25dc291ee5cce5b8faa4"}, - {file = "coverage-7.15.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:884499f42e382675be80770391983b90e0c0c774d87dbeeebf5f991cf6612b20"}, - {file = "coverage-7.15.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:840481b12e083dbcbafab14794a8781a958edf327c8d3d70b4eee42f9b8253aa"}, - {file = "coverage-7.15.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:276646e9481703d09f854f3b2f018f24e19fd7049ae670a92570043eb97203b1"}, - {file = "coverage-7.15.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4de4b4d3f5545aa6c60dc4efd9c63b5b5dcc3bf00fe83146b2bdfffb8f6613bd"}, - {file = "coverage-7.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5c504097b2a89b1e85bc6070d920df77daec701337e3aeef2c17775a5dd0ca90"}, - {file = "coverage-7.15.0-cp310-cp310-win32.whl", hash = "sha256:f6e80ed91f98316e86b9c137206b04b2bcfbffccbdff49bd2eb09dddb1cf14e0"}, - {file = "coverage-7.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:b3b3e22030f3f6f5e01a5ce69936552a5c0f6992b7698777377b99041961031f"}, - {file = "coverage-7.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:003fff99412ea848c0aaebcc78ed2b6ce7d8a1227ed17e68470672770b78a02a"}, - {file = "coverage-7.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5cbd804bf2784ce7b45114516050f346ecd50f960c4bb630a7ee9e1d78fa2118"}, - {file = "coverage-7.15.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8773e15c23305b58882a4611fb9b2755977eae0dc2e515366a1b6c98866cc4c2"}, - {file = "coverage-7.15.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f50e40081494c1dc4239ebb202014cbcc3306ea96fb6302a34c8cc0967fc5ae8"}, - {file = "coverage-7.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daf96f37f5fc3a7b6c6da862eb4aee61c426bd63da236ed4a73ef0e503b4bca5"}, - {file = "coverage-7.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:51aa20f6ae2788fd197747766edf4cd8234fd9423309b934257fa6b21a592723"}, - {file = "coverage-7.15.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03d1f922757662eb7af586e77834792274cff776bc7b1d1a0b66a49ea9d84735"}, - {file = "coverage-7.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a6d6acc9a7666245e6133dd15144ca038a85a9cd5026bb06d6bbae9e77440dc9"}, - {file = "coverage-7.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1ac2c4c27c7df851dc9a017c2d7de00b69147e84ba3d96f37a530b0b6fb51035"}, - {file = "coverage-7.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b761a1d504fd4bd1f20f418753964dca9f5862a511fc854dac58296b3b223671"}, - {file = "coverage-7.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e43b045e11c16e897895758ae90e4a90cf99e93d58549e2f90c0e2272e155695"}, - {file = "coverage-7.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:589b54513e901739f4b4582c705ce96b80c96f57641b1464607e2367a270e540"}, - {file = "coverage-7.15.0-cp311-cp311-win32.whl", hash = "sha256:106781b8482749162d0b47056937ba0933508e5d9447f65a5e7d5c422f0d6bb4"}, - {file = "coverage-7.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:821e92b3631d762a339695824cadbbc73020354eba2a23a551a99ad34938fbe6"}, - {file = "coverage-7.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:309990eb5fb8014b9f67cb211f7fd41876ec8a88a88d3ae76de0ed1d611e3640"}, - {file = "coverage-7.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b75ee5e8cb7575636ac598719b4307ac529ec8fcd79608a35c3cd4d4dada812d"}, - {file = "coverage-7.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffb31267816b93b075302248cc1737506081b4f163df4401e9df1a6424aafabe"}, - {file = "coverage-7.15.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4d0bb73455bf97ab243a8f12c37c686ccf1c13bb614b7b85f1d062f06f42b2c"}, - {file = "coverage-7.15.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:20d9ccc4ebd0edc434d86dfd2a1dd2a8efa6b6b3073d0485a394fee86459ebb4"}, - {file = "coverage-7.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20c8a976c365c8cb12f0cbd099508772ea41fb5fa80657a8506df0e11bd278c5"}, - {file = "coverage-7.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f948fd5ba1b9cbca91f0ae08b4c1ce2b139509149a435e2585d056d57d70bf01"}, - {file = "coverage-7.15.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f58185f06edf6ad68ec9fb155d63ef650c82f3fbd7e1770e2867751fb13158f4"}, - {file = "coverage-7.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02adc79a920c73c647c5d117f55747df7f2de94571884758ce8bc58e04f0a796"}, - {file = "coverage-7.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6eb7c300fbed667fd6e3588eba71c1904cdb06110ca6fdf908c26bdd88b8e382"}, - {file = "coverage-7.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b5fb23fa2de9dce1f5c36c09066d8fcda16cd96e8e26686caa2d7cb9b567d65c"}, - {file = "coverage-7.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cec79341dbe6281484024979976d0c7f22beae08b4a254655decd25d42cbe766"}, - {file = "coverage-7.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c664c5444b1d970b1b2a450e21fb19ee5c9cfdf151ded2dda37260031cca0da"}, - {file = "coverage-7.15.0-cp312-cp312-win32.whl", hash = "sha256:5f764a3fa339bde6b3aa97657f5a6a3a9451e4a5b4ea98a2892c773a43525f77"}, - {file = "coverage-7.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:52f9a4d2c4c56c8848bc2f524916698354b0211488b38c49ad9ae54f6cafbff6"}, - {file = "coverage-7.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:31e5c3e70c85307ea35a12964e2e40f56ca2ee4b1c8c721ccf4609d17071080b"}, - {file = "coverage-7.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5be4caf3b28836f078abe700f8944dac4a65d78f16d6c600c89cb624e5535782"}, - {file = "coverage-7.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dd58ad1404704303ca8d4f4b8a1095e7cbc7040ef17a66df1e6619aa10176430"}, - {file = "coverage-7.15.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bbcbb317c2e5ded5b21104af81c29f391be2af98d065693ffbe8d23949b948e5"}, - {file = "coverage-7.15.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:27f31ecb458da3f859aab3f15ada871eb7a7768807d88df4a9f186bb17737970"}, - {file = "coverage-7.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fb759be317fdc62e0f56bffdf61cfcb45c7761ad6b71e3e583e71a67ae753c"}, - {file = "coverage-7.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5cf007add5ab4bb8fa9f4c77e3732127c9e6cad501d7db43355fbfafca0be84"}, - {file = "coverage-7.15.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc78d9843bd576fbe2118248258d485e968dc535f95ed504a7b0867ba9b51389"}, - {file = "coverage-7.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a263060f1de0b4b74b4e089c2a70b8003b3781c733329a9c8fd54995328f9950"}, - {file = "coverage-7.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c48decf16e0dfd5b049c7d5e82200c23c08126719142998d4f172444e3d0529e"}, - {file = "coverage-7.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:08fb028000ed0aaa0a4cbdfbb98be7cb42f370db973fbbb469733505ab20e13e"}, - {file = "coverage-7.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb7dc0c3b7d8a1077abea0b8546ebc5e26d6ef6ecefc2f0f5ad2b8a53bdad837"}, - {file = "coverage-7.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cb3602054ccbe9f0d8c2dc04bbeba90d5719236e2cd06e042ddd6d3fc7b6e37"}, - {file = "coverage-7.15.0-cp313-cp313-win32.whl", hash = "sha256:0bf781da64326b677be344df505171435b6f58716108606621d5d27d964fff8b"}, - {file = "coverage-7.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:2c57a275078ee3fa185f83e400f765bc764a549de66d99b47881645cbd4ea629"}, - {file = "coverage-7.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:3812c61afc6685c7999b39320779ab8f43b7a3081fdb0def39976e56fbdb9a21"}, - {file = "coverage-7.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:41cb79af843222e11da87127ad0ecbfa878abadd0f770a4a99391a27d3887324"}, - {file = "coverage-7.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7d2008989ef8fe54188d3f3bfa2e3099b025af11e90a6a1b9e7dc433d04263d8"}, - {file = "coverage-7.15.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:769e8ece11a596315ebf5aa7ec383aeeed016c091d2bf6363ffb996d41529092"}, - {file = "coverage-7.15.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:65a6b6164ee5c39e2f3803f314292d6c61a607ba7fee253d1e03c42dc3903502"}, - {file = "coverage-7.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75128817f95a5c45bb01d65fd2d8b9cb54bbe03d81608fb70e3e14b437ad56c2"}, - {file = "coverage-7.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9887bb428fe2d4cd4bee89bac1a6c9932f484afd5b36fbd4ff6ea5f825bb1f5e"}, - {file = "coverage-7.15.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0bfc0be1f702042207a93a00523b1065ee1fe951e96edf311581c0bbc2e34888"}, - {file = "coverage-7.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f64627d55def5a43282d70e08396672692f77e4da610a5bb8bb4060b432b6859"}, - {file = "coverage-7.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2c6f0fa473003905c6d5bac328ee4eba9fbea654f15bc24b8a3274b23363fa99"}, - {file = "coverage-7.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2bcf9afaf064172c6ec3c58a325a9957ad1178c05dd934e25f253321776e0676"}, - {file = "coverage-7.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:baf06bc987115d6fb938d403f7eab684a057766c490367999a2b71a6883110c6"}, - {file = "coverage-7.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f0405f2ff97b1c4c0e782cb32e02f32369bcf2e6b618b591d67e1ea754575dfe"}, - {file = "coverage-7.15.0-cp314-cp314-win32.whl", hash = "sha256:ab282853ed5fbd64bbb162f19cb8fcb7087187508a6374b4f9c34ec1577c4e8f"}, - {file = "coverage-7.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bb3040e9f4bbe26fcb0cd7cc85ac63e630d3f3a9c74f027abf4caa27e706663"}, - {file = "coverage-7.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:346771144d34f7fa84ec28386f78e0f31653f33cf35e19d253d5b35f9e8201da"}, - {file = "coverage-7.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d34a010905fb6401324ba016b5da03d574967f7b21ce48ea41e66f0f1f95f641"}, - {file = "coverage-7.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bb25d825d885ca8036795dacfc3924d33091fc76d71ebc99420c6b79e77d96fa"}, - {file = "coverage-7.15.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:94c9686bfe8a9a6810297aecbd99beaa3445f9e8dc2f80b1382cca0d86b64461"}, - {file = "coverage-7.15.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9bd671c25f9d85f09d7ec481d0e43d5139f486c06a37139847a7ce569788af72"}, - {file = "coverage-7.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:110cbdf8d2e216577312cf06ccf85539c0e5a5420ef747e4a4719b5e483c88cd"}, - {file = "coverage-7.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c5d4619214f1d9993e7b00a8600d14614b7e9d84e89507460b126aa5e6559e5"}, - {file = "coverage-7.15.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:781a704516e2d8346fbbd5be6c6f3412dd824785146528b3a01816f26c081007"}, - {file = "coverage-7.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd4a1b44bcb65ee29e947ac92bbee04956df3a6bfc6143641bb6cae7ede00fc9"}, - {file = "coverage-7.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0e4950c9d6d3e39c64c991814ff315e2d0b9cb8152363594212c9e55208c0a8f"}, - {file = "coverage-7.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:fe9c87ff42e5472d80d21704972e1f96e104a0a599d77c5e35db5a3c562e2571"}, - {file = "coverage-7.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f00d5ae1dd2fe13fb8186e3e7d37bcbd8b25c0d764ff7d1b32cef9be058510a8"}, - {file = "coverage-7.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:363ab38cc78b615f11c9cac3cf1d7eef950c18b9fdedfb9066f59461dcf84d68"}, - {file = "coverage-7.15.0-cp314-cp314t-win32.whl", hash = "sha256:54fd9c53a5fafff509195f1b6a3f9be615d8e8362a3629ff1de23d270c03c86b"}, - {file = "coverage-7.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:87b47553097ba185ed964866078e7e63adea9f5f51b5f39691c34f30afd21080"}, - {file = "coverage-7.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aeefb2dd178fe7eee79f0ad25d75855cb35ee9ed472db2c5ea06f5b4fd00cec5"}, - {file = "coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19"}, - {file = "coverage-7.15.0.tar.gz", hash = "sha256:9ac3fe7a1435986463eaa8ee253ae2f2a268709ba4ae5c7dd1f52a05391ad78f"}, + {file = "coverage-7.15.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b5bd92ff1ec22e535eab0de75fa6db021992791f461a2aceb7822c625a1187d"}, + {file = "coverage-7.15.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:44826758cfe73fcd0e6af5deb4ba6d5417cc1d13df3acb35c93484a11160f846"}, + {file = "coverage-7.15.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09f5c6ec5901f667bd97dd140b5b9a2586b10efec66f46fb1e6d8135f8b95bdf"}, + {file = "coverage-7.15.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1d16e3a7104ea84f03e614611b3edbf6fb6892554b3ab0fe7fbb3f2b2ef04376"}, + {file = "coverage-7.15.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d46e62cb35d91e6e2589fda6d28074426b0e276422b5d2ebef2c6b11dc60dbfd"}, + {file = "coverage-7.15.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dfd3db045e95960ae3683059571e597fda7cc610106a8916f77c5839048c1deb"}, + {file = "coverage-7.15.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:affd532502d34c0472d0cdb181325c89f1d2c44992fef0c17e88e7b1576259a1"}, + {file = "coverage-7.15.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d17d7512151fedfcc64c1821a8977fc9be0dbf495754669afcab7b57abc98ae9"}, + {file = "coverage-7.15.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e26ff680768b8095e8874aabe0e9d3a47a2a9f176a8340d05f8604c56457c23a"}, + {file = "coverage-7.15.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7e8f27131dc7cd53de2c137dd207b3720919320b3c20d499dc30aa9ee6173287"}, + {file = "coverage-7.15.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:728a33676d4c3f0db977990a4bd421dcaa3be3e53b5b6273036fff6666008e89"}, + {file = "coverage-7.15.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:29c052f7c83ccfcc5c577eaae025d2e4a9bb80daf03c0ac31c996e83b000ce88"}, + {file = "coverage-7.15.2-cp310-cp310-win32.whl", hash = "sha256:1268ac8fb9ddcd783d3948dbabaf80a5d53bfdaa0575e873e2139a692f797443"}, + {file = "coverage-7.15.2-cp310-cp310-win_amd64.whl", hash = "sha256:9f4432898c4bf2fba0435bbe35dd4437d7264565e5a88a21f5b49d8662a6b629"}, + {file = "coverage-7.15.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f1ec6f304b156669cfde653b4e9a953f5de87e247ea02ac599bce0ab2744036"}, + {file = "coverage-7.15.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d3361879d736f469f45723c11ea1a5bbdaf1f6928f0e632c940378b5aa9b660"}, + {file = "coverage-7.15.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c6a98d698f9e2c8008d0370ec7fc452ebfcc530002ae2d0061170d768b992589"}, + {file = "coverage-7.15.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d50dd325e18ec25bfcc10cd7f99b04df1ab9ec76b0918c260e60817ad0643dee"}, + {file = "coverage-7.15.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67d7602480a47bdf5b675635403625553ebaa70d5a62a657c035149fd401cea0"}, + {file = "coverage-7.15.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cee0f89f4767a6057c8fbf168f8135f18be651300496086bd873e3189fed0487"}, + {file = "coverage-7.15.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a29ec5305a7335aacee2d799e3422e91e1c8a12474986e2b3b07e315c91be82f"}, + {file = "coverage-7.15.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:48ccc6395958eda89093ecdc35644c86f23a8b23a7f4d44958812b721aad67c1"}, + {file = "coverage-7.15.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:81f382c5a94b434ec1f6da607edb904c76d7212e618cd4d1bc9f97bed4120ef5"}, + {file = "coverage-7.15.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bbc808daf4f5cd567af8075ecc72d21c6dfef9a254709a621a84c217c935ebc0"}, + {file = "coverage-7.15.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a4c46b247b5d4b78f613bd89fea926d32b25c6cc61a50bd1e99ba310348f3dad"}, + {file = "coverage-7.15.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:094dd37f3ef7b2da8b068b583d1f4c40f91c65197e16c52a71962d5d537fc5db"}, + {file = "coverage-7.15.2-cp311-cp311-win32.whl", hash = "sha256:a63b9e190711134d581c4d703df5df09851b1acf99792c7aacbbe9f41f0283c9"}, + {file = "coverage-7.15.2-cp311-cp311-win_amd64.whl", hash = "sha256:8bb9f4b4279187560796a4cdaca3b0a93dd97e48ee667df005f4ed9a97403688"}, + {file = "coverage-7.15.2-cp311-cp311-win_arm64.whl", hash = "sha256:8c726b232659cbd2ae57ade46509eb068c9bd7a06df9fcbff6fe484870006934"}, + {file = "coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9"}, + {file = "coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73"}, + {file = "coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d"}, + {file = "coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b"}, + {file = "coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296"}, + {file = "coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6"}, + {file = "coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098"}, + {file = "coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a"}, + {file = "coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b"}, + {file = "coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2"}, + {file = "coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440"}, + {file = "coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e"}, + {file = "coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd"}, + {file = "coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40"}, + {file = "coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3"}, + {file = "coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8"}, + {file = "coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1"}, + {file = "coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578"}, + {file = "coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1"}, + {file = "coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6"}, + {file = "coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7"}, + {file = "coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d"}, + {file = "coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026"}, + {file = "coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa"}, + {file = "coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d"}, + {file = "coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b"}, + {file = "coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188"}, + {file = "coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050"}, + {file = "coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c"}, + {file = "coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b"}, + {file = "coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a"}, + {file = "coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2"}, + {file = "coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138"}, + {file = "coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f"}, + {file = "coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984"}, + {file = "coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7"}, + {file = "coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3"}, + {file = "coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee"}, + {file = "coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1"}, + {file = "coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a"}, + {file = "coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145"}, + {file = "coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446"}, + {file = "coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243"}, + {file = "coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc"}, + {file = "coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635"}, + {file = "coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be"}, + {file = "coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072"}, + {file = "coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328"}, + {file = "coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a"}, + {file = "coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0"}, + {file = "coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5"}, + {file = "coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743"}, + {file = "coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c"}, + {file = "coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071"}, + {file = "coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a"}, + {file = "coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d"}, + {file = "coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c"}, + {file = "coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688"}, + {file = "coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199"}, + {file = "coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658"}, + {file = "coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c"}, + {file = "coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d"}, ] [package.extras] @@ -845,51 +846,52 @@ files = [ ] [[package]] -name = "httpcore" -version = "1.0.9" +name = "httpcore2" +version = "2.9.1" description = "A minimal low-level HTTP client." optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, - {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, + {file = "httpcore2-2.9.1-py3-none-any.whl", hash = "sha256:6182472379e855fe4221246a2bb7ecede403bc61c6798062ae1787d051ccde26"}, + {file = "httpcore2-2.9.1.tar.gz", hash = "sha256:4d8acbf8b306f48c9d6046591fd5ba4037d1b1b1000d140fc2c3eab1e9a0c0e2"}, ] [package.dependencies] -certifi = "*" h11 = ">=0.16" +truststore = ">=0.10" [package.extras] -asyncio = ["anyio (>=4.0,<5.0)"] +asyncio = ["anyio (>=4.5.0,<5.0)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] trio = ["trio (>=0.22.0,<1.0)"] [[package]] -name = "httpx" -version = "0.28.1" +name = "httpx2" +version = "2.9.1" description = "The next generation HTTP client." optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, - {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, + {file = "httpx2-2.9.1-py3-none-any.whl", hash = "sha256:1820fe14a9ab1107bfeff39259987429450b070ec0ff38cc87eb0d8c97fdc71a"}, + {file = "httpx2-2.9.1.tar.gz", hash = "sha256:1932a768737e3666291582833da748cc4e563c337cf96706fccc04fa6e58764a"}, ] [package.dependencies] -anyio = "*" -certifi = "*" -httpcore = "==1.*" -idna = "*" +anyio = ">=4.10" +httpcore2 = "2.9.1" +idna = ">=3.18" +truststore = ">=0.10" [package.extras] brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] -cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +cli = ["click (>=8.4)", "pygments (==2.*)", "rich (>=10,<16)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] -zstd = ["zstandard (>=0.18.0)"] +ws = ["wsproto (>=1.2)"] +zstd = ["zstandard (>=0.18.0) ; python_version <= \"3.13\""] [[package]] name = "idna" @@ -952,14 +954,14 @@ i18n = ["Babel (>=2.7)"] [[package]] name = "joserfc" -version = "1.7.2" +version = "1.7.3" description = "The ultimate Python library for JOSE RFCs, including JWS, JWE, JWK, JWA, JWT" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "joserfc-1.7.2-py3-none-any.whl", hash = "sha256:ddd818c0ca9b4f17bbc2d72cb3966e6ded7502be089316c62c3cc64ae86132b5"}, - {file = "joserfc-1.7.2.tar.gz", hash = "sha256:537ffb8888b2df039cb5b6d017d7cff6f09d521ce65d89cc9b8ab752b1cff947"}, + {file = "joserfc-1.7.3-py3-none-any.whl", hash = "sha256:7c39f3f2c943dbc03122747fa8ebbd8e156e54904cf25651b452f4d2634a6075"}, + {file = "joserfc-1.7.3.tar.gz", hash = "sha256:116955c2587139dba20621fd0bd7fc9255fa960c9fe7f43c43ebef2e801dcfcf"}, ] [package.dependencies] @@ -1993,6 +1995,18 @@ files = [ {file = "tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f"}, ] +[[package]] +name = "truststore" +version = "0.10.4" +description = "Verify certificates using native system trust stores" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981"}, + {file = "truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301"}, +] + [[package]] name = "typing-extensions" version = "4.16.0" @@ -2091,4 +2105,4 @@ flask = ["Flask"] [metadata] lock-version = "2.1" python-versions = ">=3.13, <4.0" -content-hash = "a2c6efc4d455532fbabc7bcc0bcefa72631c88c68dfd5213fec02c80d8cef5ca" +content-hash = "db885ac72040f149127c4d02ae0a25adc863f0b79e76341bce560a2f3fe187ad" diff --git a/pyproject.toml b/pyproject.toml index f221570..affc064 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "authutils" -version = "8.0.0" +version = "9.0.0" description = "Gen3 auth utility functions" authors = ["CTDS UChicago "] license = "Apache-2.0" @@ -15,13 +15,14 @@ werkzeug = ">=3.1.5" xmltodict = "~=0.9" authlib = ">=1.6.6" -httpx = ">=0.23.0,<1.0.0" -pyjwt = {version = ">=2.4.0,<3.0", extras = ["crypto"]} +httpx2 = ">=2.9.1" +pyjwt = {version = ">=2.11.0,<3.0", extras = ["crypto"]} cryptography = ">=48.0.1" Flask = {version = "*", optional = true} fastapi = {version = "*", optional = true} datamodelutils = { version = "*", optional = true } +joserfc = ">=1.7.3" [tool.poetry.extras] flask = ["Flask"] diff --git a/src/authutils/dbgap.py b/src/authutils/dbgap.py index 3846753..fe327e4 100644 --- a/src/authutils/dbgap.py +++ b/src/authutils/dbgap.py @@ -3,7 +3,7 @@ Defines functionality to check case existence in dbGaP. """ -import httpx +import httpx2 import re import xmltodict @@ -32,14 +32,14 @@ class dbGaPXReferencer(object): "GetSampleStatus.cgi?study_id={phsid}&rettype=xml" ) - def __init__(self, db, logger=None, proxies={}): + def __init__(self, db, logger=None, proxy=None): """Instantiate a class to crossvalidate entity existence in dbGaP.""" self._cached_telemetry_xmls = { # "phsid": "telemetry xml" } self.db = db - self.proxies = proxies + self.proxy = proxy self.logger = logger or get_logger("dbGapXReferencer", log_level="info") self.logger.info("Creating new dbGaP Cross Referencer") @@ -96,7 +96,7 @@ def get_xml(self, phsid): self.logger.info("Pulling telemetry report from {0}".format(url)) # Request the XML - with httpx.Client(proxies=self.proxies) as client: + with httpx2.Client(proxy=self.proxy) as client: r = client.get(url) if r.status_code != 200: msg = ( @@ -217,7 +217,7 @@ def assert_project_exists(self, project_code, phsid): self.logger.info("Pulling telemetry report from {0}".format(url)) # Request the XML - with httpx.Client(proxies=self.proxies) as client: + with httpx2.Client(proxy=self.proxy) as client: r = client.get(url) if r.status_code == 400: msg = "Project appears not to exist in dbGaP." diff --git a/src/authutils/dpop.py b/src/authutils/dpop.py new file mode 100644 index 0000000..624e16b --- /dev/null +++ b/src/authutils/dpop.py @@ -0,0 +1,1058 @@ +""" +DPoP (Demonstrating Proof-of-Possession) RFC 9449 implementation. + +This module provides centralized DPoP functionality for: + +- Client-side proof generation (Gen3 ex: gen3sdk-python) +- Resource server validation (Gen3 ex: gen3-workflow) +- Authorization server validation (Gen3 ex: fence) + +Key concepts per RFC 9449: +- htm: HTTP method of the request +- htu: HTTP target URI (host + path, without query string) +- ath: Access token hash +- jti: Unique identifier for the proof +- cnf.jkt: Key thumbprint binding in the access token + +This module integrates with authutils.token.core for comprehensive access token +validation (signature, expiration, issuer, scope, purpose), allowing services to +validate both the DPoP proof AND the access token in a single operation. +""" + +import hashlib +import json +import os +import base64 +import time +from collections.abc import Callable, Collection +from typing import Any +from urllib.parse import urlsplit + +from cdislogging import get_logger +from joserfc import jwt, jwk, jws +from joserfc.errors import JoseError + +from authutils.token.dpop_nonce import verify_stateless_nonce, generate_stateless_nonce +from authutils.token import core as token_core +from authutils.token.keys import get_any_public_key_for_token_async +from authutils.errors import ( + InvalidNonceErrorResourceServer, + InvalidNonceErrorAuthorizationServer, +) + +DPOP_JWT_TYPE = "dpop+jwt" + +# frozenset, not set: joserfc treats a falsy `algorithms` collection as +# "allow every recommended algorithm" (which includes HS256), so an empty +# allowlist would silently disable algorithm pinning entirely. Making this +# immutable removes the possibility of it being cleared after import. +SUPPORTED_DPOP_ALGS = frozenset( + { + # EC + "ES256", + "ES384", + "ES512", + # RSA + "RS256", + "RS384", + "RS512", + "PS256", + "PS384", + "PS512", + } +) + +# Maps an EC curve to its one legal JWS algorithm (RFC 7518 3.4). ES256 is only +# valid with P-256, ES384 only with P-384, etc., so the proof alg cannot be +# chosen from the key type alone. +EC_CURVE_TO_ALG = { + "P-256": "ES256", + "P-384": "ES384", + "P-521": "ES512", +} + + +# Curves permitted for DPoP proof keys. +ALLOWED_EC_CURVES = frozenset(EC_CURVE_TO_ALG) + +# Minimum acceptable RSA modulus size, in bits. joserfc only emits a warning +# below 2048. +# DPoP proof keys get bound into access tokens via cnf.jkt, so a +# weak key would weaken every token bound to it. +MIN_RSA_KEY_BITS = 2048 + +# Will invalidate any proof older than 5 minutes +# +# NOTE: RFC 9449 11.1 recommends "a relatively brief period on the order of +# seconds or minutes". 300s is the outer edge of that. Because this module +# cannot itself track jti values across processes, deployments that want a +# tighter replay window should lower this AND supply a jti_seen_callback. +DPOP_PROOF_MAX_TTL = 300 + +# Will invalidate any proof created more than 60 seconds into the future +# this provides some leeway for clock skewing across client / server +DPOP_PROOF_CLOCK_SKEW_LEEWAY = 60 + +# To avoid unnecessarilly large JTIs since we may +# want to cache these in the future +MAX_JTI_LENGTH = 256 + +# Upper bound on the DPoP header we will even attempt to parse. Guards against +# a caller handing us an unbounded string before any crypto work happens. +MAX_DPOP_HEADER_LENGTH = 8192 + +# Default ports that are semantically equivalent to being absent, per RFC 9110 +# 4.2. Stripping these keeps htu comparison stable behind proxies that add an +# explicit :443 to the Host header. +DEFAULT_PORTS_BY_SCHEME = {"http": "80", "https": "443"} + +logging = get_logger(__name__) + + +def generate_dpop_proof( + key: jwk.Key, + method: str, + url: str, + access_token: str | None = None, + nonce: str | None = None, + alg: str | None = None, +) -> str: + """ + Generate a DPoP proof JWT for a request. + + Args: + key (jwk.Key): The asymmetric key for signing the proof. + method (str): HTTP method (e.g., "GET", "POST"). + url (str): Full URL being requested (htu claim). + access_token (str | None): Optional access token for ath claim calculation. + nonce (str | None): Optional server nonce to include in the proof. + alg (str | None): Optional explicit JWS algorithm. Must be one of + SUPPORTED_DPOP_ALGS. If None, resolved from the key's type/curve. + + Returns: + str: Encoded DPoP proof JWT string. + + Raises: + ValueError: If the key type/curve is unsupported, the requested alg is + not permitted for DPoP, or the access_token is scheme-prefixed. + + Example: + >>> key = jwk.generate_key("EC", "P-256", private=True) + >>> proof = generate_dpop_proof( + ... key, + ... "POST", + ... "https://fence.example.com/credentials/api/access_token", + ... ) + """ + if alg is None: + alg = _resolve_proof_alg(key) + elif alg not in SUPPORTED_DPOP_ALGS: + # Validate caller-supplied algs against our own allowlist rather than + # relying on joserfc's broader "recommended" filter, so that e.g. + # "none" and "HS256" fail here with a clear DPoP-specific error. + raise ValueError( + f"Unsupported or unpermitted DPoP signature algorithm: {alg}. " + f"Allowed: {sorted(SUPPORTED_DPOP_ALGS)}" + ) + + header = { + "typ": DPOP_JWT_TYPE, + "alg": alg, + "jwk": key.as_dict(private=False), + } + payload: dict[str, Any] = { + "jti": os.urandom(16).hex(), + "htm": method.upper(), + "htu": _get_url(url), + "iat": int(time.time()), + } + + if access_token: + _reject_bearer_prefixed_token(access_token) + payload["ath"] = compute_ath(access_token) + + if nonce: + payload["nonce"] = nonce + + return jwt.encode(header, payload, key, registry=_new_registry()) + + +async def validate_dpop_request_async( + dpop_header: str, + access_token: str, + request_method: str, + request_url: str, + issuers: list[str], + public_key: str | bytes | None = None, + scope: set[str] | list[str] | None = None, + purpose: str | None = None, + aud: str | None = None, + require_nonce: bool = False, + options: dict | None = None, + denylist_callback: Callable | None = None, + jti_seen_callback: Callable | None = None, + secret: str | None = None, + as_resource_server: bool = True, +) -> tuple[dict[str, Any], dict[str, Any], jwk.Key]: + """ + Validate both the DPoP proof AND the access token in one operation. + + This is a convenience function that combines: + - DPoP proof validation (signature, htm, htu, ath, nonce) + - Including: Key binding validation (proof key thumbprint == token cnf.jkt) + - Access token validation + + This allows a service to blindly forward the DPoP header and Authorization + header and get complete validation done in one call. + + Args: + dpop_header (str): The DPoP proof JWT string from the DPoP header. + access_token (str): The JWT access token string from the Authorization header. + request_method (str): The HTTP method of the incoming request. + request_url (str): The full URL of the incoming request (scheme + host + + path), as the CLIENT saw it. + issuers (list[str]): Allowed token issuers whitelist, exact-matched + BEFORE any key discovery. Required and non-empty: `iss` is an + unverified claim, so this list is what keeps an attacker from + choosing which host key discovery contacts. + public_key (str | bytes | None): Optional public key for token validation. If None, will be + fetched from the token issuer's JWKS endpoint. + scope (set[str] | list[str] | None): Optional required scopes that the token must satisfy. + purpose (str | None): Optional required purpose (e.g., "access"). Must match token's pur claim. + aud (str | None): Optional audience. Note: authutils no longer validates audience. + require_nonce (bool): Whether to require and validate a DPoP nonce. + options (dict | None): options to pass through to pyjwt's decode + denylist_callback (Callable | None): a callback function that takes + (jti: str) and returns True if the token is denylisted. + The callback is called after basic JWT validation. + jti_seen_callback (Callable | None): a callback taking the DPoP proof's + (jti: str) and returning True if that jti has already been used. + Enables RFC 9449 11.1 single-use replay protection, which this module + cannot provide on its own because it holds no cross-process state. + secret (str | None): Optional secret key for stateless nonce verification. + If None, defaults to the environment-configured secret. + as_resource_server (bool): Default True. Whether caller is a Resource Server. + False implies caller is the Authorization Server. This alters some of the + error messaging behavior (per the spec). + + Returns: + dict[str, Any], dict[str, Any], jwk.Key: + dict with decoded and validated claims dict from dpop, + dict with decoded and validated claims dict from access token, + validated client_jwk from dpop header + + Raises: + ValueError: If DPoP proof validation fails, or if inputs are missing/malformed. + JWTError: If access token validation fails (signature, expiration, issuer, scope, purpose). + InvalidNonceError: If nonce is missing or invalid. This will contain the error, + description, and a valid nonce for the caller to send back to client as + header (per spec). Caller must extract the information from this exception. + """ + if not dpop_header: + raise ValueError("Invalid DPoP proof: Empty string / None provided") + if not access_token: + raise ValueError("Invalid access token: Empty string / None provided") + # Checked here rather than only inside key discovery: a caller supplying its + # own public_key skips discovery entirely, and an empty allowlist would then + # reach access token validation with nothing constraining `iss`. + if not issuers: + raise ValueError( + "Invalid issuers: a non-empty allowlist is required to validate the " + "access token's iss claim" + ) + + _reject_bearer_prefixed_token(access_token) + + dpop_claims, client_jwk = validate_dpop_proof( + dpop_header=dpop_header, + request_method=request_method, + request_url=request_url, + unvalidated_access_token=access_token, + require_nonce=require_nonce, + secret=secret, + as_resource_server=as_resource_server, + jti_seen_callback=jti_seen_callback, + ) + + if not public_key: + # Enforce the issuer allowlist against the UNVERIFIED iss before doing + # any key discovery. + public_key = await get_any_public_key_for_token_async( + access_token, allowed_issuers=issuers + ) + + if isinstance(scope, list): + scope = set(scope) + + validated_access_token_claims = token_core.validate_jwt( + encoded_token=access_token, + public_key=public_key, + aud=aud, + scope=scope, + allowed_issuers=issuers, + purpose=purpose, + options=options, + denylist_callback=denylist_callback, + ) + + return dpop_claims, validated_access_token_claims, client_jwk + + +def validate_dpop_proof( + dpop_header: str, + request_method: str, + request_url: str, + unvalidated_access_token: str | None = None, + require_nonce: bool = False, + secret: str | None = None, + as_resource_server: bool = True, + jti_seen_callback: Callable | None = None, +) -> tuple[dict[str, Any], jwk.Key]: + """ + Validate a DPoP proof JWT for a resource server request. + + IMPORTANT: This DOES NOT validate the unvalidated_access_token beyond the validation + of it being bound to the DPoP Proof. e.g. you MUST separately + validate the access token and any additional authorization. + + This function performs comprehensive validation of a DPoP proof including: + - Header validation (typ, jwk presence, asymmetric key, key strength) + - Signature verification (with the algorithm allowlist pinned) + - Claim type validation (iat, exp, jti, htm, htu, nonce) + - Time-based validation (iat, exp) + - htm (HTTP method) validation + - htu (URL) validation + - ath (access token hash) validation if provided + - nonce validation if required + - jti replay rejection if jti_seen_callback is provided + + Args: + dpop_header (str): The DPoP proof JWT string from the DPoP header. + request_method (str): The HTTP method of the incoming request. + request_url (str): The full URL of the incoming request (scheme + host + + path), as the CLIENT saw it. + unvalidated_access_token (str | None): Optional access token to validate ath claim against. + require_nonce (bool): Whether to require and validate a nonce. + secret (str | None): Optional secret key for stateless nonce verification. + If None, defaults to the environment-configured secret. + as_resource_server (bool): Default True. Whether caller is a Resource Server. + False implies caller is the Authorization Server. This alters some of the + error messaging behavior (per the spec). + jti_seen_callback (Callable | None): a callback taking (jti: str) that + returns True if this jti has already been used, per RFC 9449 11.1. + + Returns: + dict[str, Any], jwk.Key: dict with decoded claims dict, validated client_jwk from dpop header + + Raises: + ValueError: If any validation fails. All underlying JoseError, + TypeError and UnicodeEncodeError failures are normalized to + ValueError so that callers can rely on a single exception type. + InvalidNonceError: If nonce is missing or invalid. This will contain the error, + description, and a valid nonce for the caller to send back to client as + header (per spec). Caller must extract the information from this exception. + + Example: + >>> claims = validate_dpop_proof( + ... dpop_header="eyJ0eXAi...", + ... request_method="GET", + ... request_url="https://api.example.com/ga4gh/tes/v1/jobs", + ... ) + """ + if not dpop_header: + raise ValueError("Invalid DPoP proof: Empty string / None provided") + + if not isinstance(dpop_header, str): + raise ValueError( + f"Invalid DPoP proof: expected a string, got {type(dpop_header).__name__}" + ) + + if len(dpop_header) > MAX_DPOP_HEADER_LENGTH: + raise ValueError( + f"Invalid DPoP proof: header exceeds {MAX_DPOP_HEADER_LENGTH} characters" + ) + + # get rid of any prefixed 'DPoP ' / 'dpop ' + dpop_header = _strip_auth_scheme(dpop_header) + + client_jwk = extract_and_validate_jwk(dpop_header) + + dpop_claims = _verify_signature_and_claims(dpop_header, client_jwk) + + _validate_proof_claims(dpop_claims, request_method, request_url) + + if require_nonce or "nonce" in dpop_claims: + _validate_nonce( + dpop_claims, + require_nonce=require_nonce, + secret=secret, + as_resource_server=as_resource_server, + ) + + if jti_seen_callback is not None: + if jti_seen_callback(dpop_claims["jti"]): + raise ValueError( + "DPoP proof replay detected: jti has already been used " + "within its validity window" + ) + + if unvalidated_access_token: + _reject_bearer_prefixed_token(unvalidated_access_token) + _validate_ath(dpop_claims, unvalidated_access_token) + _validate_key_binding(client_jwk, unvalidated_access_token) + + return dpop_claims, client_jwk + + +def extract_and_validate_jwk(dpop_header: str) -> jwk.Key: + """ + Extract and validate embedded JWK from DPoP proof header. + + Args: + dpop_header (str): Raw DPoP JWT string. + + Returns: + jwk.Key: Validated asymmetric jwk.Key. + + Raises: + ValueError: If header is malformed, missing jwk, uses a symmetric key, + uses a disallowed algorithm/curve, or the key is undersized. + """ + # get rid of any prefixed 'DPoP ' / 'dpop ' + dpop_header = _strip_auth_scheme(dpop_header) + + # Use custom registry with increased header size limit for DPoP proofs + # containing full JWKs (especially RSA keys which have large public keys). + registry = _new_registry() + + try: + # extract_compact only PARSES - it does not enforce the algorithm + # allowlist. The explicit SUPPORTED_DPOP_ALGS check below + # actually constrains the algorithm. + unverified_header: dict = jws.extract_compact( + dpop_header.encode("utf-8"), registry=registry + ).protected + except Exception as exc: + logging.error(exc, exc_info=True, stack_info=True) + raise ValueError("Invalid DPoP proof: malformed compact JWS structure") + + alg = unverified_header.get("alg") + if alg not in SUPPORTED_DPOP_ALGS: + raise ValueError(f"Unsupported or unpermitted DPoP signature algorithm: {alg}") + + typ = unverified_header.get("typ") + if not isinstance(typ, str) or typ.lower() != DPOP_JWT_TYPE: + raise ValueError(f"DPoP proof must have typ '{DPOP_JWT_TYPE}'") + + if "jwk" not in unverified_header: + raise ValueError("DPoP proof header must contain 'jwk'") + + if not isinstance(unverified_header["jwk"], dict): + raise ValueError("DPoP proof header 'jwk' must be a JSON object") + + try: + client_jwk: jwk.Key = jwk.import_key(unverified_header["jwk"]) + except Exception as exc: + logging.error(exc, exc_info=True, stack_info=True) + raise ValueError("Invalid DPoP proof: embedded jwk could not be imported") + + # Check if key is symmetric (oct/HMAC) + # joserfc exposes the key type as `key_type`, not `kty`. Read it defensively + # anyway: an unreadable or unexpected type falls into the reject branch + # below rather than being treated as asymmetric by default. + key_type = str(getattr(client_jwk, "key_type", "") or "").lower() + + if not key_type or key_type in ("none", "oct", "symmetric", "hmac"): + raise ValueError(f"DPoP proof must use asymmetric key. Got: `{key_type}`") + + if hasattr(client_jwk, "is_private") and client_jwk.is_private: + raise ValueError("DPoP proof jwk must not contain a private key") + + _validate_key_strength(client_jwk, key_type) + + return client_jwk + + +def compute_ath(access_token: str | bytes) -> str: + """ + Compute base64url(SHA-256(token)) per RFC 9449 4.2. + + "ath: Hash of the access token. The value MUST be the result + of a base64url encoding (as defined in Section 2 of [RFC7515]) + the SHA-256 [SHS] hash of the ASCII encoding of the associated + access token's value." + + Args: + access_token (str | bytes): The access token (can be string or bytes). + + Returns: + str: Base64url-encoded SHA-256 hash. + + Raises: + TypeError: If access_token is neither str nor bytes. + UnicodeEncodeError: If a str token contains non-ASCII characters, per + RFC 9449 4.2's requirement to hash the ASCII encoding. + """ + if isinstance(access_token, str): + token_bytes = access_token.encode("ascii") + elif isinstance(access_token, bytes): + token_bytes = access_token + else: + raise TypeError( + f"access_token must be str or bytes, not {type(access_token).__name__}" + ) + + digest = hashlib.sha256(token_bytes).digest() + return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + + +class _LargeHeaderRegistry(jws.JWSRegistry): + """ + JWS registry for DPoP proofs: larger header limit, mandatory alg pinning. + + Two deviations from the joserfc base class, both deliberate: + + - `max_header_length` is raised, because a DPoP proof embeds the full + public JWK in its header and RSA keys exceed the default limit. + - `algorithms` is REQUIRED and must be non-empty. joserfc's `get_alg` + guards with `if self.allowed:`, so a falsy collection (`None`, + `set()`, `[]`) silently falls through to "allow every recommended + algorithm" - which includes symetric algs not allowed. + """ + + max_header_length = 4096 + + def __init__( + self, + header_registry: dict | None = None, + algorithms: Collection[str] | None = None, + strict_check_header: bool = True, + ) -> None: + """ + Build the registry, refusing any falsy algorithm allowlist. + + Args: + header_registry (dict | None): Extra JOSE header definitions. + algorithms (Collection[str] | None): Permitted JWS algorithms. Required. + strict_check_header (bool): Whether to reject unknown header params. + + Raises: + ValueError: If algorithms is empty or None. + """ + if not algorithms: + raise ValueError( + "A non-empty 'algorithms' allowlist is required. joserfc treats a " + "falsy algorithms collection as 'allow all recommended algorithms', " + "which would permit disallowed DPoP algorithms." + ) + super().__init__( + header_registry=header_registry, + algorithms=algorithms, + strict_check_header=strict_check_header, + ) + + +def _new_registry() -> _LargeHeaderRegistry: + """ + Build a JWS registry with the DPoP algorithm allowlist pinned. + + Returns: + _LargeHeaderRegistry: Registry pinned to SUPPORTED_DPOP_ALGS. + + Raises: + ValueError: If SUPPORTED_DPOP_ALGS has been reassigned to a falsy + value, which would otherwise silently disable checks. + """ + return _LargeHeaderRegistry(algorithms=SUPPORTED_DPOP_ALGS) + + +def _strip_auth_scheme(header_value: str, expected_scheme: str = "dpop") -> str: + """ + Strip an optional auth scheme prefix (e.g. "DPoP ") from a header. + + Rejects a mismatched scheme rather than silently accepting it, so that a + value copied out of the wrong header (e.g. "Bearer eyJ...") fails loudly. + + Args: + header_value (str): Raw header value. + expected_scheme (str): Scheme to permit, compared case-insensitively. + + Returns: + str: The header value with any scheme prefix removed. + + Raises: + ValueError: If a scheme prefix is present but is not expected_scheme. + """ + stripped = header_value.strip() + if " " not in stripped: + return stripped + + scheme, _, remainder = stripped.partition(" ") + remainder = remainder.strip() + + # A JWT has no spaces, so a leading token containing no '.' is a scheme. + if "." not in scheme: + if scheme.lower() != expected_scheme.lower(): + raise ValueError( + f"Invalid DPoP proof: unexpected authorization scheme '{scheme}'. " + f"Expected '{expected_scheme}' or a bare JWT." + ) + return remainder + + raise ValueError("Invalid DPoP proof: contains unexpected whitespace") + + +def _reject_bearer_prefixed_token(access_token: str) -> str: + """ + Reject an access token that still carries the 'Bearer ' prefix. + + The ath claim is computed over the raw token, so a full Authorization + header value would silently produce an ath mismatch (or a JSON parse + failure when reading cnf.jkt). Fail with a clear message instead. + + Args: + access_token (str): The access token as supplied by the caller. + + Returns: + str: The access token unchanged. + + Raises: + ValueError: If the value is prefixed with an auth scheme. + """ + if not isinstance(access_token, str): + raise ValueError( + f"access_token must be a string, not {type(access_token).__name__}" + ) + + lowered = access_token.lower() + for scheme in ("bearer ", "dpop "): + if lowered.startswith(scheme): + raise ValueError( + f"The provided access_token contains the '{scheme.strip()} ' prefix. " + "DPoP ath and cnf.jkt checks must use only the raw JWT/access token. " + "Ensure you are not passing an Authorization header's full contents." + ) + return access_token + + +def _resolve_proof_alg(key: jwk.Key) -> str: + """ + Pick the correct JWS algorithm for a DPoP proof signing key. + + Args: + key (jwk.Key): The proof signing key. + + Returns: + str: The JWS "alg" value to use. + + Raises: + ValueError: If the key type or curve is not supported for DPoP. + """ + key_type = (getattr(key, "kty", "") or getattr(key, "key_type", "")).upper() + + if key_type == "RSA": + return "RS256" + + if key_type == "EC": + crv = key.as_dict(private=False).get("crv") + if crv not in EC_CURVE_TO_ALG: + raise ValueError( + f"Unsupported EC curve for DPoP proof: {crv}. " + f"Allowed: {sorted(ALLOWED_EC_CURVES)}" + ) + return EC_CURVE_TO_ALG[crv] + + raise ValueError( + f"Unsupported key type for DPoP proof: '{key_type}'. " + "DPoP requires an asymmetric EC or RSA key." + ) + + +def _validate_key_strength(client_jwk: jwk.Key, key_type: str) -> None: + """ + Reject proof keys that are too weak or use a disallowed curve. + + joserfc only warns for RSA moduli below 2048 bits, and does not restrict + EC curves. A DPoP proof key is bound into issued access tokens via + cnf.jkt, so a weak key weakens every token bound to it. + + Args: + client_jwk (jwk.Key): The public key from the DPoP proof header. + key_type (str): Lowercased key type ("rsa" or "ec"). + + Raises: + ValueError: If the key is undersized or uses a disallowed curve. + """ + key_dict = client_jwk.as_dict(private=False) + + if key_type == "rsa": + # The modulus is known-decodable here: jwk.import_key decodes n to + # build the key and raises before this point if it cannot. + modulus = str(key_dict.get("n", "")) + padding = "=" * (-len(modulus) % 4) + modulus_bits = len(base64.urlsafe_b64decode(modulus + padding)) * 8 + if modulus_bits < MIN_RSA_KEY_BITS: + raise ValueError( + f"DPoP proof RSA key is too small: {modulus_bits} bits " + f"(minimum {MIN_RSA_KEY_BITS})" + ) + + elif key_type == "ec": + crv = key_dict.get("crv") + if crv not in ALLOWED_EC_CURVES: + raise ValueError( + f"DPoP proof uses a disallowed EC curve: {crv}. " + f"Allowed: {sorted(ALLOWED_EC_CURVES)}" + ) + + +def _validate_key_binding( + client_jwk: jwk.Key, + access_token: str, +) -> None: + """ + Validate that a client's public key matches a token's cnf.jkt claim. + + Args: + client_jwk (jwk.Key): The public key from the DPoP proof header. + access_token (str): The JWT access token string. + + Raises: + ValueError: If binding validation fails. + """ + token_jkt = _get_token_jkt(access_token) + proof_thumbprint = client_jwk.thumbprint() + + if proof_thumbprint != token_jkt: + raise ValueError( + f"Key binding mismatch: proof thumbprint '{proof_thumbprint}' " + f"doesn't match token cnf.jkt '{token_jkt}'" + ) + + +def _validate_claim_types(claims_dict: dict[str, Any]) -> None: + """ + Enforce claim types before any arithmetic or string comparison. + + Without this, a proof carrying e.g. {"iat": "9999999999"} reaches the + freshness arithmetic and raises an uncaught TypeError. + + Args: + claims_dict (dict[str, Any]): Decoded DPoP proof claims. + + Raises: + ValueError: If any present claim has an unexpected type. + """ + for numeric_claim in ("iat", "exp", "nbf"): + if numeric_claim in claims_dict: + value = claims_dict[numeric_claim] + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError( + f"DPoP proof '{numeric_claim}' claim must be a number, " + f"got {type(value).__name__}" + ) + + for string_claim in ("jti", "htm", "htu", "nonce", "ath"): + if string_claim in claims_dict and not isinstance( + claims_dict[string_claim], str + ): + raise ValueError( + f"DPoP proof '{string_claim}' claim must be a string, " + f"got {type(claims_dict[string_claim]).__name__}" + ) + + +def _verify_signature_and_claims( + dpop_header: str, client_jwk: jwk.Key +) -> dict[str, Any]: + """ + Verify JWS signature and decode claims with leeway. + + Args: + dpop_header (str): Raw DPoP JWT string. + client_jwk (jwk.Key): Public key extracted from the proof header. + + Returns: + dict[str, Any]: Decoded claims dict. + + Raises: + ValueError: If signature verification, claim typing, or time + validation fails. joserfc's JoseError subclasses are normalized to + ValueError here so callers see a single exception type. + """ + registry = _new_registry() + + try: + dpop_claims = jwt.decode(dpop_header, client_jwk, registry=registry) + except JoseError as exc: + logging.error(exc, exc_info=True, stack_info=True) + raise ValueError(f"Invalid DPoP proof: {exc}") + except Exception as exc: + logging.error(exc, exc_info=True, stack_info=True) + raise ValueError("Invalid DPoP proof: could not verify signature") + + claims_dict = dpop_claims.claims + + if not isinstance(claims_dict, dict): + raise ValueError("Invalid DPoP proof: payload is not a JSON object") + + _validate_claim_types(claims_dict) + + current_time = int(time.time()) + + if "exp" in claims_dict: + if current_time >= claims_dict["exp"]: + raise ValueError("Invalid DPoP proof: expired") + + # Check iat (issued at) - not too old (5 min leeway like authlib) + # The proof should be generated moments before the request is sent + if "iat" in claims_dict: + # Reject if proof is older than DPOP_PROOF_MAX_TTL + if claims_dict["iat"] + DPOP_PROOF_MAX_TTL < current_time: + raise ValueError("Invalid DPoP proof: proof created too far in the past") + + # Reject if proof is issued in the future (allowing minor clock skew) + if claims_dict["iat"] - DPOP_PROOF_CLOCK_SKEW_LEEWAY > current_time: + raise ValueError("Invalid DPoP proof: proof issued in the future") + + return claims_dict + + +def _validate_proof_claims( + dpop_claims: dict[str, Any], request_method: str, request_url: str +) -> None: + """ + Validate required claims for a DPoP proof on a resource request. + + Args: + dpop_claims (dict[str, Any]): Decoded DPoP proof claims. + request_method (str): The HTTP method of the incoming request. + request_url (str): The full URL of the incoming request. + + Raises: + ValueError: If any claim is missing or mismatched. + """ + jti = dpop_claims.get("jti") + if not jti: + raise ValueError("DPoP proof missing jti") + + if "iat" not in dpop_claims: + raise ValueError("DPoP proof missing required 'iat' claim") + + # protect against arbitrarily large jti's + if not isinstance(jti, str) or len(jti) > MAX_JTI_LENGTH: + raise ValueError("DPoP proof jti is malformed or unnecessarily large") + + if not isinstance(request_method, str) or not request_method: + raise ValueError("request_method must be a non-empty string") + + # Treat a missing URL as a caller bug rather than comparing. + if not isinstance(request_url, str) or not request_url: + raise ValueError("request_url must be a non-empty string") + + htm_value = dpop_claims.get("htm", "") + if request_method.upper() != htm_value.upper(): + raise ValueError( + f"htm mismatch: expected '{request_method}', htm in proof: '{htm_value}'" + ) + + htu_value = dpop_claims.get("htu", "") + + # RFC 9449 4.2 requires htu to carry no query or fragment. Reject rather + # than normalize, so a non-compliant proof cannot be silently accepted. + if "?" in htu_value or "#" in htu_value: + raise ValueError( + "DPoP proof 'htu' claim must not contain a query string or fragment" + ) + + # Normalize BOTH sides so that an explicit default port (e.g. :443 added by + # a reverse proxy) does not cause a mismatch. + actual_url = _get_url(request_url) + if actual_url != _get_url(htu_value): + raise ValueError( + f"htu mismatch: request URL '{actual_url}' != proof htu '{htu_value}'" + ) + + +def _validate_nonce( + dpop_claims: dict[str, Any], + require_nonce: bool, + secret: str | None = None, + as_resource_server: bool = True, +) -> None: + """ + Validate DPoP nonce; raise InvalidNonceError if missing or expired. + InvalidNonceError will CONTAIN the new nonce and error response information + required per the spec. + + Args: + dpop_claims (dict[str, Any]): Decoded DPoP proof claims. + require_nonce (bool): Whether a nonce is required. If True and no nonce + is present, raises InvalidNonceError. If False and no nonce is present, + the function returns early without validation. + secret (str | None): Optional secret key for stateless nonce verification. + If None, defaults to the environment-configured secret. + as_resource_server (bool): Whether the caller is a Resource Server, + which determines the error class and HTTP status used. + + Raises: + InvalidNonceError: If nonce is missing when required, or if the nonce is + invalid or expired according to verify_stateless_nonce. + """ + client_nonce = dpop_claims.get("nonce", "") + + if not client_nonce: + if require_nonce: + _raise_invalid_nonce(as_resource_server, secret) + + # Nonce wasn't required and wasn't provided, safe to skip + return + + if not verify_stateless_nonce(client_nonce, secret=secret): + _raise_invalid_nonce(as_resource_server, secret) + + +def _raise_invalid_nonce(as_resource_server: bool, secret: str | None) -> None: + """Raises appropriate error based on whether the caller is a Resource Server.""" + if as_resource_server: + raise InvalidNonceErrorResourceServer( + new_nonce=generate_stateless_nonce(secret=secret) + ) + raise InvalidNonceErrorAuthorizationServer( + new_nonce=generate_stateless_nonce(secret=secret) + ) + + +def _validate_ath(dpop_claims: dict[str, Any], access_token: str) -> None: + """ + Validate access token hash per RFC 9449 4.2. + + Args: + dpop_claims (dict[str, Any]): Decoded DPoP proof claims. + access_token (str): The access token string. + + Raises: + ValueError: If ath is absent, or does not match SHA-256 of the token. + """ + if "ath" not in dpop_claims: + raise ValueError( + "DPoP proof is missing the 'ath' claim, which is required when " + "presented alongside an access token (RFC 9449 4.2)" + ) + + try: + expected_ath = compute_ath(access_token) + except (UnicodeEncodeError, TypeError) as exc: + raise ValueError(f"Could not compute ath for the provided access token: {exc}") + + if dpop_claims.get("ath") != expected_ath: + # Deliberately does not include the token or its expected hash. + raise ValueError("ath claim does not match the presented access token") + + +def _get_token_jkt(access_token: str) -> str: + """ + Extract the jkt (key thumbprint) from a token's cnf claim. + + Args: + access_token (str): The JWT access token string. + + Returns: + str: The key thumbprint string. + + Raises: + ValueError: If the token is malformed or the jkt claim is missing/invalid. + """ + claims = _get_unverified_claims(access_token) + cnf = claims.get("cnf", {}) + + if not isinstance(cnf, dict): + raise ValueError("Access token cnf claim must be an object") + + jkt = cnf.get("jkt") + + # Validate jkt is a non-empty string (reject null, array, empty string) + if not isinstance(jkt, str) or not jkt: + raise ValueError("Access token cnf.jkt must be a non-empty string") + + return jkt + + +def _get_url(url: str) -> str: + """ + Extract the scheme, host, and path from a URL for the DPoP htu claim. + + Strips out query strings and fragments per RFC 9449 Section 4.2, and + removes a scheme's default port so that 'https://x/y' and + 'https://x:443/y' compare equal (RFC 9110 4.2). + + Args: + url (str): The full request URL. + + Returns: + str: The sanitized HTTP target URI (e.g., 'https://example.com/api/v1/resource') + """ + if not url: + logging.warning( + "No URL provided for DPoP htu claim. Using empty string instead." + ) + return "" + + # urlsplit, not urlparse: urlparse peels ";params" off the last path + # segment into a separate field, so rebuilding from .path alone would + # silently drop it - making a proof minted for "/a" also satisfy a request + # to "/a;evil", which weakens the request binding htu exists to provide. + parsed = urlsplit(url) + scheme = parsed.scheme.lower() + + # Drop an explicit default port so proxy-added ':443' does not break htu + # comparison. + try: + port = parsed.port + except ValueError: + raise ValueError(f"URL contains an invalid port: {url}") + + hostname = (parsed.hostname or "").lower() + netloc = hostname + if port is not None and str(port) != DEFAULT_PORTS_BY_SCHEME.get(scheme): + netloc = f"{hostname}:{port}" + + # Rebuild using only scheme, netloc (host/port), and path. + # This automatically drops parsed.query and parsed.fragment. + return f"{scheme}://{netloc}{parsed.path}" + + +def _get_unverified_claims(token: str) -> dict[str, Any]: + """ + Extract the JWT claims payload WITHOUT verifying the signature. + + IMPORTANT: Verify the signature elsewhere! + + Args: + token (str): The encoded JWT. + + Returns: + dict[str, Any]: The decoded claims payload. + + Raises: + ValueError: If the token is not a well-formed JWS with a JSON payload. + """ + if not isinstance(token, str) or not token: + raise ValueError("Cannot read claims: token must be a non-empty string") + + try: + token_bytes = token.encode("utf-8") + + # (header, payload, signature) + obj = jws.extract_compact(token_bytes) + + # The payload is returned as bytes, so we decode and parse the JSON + claims = json.loads(obj.payload.decode("utf-8")) + except Exception as exc: + logging.error(exc, exc_info=True, stack_info=True) + raise ValueError("Could not read claims: token is not a well-formed JWT") + + if not isinstance(claims, dict): + raise ValueError("Could not read claims: token payload is not a JSON object") + + return claims diff --git a/src/authutils/errors.py b/src/authutils/errors.py index 6ce5d97..c3c2e51 100644 --- a/src/authutils/errors.py +++ b/src/authutils/errors.py @@ -1,6 +1,110 @@ from cdiserrors import AuthNError as CDISAuthNError +class InvalidNonceError(CDISAuthNError): + """ + Base for the DPoP nonce errors, so a caller can catch either with one name. + + Both subclasses carry a freshly minted nonce for the client in + `error_headers`; which one is raised depends only on whether the caller is + an authorization server or a resource server. Declared here so that a caller + catching this base can read the headers without narrowing to a subclass. + """ + + error_headers: dict[str, str] + + +class InvalidNonceErrorAuthorizationServer(InvalidNonceError): + """ + Handle DPoP Nonce per the spec RFC9449. + + This error contains the necessary information for an + Authorization server to + return an error specifying a new nonce to the client (in not + provided one or nonce provided is invalid). + """ + + def __init__( + self, + new_nonce: str, + message: str = "use_dpop_nonce", + code: int = 400, + json: dict | None = None, + ) -> None: + """ + Build the error carrying a freshly minted nonce for the client. + + Args: + new_nonce (str): Nonce to hand back in the DPoP-Nonce header. + message (str): Error code per RFC 9449 8. + code (int): HTTP status the authorization server should return. + json (dict | None): Response body override. Defaults to the RFC + 9449 use_dpop_nonce body. Built per instance rather than as a + default argument, so a caller mutating exc.json cannot alter + every subsequent error. + """ + super().__init__( + message, + code=code, + json=( + json + if json is not None + else { + "error": "use_dpop_nonce", + "error_description": "Authorization server requires nonce in DPoP proof", + } + ), + ) + self.error_headers = {"DPoP-Nonce": new_nonce} + + +class InvalidNonceErrorResourceServer(InvalidNonceError): + """ + Handle DPoP Nonce per the spec RFC9449. + + This error contains the necessary information for a + Resource Server to + return an error specifying a new nonce to the client (in not + provided one or nonce provided is invalid). + """ + + def __init__( + self, + new_nonce: str, + message: str = "use_dpop_nonce", + code: int = 401, + json: dict | None = None, + ) -> None: + """ + Build the error carrying a freshly minted nonce for the client. + + Args: + new_nonce (str): Nonce to hand back in the DPoP-Nonce header. + message (str): Error code per RFC 9449 8. + code (int): HTTP status the resource server should return. + json (dict | None): Response body override. Defaults to the RFC + 9449 use_dpop_nonce body. Built per instance rather than as a + default argument, so a caller mutating exc.json cannot alter + every subsequent error. + """ + super().__init__( + message, + code=code, + json=( + json + if json is not None + else { + "error": "use_dpop_nonce", + "error_description": "Resource server requires nonce in DPoP proof", + } + ), + ) + self.error_headers = { + "DPoP-Nonce": new_nonce, + "WWW-Authenticate": 'DPoP error="use_dpop_nonce", error_description="Resource server requires nonce in DPoP proof"', + } + + class AuthError(CDISAuthNError): pass diff --git a/src/authutils/globals.py b/src/authutils/globals.py index d2b982f..b5857f1 100644 --- a/src/authutils/globals.py +++ b/src/authutils/globals.py @@ -3,7 +3,6 @@ global variables for auth """ - ROLES = { "ADMIN": "admin", "CREATE": "create", diff --git a/src/authutils/oauth2/client/blueprint.py b/src/authutils/oauth2/client/blueprint.py index a002a33..d8a1eb1 100644 --- a/src/authutils/oauth2/client/blueprint.py +++ b/src/authutils/oauth2/client/blueprint.py @@ -33,7 +33,6 @@ import authutils.oauth2.client.authorize - blueprint = flask.Blueprint("oauth", __name__) diff --git a/src/authutils/oauth2/client/client.py b/src/authutils/oauth2/client/client.py index 5938824..ba74417 100644 --- a/src/authutils/oauth2/client/client.py +++ b/src/authutils/oauth2/client/client.py @@ -1,5 +1,4 @@ # rename to maintain backwards compatibility from authlib.integrations.flask_client import OAuth as OAuthClient - __all__ = ["OAuthClient"] diff --git a/src/authutils/token/__init__.py b/src/authutils/token/__init__.py index d9f72b4..4c063bf 100644 --- a/src/authutils/token/__init__.py +++ b/src/authutils/token/__init__.py @@ -8,9 +8,9 @@ get_jwt_token, ) except ImportError: - current_token = ( - set_current_token - ) = store_session_token = get_session_token = get_jwt_token = None + current_token = set_current_token = store_session_token = get_session_token = ( + get_jwt_token + ) = None __all__ = [ "current_token", diff --git a/src/authutils/token/core.py b/src/authutils/token/core.py index d0b2ecd..69bfd96 100644 --- a/src/authutils/token/core.py +++ b/src/authutils/token/core.py @@ -1,5 +1,8 @@ -import httpx +import httpx2 import jwt +from collections.abc import Callable + +from jwt.types import Options from ..errors import ( JWTAudienceError, @@ -9,22 +12,64 @@ JWTError, ) +from cdislogging import get_logger + +logging = get_logger(__name__) + +# Timeout for each outbound key-discovery request. +# NOTE: the discovery doc here and the JWKS fetch in token.keys both use +# it, so a cold lookup can take 2x this. +KEYS_REQUEST_TIMEOUT = 10.0 + def get_keys_url(issuer, force_issuer=None): """ Prefer OIDC discovery doc, but fall back on Fence-specific /jwt/keys for backwards compatibility (or if `force_issuer` is True) """ - jwt_keys_url = "/".join([issuer.strip("/"), "jwt", "keys"]) - if force_issuer: + jwt_keys_url, openid_cfg_path = _keys_url_candidates(issuer, force_issuer) + if openid_cfg_path is None: return jwt_keys_url - openid_cfg_path = "/".join( - [issuer.strip("/"), ".well-known", "openid-configuration"] - ) try: - jwks_uri = httpx.get(openid_cfg_path).json().get("jwks_uri", "") + jwks_uri = ( + httpx2.get( + openid_cfg_path, timeout=httpx2.Timeout(timeout=KEYS_REQUEST_TIMEOUT) + ) + .json() + .get("jwks_uri", "") + ) return jwks_uri - except Exception: + except Exception as exc: + _log_discovery_fallback(openid_cfg_path, jwt_keys_url, exc) + return jwt_keys_url + + +async def get_keys_url_async(issuer: str, force_issuer: bool | None = None) -> str: + """ + Async counterpart of get_keys_url. + + Same discovery-then-fallback behavior; issues the OIDC discovery request + on an AsyncClient so an async caller does not block its event loop. + + Args: + issuer (str): The token issuer. + force_issuer (bool | None): Skip discovery and use /jwt/keys. + + Returns: + str: The keys URL to fetch. + """ + jwt_keys_url, openid_cfg_path = _keys_url_candidates(issuer, force_issuer) + if openid_cfg_path is None: + return jwt_keys_url + + try: + async with httpx2.AsyncClient() as client: + response = await client.get( + openid_cfg_path, timeout=httpx2.Timeout(timeout=KEYS_REQUEST_TIMEOUT) + ) + return response.json().get("jwks_uri", "") + except Exception as exc: + _log_discovery_fallback(openid_cfg_path, jwt_keys_url, exc) return jwt_keys_url @@ -61,18 +106,24 @@ def validate_purpose(claims, pur): the expected value """ if "pur" not in claims: - raise JWTPurposeError("claims missing `pur` claim") + raise JWTPurposeError("claims missing ``pur`` claim") if claims["pur"] != pur: raise JWTPurposeError( - "claims have incorrect purpose: expected {}, got {}".format( - pur, claims["pur"] - ) + f"claims have incorrect purpose: expected {pur}, got {claims['pur']}" ) def validate_jwt( - encoded_token, public_key, aud, scope, issuers, options={}, logger=None -): + encoded_token: str, + public_key: str | bytes, + aud: str | list[str] | None, + scope: set[str] | list[str] | None, + allowed_issuers: set[str] | list[str], + purpose: str | None = None, + options: dict | None = None, + denylist_callback: Callable | None = None, + logger: Callable | None = None, +) -> dict: """ Validate the encoded JWT ``encoded_token``, which must satisfy the scopes ``scope``. @@ -86,54 +137,72 @@ def validate_jwt( ``aud`` arg is passed, or if the ``aud`` arg does not match one of the items in the token aud field, because the audience is not validated anymore - - Check issuers: token iss field must match one of the items in the - ``issuers`` arg + - Check allowed_issuers: token iss field must match one of the items in the + ``allowed_issuers`` arg - Check scopes: token scopes must be a superset of required scopes (the ``scope`` argument); fail if not satisfied + - Validate purpose: optional check of the ``pur`` claim + - Denylist validation: optional callback to check if token is denylisted Args: encoded_token (str): encoded JWT - public_key (str): public key to validate the JWT signature - aud (Optional[str|list]): + public_key (str | bytes): public key to validate the JWT signature + aud (str | list[str] | None): if provided, JWT validation will require that the token's ``aud`` value contains the arg value; if not provided, validation will require that the token not have an aud field. - scope (Optional[Iterable[str]]): + scope (set[str] | list[str] | None): set of scopes, each of which the JWT must satisfy in its - ``scope`` claim. Optional. - issuers (list or set): allowed issuers whitelist - options (Optional[dict]): options to pass through to pyjwt's decode + ``scope`` claim. Pass None to skip the check. + allowed_issuers (set[str] | list[str]): allowed issuers whitelist. + Required and must be non-empty: there is no way to ask this + function to accept a token from any issuer. + options (dict | None): options to pass through to pyjwt's decode + purpose (str | None): expected purpose of the token (e.g., 'access', 'refresh') + IF PURPOSE IS NONE (DEFAULT) THIS SKIPS VALIDATION. + denylist_callback (Callable | None): a callback function that takes + (jti: str) and returns True if the token is denylisted. + The callback is called after basic JWT validation. - Return: + Returns: dict: the decoded and validated JWT Raises: - ValueError: if receiving an incorrectly-typed argument + ValueError: if receiving an incorrectly-typed argument, or if + allowed_issuers is empty JWTExpiredError: if token is expired JWTAudienceError: if aud validation fails JWTScopeError: if scope validation fails - JWTError: if some other token validation step fails + JWTPurposeError: if purpose validation fails + JWTError: if some other token validation step fails, including if + the denylist_callback indicates the token is denylisted """ + options = options or {} - # Typecheck arguments. - if not isinstance(aud, str) and not isinstance(aud, list) and not aud is None: + if not isinstance(aud, str) and not isinstance(aud, list) and aud is not None: raise ValueError( - "aud must be string, list or None. Instead received aud of type {}".format( - type(aud) - ) + f"aud must be string, list or None. Instead received aud of type {type(aud)}" ) - if not isinstance(scope, set) and not isinstance(scope, list) and not scope is None: + if not isinstance(scope, set) and not isinstance(scope, list) and scope is not None: raise ValueError( - "scope must be set or list or None. Instead received scope of type {}".format( - type(scope) - ) + f"scope must be set or list or None. Instead received scope of type {type(scope)}" ) - if not isinstance(issuers, set) and not isinstance(issuers, list): + if not isinstance(allowed_issuers, set) and not isinstance(allowed_issuers, list): raise ValueError( - "issuers must be set or list. Instead received issuers of type {}".format( - type(issuers) - ) + f"allowed_issuers must be set or list. Instead received allowed_issuers of type {type(allowed_issuers)}" + ) + if not allowed_issuers: + raise ValueError( + "allowed_issuers must be non-empty. An empty allowlist would accept " + "a token from any issuer, so it is rejected rather than treated as " + "'skip the issuer check'." ) + if purpose is not None and not isinstance(purpose, str): + raise ValueError( + f"purpose must be a string or None. Instead received purpose of type {type(purpose)}. Value: {purpose}" + ) + if scope and isinstance(scope, list): + scope = set(scope) try: token = jwt.decode( @@ -141,22 +210,27 @@ def validate_jwt( key=public_key, algorithms=["RS256"], audience=aud, - options=options, + options=Options(**options), ) except jwt.InvalidAudienceError as e: - raise JWTAudienceError(e) + # aud may not be in scope, use original value + raise JWTAudienceError( + f"token audience validation failed: expected {aud}, got unknown" + ) except jwt.ExpiredSignatureError as e: - raise JWTExpiredError(e) + raise JWTExpiredError("token has expired") except jwt.InvalidTokenError as e: - raise JWTError(e) + raise JWTError(f"token validation failed: {e}") # PyJWT validates iat, exp, and aud fields; everything else # must happen here. # iss # Check that the issuer of the token has the expected hostname. - if token["iss"] not in issuers: - msg = "invalid issuer {}; expected: {}".format(token["iss"], issuers) + # Read with .get: a token carrying no iss at all must fail as a JWTError + token_iss = token.get("iss") + if token_iss not in allowed_issuers: + msg = f"invalid issuer {token_iss}; expected one of: {allowed_issuers}" raise JWTError(msg) # scope @@ -167,14 +241,58 @@ def validate_jwt( token_scopes = token_scopes.split() if not isinstance(token_scopes, list): raise JWTError( - "invalid format in scope claim: {}; expected string or list".format( - token["scopes"] - ) + f"invalid format in scope claim: {token.get('scopes')}; expected string or list" ) missing_scopes = set(scope) - set(token_scopes) if missing_scopes: raise JWTScopeError( - "token is missing required scopes: " + str(missing_scopes) + f"token is missing required scopes: {', '.join(sorted(missing_scopes))}" + ) + + # Validate the purpose claim if provided + if purpose: + validate_purpose(token, purpose) + + # Denylist validation: call the Denylist callback if provided + if denylist_callback is not None: + if not callable(denylist_callback): + raise ValueError( + "denylist_callback must be a callable that takes (jti) argument" ) + jti = token.get("jti", "") + if denylist_callback(jti): + raise JWTError("token is denylisted") return token + + +def _keys_url_candidates( + issuer: str, force_issuer: bool | None = None +) -> tuple[str, str | None]: + """ + Build the legacy keys URL and, unless forced, the OIDC discovery URL. + + Args: + issuer (str): The token issuer. + force_issuer (bool | None): Skip discovery and use /jwt/keys. + + Returns: + tuple[str, str | None]: The /jwt/keys URL, and the discovery URL or + None when discovery should be skipped. + """ + jwt_keys_url = "/".join([issuer.strip("/"), "jwt", "keys"]) + if force_issuer: + return jwt_keys_url, None + openid_cfg_path = "/".join( + [issuer.strip("/"), ".well-known", "openid-configuration"] + ) + return jwt_keys_url, openid_cfg_path + + +def _log_discovery_fallback( + openid_cfg_path: str, jwt_keys_url: str, exc: Exception +) -> None: + """Log that OIDC discovery failed and the legacy keys URL will be used.""" + logging.info( + f"Could not get public keys from: {openid_cfg_path}. Falling back to iss: {jwt_keys_url}. Exception: {exc}" + ) diff --git a/src/authutils/token/dpop_nonce.py b/src/authutils/token/dpop_nonce.py new file mode 100644 index 0000000..8dbad4b --- /dev/null +++ b/src/authutils/token/dpop_nonce.py @@ -0,0 +1,107 @@ +""" +Stateless DPoP nonce management using shared symmetric HS256 JWTs. + +The nonce is itself a JWT signed with a shared secret. +""" + +import time +import os + +from cdislogging import get_logger + +from joserfc import jwt +from joserfc.jwk import OctKey +from joserfc.errors import JoseError + +logging = get_logger(__name__) + + +def generate_stateless_nonce(secret: str | None = None) -> str: + """ + Mint a symmetric nonce token valid for DPOP_NONCE_TTL seconds. + + Returns: + str: HS256-signed JWT nonce token. + + Raises: + RuntimeError: If DPOP_SHARED_SECRET environment variable not set. + """ + shared_secret = _get_shared_secret(secret=secret) + if not shared_secret: + raise RuntimeError("DPOP_SHARED_SECRET environment variable not set") + + iat: int = int(time.time()) + exp: int = iat + _get_nonce_ttl() + + claims: dict = { + "iat": iat, + "exp": exp, + "purpose": "dpop_nonce", + } + header: dict = {"alg": "HS256"} + key = OctKey.import_key(shared_secret) + return jwt.encode(header, claims, key) + + +def verify_stateless_nonce(client_nonce: str, secret: str | None = None) -> bool: + """ + Verify nonce originated from this cluster and hasn't expired. + + Args: + client_nonce (str): Nonce token to verify. + secret (str | None): Shared secret. Defaults to DPOP_SHARED_SECRET. + + Returns: + bool: True if nonce is valid and within TTL, False otherwise. Never + raises: every failure mode, including an unexpected one, is reported + as an invalid nonce. + """ + if not isinstance(client_nonce, str): + return False + + shared_secret = _get_shared_secret(secret=secret) + if not client_nonce or not shared_secret: + return False + + try: + key = OctKey.import_key(shared_secret) + token = jwt.decode( + client_nonce, + key, + # This symetric alg uses secret key for both signing and verifying, + # so only parties with the shared key can validate. + algorithms=["HS256"], + ) + claims = token.claims + + now = int(time.time()) + iat = claims.get("iat") + exp = claims.get("exp") + + # exp is required, not just checked when present + if exp is None or exp < now: + return False + + if iat is not None and exp < iat: + return False + + return claims.get("purpose") == "dpop_nonce" + except (JoseError, TypeError): + # BadSignatureError and InvalidPayloadError are JoseError subclasses. + logging.debug("invalid nonce", exc_info=True) + return False + except Exception: + logging.exception( + "unknown error when attempting to verify nonce. Returning False / invalid." + ) + return False + + +def _get_shared_secret(secret: str | None = None) -> str | None: + """Get DPOP_SHARED_SECRET from environment (read at runtime for testability).""" + return secret or os.getenv("DPOP_SHARED_SECRET") + + +def _get_nonce_ttl() -> int: + """Get DPOP_NONCE_TTL from environment (read at runtime for testability).""" + return int(os.getenv("DPOP_NONCE_TTL", "300")) diff --git a/src/authutils/token/fastapi.py b/src/authutils/token/fastapi.py index 7df8282..311df25 100644 --- a/src/authutils/token/fastapi.py +++ b/src/authutils/token/fastapi.py @@ -1,7 +1,7 @@ from asyncio import Future, get_event_loop from collections import OrderedDict -import httpx +import httpx2 from fastapi import Security, HTTPException from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from starlette.status import HTTP_403_FORBIDDEN @@ -53,46 +53,54 @@ def whoami(token=Depends(access_token("user", "openapi", purpose="access"))): allowed_issuers = [issuer] async def getter(token: HTTPAuthorizationCredentials = Security(bearer)): - nonlocal issuer, allowed_issuers assert token.scheme.lower() == "bearer" token = token.credentials loop = get_event_loop() + # Per-request copies. These are derived from the incoming + # token when the caller did not pin them, so persisting + # would let the first request seen decide the issuer for every + # later request -- one junk `iss` would 403 all subsequent valid tokens + # for the life of the process. + request_issuer = issuer + request_allowed_issuers = allowed_issuers + # get kid and issuer try: kid = await loop.run_in_executor(None, core.get_kid, token) - if issuer is None: - issuer = await loop.run_in_executor(None, core.get_iss, token) + if request_issuer is None: + request_issuer = await loop.run_in_executor(None, core.get_iss, token) except JWTError as e: raise HTTPException( status_code=HTTP_403_FORBIDDEN, detail="Bad bearer token: " + str(e) ) - if not allowed_issuers: - allowed_issuers = [issuer] - if issuer not in allowed_issuers: + if not request_allowed_issuers: + request_allowed_issuers = [request_issuer] + if request_issuer not in request_allowed_issuers: raise HTTPException( status_code=HTTP_403_FORBIDDEN, - detail="Bad bearer token: issuer is not allowed: " + issuer, + detail="Bad bearer token: issuer is not allowed: " + request_issuer, ) # get public key from cache, or fetch from issuer - pub_keys = _jwt_public_keys.get(issuer) + pub_keys = _jwt_public_keys.get(request_issuer) if not pub_keys: - pub_keys = _jwt_public_keys[issuer] = Future() + pub_keys = _jwt_public_keys[request_issuer] = Future() try: - async with httpx.AsyncClient() as client: - resp = await client.get(core.get_keys_url(issuer, force_issuer)) + keys_url = await core.get_keys_url_async(request_issuer, force_issuer) + async with httpx2.AsyncClient() as client: + resp = await client.get(keys_url) resp.raise_for_status() pub_keys.set_result( OrderedDict(get_pem_key(key) for key in resp.json()["keys"]) ) except Exception as e: - _jwt_public_keys.pop(issuer) + _jwt_public_keys.pop(request_issuer) pub_keys.set_exception( HTTPException( status_code=HTTP_403_FORBIDDEN, detail="Cannot fetch pubkey from issuer {}: {}".format( - issuer, str(e) + request_issuer, str(e) ), ) ) @@ -101,7 +109,7 @@ async def getter(token: HTTPAuthorizationCredentials = Security(bearer)): if not pub_key: raise HTTPException( status_code=HTTP_403_FORBIDDEN, - detail="Bad bearer token: kid not found in issuer: " + issuer, + detail="Bad bearer token: kid not found in issuer: " + request_issuer, ) # decode and validate the token @@ -113,7 +121,7 @@ async def getter(token: HTTPAuthorizationCredentials = Security(bearer)): pub_key, audience, scopes, - allowed_issuers, + request_allowed_issuers, ) if purpose: diff --git a/src/authutils/token/keys.py b/src/authutils/token/keys.py index dd3dc9c..5f5047b 100644 --- a/src/authutils/token/keys.py +++ b/src/authutils/token/keys.py @@ -22,8 +22,14 @@ """ import base64 +import binascii import json +import threading +import time from collections import OrderedDict +from collections.abc import Iterable +from typing import Any + from cdislogging import get_logger @@ -33,15 +39,129 @@ print( "Unable to import flask. Some functionalities may not work. Flask can be installed as an extra." ) -import jwt -import httpx +import httpx2 from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa from authutils.errors import JWTError -from .core import get_keys_url, get_kid, get_iss +from .core import ( + KEYS_REQUEST_TIMEOUT, + get_keys_url, + get_keys_url_async, + get_kid, + get_iss, +) + +logging = get_logger(__name__) + +# In-memory cache for token public key lookups (with TTL) +# Maps "issuer:kid" to {"key": pem_key, "expires_at": timestamp} +# An OrderedDict so that eviction can be true LRU (move_to_end on read). +_token_public_key_cache: OrderedDict[str, dict] = OrderedDict() + +# Maximum number of entries to cache to prevent memory overload +_TOKEN_PUBLIC_KEY_CACHE_MAX_SIZE = 100 + +# Guards _token_public_key_cache. It is module-global and mutated (including +# iterated during eviction) from request threads, so unsynchronized access can +# raise "dictionary changed size during iteration". +_token_public_key_cache_lock = threading.Lock() + + +async def get_any_public_key_for_token_async( + encoded_token: str, + allowed_issuers: Iterable[str], + cache_ttl: int = 300, + logger: Any = None, +) -> bytes: + """ + Get the public key for a token, without requiring a Flask app context. + + Includes an in-memory cache with TTL to avoid excessive network requests. + + Async-only by design. Discovery costs a DNS lookup plus up to two HTTP + round trips on a cache miss, which a synchronous version would spend + holding the caller's event loop. Flask services that need a key inside a + request context should keep using get_public_key_for_token. + + The token's `kid` selects the key. If the token names a `kid` that the + issuer does not publish, this raises. + + Args: + encoded_token (str): Encoded JWT token. + allowed_issuers (Iterable[str]): Exact-match allowlist of issuers, + enforced before any outbound request. Required, and the only thing + standing between an unverified `iss` claim and an outbound request + to a host of the caller's choosing. + cache_ttl (int): Cache time-to-live in seconds (default: 300s / 5 minutes). + logger (Any): Logger instance. Defaults to module logger. + + Returns: + bytes: Public key in PEM format. + + Raises: + ValueError: If allowed_issuers is empty. + JWTError: If the token is malformed, the issuer is not allowed, or the + public key cannot be fetched or is not published by the issuer. + """ + logger = logger or get_logger(__name__) + + allowed_issuers = set(allowed_issuers) + if not allowed_issuers: + raise ValueError( + "allowed_issuers must be non-empty: it is what constrains which host " + "key discovery will contact for an unverified iss claim." + ) + + try: + iss = get_iss(encoded_token) + token_kid = get_kid(encoded_token) + except JWTError as e: + raise JWTError(f"Could not extract issuer/kid from token: {str(e)}") + + if not iss: + raise JWTError("Token is missing issuer (iss claim)") + + # Enforce the issuer allowlist before touching the network. + _assert_issuer_allowed(iss, allowed_issuers, logger) + + cache_key = f"{iss}:{token_kid}" + + cached_key = _get_public_key_from_cache(cache_key, logger) + if cached_key: + return cached_key + + logger.debug(f"cache miss. attempting to get keys URL from iss: {iss}...") + try: + keys_url = await get_keys_url_async(iss) + except Exception as exc: + logger.error(exc, stack_info=True, exc_info=True) + raise JWTError(f"Could not resolve keys URL for iss {iss}: {str(exc)}") + + try: + logger.info(f"hitting keys URL from iss: {iss}, keys_url: {keys_url}...") + jwks_data = await _fetch_jwks_async(keys_url) + except Exception as exc: + logger.error(exc, stack_info=True, exc_info=True) + raise JWTError(f"Could not fetch JWKS from {keys_url}: {str(exc)}") + + pem_key = _pem_key_from_jwks(jwks_data, keys_url, token_kid, iss, logger) + _save_public_key_to_cache(cache_key, pem_key, cache_ttl, logger) + + return pem_key + + +def clear_public_key_cache() -> None: + """ + Empty the in-memory public key cache. + + Intended for tests and for operational cache invalidation after key + rotation. + """ + with _token_public_key_cache_lock: + _token_public_key_cache.clear() def get_pem_key(key, logger=None): @@ -56,12 +176,8 @@ def get_pem_key(key, logger=None): "Serializing RSA public key (kid: {}) to PEM format.".format(key["kid"]) ) # Decode public numbers https://tools.ietf.org/html/rfc7518#section-6.3.1 - n_padded_bytes = base64.urlsafe_b64decode( - key["n"] + "=" * (4 - len(key["n"]) % 4) - ) - e_padded_bytes = base64.urlsafe_b64decode( - key["e"] + "=" * (4 - len(key["e"]) % 4) - ) + n_padded_bytes = _strict_b64url_decode(key["n"], "n", key.get("kid")) + e_padded_bytes = _strict_b64url_decode(key["e"], "e", key.get("kid")) n = int.from_bytes(n_padded_bytes, "big", signed=False) e = int.from_bytes(e_padded_bytes, "big", signed=False) # Serialize and encode public key--PyJWT decode/validation requires PEM @@ -131,7 +247,7 @@ def refresh_jwt_public_keys(user_api=None, pkey_cache=None, logger=None): Raises: ValueError: if user_api is not provided or set in app config """ - logger = logger or get_logger(__name__, log_level="info") + logger = logger or get_logger(__name__) if pkey_cache is None: pkey_cache = {} # First, make sure the app has a ``jwt_public_keys`` attribute set up. @@ -160,7 +276,7 @@ def refresh_jwt_public_keys(user_api=None, pkey_cache=None, logger=None): ) path = get_keys_url(user_api, force_issuer) try: - jwt_public_keys = httpx.get(path).json()["keys"] + jwt_public_keys = httpx2.get(path).json()["keys"] except: raise JWTError( "Attempted to refresh public keys for {}," @@ -231,7 +347,7 @@ def get_public_key(kid, iss=None, attempt_refresh=True, pkey_cache=None, logger= or flask.current_app.config.get("OIDC_ISSUER") or flask.current_app.config["USER_API"] ) - logger = logger or get_logger(__name__, log_level="info") + logger = logger or get_logger(__name__) if flask.has_app_context(): need_refresh = not hasattr(flask.current_app, "jwt_public_keys") or ( @@ -282,7 +398,7 @@ def get_public_key_for_token( Return: str: public RSA key for token verification """ - logger = logger or get_logger(__name__, log_level="info") + logger = logger or get_logger(__name__) kid = get_kid(encoded_token) force_issuer = ( @@ -301,3 +417,276 @@ def get_public_key_for_token( pkey_cache=pkey_cache, logger=logger, ) + + +def _assert_issuer_allowed( + iss: str, allowed_issuers: Iterable[str], logger: Any = None +) -> None: + """ + Verify an unverified `iss` claim before it is used for key discovery. + Key discovery makes outbound HTTP requests derived from `iss`. + + Args: + iss (str): The issuer claim taken from the unverified token. + allowed_issuers (Iterable[str]): Exact-match allowlist of issuers. + logger (Any): Logger instance. Defaults to module logger. + + Raises: + JWTError: If the issuer is not in the allowlist. + """ + logger = logger or get_logger(__name__) + + if iss not in set(allowed_issuers): + raise JWTError( + f"Token issuer is not in the allowed issuers list: {iss}. " + "Refusing to perform key discovery for an untrusted issuer." + ) + + +async def _fetch_jwks_async(keys_url: str) -> Any: + """ + Fetch and parse a JWKS document. + + The single outbound-request seam for key discovery, which keeps the + transport in one place and gives tests one thing to stub. + + Args: + keys_url (str): The validated JWKS / keys URL. + + Returns: + Any: The parsed response body. + + Raises: + httpx2.HTTPError: If the request fails or returns an error status. + """ + async with httpx2.AsyncClient( + timeout=httpx2.Timeout(timeout=KEYS_REQUEST_TIMEOUT) + ) as client: + response = await client.get(keys_url) + response.raise_for_status() + return response.json() + + +def _pem_key_from_jwks( + jwks_data: Any, keys_url: str, token_kid: str | None, iss: str, logger: Any +) -> bytes: + """ + Select the token's key from a fetched JWKS document and serialize it. + + Args: + jwks_data (Any): The parsed JWKS response body. + keys_url (str): The URL it came from, for error messages. + token_kid (str | None): The kid from the token header, if any. + iss (str): The issuer, for error messages. + logger (Any): Logger instance. + + Returns: + bytes: Public key in PEM format. + + Raises: + JWTError: If the document carries no usable keys, does not publish the + token's kid, or the selected key cannot be serialized. + """ + if not jwks_data or isinstance(jwks_data, str): + logger.error(f"invalid jwks data: {jwks_data}") + raise JWTError( + f"Could not fetch JWKS from {keys_url}. Response JSON is empty or a string." + ) + + # Safely extract keys array (handles root dict or fallback list) + raw_keys = jwks_data.get("keys", []) if isinstance(jwks_data, dict) else jwks_data + keys_by_id = _build_keys_by_id(raw_keys, logger) + + if not keys_by_id: + raise JWTError(f"Got no keys from {keys_url} for iss: {iss}") + + selected_kid, key_data = _select_key(keys_by_id, token_kid, iss, logger) + + try: + _, pem_key = get_pem_key(key_data, logger) + except Exception as exc: + logger.error(exc, stack_info=True, exc_info=True) + raise JWTError( + f"Could not serialize public key for kid={selected_kid} " + f"at issuer {iss}: {exc}" + ) + + if not pem_key: + raise JWTError( + f"Public key for kid={selected_kid} at issuer {iss} is empty or unusable" + ) + + return pem_key + + +def _build_keys_by_id( + raw_keys: Iterable[Any], logger: Any = None +) -> OrderedDict[str, Any]: + """ + Index a JWKS response by key id, preserving document order. + + Handles both the official JWKS object format and the legacy `/jwt/keys` + `[kid, pem]` pair format. Legacy entries keep their `(kid, pem)` shape + so that get_pem_key, which unpacks positionally, still works on them. + + Args: + raw_keys (Iterable[Any]): The `keys` array from a JWKS response. + logger (Any): Logger instance. + + Returns: + OrderedDict[str, Any]: kid -> JWK dict or (kid, pem) pair. + """ + keys_by_id: OrderedDict[str, Any] = OrderedDict() + + for item in raw_keys or []: + # Legacy custom format: ["kid", "-----BEGIN PUBLIC KEY..."] + if isinstance(item, (list, tuple)) and len(item) >= 2: + keys_by_id[item[0]] = item + + # Official .well-known JWKS format: {"kid": "...", "kty": "RSA", ...} + elif isinstance(item, dict) and "kid" in item: + keys_by_id[item["kid"]] = item + + elif logger: + logger.debug(f"Skipping unrecognized JWKS entry: {type(item).__name__}") + + return keys_by_id + + +def _select_key( + keys_by_id: OrderedDict[str, Any], + token_kid: str | None, + iss: str, + logger: Any = None, +) -> tuple[str, Any]: + """ + Choose the published key that the token's `kid` names. + + A kid the issuer does not publish is an error. + Only a token declaring no kid at all falls back to the first published key. + + Args: + keys_by_id (OrderedDict[str, Any]): kid -> key data, in document order. + token_kid (str | None): The kid from the token header, if any. + iss (str): Issuer, used for error messages. + logger (Any): Logger instance. Defaults to module logger. + + Returns: + tuple[str, Any]: The selected kid and its key data. + + Raises: + JWTError: If the token names a kid the issuer does not publish. + """ + logger = logger or get_logger(__name__) + + if token_kid is not None: + if token_kid not in keys_by_id: + raise JWTError( + f"No public key found for kid={token_kid} at issuer {iss}. " + f"Issuer publishes: {list(keys_by_id)}" + ) + return token_kid, keys_by_id[token_kid] + + selected_kid, key_data = next(iter(keys_by_id.items())) + logger.debug( + f"Token declared no kid; using first published key {selected_kid!r} " + f"from issuer {iss}" + ) + return selected_kid, key_data + + +def _strict_b64url_decode(value: str, param: str, kid: Any = None) -> bytes: + """ + Base64url-decode a JWK parameter, rejecting characters outside the alphabet. + + base64.urlsafe_b64decode silently DISCARDS non-alphabet characters, so a + JWKS advertising n="!!!not-base64!!!" would otherwise yield a small but + structurally valid RSA key rather than an error. Every token checked + against it then fails signature verification, which reads as "bad token" + instead of "the issuer published a malformed key". + + Args: + value (str): The base64url-encoded parameter value. + param (str): Parameter name, for the error message. + kid (Any): Key id, for the error message. + + Returns: + bytes: The decoded bytes. + + Raises: + JWTError: If the value is empty or is not valid base64url. + """ + if not value or not isinstance(value, str): + raise JWTError(f"JWKS key (kid: {kid}) has an empty '{param}' parameter") + + try: + # validate=True is the important flag here: urlsafe_b64decode drops stray characters + # with validate=false (default) instead of failing. + return base64.b64decode( + value + "=" * (-len(value) % 4), altchars=b"-_", validate=True + ) + except binascii.Error as exc: + raise JWTError( + f"JWKS key (kid: {kid}) has a malformed '{param}' parameter: {exc}" + ) + + +def _get_public_key_from_cache(cache_key: str, logger: Any = None) -> bytes | None: + """ + Retrieve a public key from cache if it exists and hasn't expired. + + Args: + cache_key (str): Cache key in format "issuer:kid". + logger (Any): Logger instance. + + Returns: + bytes | None: Public key in PEM format if valid entry exists, None otherwise. + """ + with _token_public_key_cache_lock: + cached_entry = _token_public_key_cache.get(cache_key) + if cached_entry is None: + return None + + if time.time() < cached_entry["expires_at"]: + # Mark as recently used so eviction is LRU. + _token_public_key_cache.move_to_end(cache_key) + if logger: + logger.debug(f"Using cached public key for {cache_key}") + return cached_entry["key"] + + del _token_public_key_cache[cache_key] + return None + + +def _save_public_key_to_cache( + cache_key: str, pem_key: bytes, cache_ttl: int, logger: Any = None +) -> None: + """ + Save a public key to cache with TTL, enforcing cache size limits via LRU eviction. + + Args: + cache_key (str): Cache key in format "issuer:kid". + pem_key (bytes): Public key in PEM format. + cache_ttl (int): Cache time-to-live in seconds. + logger (Any): Logger instance. + + Side Effects: + - Adds entry to _token_public_key_cache. + - May evict the least-recently-used entry if the cache is at capacity. + """ + with _token_public_key_cache_lock: + # Cache the key (move_to_end keeps insertion/refresh at the MRU end) + _token_public_key_cache[cache_key] = { + "key": pem_key, + "expires_at": time.time() + cache_ttl, + } + _token_public_key_cache.move_to_end(cache_key) + + # Enforce cache size limit: evict least-recently-used entries. + while len(_token_public_key_cache) > _TOKEN_PUBLIC_KEY_CACHE_MAX_SIZE: + evicted_key, _ = _token_public_key_cache.popitem(last=False) + if logger: + logger.debug( + f"Cache at capacity ({_TOKEN_PUBLIC_KEY_CACHE_MAX_SIZE}), " + f"evicted least-recently-used entry: {evicted_key}" + ) diff --git a/src/authutils/token/validate.py b/src/authutils/token/validate.py index bf40249..4505c79 100644 --- a/src/authutils/token/validate.py +++ b/src/authutils/token/validate.py @@ -106,7 +106,7 @@ def validate_jwt( if auth header is missing, decoding fails, or the JWT fails to satisfy any expectation """ - logger = logger or get_logger(__name__, log_level="info") + logger = logger or get_logger(__name__) if not issuers: issuers = [] @@ -121,10 +121,16 @@ def validate_jwt( ) claims = core.validate_jwt( - encoded_token, public_key, aud, scope, issuers, options, logger=logger + encoded_token, + public_key, + aud=aud, + scope=scope, + allowed_issuers=issuers, + purpose=purpose, + options=options, + logger=logger, ) - if purpose: - core.validate_purpose(claims, purpose) + return claims @@ -133,7 +139,7 @@ def validate_request(scope=set(), audience=None, purpose="access", logger=None): Validate a ``flask.request`` by checking the JWT contained in the request headers. """ - logger = logger or get_logger(__name__, log_level="info") + logger = logger or get_logger(__name__) # Get token from the headers. try: encoded_token = flask.request.headers["Authorization"].split(" ")[1] @@ -166,7 +172,7 @@ def require_auth_header(scope=set(), audience=None, purpose=None, logger=None): purpose (Optional[str]) logger (Optional) """ - logger = logger or get_logger(__name__, log_level="info") + logger = logger or get_logger(__name__) def decorator(f): """ diff --git a/tests/conftest.py b/tests/conftest.py index 012a097..3ca0296 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,7 +12,7 @@ import jwt import mock import pytest -import httpx +import httpx2 from authutils.testing.fixtures import ( _hazmat_rsa_private_key, @@ -25,11 +25,16 @@ from tests.utils import TEST_RESPONSE_JSON - USER_API = "https://user-api.test.net" KEYS_URL = "https://user-api.test.net/jwt/keys" +@pytest.fixture(scope="session") +def anyio_backend(): + """Run every `@pytest.mark.anyio` test on asyncio only.""" + return "asyncio" + + @pytest.fixture(scope="session") def iss(): """ @@ -172,9 +177,9 @@ def test_endpoint(): def mock_get(monkeypatch, example_keys_response): """ Provide a function to patch the value of the JSON returned by - ``httpx.get``. + ``httpx2.get``. - (NOTE that this only patches what will return from ``httpx.get`` so if + (NOTE that this only patches what will return from ``httpx2.get`` so if the implementation of ``refresh_jwt_public_keys`` is changed to use a different method to access the fence endpoint, this should be updated.) @@ -183,7 +188,7 @@ def mock_get(monkeypatch, example_keys_response): Return: Calllable[dict, None]: - function which sets the reponse JSON of ``httpx.get`` + function which sets the reponse JSON of ``httpx2.get`` """ def do_patch(urls_to_responses=None): @@ -195,7 +200,7 @@ def do_patch(urls_to_responses=None): None Side Effects: - Patch ``httpx.get`` + Patch ``httpx2.get`` """ urls_to_responses = urls_to_responses or {} defaults = {KEYS_URL: example_keys_response} @@ -204,11 +209,11 @@ def do_patch(urls_to_responses=None): def get(url): """Define a mock ``get`` function to return a mocked response.""" - mocked_response = mock.MagicMock(httpx.Response) + mocked_response = mock.MagicMock(httpx2.Response) mocked_response.json.return_value = urls_to_responses[url] return mocked_response - monkeypatch.setattr("httpx.get", mock.MagicMock(side_effect=get)) + monkeypatch.setattr("httpx2.get", mock.MagicMock(side_effect=get)) return do_patch @@ -217,9 +222,9 @@ def get(url): def mock_async_get(monkeypatch, example_keys_response): """ Provide a function to patch the value of the JSON returned by - ``httpx.get``. + ``httpx2.get``. - (NOTE that this only patches what will return from ``httpx.get`` so if + (NOTE that this only patches what will return from ``httpx2.get`` so if the implementation of ``refresh_jwt_public_keys`` is changed to use a different method to access the fence endpoint, this should be updated.) @@ -228,7 +233,7 @@ def mock_async_get(monkeypatch, example_keys_response): Return: Calllable[dict, None]: - function which sets the reponse JSON of ``httpx.get`` + function which sets the reponse JSON of ``httpx2.get`` """ def do_patch(urls_to_responses=None): @@ -240,7 +245,7 @@ def do_patch(urls_to_responses=None): None Side Effects: - Patch ``httpx.get`` + Patch ``httpx2.get`` """ urls_to_responses = urls_to_responses or {} defaults = {KEYS_URL: example_keys_response} @@ -249,13 +254,13 @@ def do_patch(urls_to_responses=None): async def get(url): """Define a mock ``get`` function to return a mocked response.""" - mocked_response = mock.MagicMock(httpx.Response) + mocked_response = mock.MagicMock(httpx2.Response) if url in urls_to_responses: mocked_response.json.return_value = urls_to_responses[url] else: mocked_response.raise_for_status.side_effect = Exception return mocked_response - monkeypatch.setattr("httpx.AsyncClient.get", mock.MagicMock(side_effect=get)) + monkeypatch.setattr("httpx2.AsyncClient.get", mock.MagicMock(side_effect=get)) return do_patch diff --git a/tests/test_dpop.py b/tests/test_dpop.py new file mode 100644 index 0000000..658107d --- /dev/null +++ b/tests/test_dpop.py @@ -0,0 +1,3361 @@ +""" +Test DPoP functionality in authutils.dpop module. + +Organized by the class of thing being validated, mirroring RFC 9449's own +structure (https://datatracker.ietf.org/doc/html/rfc9449). + +- Proof construction (generate_dpop_proof, compute_ath) +- Proof header validation (typ, jwk, asymmetric alg) [RFC 9449 4.3] +- Proof signature verification (the proof is signed by the embedded jwk) +- Request binding validation (htm, htu, ath match the actual request) +- Proof freshness / replay (iat window, jti) [RFC 9449 11.1] +- Key binding defense (proof key thumbprint == token cnf.jkt) +- validate_dpop_proof contract (return shape, input guards) +- Stateless nonce lifecycle (generate/verify in isolation) +- DPoP proof nonce requirement (require_nonce inside validate_dpop_proof) +- validate_dpop_request integration (the combined convenience wrapper) +""" + +import base64 +import hashlib +import json +import os +import time +from typing import Any +from unittest.mock import AsyncMock, patch + +import anyio +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from joserfc import jwk, jws, jwt +from joserfc.errors import JoseError + +import authutils.dpop +from authutils.token import dpop_nonce +from authutils.dpop import ( + DPOP_PROOF_CLOCK_SKEW_LEEWAY, + DPOP_PROOF_MAX_TTL, + MAX_DPOP_HEADER_LENGTH, + MAX_JTI_LENGTH, + MIN_RSA_KEY_BITS, + SUPPORTED_DPOP_ALGS, + compute_ath, + generate_dpop_proof, + validate_dpop_proof, + validate_dpop_request_async, +) +from authutils.errors import ( + InvalidNonceError, + InvalidNonceErrorResourceServer, + InvalidNonceErrorAuthorizationServer, + JWTScopeError, + JWTPurposeError, +) + + +def _b64url(raw: bytes) -> str: + """Base64url-encode without padding, as a JWT segment.""" + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + + +_INVALID_JSON = _b64url(b"{invalid json}") +_EMPTY_JSON = _b64url(b"{}") +_JWT_HEADER = _b64url(b'{"typ":"JWT"}') + +_INVALID_JSON_HEADER_NONCE = f"{_INVALID_JSON}.{_EMPTY_JSON}.sig" +_INVALID_JSON_PAYLOAD_NONCE = f"{_JWT_HEADER}.{_INVALID_JSON}.sig" + + +@pytest.fixture(autouse=True) +def set_shared_secret(): + """Set the cluster secret environment variable before each test.""" + os.environ["DPOP_SHARED_SECRET"] = ( + "test-secret-32chars-minimum-test" # pragma: allowlist secret + ) + yield + os.environ.pop("DPOP_SHARED_SECRET", None) + + +class TestGenerateDpopProof: + """Direct unit tests for generate_dpop_proof behavior and edge cases.""" + + def test_proof_structure_and_claims(self): + """Proof carries the RFC 9449 4.2 header params and payload claims.""" + key = jwk.generate_key("EC", "P-256") + method = "POST" + url = "https://gen3.example.com/ga4gh/tes/v1/tasks" + access_token = "test-access-token-123" + + proof = generate_dpop_proof( + key=key, + method=method, + url=url, + access_token=access_token, + ) + + header = _decode_jwt_header(proof) + payload = _decode_jwt_payload(proof) + + # JWT Header Assertions (RFC 9449 4.2) + assert header.get("typ") == "dpop+jwt" + assert header.get("alg") == "ES256" + assert "jwk" in header + assert header["jwk"]["kty"] == "EC" + # Public key parameters only + assert "d" not in header["jwk"] + + # JWT Payload Assertions + assert payload.get("htm") == method + assert payload.get("htu") == url + assert "jti" in payload + assert isinstance(payload["jti"], str) + assert "iat" in payload + assert abs(payload["iat"] - int(time.time())) <= 5 + assert payload.get("ath") == compute_ath(access_token) + + def test_htu_strips_query_string(self): + """htu omits the query string, per RFC 9449 4.2.""" + key = jwk.generate_key("EC", "P-256") + url_with_query = ( + "https://gen3.example.com/api/v1/resource?param1=foo¶m2=bar" + ) + proof = generate_dpop_proof( + key=key, + method="GET", + url=url_with_query, + ) + + payload = _decode_jwt_payload(proof) + assert payload["htu"] == "https://gen3.example.com/api/v1/resource" + + def test_nonce_handling(self): + """A supplied nonce is included; None omits the claim entirely.""" + key = jwk.generate_key("EC", "P-256") + url = "https://gen3.example.com/api/v1/resource" + + # Nonce provided + proof_with_nonce = generate_dpop_proof( + key=key, + method="GET", + url=url, + nonce="server-nonce-xyz-789", + ) + assert ( + _decode_jwt_payload(proof_with_nonce).get("nonce") == "server-nonce-xyz-789" + ) + + # Nonce is None + proof_without_nonce = generate_dpop_proof( + key=key, + method="GET", + url=url, + nonce=None, + ) + assert "nonce" not in _decode_jwt_payload(proof_without_nonce) + + def test_edge_cases(self): + """Empty/unicode URLs and long or numeric nonces round-trip intact.""" + key = jwk.generate_key("EC", "P-256") + + # Empty URL + proof_empty_url = generate_dpop_proof( + key=key, + method="GET", + url="", + ) + assert _decode_jwt_payload(proof_empty_url).get("htu") == "" + + # Unicode characters in URL + unicode_url = "https://gen3.example.com/path/ñ/日本語" + proof_unicode_url = generate_dpop_proof( + key=key, + method="GET", + url=unicode_url, + ) + assert _decode_jwt_payload(proof_unicode_url).get("htu") == unicode_url + + # Extremely long nonce + long_nonce = "n" * 2048 + proof_long_nonce = generate_dpop_proof( + key=key, + method="GET", + url="https://gen3.example.com", + nonce=long_nonce, + ) + assert _decode_jwt_payload(proof_long_nonce).get("nonce") == long_nonce + + # Numeric string nonce + numeric_nonce = "9876543210" + proof_numeric_nonce = generate_dpop_proof( + key=key, + method="GET", + url="https://gen3.example.com", + nonce=numeric_nonce, + ) + assert _decode_jwt_payload(proof_numeric_nonce).get("nonce") == numeric_nonce + + @pytest.mark.parametrize( + "curve, expected_alg", + [("P-256", "ES256"), ("P-384", "ES384"), ("P-521", "ES512")], + ) + def test_ec_curve_determines_alg(self, curve, expected_alg): + """EC algs are curve-bound, so P-384/P-521 keys must not use ES256.""" + key = jwk.ECKey.generate_key(crv=curve) + proof = generate_dpop_proof( + key=key, method="GET", url="https://gen3.example.com" + ) + assert _decode_jwt_header(proof).get("alg") == expected_alg + + def test_bearer_prefixed_access_token_rejected(self): + """A scheme-prefixed access token is rejected rather than mis-hashed.""" + key = jwk.generate_key("EC", "P-256") + with pytest.raises(ValueError, match="[Bb]earer"): + generate_dpop_proof( + key=key, + method="GET", + url="https://gen3.example.com", + access_token="Bearer eyJhbGciOiJSUzI1NiJ9.e30.sig", # pragma: allowlist secret + ) + + def test_explicit_alg_matches_key_default(self): + """ + An explicit `alg` matching the key's auto-selected one is a no-op. + + The override path and the auto-resolution path have to agree, or a + caller pinning the algorithm it already expects would change behavior. + """ + key = jwk.generate_key("EC", "P-256") + proof = generate_dpop_proof( + key=key, method="GET", url="https://gen3.example.com", alg="ES256" + ) + assert _decode_jwt_header(proof).get("alg") == "ES256" + + def test_explicit_unsupported_alg_rejected(self): + """ + A caller-supplied alg outside SUPPORTED_DPOP_ALGS is rejected, so + "none" and symmetric algs can never produce a usable proof. + """ + key = jwk.generate_key("EC", "P-256") + for bad_alg in ("none", "HS256", "HS512"): + with pytest.raises(ValueError): + generate_dpop_proof( + key=key, method="GET", url="https://gen3.example.com", alg=bad_alg + ) + + def test_disallowed_ec_curve_cannot_produce_a_proof(self): + """A key on a curve outside the allowlist is refused at generation.""" + key = jwk.ECKey.generate_key(crv="secp256k1") + with pytest.raises(ValueError, match="Unsupported EC curve"): + generate_dpop_proof(key=key, method="GET", url="https://gen3.example.com") + + def test_symmetric_key_cannot_produce_a_proof(self): + """A symmetric key is refused at generation, not silently downgraded.""" + with pytest.raises(ValueError, match="Unsupported key type"): + generate_dpop_proof( + key=jwk.OctKey.generate_key(), + method="GET", + url="https://gen3.example.com", + ) + + +class TestComputeAth: + """Direct unit tests for RFC 9449 access token hash computation.""" + + def test_rfc_compliance(self): + """ath is unpadded base64url of SHA-256, 43 chars, per RFC 9449 4.2.""" + token = "K945938459384593845" + ath = compute_ath(token) + + assert len(ath) == 43 + assert "=" not in ath + + expected_ath = ( + base64.urlsafe_b64encode(hashlib.sha256(token.encode("ascii")).digest()) + .rstrip(b"=") + .decode("ascii") + ) + assert ath == expected_ath + + def test_edge_cases(self): + """Empty and very long tokens hash fine; non-ASCII raises per the RFC.""" + # Empty token + empty_ath = compute_ath("") + assert len(empty_ath) == 43 + + # Non-ASCII Unicode token raises UnicodeEncodeError per RFC 9449 4.2 + unicode_token = "token-ñ-日本語-🔑" + with pytest.raises(UnicodeEncodeError): + compute_ath(unicode_token) + + # Long token input + long_token = "a" * 1_000_000 + long_ath = compute_ath(long_token) + assert len(long_ath) == 43 + + def test_bytes_input(self): + """ + compute_ath is typed to accept `str | bytes`. Confirm bytes + input produces the same digest as the equivalent str input. + """ + token_str = "K945938459384593845" + token_bytes = token_str.encode("ascii") + assert compute_ath(token_bytes) == compute_ath(token_str) + + @pytest.mark.parametrize( + "not_a_token", + [ + pytest.param(None, id="none"), + pytest.param(12345, id="int"), + pytest.param(["a"], id="list"), + pytest.param({"a": 1}, id="dict"), + ], + ) + def test_non_str_or_bytes_input_raises_type_error(self, not_a_token): + """compute_ath rejects anything that is neither str nor bytes.""" + with pytest.raises(TypeError, match="must be str or bytes"): + compute_ath(not_a_token) + + +class TestProofHeaderValidation: + """ + Algorithm Whitelisting & Key Cross-Compatibility, plus header + well-formedness checks called out in validate_dpop_proof's docstring + ("Header validation (typ, jwk presence, asymmetric key)"). + """ + + def test_rsa_verification(self): + """An RSA proof key resolves to RS256 without the caller naming an alg.""" + rsa_key = jwk.RSAKey.generate_key() + + proof = authutils.dpop.generate_dpop_proof( + rsa_key, "GET", "https://example.com/resource" + ) + + header_b64, _, _ = proof.split(".") + header_padded = header_b64 + "=" * (4 - len(header_b64) % 4) + header_json = json.loads( + base64.urlsafe_b64decode(header_padded).decode("utf-8") + ) + assert header_json["alg"] == "RS256" + assert header_json.get("typ") == "dpop+jwt" + assert "jwk" in header_json + + def test_algorithm_confusion_injection(self): + """A valid EC proof whose header alg is swapped to HS256 is rejected.""" + ec_key = jwk.ECKey.generate_key(crv="P-256") + proof = authutils.dpop.generate_dpop_proof( + ec_key, "GET", "https://example.com/resource" + ) + + header_b64, payload_b64, signature = proof.split(".") + + header_padded = header_b64 + "=" * (4 - len(header_b64) % 4) + header_json = json.loads( + base64.urlsafe_b64decode(header_padded).decode("utf-8") + ) + header_json["alg"] = "HS256" + + # Re-encode the header + new_header_json = json.dumps(header_json) + new_header_b64 = ( + base64.urlsafe_b64encode(new_header_json.encode()).rstrip(b"=").decode() + ) + + new_proof = f"{new_header_b64}.{payload_b64}.{signature}" + + with pytest.raises(ValueError): + authutils.dpop.validate_dpop_proof( + new_proof, "GET", "https://example.com/resource" + ) + + @pytest.mark.parametrize( + "registry_kwargs", + [ + pytest.param({}, id="algorithms_omitted"), + pytest.param({"algorithms": None}, id="algorithms_none"), + pytest.param({"algorithms": set()}, id="algorithms_empty_set"), + pytest.param({"algorithms": []}, id="algorithms_empty_list"), + ], + ) + def test_unpinned_registry_cannot_be_constructed(self, registry_kwargs): + """ + An unpinned or empty allowlist is refused at registry construction. + + joserfc's get_alg guards with `if self.allowed:`, so a falsy + collection silently means "allow all recommended algorithms" -- + which includes HS256 and would defeat DPoP algorithm pinning. + """ + with pytest.raises(ValueError, match="allowlist"): + authutils.dpop._LargeHeaderRegistry(**registry_kwargs) + + def test_supported_algs_is_immutable(self): + """SUPPORTED_DPOP_ALGS is a frozenset, so it cannot be emptied at runtime.""" + assert isinstance(SUPPORTED_DPOP_ALGS, frozenset) + with pytest.raises(AttributeError): + SUPPORTED_DPOP_ALGS.clear() + + def test_hs256_proof_rejected_end_to_end(self): + """A symmetric HS256 proof is rejected by validate_dpop_proof.""" + hs_key = jwk.OctKey.generate_key() + proof = jwt.encode( + {"typ": "dpop+jwt", "alg": "HS256", "jwk": hs_key.as_dict()}, + { + "htm": "GET", + "htu": "https://example.com/resource", + "iat": int(time.time()), + "jti": "hs256-e2e-jti", + }, + hs_key, + ) + + with pytest.raises(ValueError): + authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource" + ) + + def test_algorithm_allowlist_is_pinned_in_the_jws_registry(self): + """ + The registry itself pins the allowlist, since joserfc ignores + `algorithms=` on jwt.decode whenever a registry is supplied. + + This is the regression guard for the signature-verification layer: + without registry-level pinning, jwt.decode accepts every algorithm + joserfc deems "recommended", including HS256. + """ + hs_key = jwk.OctKey.generate_key() + proof = jwt.encode( + {"typ": "dpop+jwt", "alg": "HS256", "jwk": hs_key.as_dict()}, + {"htm": "GET", "htu": "https://example.com/resource"}, + hs_key, + ) + + with pytest.raises(JoseError): + jwt.decode(proof, hs_key, registry=authutils.dpop._new_registry()) + + def test_private_key_in_header_rejected(self): + """A jwk header leaking private parameters is rejected.""" + key = jwk.ECKey.generate_key(crv="P-256") + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource" + ) + tampered = _replace_header_field(proof, "jwk", key.as_dict(private=True)) + + with pytest.raises(ValueError): + authutils.dpop.validate_dpop_proof( + tampered, "GET", "https://example.com/resource" + ) + + def test_undersized_rsa_key_rejected(self): + """An RSA proof key below MIN_RSA_KEY_BITS is rejected.""" + small = rsa.generate_private_key(public_exponent=65537, key_size=1024) + pem = small.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + weak_key = jwk.RSAKey.import_key(pem) + proof = jwt.encode( + { + "typ": "dpop+jwt", + "alg": "RS256", + "jwk": weak_key.as_dict(private=False), + }, + { + "htm": "GET", + "htu": "https://example.com/resource", + "iat": int(time.time()), + "jti": "weak-rsa-jti", + }, + weak_key, + registry=_large_registry(), + ) + + with pytest.raises(ValueError, match="too small"): + authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource" + ) + + def test_rsa_key_at_minimum_size_accepted(self): + """An RSA proof key at exactly MIN_RSA_KEY_BITS is accepted.""" + key = jwk.RSAKey.generate_key(MIN_RSA_KEY_BITS) + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource" + ) + + _, client_jwk = authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource" + ) + assert client_jwk.as_dict(private=False)["kty"] == "RSA" + + def test_jwk_header_not_an_object_rejected(self): + """A non-object jwk header value is rejected without a crash.""" + key = jwk.ECKey.generate_key(crv="P-256") + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource" + ) + tampered = _replace_header_field(proof, "jwk", "not-an-object") + + with pytest.raises(ValueError): + authutils.dpop.validate_dpop_proof( + tampered, "GET", "https://example.com/resource" + ) + + def test_okp_key_rejected(self): + """OKP/EdDSA keys are not in SUPPORTED_DPOP_ALGS, so they are refused.""" + key = jwk.OKPKey.generate_key("Ed25519") + + with pytest.raises(ValueError, match="[Uu]nsupported key type"): + authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource" + ) + + def test_missing_typ_header_rejected(self): + """ + RFC 9449 4.3 requires "The typ JOSE Header Parameter has the + value dpop+jwt." A proof with no `typ` at all must be rejected. + """ + key = jwk.ECKey.generate_key(crv="P-256") + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource" + ) + new_proof = _replace_header_field(proof, "typ", remove=True) + + with pytest.raises(ValueError): + authutils.dpop.validate_dpop_proof( + new_proof, "GET", "https://example.com/resource" + ) + + def test_wrong_typ_header_value_rejected(self): + """ + A `typ` value other than "dpop+jwt" (e.g. plain "JWT") must be + rejected, not silently accepted. + """ + key = jwk.ECKey.generate_key(crv="P-256") + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource" + ) + new_proof = _replace_header_field(proof, "typ", "JWT") + + with pytest.raises(ValueError): + authutils.dpop.validate_dpop_proof( + new_proof, "GET", "https://example.com/resource" + ) + + def test_missing_jwk_header_rejected(self): + """ + validate_dpop_proof's docstring calls out "jwk presence" as a + validated header condition. A proof missing the `jwk` header + parameter entirely must be rejected. + """ + key = jwk.ECKey.generate_key(crv="P-256") + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource" + ) + new_proof = _replace_header_field(proof, "jwk", remove=True) + + with pytest.raises(ValueError): + authutils.dpop.validate_dpop_proof( + new_proof, "GET", "https://example.com/resource" + ) + + def test_symmetric_key_signing_rejected(self): + """ + DPoP proofs must be signed with an asymmetric key so the + public key can be safely embedded in the `jwk` header. A proof + actually signed end-to-end with a symmetric (oct) key/HS256 must be + rejected, independent of whether the signature itself verifies. + """ + hs_key = jwk.OctKey.generate_key() + header = { + "typ": "dpop+jwt", + "alg": "HS256", + "jwk": hs_key.as_dict(), + } + claims = { + "htm": "GET", + "htu": "https://example.com/resource", + "iat": int(time.time()), + "jti": "symmetric-key-test-jti", + } + proof = jwt.encode(header, claims, hs_key) + + with pytest.raises(ValueError): + authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource" + ) + + def test_symmetric_jwk_declaring_an_allowlisted_alg_rejected(self): + """An oct jwk is rejected even when the header claims an asymmetric alg.""" + oct_key = jwk.OctKey.generate_key() + proof = _build_unverifiable_proof( + header={"typ": "dpop+jwt", "alg": "ES256", "jwk": oct_key.as_dict()}, + claims=_minimal_proof_claims(), + ) + + with pytest.raises(ValueError, match="asymmetric key"): + authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource" + ) + + def test_unimportable_embedded_jwk_rejected(self): + """A jwk missing required key parameters is rejected, not raised through.""" + proof = _build_unverifiable_proof( + header={ + "typ": "dpop+jwt", + "alg": "ES256", + "jwk": {"kty": "EC", "crv": "P-256"}, + }, + claims=_minimal_proof_claims(), + ) + + with pytest.raises(ValueError, match="could not be imported"): + authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource" + ) + + def test_disallowed_ec_curve_rejected(self): + """ + A secp256k1 proof key is rejected by the curve allowlist. + + The header claims ES256 so that the alg allowlist passes and the curve + check is actually the thing doing the rejecting; ES256K would otherwise + be caught earlier and this would pass for the wrong reason. + """ + secp_key = jwk.ECKey.generate_key(crv="secp256k1") + proof = _build_unverifiable_proof( + header={ + "typ": "dpop+jwt", + "alg": "ES256", + "jwk": secp_key.as_dict(private=False), + }, + claims=_minimal_proof_claims(), + ) + + with pytest.raises(ValueError, match="disallowed EC curve"): + authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource" + ) + + @pytest.mark.parametrize( + "malformed", + [ + pytest.param("not.a.jwt", id="non_base64_segments"), + pytest.param("onlyonesegment", id="single_segment"), + pytest.param("two.segments", id="two_segments"), + pytest.param("a.b.c.d", id="four_segments"), + pytest.param("...", id="empty_segments"), + ], + ) + def test_malformed_compact_jws_rejected(self, malformed): + """A value that is not a well-formed compact JWS is rejected as ValueError.""" + with pytest.raises(ValueError, match="Invalid DPoP proof"): + authutils.dpop.validate_dpop_proof( + malformed, "GET", "https://example.com/resource" + ) + + +class TestProofSignatureVerification: + """ + The proof must actually be signed by the key embedded in its own jwk + header. Without this, the jwk header is an unauthenticated attacker-chosen + value and every downstream check (including cnf.jkt key binding) is + meaningless. + """ + + def test_proof_signed_by_key_other_than_embedded_jwk_rejected(self): + """ + A proof signed by one key but advertising another key's jwk is rejected. + + This is the core DPoP forgery: an attacker who has observed a victim's + public key would otherwise be able to mint proofs whose thumbprint + matches the victim's cnf.jkt. + """ + victim_key = jwk.ECKey.generate_key(crv="P-256") + attacker_key = jwk.ECKey.generate_key(crv="P-256") + + proof = authutils.dpop.generate_dpop_proof( + attacker_key, "GET", "https://example.com/resource" + ) + forged = _replace_header_field(proof, "jwk", victim_key.as_dict(private=False)) + + with pytest.raises(ValueError) as exc_info: + authutils.dpop.validate_dpop_proof( + forged, "GET", "https://example.com/resource" + ) + + assert "bad_signature" in str(exc_info.value) + + def test_validly_signed_non_object_payload_rejected(self): + """ + A correctly signed proof whose payload is a JSON string is rejected. + + joserfc's jwt.decode does not require an object payload -- it returns + the bare string as `claims`. Without the shape check, claim lookups + would degrade into substring tests (`"nonce" in claims`) and + `claims.get(...)` would raise AttributeError. + """ + key = jwk.ECKey.generate_key(crv="P-256") + proof = jws.serialize_compact( + {"alg": "ES256", "typ": "dpop+jwt", "jwk": key.as_dict(private=False)}, + b'"just a string"', + key, + ) + + with pytest.raises(ValueError, match="payload is not a JSON object"): + authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource" + ) + + def test_tampered_payload_rejected(self): + """Re-encoding the payload under the original signature is rejected.""" + key = jwk.ECKey.generate_key(crv="P-256") + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource" + ) + tampered = _replace_payload_field(proof, "jti", "attacker-chosen-jti") + + with pytest.raises(ValueError) as exc_info: + authutils.dpop.validate_dpop_proof( + tampered, "GET", "https://example.com/resource" + ) + + assert "bad_signature" in str(exc_info.value) + + def test_tampered_signature_rejected(self): + """A proof whose signature segment has been altered is rejected.""" + key = jwk.ECKey.generate_key(crv="P-256") + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource" + ) + header_b64, payload_b64, signature = proof.split(".") + flipped = ("B" if signature[0] != "B" else "C") + signature[1:] + + with pytest.raises(ValueError): + authutils.dpop.validate_dpop_proof( + f"{header_b64}.{payload_b64}.{flipped}", + "GET", + "https://example.com/resource", + ) + + def test_signature_failure_surfaces_as_value_error_not_jose_error(self): + """ + A bad signature normalizes to ValueError. + + JoseError does not subclass ValueError, so a caller doing + `except ValueError: return 401` would otherwise emit a 500 on the most + common attack path. + """ + key = jwk.ECKey.generate_key(crv="P-256") + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource" + ) + + # Retarget the proof at POST and validate a POST request, so every + # non-signature check passes and only the signature can reject it. + tampered = _replace_payload_field(proof, "htm", "POST") + + with pytest.raises(ValueError) as exc_info: + authutils.dpop.validate_dpop_proof( + tampered, "POST", "https://example.com/resource" + ) + + assert not isinstance(exc_info.value, JoseError) + + +class TestRequestBindingValidation: + """ + Confirms a proof is bound to the specific request/token it was + generated for, per the four checks in validate_dpop_proof's docstring + (signature, htm, htu, ath). + """ + + def test_htm_mismatch_rejected(self): + """ + A proof generated for GET must not validate against a request + made with a different HTTP method. + """ + key = jwk.ECKey.generate_key(crv="P-256") + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource" + ) + + with pytest.raises(ValueError): + authutils.dpop.validate_dpop_proof( + proof, "POST", "https://example.com/resource" + ) + + def test_htu_mismatch_rejected(self): + """ + A proof generated for one URL must not validate against a + request made to a different URL. + """ + key = jwk.ECKey.generate_key(crv="P-256") + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource" + ) + + with pytest.raises(ValueError): + authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/other-resource" + ) + + def test_default_port_is_normalized_away(self): + """https://host/x and https://host:443/x compare equal (RFC 9110 4.2).""" + key = jwk.ECKey.generate_key(crv="P-256") + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com:443/resource" + ) + + claims, _ = authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource" + ) + assert claims["htu"] == "https://example.com/resource" + + def test_non_default_port_remains_significant(self): + """A non-default port is not normalized away, so it must still match.""" + key = jwk.ECKey.generate_key(crv="P-256") + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com:8443/resource" + ) + + with pytest.raises(ValueError): + authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource" + ) + + def test_htu_containing_query_string_rejected(self): + """A proof whose htu retains a query string is rejected, not normalized.""" + key = jwk.ECKey.generate_key(crv="P-256") + proof = _build_raw_dpop_proof( + key, + claims={ + "htm": "GET", + "htu": "https://example.com/resource?admin=1", + "iat": int(time.time()), + "jti": "htu-query-jti", + }, + ) + + with pytest.raises(ValueError, match="query"): + authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource" + ) + + def test_missing_ath_when_access_token_presented_rejected(self): + """RFC 9449 4.2 requires ath whenever a token accompanies the proof.""" + key = jwk.ECKey.generate_key(crv="P-256") + # Generated without access_token, so no ath claim is present. + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource" + ) + + with pytest.raises(ValueError, match="ath"): + authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource", "some-access-token" + ) + + def test_htu_trailing_slash_is_not_normalized(self): + """ + Documents current strict string-matching behavior for htu. + RFC 9449 only specifies stripping query/fragment before comparison; + it does not require normalizing trailing slashes, so a proof for + ".../resource" should NOT validate against ".../resource/". + """ + key = jwk.ECKey.generate_key(crv="P-256") + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource" + ) + + with pytest.raises(ValueError): + authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource/" + ) + + def test_htu_path_parameters_are_significant(self): + """ + A proof for "/resource" does not validate a request to "/resource;x". + + Path parameters are part of the path (RFC 3986 3.3), so dropping them + during normalization would let one proof satisfy two distinct target + URIs. urlparse peels them into a separate field and would do exactly + that; urlsplit keeps them in the path. + """ + key = jwk.ECKey.generate_key(crv="P-256") + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource" + ) + + with pytest.raises(ValueError, match="htu mismatch"): + authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource;evil" + ) + + def test_htu_with_path_parameters_round_trips(self): + """A proof minted for a URL carrying path parameters validates itself.""" + key = jwk.ECKey.generate_key(crv="P-256") + url = "https://example.com/resource;v=1" + proof = authutils.dpop.generate_dpop_proof(key, "GET", url) + + claims, _ = authutils.dpop.validate_dpop_proof(proof, "GET", url) + + assert claims["htu"] == url + + @pytest.mark.parametrize( + "bad_url", + [pytest.param("", id="empty"), pytest.param(None, id="none")], + ) + def test_missing_request_url_rejected(self, bad_url): + """ + An empty request_url is a caller bug, not a match against an empty htu. + + generate_dpop_proof will mint a proof with htu="" for an empty URL, and + an empty request_url normalizes to "" as well, so comparing them would + accept that proof for any resource. + """ + key = jwk.ECKey.generate_key(crv="P-256") + proof = authutils.dpop.generate_dpop_proof(key, "GET", "") + + with pytest.raises(ValueError, match="request_url"): + authutils.dpop.validate_dpop_proof(proof, "GET", bad_url) + + def test_request_method_case_insensitive_by_design(self): + """ + Documents that htm comparison is intentionally + case-INsensitive (_validate_proof_claims compares + request_method.upper() != htm_value.upper()), so a lowercase + request method must still match an uppercase htm claim. + """ + key = jwk.ECKey.generate_key(crv="P-256") + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource" + ) + + dpop_claims, _ = authutils.dpop.validate_dpop_proof( + proof, "get", "https://example.com/resource" + ) + assert dpop_claims["htm"] == "GET" + + def test_ath_mismatch_rejected(self): + """ + A proof generated with the hash of one access token must not + validate when checked against a different access token's value, + even when the key binding would otherwise be fine. + """ + key = jwk.ECKey.generate_key(crv="P-256") + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource", access_token="token-A" + ) + + with pytest.raises(ValueError): + authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource", "token-B" + ) + + def test_non_ascii_access_token_rejected_as_value_error(self): + """ + A non-ASCII access token yields ValueError, not UnicodeEncodeError. + + RFC 9449 4.2 defines ath over the token's ASCII encoding, so a + non-ASCII token cannot be hashed; that must surface as a 401-shaped + failure rather than an unhandled 500. + """ + key = jwk.ECKey.generate_key(crv="P-256") + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource", access_token="ascii-token" + ) + + with pytest.raises(ValueError, match="Could not compute ath"): + authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource", "t€ken" + ) + + +class TestProofFreshnessAndReplay: + """ + Freshness and replay protections. DPOP_PROOF_MAX_TTL bounds how long + a proof's `iat` may be considered fresh. + """ + + def test_stale_iat_rejected(self): + """ + A proof whose `iat` is well outside DPOP_PROOF_MAX_TTL in the + past must be rejected, per RFC 9449's guidance that servers "only + accept DPoP proofs for a limited time after their creation." + """ + key = jwk.ECKey.generate_key(crv="P-256") + stale_iat = int(time.time()) - DPOP_PROOF_MAX_TTL - 3600 + proof = _build_raw_dpop_proof( + key, + claims={ + "htm": "GET", + "htu": "https://example.com/resource", + "iat": stale_iat, + "jti": "stale-iat-test-jti", + }, + ) + + with pytest.raises(ValueError): + authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource" + ) + + def test_future_iat_beyond_skew_rejected(self): + """ + A proof whose `iat` is far in the future (well beyond + reasonable clock skew) must be rejected -- otherwise a client could + pre-generate proofs valid arbitrarily far ahead of time. + """ + key = jwk.ECKey.generate_key(crv="P-256") + future_iat = int(time.time()) + DPOP_PROOF_MAX_TTL + 3600 + proof = _build_raw_dpop_proof( + key, + claims={ + "htm": "GET", + "htu": "https://example.com/resource", + "iat": future_iat, + "jti": "future-iat-test-jti", + }, + ) + + with pytest.raises(ValueError): + authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource" + ) + + def test_duplicate_jti_rejected_with_callback(self): + """Replaying a proof is rejected when jti_seen_callback reports reuse.""" + key = jwk.ECKey.generate_key(crv="P-256") + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource" + ) + + seen = set() + + def jti_seen_callback(jti): + if jti in seen: + return True + seen.add(jti) + return False + + # First use is accepted and records the jti. + authutils.dpop.validate_dpop_proof( + proof, + "GET", + "https://example.com/resource", + jti_seen_callback=jti_seen_callback, + ) + + # Replaying the exact same proof (same jti) is rejected. + with pytest.raises(ValueError, match="replay"): + authutils.dpop.validate_dpop_proof( + proof, + "GET", + "https://example.com/resource", + jti_seen_callback=jti_seen_callback, + ) + + def test_distinct_jtis_both_accepted_with_callback(self): + """Two separately generated proofs have distinct jtis and both pass.""" + key = jwk.ECKey.generate_key(crv="P-256") + seen = set() + + def jti_seen_callback(jti): + if jti in seen: + return True + seen.add(jti) + return False + + for _ in range(2): + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource" + ) + authutils.dpop.validate_dpop_proof( + proof, + "GET", + "https://example.com/resource", + jti_seen_callback=jti_seen_callback, + ) + + assert len(seen) == 2, "each generated proof should carry a unique jti" + + def test_no_replay_protection_without_callback(self): + """Without a callback the module is stateless, so a proof reuses freely.""" + key = jwk.ECKey.generate_key(crv="P-256") + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource" + ) + + first, _ = authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource" + ) + second, _ = authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource" + ) + + assert first["jti"] == second["jti"] + + def test_iat_just_within_max_ttl_accepted(self): + """A proof at the inner edge of DPOP_PROOF_MAX_TTL still validates.""" + key = jwk.ECKey.generate_key(crv="P-256") + proof = _build_raw_dpop_proof( + key, + claims={ + "htm": "GET", + "htu": "https://example.com/resource", + "iat": int(time.time()) - DPOP_PROOF_MAX_TTL + 10, + "jti": "inner-edge-jti", + }, + ) + + claims, _ = authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource" + ) + assert claims["jti"] == "inner-edge-jti" + + def test_iat_within_clock_skew_leeway_accepted(self): + """A slightly future iat inside the skew leeway is tolerated.""" + key = jwk.ECKey.generate_key(crv="P-256") + proof = _build_raw_dpop_proof( + key, + claims={ + "htm": "GET", + "htu": "https://example.com/resource", + "iat": int(time.time()) + DPOP_PROOF_CLOCK_SKEW_LEEWAY - 10, + "jti": "skew-leeway-jti", + }, + ) + + claims, _ = authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource" + ) + assert claims["jti"] == "skew-leeway-jti" + + def test_expired_exp_rejected(self): + """An exp already in the past is rejected.""" + key = jwk.ECKey.generate_key(crv="P-256") + now = int(time.time()) + proof = _build_raw_dpop_proof( + key, + claims={ + "htm": "GET", + "htu": "https://example.com/resource", + "iat": now, + "exp": now - 1, + "jti": "expired-exp-jti", + }, + ) + + with pytest.raises(ValueError): + authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource" + ) + + def test_missing_iat_rejected(self): + """ + A proof with no iat at all is rejected. + + iat is what bounds the replay window, so a proof omitting it would + otherwise be accepted indefinitely. + """ + key = jwk.ECKey.generate_key(crv="P-256") + proof = _build_raw_dpop_proof( + key, + claims={ + "htm": "GET", + "htu": "https://example.com/resource", + "jti": "missing-iat-jti", + }, + ) + + with pytest.raises(ValueError, match="missing required 'iat' claim"): + authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource" + ) + + @pytest.mark.parametrize( + "bad_iat", + [ + pytest.param("9999999999", id="string"), + pytest.param(None, id="none"), + pytest.param(True, id="bool"), + pytest.param([1], id="list"), + pytest.param({"a": 1}, id="dict"), + ], + ) + def test_non_numeric_iat_raises_value_error(self, bad_iat): + """A non-numeric iat yields ValueError, never an uncaught TypeError.""" + key = jwk.ECKey.generate_key(crv="P-256") + proof = _build_raw_dpop_proof( + key, + claims={ + "htm": "GET", + "htu": "https://example.com/resource", + "iat": bad_iat, + "jti": "bad-iat-jti", + }, + ) + + with pytest.raises(ValueError): + authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource" + ) + + @pytest.mark.parametrize( + "bad_exp", + [ + pytest.param("9999999999", id="string"), + pytest.param(None, id="none"), + pytest.param([1], id="list"), + ], + ) + def test_non_numeric_exp_raises_value_error(self, bad_exp): + """A non-numeric exp yields ValueError, never an uncaught TypeError.""" + key = jwk.ECKey.generate_key(crv="P-256") + proof = _build_raw_dpop_proof( + key, + claims={ + "htm": "GET", + "htu": "https://example.com/resource", + "iat": int(time.time()), + "exp": bad_exp, + "jti": "bad-exp-jti", + }, + ) + + with pytest.raises(ValueError): + authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource" + ) + + def test_jose_error_never_escapes_as_non_value_error(self): + """ + Freshness failures surface as ValueError, not a bare JoseError. + + JoseError does not subclass ValueError, so a caller doing + `except ValueError: return 401` would otherwise emit a 500. + """ + key = jwk.ECKey.generate_key(crv="P-256") + proof = _build_raw_dpop_proof( + key, + claims={ + "htm": "GET", + "htu": "https://example.com/resource", + "iat": int(time.time()) - DPOP_PROOF_MAX_TTL - 3600, + "jti": "contract-jti", + }, + ) + + with pytest.raises(ValueError) as exc_info: + authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource" + ) + + assert not isinstance( + exc_info.value, JoseError + ), "validate_dpop_proof must normalize JoseError to ValueError" + + +@pytest.mark.anyio +class TestKeyBindingDefense: + """Bidirectional Binding (Stolen Token Defense)""" + + def test_stolen_token_defense(self): + """A proof signed by the attacker's key cannot carry Alice's token.""" + alice_key = jwk.ECKey.generate_key(crv="P-256") + alice_thumbprint = alice_key.thumbprint() + + alice_token_payload = {"sub": "alice", "cnf": {"jkt": alice_thumbprint}} + hs_key = jwk.OctKey.import_key("test-secret-32chars-minimum-test") + alice_token = jwt.encode( + {"alg": "HS256", "typ": "JWT"}, + alice_token_payload, + hs_key, + ) + + attacker_key = jwk.ECKey.generate_key(crv="P-256") + + # The attacker CAN mint a well-formed proof over Alice's token -- the ath + # claim is only a hash of a token they hold. cnf.jkt is what stops them. + attacker_proof = authutils.dpop.generate_dpop_proof( + attacker_key, "GET", "https://example.com/resource", alice_token + ) + + with pytest.raises(ValueError): + authutils.dpop.validate_dpop_proof( + attacker_proof, "GET", "https://example.com/resource", alice_token + ) + + @pytest.mark.parametrize( + "cnf_claim, test_id", + [ + pytest.param(None, "cnf_absent", id="cnf_absent"), + pytest.param("not-a-dict", "cnf_not_object", id="cnf_not_object"), + pytest.param({}, "jkt_absent", id="jkt_absent"), + pytest.param({"jkt": ""}, "jkt_empty", id="jkt_empty"), + pytest.param({"jkt": None}, "jkt_null", id="jkt_null"), + pytest.param({"jkt": ["a"]}, "jkt_list", id="jkt_list"), + ], + ) + def test_malformed_cnf_jkt_rejected(self, cnf_claim, test_id): + """A token whose cnf/cnf.jkt is absent or malformed fails key binding.""" + key = jwk.ECKey.generate_key(crv="P-256") + payload = {"sub": "alice"} + if cnf_claim is not None: + payload["cnf"] = cnf_claim + + hs_key = jwk.OctKey.import_key("test-secret-32chars-minimum-test") + access_token = jwt.encode({"alg": "HS256", "typ": "JWT"}, payload, hs_key) + + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource", access_token + ) + + with pytest.raises(ValueError): + authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource", access_token + ) + + def test_malformed_access_token_rejected_when_reading_cnf(self): + """A non-JWT access token yields ValueError, not a JSON/decode crash.""" + key = jwk.ECKey.generate_key(crv="P-256") + garbage_token = "not.a.jwt" + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource", garbage_token + ) + + with pytest.raises(ValueError): + authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource", garbage_token + ) + + def test_access_token_payload_that_is_not_a_json_object_rejected(self): + """A well-formed JWS whose payload is a JSON scalar yields ValueError.""" + key = jwk.ECKey.generate_key(crv="P-256") + header_b64 = _b64url(json.dumps({"alg": "RS256"}).encode()) + scalar_payload_token = f"{header_b64}.{_b64url(b'0')}.AAAA" + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource", scalar_payload_token + ) + + with pytest.raises(ValueError, match="not a JSON object"): + authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource", scalar_payload_token + ) + + @patch("authutils.dpop.get_any_public_key_for_token_async", new_callable=AsyncMock) + @patch("authutils.dpop.token_core.validate_jwt") + async def test_validate_dpop_request_cnf_jkt_mismatch_rejected( + self, mock_validate_jwt, mock_get_public_key + ): + """ + validate_dpop_request's docstring specifically calls out + "Key binding validation (proof key thumbprint == token cnf.jkt)" + as part of what it does. Here the token's cnf.jkt belongs to a + different key than the one used to sign the DPoP proof, so + validation must fail. + """ + dpop_key = jwk.RSAKey.generate_key() + other_key = jwk.RSAKey.generate_key() + + # Built manually (rather than via _create_signed_access_token, which + # always overwrites cnf.jkt with the *signing* key's own thumbprint) + # so the token is signed by dpop_key but bound (cnf.jkt) to a + # different key entirely -- the real-world mismatch scenario. + now = int(time.time()) + payload = { + "sub": "test-user", + "iss": "https://example.com", + "aud": "test-audience", + "iat": now, + "exp": now + 3600, + "pur": "access", + "scope": ["openid", "user"], + "cnf": {"jkt": other_key.thumbprint()}, + } + access_token = jwt.encode({"alg": "RS256", "typ": "JWT"}, payload, dpop_key) + + proof = authutils.dpop.generate_dpop_proof( + dpop_key, "GET", "https://example.com/api/resource", access_token + ) + + # validate_dpop_proof reads cnf.jkt straight off the raw access + # token (not from the mocked validate_jwt claims), so the mock + # return value doesn't need to reflect cnf itself -- it's only + # reached if key binding passes. + mock_get_public_key.return_value = dpop_key.as_pem() + mock_validate_jwt.return_value = { + "sub": "test-user", + "iss": "https://example.com", + "aud": "test-audience", + "pur": "access", + "scope": ["openid", "user"], + } + + with pytest.raises(ValueError): + await authutils.dpop.validate_dpop_request_async( + dpop_header=proof, + access_token=access_token, + request_method="GET", + request_url="https://example.com/api/resource", + issuers=["https://example.com"], + ) + + +class TestValidateDpopProofContract: + """ + Return-shape and input-guard tests for validate_dpop_proof itself, + independent of any specific claim being validated. + """ + + def test_returns_dpop_claims_and_client_jwk_tuple(self): + """ + Returns the proof's claims and its signing key, so no second extract call. + + A caller that only got the claims back would have to re-parse the proof + with extract_and_validate_jwk to do anything key-bound with it. + """ + key = jwk.ECKey.generate_key(crv="P-256") + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource" + ) + + dpop_claims, client_jwk = authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource" + ) + + assert dpop_claims["htm"] == "GET" + assert dpop_claims["htu"] == "https://example.com/resource" + assert "iat" in dpop_claims + assert client_jwk.thumbprint() == key.thumbprint() + + def test_jti_missing_rejected(self): + """A proof with no jti is rejected.""" + key = jwk.ECKey.generate_key(crv="P-256") + proof = _build_raw_dpop_proof( + key, + claims={ + "htm": "GET", + "htu": "https://example.com/resource", + "iat": int(time.time()), + }, + ) + + with pytest.raises(ValueError, match="jti"): + authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource" + ) + + def test_jti_non_string_rejected(self): + """A non-string jti is rejected rather than compared by length.""" + key = jwk.ECKey.generate_key(crv="P-256") + proof = _build_raw_dpop_proof( + key, + claims={ + "htm": "GET", + "htu": "https://example.com/resource", + "iat": int(time.time()), + "jti": 12345, + }, + ) + + with pytest.raises(ValueError, match="jti"): + authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource" + ) + + def test_oversized_jti_rejected(self): + """A jti beyond MAX_JTI_LENGTH is rejected to bound replay-cache size.""" + key = jwk.ECKey.generate_key(crv="P-256") + proof = _build_raw_dpop_proof( + key, + claims={ + "htm": "GET", + "htu": "https://example.com/resource", + "iat": int(time.time()), + "jti": "j" * (MAX_JTI_LENGTH + 1), + }, + ) + + with pytest.raises(ValueError, match="jti"): + authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource" + ) + + @pytest.mark.parametrize("prefix", ["DPoP ", "dpop ", ""]) + def test_dpop_scheme_prefix_is_stripped(self, prefix): + """A 'DPoP ' scheme prefix is accepted and stripped, in any casing.""" + key = jwk.ECKey.generate_key(crv="P-256") + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource" + ) + + claims, _ = authutils.dpop.validate_dpop_proof( + prefix + proof, "GET", "https://example.com/resource" + ) + assert claims["htm"] == "GET" + + def test_wrong_auth_scheme_prefix_rejected(self): + """A 'Bearer ' prefix on the DPoP header is rejected, not silently stripped.""" + key = jwk.ECKey.generate_key(crv="P-256") + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource" + ) + + with pytest.raises(ValueError, match="scheme"): + authutils.dpop.validate_dpop_proof( + "Bearer " + proof, "GET", "https://example.com/resource" + ) + + def test_oversized_dpop_header_rejected_before_crypto(self): + """An absurdly long header is rejected before any signature work.""" + with pytest.raises(ValueError, match="exceeds"): + authutils.dpop.validate_dpop_proof( + "a" * (MAX_DPOP_HEADER_LENGTH + 1), + "GET", + "https://example.com/resource", + ) + + @pytest.mark.parametrize( + "non_string", [pytest.param(12345, id="int"), pytest.param(["a"], id="list")] + ) + def test_non_string_dpop_header_rejected(self, non_string): + """A non-string DPoP header raises ValueError, not AttributeError.""" + with pytest.raises(ValueError): + authutils.dpop.validate_dpop_proof( + non_string, "GET", "https://example.com/resource" + ) + + @pytest.mark.parametrize( + "invalid_header", + [ + pytest.param(None, id="none_header"), + pytest.param("", id="empty_string_header"), + ], + ) + def test_invalid_dpop_header_raises_value_error(self, invalid_header): + """ + Test that validate_dpop_proof raises ValueError when dpop_header is None or empty string. + """ + with pytest.raises(ValueError) as exc_info: + authutils.dpop.validate_dpop_proof( + invalid_header, "GET", "https://example.com/resource" + ) + assert "Invalid DPoP proof: Empty string / None provided" in str(exc_info.value) + + def test_proof_with_embedded_whitespace_rejected(self): + """A proof containing internal whitespace is rejected, not silently split.""" + key = jwk.ECKey.generate_key(crv="P-256") + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource" + ) + + with pytest.raises(ValueError, match="unexpected whitespace"): + authutils.dpop.validate_dpop_proof( + f"{proof} trailing-junk", "GET", "https://example.com/resource" + ) + + def test_request_url_with_invalid_port_rejected(self): + """A request URL whose port is not numeric yields ValueError.""" + key = jwk.ECKey.generate_key(crv="P-256") + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource" + ) + + with pytest.raises(ValueError, match="invalid port"): + authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com:notaport/resource" + ) + + @pytest.mark.parametrize( + "bad_method", + [pytest.param("", id="empty"), pytest.param(None, id="none")], + ) + def test_missing_request_method_rejected(self, bad_method): + """An empty or missing request_method yields ValueError, not a silent pass.""" + key = jwk.ECKey.generate_key(crv="P-256") + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource" + ) + + with pytest.raises(ValueError, match="request_method"): + authutils.dpop.validate_dpop_proof( + proof, bad_method, "https://example.com/resource" + ) + + +class TestStatelessNonceLifecycle: + """ + Tests for generate_stateless_nonce and verify_stateless_nonce, + exercised directly against authutils.token.dpop_nonce (not through a + DPoP proof). + """ + + def test_generate_nonce_success(self): + """Returns valid HS256 JWT with purpose=dpop_nonce and correct exp.""" + nonce = dpop_nonce.generate_stateless_nonce() + + # Decode without verification to check claims + hs_key = jwk.OctKey.import_key(os.environ["DPOP_SHARED_SECRET"]) + token_obj = jwt.decode(nonce, key=hs_key) + decoded = token_obj.claims + assert decoded["purpose"] == "dpop_nonce" + assert "iat" in decoded + assert "exp" in decoded + + # Check TTL is approximately correct + now = int(time.time()) + ttl = decoded["exp"] - decoded["iat"] + assert abs(ttl - DPOP_PROOF_MAX_TTL) < 3 + + def test_generate_nonce_missing_secret_raises(self): + """Raises RuntimeError when DPOP_SHARED_SECRET is unset.""" + # Set a valid secret first + os.environ["DPOP_SHARED_SECRET"] = ( + "test-secret-32chars-minimum-test" # pragma: allowlist secret + ) + + # Remove the environment variable + old_secret = os.environ.pop("DPOP_SHARED_SECRET", None) + + try: + with pytest.raises(RuntimeError): + dpop_nonce.generate_stateless_nonce() + finally: + os.environ["DPOP_SHARED_SECRET"] = old_secret + + def test_generate_nonce_with_special_characters_in_secret(self): + """Nonce generation works with special characters in secret.""" + os.environ["DPOP_SHARED_SECRET"] = "test!@#$%^&*()-_+=[]{}|;':\",./<>?" + nonce = dpop_nonce.generate_stateless_nonce() + # Should generate a valid nonce + hs_key = jwk.OctKey.import_key(os.environ["DPOP_SHARED_SECRET"]) + token_obj = jwt.decode(nonce, key=hs_key) + decoded = token_obj.claims + assert decoded["purpose"] == "dpop_nonce" + + def test_verify_nonce_valid(self): + """Returns True for a freshly generated nonce.""" + nonce = dpop_nonce.generate_stateless_nonce() + assert dpop_nonce.verify_stateless_nonce(nonce) is True + + def test_verify_nonce_with_different_secret(self): + """Returns False when verified with a different secret.""" + # Set a valid secret + old_secret = os.environ["DPOP_SHARED_SECRET"] + + # Generate nonce with original secret + nonce = dpop_nonce.generate_stateless_nonce() + + # Change the secret + os.environ["DPOP_SHARED_SECRET"] = ( + "completely-different-secret-key-123" # pragma: allowlist secret + ) + + try: + # Should return False with different secret + assert dpop_nonce.verify_stateless_nonce(nonce) is False + finally: + os.environ["DPOP_SHARED_SECRET"] = old_secret + + def test_verify_nonce_empty_string(self): + """Returns False for empty/None input.""" + assert dpop_nonce.verify_stateless_nonce("") is False + assert dpop_nonce.verify_stateless_nonce(None) is False + + def test_verify_nonce_expired(self): + """Returns False for an expired nonce.""" + now = int(time.time()) + expired_payload = { + "iat": now - 10000, + "exp": now - 5000, + "purpose": "dpop_nonce", + } + hs_key = jwk.OctKey.import_key(os.environ["DPOP_SHARED_SECRET"]) + expired_nonce = jwt.encode( + header={"alg": "HS256", "typ": "JWT"}, + claims=expired_payload, + key=hs_key, + ) + assert dpop_nonce.verify_stateless_nonce(expired_nonce) is False + + def test_verify_nonce_without_exp_rejected(self): + """ + A correctly signed nonce carrying no exp is rejected. + + generate_stateless_nonce always sets exp, so one without it is + malformed. Skipping the check when the claim is absent would make such + a nonce valid forever, which defeats DPOP_NONCE_TTL entirely. + """ + hs_key = jwk.OctKey.import_key(os.environ["DPOP_SHARED_SECRET"]) + no_exp = jwt.encode( + header={"alg": "HS256", "typ": "JWT"}, + claims={"iat": int(time.time()), "purpose": "dpop_nonce"}, + key=hs_key, + ) + + assert dpop_nonce.verify_stateless_nonce(no_exp) is False + + def test_verify_nonce_wrong_purpose(self): + """Returns False for nonce with wrong purpose.""" + payload = { + "iat": int(time.time()), + "exp": int(time.time()) + 345600, + "purpose": "wrong_purpose", + } + hs_key = jwk.OctKey.import_key(os.environ["DPOP_SHARED_SECRET"]) + nonce = jwt.encode( + header={"alg": "HS256", "typ": "JWT"}, claims=payload, key=hs_key + ) + assert dpop_nonce.verify_stateless_nonce(nonce) is False + + def test_verify_nonce_tampered_signature(self): + """Returns False for a modified nonce string.""" + nonce = dpop_nonce.generate_stateless_nonce() + tampered = nonce[:-5] + "foobar" + assert dpop_nonce.verify_stateless_nonce(tampered) is False + + @pytest.mark.parametrize( + "malformed", + [ + pytest.param("abc.def.ghi", id="non-base64-parts"), + pytest.param("!!!.!!!.!!!", id="outside-base64-alphabet"), + pytest.param("a.", id="two-parts"), + pytest.param("..", id="all-parts-empty"), + pytest.param("a..", id="empty-payload-and-signature"), + pytest.param("..c", id="empty-header-and-payload"), + pytest.param( + _INVALID_JSON_HEADER_NONCE, + id="invalid-json-header", + ), + pytest.param( + _INVALID_JSON_PAYLOAD_NONCE, + id="invalid-json-payload", + ), + ], + ) + def test_verify_nonce_rejects_malformed_string(self, malformed): + """Returns False for a string that is not a well-formed nonce JWT.""" + assert dpop_nonce.verify_stateless_nonce(malformed) is False + + @pytest.mark.parametrize( + "mutate", + [ + pytest.param(lambda nonce: nonce.split(".")[0], id="header-only"), + pytest.param( + lambda nonce: ".".join(nonce.split(".")[:2]), id="no-signature" + ), + pytest.param(lambda nonce: " " + nonce, id="leading-space"), + pytest.param(lambda nonce: nonce + " ", id="trailing-space"), + pytest.param( + lambda nonce: nonce.replace(".", ". "), id="space-after-separator" + ), + ], + ) + def test_verify_nonce_rejects_mutated_valid_nonce(self, mutate): + """Returns False once a freshly minted nonce is truncated or padded.""" + nonce = dpop_nonce.generate_stateless_nonce() + assert dpop_nonce.verify_stateless_nonce(mutate(nonce)) is False + + @pytest.mark.parametrize( + "not_a_string", + [ + pytest.param(12345, id="positive-int"), + pytest.param(0, id="zero"), + pytest.param(-1, id="negative-int"), + pytest.param(["a", "b", "c"], id="list"), + pytest.param([], id="empty-list"), + pytest.param({"a": "b"}, id="dict"), + pytest.param({}, id="empty-dict"), + ], + ) + def test_verify_nonce_rejects_non_string_input(self, not_a_string): + """Returns False rather than raising when handed a non-string.""" + assert dpop_nonce.verify_stateless_nonce(not_a_string) is False + + def test_verify_nonce_with_expired_but_malformed(self): + """Returns False for expired nonce with malformed timestamp.""" + # Create a JWT with negative timestamp (invalid) + payload = { + "iat": -10000, + "exp": -5000, + "purpose": "dpop_nonce", + } + hs_key = jwk.OctKey.import_key(os.environ["DPOP_SHARED_SECRET"]) + malformed = jwt.encode( + header={"alg": "HS256", "typ": "JWT"}, claims=payload, key=hs_key + ) + assert dpop_nonce.verify_stateless_nonce(malformed) is False + + def test_verify_nonce_with_future_timestamp_malformation(self): + """Returns False for token with future but malformed timestamps.""" + now = int(time.time()) + # CreateExpired but with exp before iat (malformed) + payload = { + "iat": now + 10000, + "exp": now + 5000, # exp < iat, malformed + "purpose": "dpop_nonce", + } + hs_key = jwk.OctKey.import_key(os.environ["DPOP_SHARED_SECRET"]) + malformed = jwt.encode( + header={"alg": "HS256", "typ": "JWT"}, claims=payload, key=hs_key + ) + assert dpop_nonce.verify_stateless_nonce(malformed) is False + + def test_verify_nonce_with_extremely_short_ttl(self): + """Returns False for nonce with negative TTL (exp < iat).""" + now = int(time.time()) + # Expired immediately + payload = { + "iat": now, + "exp": now - 1, # exp < iat + "purpose": "dpop_nonce", + } + hs_key = jwk.OctKey.import_key(os.environ["DPOP_SHARED_SECRET"]) + short_ttl = jwt.encode( + header={"alg": "HS256", "typ": "JWT"}, claims=payload, key=hs_key + ) + assert dpop_nonce.verify_stateless_nonce(short_ttl) is False + + @pytest.mark.parametrize( + "error_class", + [InvalidNonceErrorResourceServer, InvalidNonceErrorAuthorizationServer], + ) + def test_nonce_error_bodies_are_not_shared_between_instances(self, error_class): + """Mutating one nonce error's json does not affect the next instance.""" + first = error_class(new_nonce="nonce-one") + first.json["error"] = "mutated" + + assert error_class(new_nonce="nonce-two").json["error"] == "use_dpop_nonce" + + @pytest.mark.parametrize( + "error_class, expected_code", + [ + (InvalidNonceErrorResourceServer, 401), + (InvalidNonceErrorAuthorizationServer, 400), + ], + ) + def test_nonce_error_describes_its_own_role(self, error_class, expected_code): + """Each nonce error carries the status and description for its own role.""" + err = error_class(new_nonce="a-nonce") + + assert err.code == expected_code + assert err.error_headers["DPoP-Nonce"] == "a-nonce" + + +class TestDpopProofNonceRequirement: + """ + Tests for DPoP nonce validation and InvalidNonceError handling + inside validate_dpop_proof, per RFC 9449's server-provided-nonce + mechanism, parametrized across as_resource_server and the nonce + verification import path. + """ + + @pytest.mark.parametrize( + "as_resource_server", + [ + pytest.param(True, id="resource-server"), + pytest.param(False, id="auth-server"), + ], + ) + def test_nonce_errors_share_a_catchable_base(self, as_resource_server): + """Either mode's nonce error is catchable as InvalidNonceError.""" + key = jwk.generate_key("EC", "P-256") + url = "https://gen3.example.com/api/v1/resource" + proof = generate_dpop_proof(key=key, method="GET", url=url, nonce=None) + + with pytest.raises(InvalidNonceError) as exc_info: + validate_dpop_proof( + dpop_header=proof, + request_method="GET", + request_url=url, + require_nonce=True, + as_resource_server=as_resource_server, + ) + + assert exc_info.value.error_headers["DPoP-Nonce"] + + @pytest.mark.parametrize( + "as_resource_server, expected_error_cls, expected_status_code", + [ + (True, InvalidNonceErrorResourceServer, 401), + (False, InvalidNonceErrorAuthorizationServer, 400), + ], + ) + def test_missing_required_nonce( + self, as_resource_server, expected_error_cls, expected_status_code + ): + """A missing nonce under require_nonce raises the mode-specific error.""" + key = jwk.generate_key("EC", "P-256") + url = "https://gen3.example.com/api/v1/resource" + proof = generate_dpop_proof(key=key, method="GET", url=url, nonce=None) + + with pytest.raises(expected_error_cls) as exc_info: + validate_dpop_proof( + dpop_header=proof, + request_method="GET", + request_url=url, + require_nonce=True, + as_resource_server=as_resource_server, + ) + + assert exc_info.value.code == expected_status_code + if as_resource_server: + _assert_nonce_error_for_resource_server(exc_info.value) + else: + _assert_nonce_error_for_authorization_server(exc_info.value) + + @pytest.mark.parametrize( + "as_resource_server, expected_error_cls, expected_status_code", + [ + (True, InvalidNonceErrorResourceServer, 401), + (False, InvalidNonceErrorAuthorizationServer, 400), + ], + ) + def test_invalid_nonce_rejected( + self, as_resource_server, expected_error_cls, expected_status_code + ): + """ + Garbage nonce string, no mocking -- exercises the real + verify_stateless_nonce failure path. + """ + key = jwk.generate_key("EC", "P-256") + url = "https://gen3.example.com/api/v1/resource" + proof = generate_dpop_proof(key=key, method="GET", url=url, nonce="bad-nonce") + + with pytest.raises(expected_error_cls) as exc_info: + validate_dpop_proof( + dpop_header=proof, + request_method="GET", + request_url=url, + require_nonce=True, + secret="test-secret-32chars-minimum-test", # pragma: allowlist secret + as_resource_server=as_resource_server, + ) + + assert exc_info.value.code == expected_status_code + + @pytest.mark.parametrize( + "patch_target", + [ + "authutils.dpop.verify_stateless_nonce", + "authutils.token.dpop_nonce.verify_stateless_nonce", + ], + ) + def test_invalid_nonce_rejected_regardless_of_import_path(self, patch_target): + """ + Even with require_nonce=False, a nonce that IS present but fails + verification must still raise. Parametrized over both the + dpop-module-local import and the source module, since dpop.py may + reference verify_stateless_nonce via either path. + """ + key = jwk.ECKey.generate_key(crv="P-256") + invalid_nonce = "garbage-nonce-not-valid" + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource", nonce=invalid_nonce + ) + + with patch(patch_target, return_value=False): + with pytest.raises( + (InvalidNonceErrorAuthorizationServer, InvalidNonceErrorResourceServer) + ) as exc_info: + authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource", require_nonce=False + ) + + _assert_nonce_error_for_resource_server(exc_info.value) + + def test_valid_nonce_passes_validation(self): + """ + Test that validate_dpop_proof succeeds without error when a valid stateless nonce + is provided and require_nonce=True. + """ + valid_nonce = dpop_nonce.generate_stateless_nonce() + key = jwk.ECKey.generate_key(crv="P-256") + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource", nonce=valid_nonce + ) + + dpop_claims, client_jwk = authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource", require_nonce=True + ) + + assert dpop_claims["nonce"] == valid_nonce + assert client_jwk is not None + + def test_empty_nonce_claim_accepted_when_nonce_not_required(self): + """ + A present-but-empty nonce is treated as no nonce when none is required. + + The claim's presence is what routes into nonce validation at all, so an + empty value has to be distinguished there from a real one; a garbage + non-empty nonce is still verified and rejected. + """ + key = jwk.ECKey.generate_key(crv="P-256") + proof = _build_raw_dpop_proof( + key, + claims={ + "htm": "GET", + "htu": "https://example.com/resource", + "iat": int(time.time()), + "jti": "empty-nonce-jti", + "nonce": "", + }, + ) + + dpop_claims, _ = authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource" + ) + + assert dpop_claims["nonce"] == "" + + def test_garbage_nonce_rejected_even_when_not_required(self): + """A non-empty but unverifiable nonce is rejected regardless of require_nonce.""" + key = jwk.ECKey.generate_key(crv="P-256") + proof = _build_raw_dpop_proof( + key, + claims={ + "htm": "GET", + "htu": "https://example.com/resource", + "iat": int(time.time()), + "jti": "garbage-nonce-jti", + "nonce": "not-a-real-nonce", + }, + ) + + with pytest.raises(InvalidNonceErrorResourceServer): + authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource" + ) + + def test_custom_secret_matching_and_mismatched(self): + """ + Test that custom secrets passed to validate_dpop_proof correctly validate nonces. + """ + custom_secret = "custom-test-secret-32-chars-long!" # pragma: allowlist secret + valid_nonce = dpop_nonce.generate_stateless_nonce(secret=custom_secret) + + key = jwk.ECKey.generate_key(crv="P-256") + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource", nonce=valid_nonce + ) + + # Verification with matching secret should succeed + dpop_claims, _ = authutils.dpop.validate_dpop_proof( + proof, + "GET", + "https://example.com/resource", + require_nonce=True, + secret=custom_secret, + ) + assert dpop_claims["nonce"] == valid_nonce + + # Verification with mismatched secret should fail and raise (InvalidNonceErrorAuthorizationServer, InvalidNonceErrorResourceServer) + with pytest.raises( + (InvalidNonceErrorAuthorizationServer, InvalidNonceErrorResourceServer) + ): + authutils.dpop.validate_dpop_proof( + proof, + "GET", + "https://example.com/resource", + require_nonce=True, + secret="wrong-secret-key-32-chars-long!!", # pragma: allowlist secret + ) + + def test_custom_secret_mismatch_resource_server(self): + """A nonce not signed with the supplied secret is rejected as a RS.""" + key = jwk.generate_key("EC", "P-256") + url = "https://gen3.example.com/api/v1/resource" + proof = generate_dpop_proof(key=key, method="GET", url=url, nonce="some-nonce") + + with pytest.raises(InvalidNonceErrorResourceServer): + validate_dpop_proof( + dpop_header=proof, + request_method="GET", + request_url=url, + require_nonce=True, + secret="custom-secret-key", # pragma: allowlist secret + as_resource_server=True, + ) + + def test_secret_parameter_fallback_to_env(self): + """ + Test that when secret=None is passed, it defaults to the environment variable. + """ + key = jwk.ECKey.generate_key(crv="P-256") + nonce = dpop_nonce.generate_stateless_nonce() + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource", nonce=nonce + ) + + # Call with secret=None should use the DPOP_SHARED_SECRET from fixture + dpop_claims, client_jwk = authutils.dpop.validate_dpop_proof( + proof, + "GET", + "https://example.com/resource", + require_nonce=True, + secret=None, + ) + assert dpop_claims.get("nonce") == nonce + + def test_secret_parameter_explicit_value(self): + """ + Test that when an explicit secret is passed, it uses that secret. + """ + key = jwk.ECKey.generate_key(crv="P-256") + nonce = dpop_nonce.generate_stateless_nonce() + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource", nonce=nonce + ) + + # Get the secret from the fixture to verify we can pass it explicitly + explicit_secret = os.environ["DPOP_SHARED_SECRET"] + + # Call with explicit secret should work the same + dpop_claims, client_jwk = authutils.dpop.validate_dpop_proof( + proof, + "GET", + "https://example.com/resource", + require_nonce=True, + secret=explicit_secret, + ) + assert dpop_claims.get("nonce") == nonce + + def test_secret_parameter_passed_to_verify(self): + """ + Test that the secret parameter is correctly passed to verify_stateless_nonce. + By generating a nonce with a known secret and then verifying with the same secret, + we confirm that the secret is being passed through correctly. + """ + key = jwk.ECKey.generate_key(crv="P-256") + # Use a custom secret that we control + custom_secret = ( + "custom-test-secret-32chars-minimum-test" # pragma: allowlist secret + ) + + # Temporarily set the custom secret in environment + old_secret = os.environ.get("DPOP_SHARED_SECRET") + os.environ["DPOP_SHARED_SECRET"] = custom_secret + try: + # Generate nonce with custom secret + nonce = dpop_nonce.generate_stateless_nonce() + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource", nonce=nonce + ) + finally: + # Restore original secret + if old_secret: + os.environ["DPOP_SHARED_SECRET"] = old_secret + else: + os.environ.pop("DPOP_SHARED_SECRET", None) + + # Now verify with explicit secret - should succeed if secret is passed correctly + dpop_claims, client_jwk = authutils.dpop.validate_dpop_proof( + proof, + "GET", + "https://example.com/resource", + require_nonce=True, + secret=custom_secret, + ) + assert dpop_claims.get("nonce") == nonce + + +@pytest.mark.anyio +class TestValidateDpopRequestIntegration: + """ + Tests for validate_dpop_request -- the wrapper that combines proof + validation and access-token validation in one call. + """ + + @pytest.fixture + def rsa_key(self): + """Generate an RSA key for testing.""" + return jwk.RSAKey.generate_key() + + @pytest.fixture + def ec_key(self): + """Generate an EC key for DPoP proof testing.""" + return jwk.ECKey.generate_key(crv="P-256") + + @patch("authutils.dpop.get_any_public_key_for_token_async", new_callable=AsyncMock) + @patch("authutils.dpop.token_core.validate_jwt") + async def test_validate_dpop_request_return( + self, mock_validate_jwt, mock_get_public_key, rsa_key + ): + """ + Test that validate_dpop_request returns a tuple with exactly 3 elements: + (dpop_claims, access_token_claims, client_jwk). + """ + # Create signed access token with proper key binding (RSA for token validation) + access_token = _create_signed_access_token(rsa_key) + + dpop_proof = authutils.dpop.generate_dpop_proof( + rsa_key, "POST", "https://example.com/api/resource", access_token + ) + + mock_get_public_key.return_value = rsa_key.as_pem() + mock_validate_jwt.return_value = { + "sub": "test-user", + "iss": "https://example.com", + "aud": "test-audience", + "pur": "access", + "scope": ["openid", "user"], + } + + result = await authutils.dpop.validate_dpop_request_async( + dpop_header=dpop_proof, + access_token=access_token, + request_method="POST", + request_url="https://example.com/api/resource", + issuers=["https://example.com"], + ) + + assert len(result) == 3, "validate_dpop_request should return a 3-element tuple" + dpop_claims, token_claims, client_jwk = result + + assert isinstance(dpop_claims, dict), "First element should be dpop_claims dict" + assert "htm" in dpop_claims, "dpop_claims should contain 'htm'" + assert "htu" in dpop_claims, "dpop_claims should contain 'htu'" + assert "iat" in dpop_claims, "dpop_claims should contain 'iat'" + assert "jti" in dpop_claims, "dpop_claims should contain 'jti'" + assert dpop_claims["htm"] == "POST", "htm should match request method" + assert ( + dpop_claims["htu"] == "https://example.com/api/resource" + ), "htu should match request URL" + + assert isinstance( + token_claims, dict + ), "Second element should be token_claims dict" + assert token_claims["sub"] == "test-user", "token should contain correct sub" + assert ( + token_claims["iss"] == "https://example.com" + ), "token should contain correct iss" + assert token_claims["pur"] == "access", "token should contain correct pur" + + assert client_jwk is not None, "client_jwk should not be None" + assert hasattr(client_jwk, "as_dict"), "client_jwk should have as_dict method" + jwk_dict = client_jwk.as_dict(private=False) + assert "kty" in jwk_dict, "client_jwk should have 'kty' attribute" + assert jwk_dict["kty"] == "RSA", "client_jwk should be RSA type" + + @pytest.mark.parametrize("http_method", ["GET", "POST", "PUT", "DELETE", "PATCH"]) + @patch("authutils.dpop.get_any_public_key_for_token_async", new_callable=AsyncMock) + @patch("authutils.dpop.token_core.validate_jwt") + async def test_different_http_methods( + self, mock_validate_jwt, mock_get_public_key, http_method + ): + """ + Test that validate_dpop_request works with different HTTP methods. + """ + # Use RSA key since access token validation only supports RS256 + dpop_key = jwk.RSAKey.generate_key() + access_token = _create_signed_access_token(dpop_key) + proof = authutils.dpop.generate_dpop_proof( + dpop_key, http_method, "https://example.com/api/resource", access_token + ) + + mock_get_public_key.return_value = dpop_key.as_pem() + mock_validate_jwt.return_value = { + "sub": "test-user", + "iss": "https://example.com", + "aud": "test-audience", + "pur": "access", + "scope": ["openid", "user"], + } + + result = await authutils.dpop.validate_dpop_request_async( + dpop_header=proof, + access_token=access_token, + request_method=http_method, + request_url="https://example.com/api/resource", + issuers=["https://example.com"], + ) + + dpop_claims, _, _ = result + assert dpop_claims["htm"] == http_method + + @pytest.mark.parametrize("key_type", ["EC", "RSA"]) + @patch("authutils.dpop.get_any_public_key_for_token_async", new_callable=AsyncMock) + @patch("authutils.dpop.token_core.validate_jwt") + async def test_different_key_types( + self, mock_validate_jwt, mock_get_public_key, key_type + ): + """ + Test that validate_dpop_request works with different key types (EC and RSA). + Note: Access tokens use RS256, so we use RSA keys for access token. + For EC key tests, the DPoP proof uses EC but access token uses RS256 with RSA key. + """ + if key_type == "EC": + dpop_key = jwk.ECKey.generate_key(crv="P-256") + # Create RSA key for access token (RS256 only) + rsa_key = jwk.RSAKey.generate_key() + access_token = _create_signed_access_token(rsa_key) + # Use RSA key for DPoP proof for consistency + dpop_key = rsa_key + else: + dpop_key = jwk.RSAKey.generate_key() + + access_token = _create_signed_access_token(dpop_key) + proof = authutils.dpop.generate_dpop_proof( + dpop_key, "GET", "https://example.com/api/resource", access_token + ) + + mock_get_public_key.return_value = dpop_key.as_pem() + mock_validate_jwt.return_value = { + "sub": "test-user", + "iss": "https://example.com", + "aud": "test-audience", + "pur": "access", + "scope": ["openid", "user"], + } + + result = await authutils.dpop.validate_dpop_request_async( + dpop_header=proof, + access_token=access_token, + request_method="GET", + request_url="https://example.com/api/resource", + issuers=["https://example.com"], + ) + + dpop_claims, token_claims, client_jwk = result + + assert dpop_claims["htm"] == "GET" + assert dpop_claims["htu"] == "https://example.com/api/resource" + + assert token_claims["sub"] == "test-user" + + jwk_dict = client_jwk.as_dict(private=False) + assert ( + jwk_dict["kty"] == "RSA" + ), "All tests use RSA keys due to RS256 requirement" + + @patch("authutils.dpop.get_any_public_key_for_token_async", new_callable=AsyncMock) + @patch("authutils.dpop.token_core.validate_jwt") + async def test_access_token_scopes_validation( + self, mock_validate_jwt, mock_get_public_key + ): + """ + Test that validate_dpop_request validates the required scopes. + """ + # Use RSA key for access token (RS256 only) + dpop_key = jwk.RSAKey.generate_key() + access_token = _create_signed_access_token( + dpop_key, scopes=["openid", "user", "data"] + ) + + proof = authutils.dpop.generate_dpop_proof( + dpop_key, "GET", "https://example.com/api/resource", access_token + ) + + mock_get_public_key.return_value = dpop_key.as_pem() + mock_validate_jwt.return_value = { + "sub": "test-user", + "iss": "https://example.com", + "aud": "test-audience", + "pur": "access", + "scope": ["openid", "user", "data"], + } + + # Should succeed with matching scopes + result = await authutils.dpop.validate_dpop_request_async( + dpop_header=proof, + access_token=access_token, + request_method="GET", + request_url="https://example.com/api/resource", + issuers=["https://example.com"], + scope={"openid", "user", "data"}, + ) + + dpop_claims, token_claims, client_jwk = result + assert dpop_claims["htm"] == "GET" + + @patch("authutils.dpop.get_any_public_key_for_token_async", new_callable=AsyncMock) + @patch("authutils.dpop.token_core.validate_jwt") + async def test_validate_dpop_request_secret_parameter( + self, mock_validate_jwt, mock_get_public_key + ): + """ + Test that secret parameter is properly passed through from validate_dpop_request. + """ + key = jwk.RSAKey.generate_key() + custom_secret = ( + "custom-request-secret-32chars-minimum-test" # pragma: allowlist secret + ) + + # Temporarily set the custom secret for nonce generation + old_secret = os.environ.get("DPOP_SHARED_SECRET") + os.environ["DPOP_SHARED_SECRET"] = custom_secret + try: + nonce = dpop_nonce.generate_stateless_nonce() + + # Create access token with key binding + access_token = _create_signed_access_token( + key, additional_claims={"cnf": {"jkt": key.thumbprint()}} + ) + + proof = authutils.dpop.generate_dpop_proof( + key, + "POST", + "https://example.com/api/resource", + access_token, + nonce=nonce, + ) + + mock_get_public_key.return_value = key.as_pem() + mock_validate_jwt.return_value = { + "sub": "test-user", + "iss": "https://example.com", + "aud": "test-audience", + "pur": "access", + "scope": ["openid", "user"], + } + + ( + dpop_claims, + token_claims, + client_jwk, + ) = await authutils.dpop.validate_dpop_request_async( + dpop_header=proof, + access_token=access_token, + request_method="POST", + request_url="https://example.com/api/resource", + issuers=["https://example.com"], + require_nonce=True, + secret=custom_secret, + ) + assert dpop_claims["nonce"] == nonce + finally: + # Restore original secret + if old_secret: + os.environ["DPOP_SHARED_SECRET"] = old_secret + else: + os.environ.pop("DPOP_SHARED_SECRET", None) + + @pytest.mark.parametrize( + "as_resource_server, expected_error_cls", + [ + (True, InvalidNonceErrorResourceServer), + (False, InvalidNonceErrorAuthorizationServer), + ], + ) + @patch("authutils.dpop.get_any_public_key_for_token_async", new_callable=AsyncMock) + @patch("authutils.dpop.token_core.validate_jwt") + async def test_require_nonce_missing_raises_through_request( + self, + mock_validate_jwt, + mock_get_public_key, + as_resource_server, + expected_error_cls, + rsa_key, + ): + """ + Covers the docstring's own TODO ("add unit test(s) for + as_resource_server") for validate_dpop_request specifically, using + the require_nonce path. + """ + access_token = _create_signed_access_token(rsa_key) + proof = authutils.dpop.generate_dpop_proof( + rsa_key, "GET", "https://example.com/api/resource", access_token + ) + + mock_get_public_key.return_value = rsa_key.as_pem() + mock_validate_jwt.return_value = { + "sub": "test-user", + "iss": "https://example.com", + "aud": "test-audience", + "pur": "access", + "scope": ["openid", "user"], + } + + with pytest.raises(expected_error_cls): + await authutils.dpop.validate_dpop_request_async( + dpop_header=proof, + access_token=access_token, + request_method="GET", + request_url="https://example.com/api/resource", + issuers=["https://example.com"], + require_nonce=True, + as_resource_server=as_resource_server, + ) + + @patch("authutils.dpop.get_any_public_key_for_token_async", new_callable=AsyncMock) + @patch("authutils.dpop.token_core.validate_jwt") + async def test_require_nonce_invalid_raises_through_request( + self, mock_validate_jwt, mock_get_public_key, rsa_key + ): + """ + An invalid (not just missing) nonce must also raise through + the validate_dpop_request wrapper, not only through + validate_dpop_proof directly. + """ + access_token = _create_signed_access_token(rsa_key) + proof = authutils.dpop.generate_dpop_proof( + rsa_key, + "GET", + "https://example.com/api/resource", + access_token, + nonce="not-a-real-nonce", + ) + + mock_get_public_key.return_value = rsa_key.as_pem() + mock_validate_jwt.return_value = { + "sub": "test-user", + "iss": "https://example.com", + "aud": "test-audience", + "pur": "access", + "scope": ["openid", "user"], + } + + with pytest.raises( + (InvalidNonceErrorAuthorizationServer, InvalidNonceErrorResourceServer) + ): + await authutils.dpop.validate_dpop_request_async( + dpop_header=proof, + access_token=access_token, + request_method="GET", + request_url="https://example.com/api/resource", + issuers=["https://example.com"], + require_nonce=True, + ) + + @patch("authutils.dpop.get_any_public_key_for_token_async", new_callable=AsyncMock) + @patch("authutils.dpop.token_core.validate_jwt") + async def test_public_key_param_bypasses_jwks_lookup( + self, mock_validate_jwt, mock_get_public_key, rsa_key + ): + """ + When an explicit `public_key` is supplied, validate_dpop_request + should use it directly rather than fetching from the issuer's JWKS + endpoint via get_any_public_key_for_token. + """ + access_token = _create_signed_access_token(rsa_key) + proof = authutils.dpop.generate_dpop_proof( + rsa_key, "GET", "https://example.com/api/resource", access_token + ) + + mock_validate_jwt.return_value = { + "sub": "test-user", + "iss": "https://example.com", + "aud": "test-audience", + "pur": "access", + "scope": ["openid", "user"], + } + + await authutils.dpop.validate_dpop_request_async( + dpop_header=proof, + access_token=access_token, + request_method="GET", + request_url="https://example.com/api/resource", + issuers=["https://example.com"], + public_key=rsa_key.as_pem(), + ) + + mock_get_public_key.assert_not_called() + + @patch("authutils.dpop.get_any_public_key_for_token_async", new_callable=AsyncMock) + @patch("authutils.dpop.token_core.validate_jwt") + async def test_options_param_forwarded_to_validate_jwt( + self, mock_validate_jwt, mock_get_public_key, rsa_key + ): + """ + The `options` dict should be passed through to the underlying + token_core.validate_jwt call (e.g. for pyjwt-style decode options). + """ + access_token = _create_signed_access_token(rsa_key) + proof = authutils.dpop.generate_dpop_proof( + rsa_key, "GET", "https://example.com/api/resource", access_token + ) + + mock_get_public_key.return_value = rsa_key.as_pem() + mock_validate_jwt.return_value = { + "sub": "test-user", + "iss": "https://example.com", + "aud": "test-audience", + "pur": "access", + "scope": ["openid", "user"], + } + custom_options = {"verify_aud": False} + + await authutils.dpop.validate_dpop_request_async( + dpop_header=proof, + access_token=access_token, + request_method="GET", + request_url="https://example.com/api/resource", + issuers=["https://example.com"], + options=custom_options, + ) + + _, call_kwargs = mock_validate_jwt.call_args + assert call_kwargs.get("options") == custom_options + + @patch("authutils.dpop.get_any_public_key_for_token_async", new_callable=AsyncMock) + @patch("authutils.dpop.token_core.validate_jwt") + async def test_access_token_scope_missing_raises( + self, mock_validate_jwt, mock_get_public_key + ): + """ + Test that validate_dpop_request raises JWTScopeError when required scope is missing. + """ + # Use RSA key for access token (RS256 only) + dpop_key = jwk.RSAKey.generate_key() + access_token = _create_signed_access_token(dpop_key, scopes=["openid", "user"]) + + proof = authutils.dpop.generate_dpop_proof( + dpop_key, "GET", "https://example.com/api/resource", access_token + ) + + # Set up mock to raise JWTScopeError + mock_get_public_key.return_value = dpop_key.as_pem() + mock_validate_jwt.side_effect = JWTScopeError("token scope validation failed") + + with pytest.raises(JWTScopeError): + await authutils.dpop.validate_dpop_request_async( + dpop_header=proof, + access_token=access_token, + request_method="GET", + request_url="https://example.com/api/resource", + issuers=["https://example.com"], + scope={"openid", "user", "data"}, + ) + + @patch("authutils.dpop.get_any_public_key_for_token_async", new_callable=AsyncMock) + @patch("authutils.dpop.token_core.validate_jwt") + async def test_access_token_purpose_validation( + self, mock_validate_jwt, mock_get_public_key + ): + """ + Test that validate_dpop_request validates the required purpose. + """ + # Use RSA key for access token (RS256 only) + dpop_key = jwk.RSAKey.generate_key() + access_token = _create_signed_access_token(dpop_key, purpose="access") + + proof = authutils.dpop.generate_dpop_proof( + dpop_key, "GET", "https://example.com/api/resource", access_token + ) + + mock_get_public_key.return_value = dpop_key.as_pem() + mock_validate_jwt.return_value = { + "sub": "test-user", + "iss": "https://example.com", + "aud": "test-audience", + "pur": "access", + "scope": ["openid", "user"], + } + + result = await authutils.dpop.validate_dpop_request_async( + dpop_header=proof, + access_token=access_token, + request_method="GET", + request_url="https://example.com/api/resource", + issuers=["https://example.com"], + purpose="access", + ) + + dpop_claims, token_claims, client_jwk = result + assert token_claims["pur"] == "access" + + @patch("authutils.dpop.get_any_public_key_for_token_async", new_callable=AsyncMock) + @patch("authutils.dpop.token_core.validate_jwt") + async def test_access_token_purpose_mismatch_raises( + self, mock_validate_jwt, mock_get_public_key + ): + """ + Test that validate_dpop_request raises JWTPurposeError when purpose doesn't match. + """ + # Use RSA key for access token (RS256 only) + dpop_key = jwk.RSAKey.generate_key() + access_token = _create_signed_access_token(dpop_key, purpose="refresh") + + proof = authutils.dpop.generate_dpop_proof( + dpop_key, "GET", "https://example.com/api/resource", access_token + ) + + # Set up mock to raise JWTPurposeError + mock_get_public_key.return_value = dpop_key.as_pem() + mock_validate_jwt.side_effect = JWTPurposeError( + "token purpose validation failed" + ) + + with pytest.raises(JWTPurposeError): + await authutils.dpop.validate_dpop_request_async( + dpop_header=proof, + access_token=access_token, + request_method="GET", + request_url="https://example.com/api/resource", + issuers=["https://example.com"], + purpose="access", + ) + + @patch("authutils.dpop.get_any_public_key_for_token_async", new_callable=AsyncMock) + @patch("authutils.dpop.token_core.validate_jwt") + async def test_denylist_callback_denylisted( + self, mock_validate_jwt, mock_get_public_key, rsa_key + ): + """ + Test that validate_dpop_request raises JWTError when the token is denylisted. + """ + from authutils.token import core as token_core + + access_token = _create_signed_access_token( + rsa_key, additional_claims={"jti": "test-jti-123"} + ) + + proof = authutils.dpop.generate_dpop_proof( + rsa_key, "GET", "https://example.com/api/resource", access_token + ) + + mock_get_public_key.return_value = rsa_key.as_pem() + + def denylist_callback(jti): + # This jti is in the token, so it should return True + return jti == "test-jti-123" + + # Set up mock to raise JWTError - simulating denylist behavior + mock_validate_jwt.side_effect = token_core.JWTError("token is denylisted") + + # Should raise JWTError when token is denylisted + with pytest.raises(token_core.JWTError, match="token is denylisted"): + await authutils.dpop.validate_dpop_request_async( + dpop_header=proof, + access_token=access_token, + request_method="GET", + request_url="https://example.com/api/resource", + issuers=["https://example.com"], + denylist_callback=denylist_callback, + ) + + @patch("authutils.dpop.get_any_public_key_for_token_async", new_callable=AsyncMock) + @patch("authutils.dpop.token_core.validate_jwt") + async def test_denylist_callback_not_denylisted( + self, mock_validate_jwt, mock_get_public_key, rsa_key + ): + """ + Test that validate_dpop_request succeeds when the token is not denylisted. + """ + access_token = _create_signed_access_token( + rsa_key, additional_claims={"jti": "test-jti-123"} + ) + + proof = authutils.dpop.generate_dpop_proof( + rsa_key, "GET", "https://example.com/api/resource", access_token + ) + + mock_get_public_key.return_value = rsa_key.as_pem() + + def denylist_callback(jti): + # This jti is NOT in the denylist, so it should return False + return jti == "denylisted-jti" + + mock_validate_jwt.return_value = { + "sub": "test-user", + "iss": "https://example.com", + "aud": "test-audience", + "pur": "access", + "scope": ["openid", "user"], + "jti": "test-jti-123", + } + + result = await authutils.dpop.validate_dpop_request_async( + dpop_header=proof, + access_token=access_token, + request_method="GET", + request_url="https://example.com/api/resource", + issuers=["https://example.com"], + denylist_callback=denylist_callback, + ) + + dpop_claims, token_claims, client_jwk = result + assert dpop_claims["htm"] == "GET" + + @pytest.mark.parametrize( + "bad_header", + [pytest.param(None, id="none"), pytest.param("", id="empty")], + ) + async def test_missing_dpop_header_raises_value_error(self, bad_header): + """ + A None/empty DPoP header raises ValueError, not AttributeError. + + validate_dpop_request strips an auth scheme prefix from the header, so + without an explicit guard a None header would crash on .split(). + """ + with pytest.raises(ValueError): + await authutils.dpop.validate_dpop_request_async( + dpop_header=bad_header, + access_token="some-token", + request_method="GET", + request_url="https://example.com/api/resource", + issuers=["https://example.com"], + ) + + @pytest.mark.parametrize( + "bad_token", + [pytest.param(None, id="none"), pytest.param("", id="empty")], + ) + async def test_missing_access_token_raises_value_error(self, bad_token, rsa_key): + """A None/empty access token raises ValueError before any validation.""" + proof = authutils.dpop.generate_dpop_proof( + rsa_key, "GET", "https://example.com/api/resource" + ) + + with pytest.raises(ValueError): + await authutils.dpop.validate_dpop_request_async( + dpop_header=proof, + access_token=bad_token, + request_method="GET", + request_url="https://example.com/api/resource", + issuers=["https://example.com"], + ) + + async def test_bearer_prefixed_access_token_raises_value_error(self, rsa_key): + """A full 'Bearer ...' Authorization value is rejected with a clear error.""" + access_token = _create_signed_access_token(rsa_key) + proof = authutils.dpop.generate_dpop_proof( + rsa_key, "GET", "https://example.com/api/resource", access_token + ) + + with pytest.raises(ValueError, match="[Bb]earer"): + await authutils.dpop.validate_dpop_request_async( + dpop_header=proof, + access_token="Bearer " + access_token, + request_method="GET", + request_url="https://example.com/api/resource", + issuers=["https://example.com"], + ) + + @pytest.mark.parametrize("empty", [[], None]) + async def test_empty_issuers_rejected_even_with_public_key(self, empty, rsa_key): + """ + An empty allowlist is rejected even when the caller supplies the key. + + Supplying public_key skips key discovery, which is the other place the + allowlist is enforced, so the access token would otherwise be validated + with nothing constraining its iss claim. + """ + access_token = _create_signed_access_token(rsa_key) + proof = authutils.dpop.generate_dpop_proof( + rsa_key, "GET", "https://example.com/api/resource", access_token + ) + + with pytest.raises(ValueError, match="issuers"): + await authutils.dpop.validate_dpop_request_async( + dpop_header=proof, + access_token=access_token, + request_method="GET", + request_url="https://example.com/api/resource", + issuers=empty, + public_key=rsa_key.as_pem(), + ) + + @patch("authutils.dpop.get_any_public_key_for_token_async", new_callable=AsyncMock) + @patch("authutils.dpop.token_core.validate_jwt") + async def test_issuer_allowlist_passed_to_key_lookup( + self, mock_validate_jwt, mock_get_public_key, rsa_key + ): + """ + The issuers allowlist reaches key discovery, blocking iss-driven SSRF. + + Key discovery makes outbound HTTP derived from the unverified iss + claim, so the allowlist must be enforced there rather than only later + inside validate_jwt. + """ + access_token = _create_signed_access_token(rsa_key) + proof = authutils.dpop.generate_dpop_proof( + rsa_key, "GET", "https://example.com/api/resource", access_token + ) + + mock_get_public_key.return_value = rsa_key.as_pem() + mock_validate_jwt.return_value = { + "sub": "test-user", + "iss": "https://example.com", + "aud": "test-audience", + "pur": "access", + "scope": ["openid", "user"], + } + + await authutils.dpop.validate_dpop_request_async( + dpop_header=proof, + access_token=access_token, + request_method="GET", + request_url="https://example.com/api/resource", + issuers=["https://example.com"], + ) + + _, call_kwargs = mock_get_public_key.call_args + assert call_kwargs.get("allowed_issuers") == ["https://example.com"], ( + "validate_dpop_request must forward the issuer allowlist to key " + "discovery so an untrusted iss cannot trigger an outbound fetch" + ) + + @patch("authutils.dpop.get_any_public_key_for_token_async", new_callable=AsyncMock) + @patch("authutils.dpop.token_core.validate_jwt") + async def test_jti_seen_callback_rejects_replay_through_request( + self, mock_validate_jwt, mock_get_public_key, rsa_key + ): + """jti replay protection is enforced through the combined wrapper too.""" + access_token = _create_signed_access_token(rsa_key) + proof = authutils.dpop.generate_dpop_proof( + rsa_key, "GET", "https://example.com/api/resource", access_token + ) + + mock_get_public_key.return_value = rsa_key.as_pem() + mock_validate_jwt.return_value = { + "sub": "test-user", + "iss": "https://example.com", + "aud": "test-audience", + "pur": "access", + "scope": ["openid", "user"], + } + + seen = set() + + def jti_seen_callback(jti): + if jti in seen: + return True + seen.add(jti) + return False + + kwargs = dict( + dpop_header=proof, + access_token=access_token, + request_method="GET", + request_url="https://example.com/api/resource", + issuers=["https://example.com"], + jti_seen_callback=jti_seen_callback, + ) + + await authutils.dpop.validate_dpop_request_async(**kwargs) + + with pytest.raises(ValueError, match="replay"): + await authutils.dpop.validate_dpop_request_async(**kwargs) + + @patch("authutils.dpop.get_any_public_key_for_token_async", new_callable=AsyncMock) + @patch("authutils.dpop.token_core.validate_jwt") + async def test_token_signature_failure_still_rejects_after_key_binding_passes( + self, mock_validate_jwt, mock_get_public_key, rsa_key + ): + """ + Key binding reads the unverified token, so validate_jwt must still gate. + + cnf.jkt is read before the access token's signature is checked, so this + confirms a forged-but-correctly-bound token is rejected downstream. + """ + from authutils.token import core as token_core + + access_token = _create_signed_access_token(rsa_key) + proof = authutils.dpop.generate_dpop_proof( + rsa_key, "GET", "https://example.com/api/resource", access_token + ) + + mock_get_public_key.return_value = rsa_key.as_pem() + mock_validate_jwt.side_effect = token_core.JWTError("bad signature") + + with pytest.raises(token_core.JWTError): + await authutils.dpop.validate_dpop_request_async( + dpop_header=proof, + access_token=access_token, + request_method="GET", + request_url="https://example.com/api/resource", + issuers=["https://example.com"], + ) + + @patch("authutils.dpop.get_any_public_key_for_token_async", new_callable=AsyncMock) + @patch("authutils.dpop.token_core.validate_jwt") + async def test_denylist_callback_missing_jti_claim( + self, mock_validate_jwt, mock_get_public_key, rsa_key + ): + """ + Confirms validate_dpop_request's own return path doesn't + assume `jti` is present on token_claims when a denylist_callback is + supplied. Note: the actual denylist_callback(jti) invocation lives + inside token_core.validate_jwt, which is mocked out here -- so this + does not exercise real missing-jti handling there. If that matters, + it needs coverage in token_core's own test suite instead. + """ + access_token = _create_signed_access_token(rsa_key) + proof = authutils.dpop.generate_dpop_proof( + rsa_key, "GET", "https://example.com/api/resource", access_token + ) + + mock_get_public_key.return_value = rsa_key.as_pem() + mock_validate_jwt.return_value = { + "sub": "test-user", + "iss": "https://example.com", + "aud": "test-audience", + "pur": "access", + "scope": ["openid", "user"], + # deliberately no "jti" + } + + def denylist_callback(jti): + return False + + result = await authutils.dpop.validate_dpop_request_async( + dpop_header=proof, + access_token=access_token, + request_method="GET", + request_url="https://example.com/api/resource", + issuers=["https://example.com"], + denylist_callback=denylist_callback, + ) + + dpop_claims, _, _ = result + assert dpop_claims["htm"] == "GET" + + @patch("authutils.dpop.get_any_public_key_for_token_async", new_callable=AsyncMock) + @patch("authutils.dpop.token_core.validate_jwt") + async def test_scope_list_is_normalized_to_a_set( + self, mock_validate_jwt, mock_get_public_key, rsa_key + ): + """A scope passed as a list reaches validate_jwt as a set.""" + access_token = _create_signed_access_token(rsa_key) + proof = authutils.dpop.generate_dpop_proof( + rsa_key, "GET", "https://example.com/api/resource", access_token + ) + + mock_get_public_key.return_value = rsa_key.as_pem() + mock_validate_jwt.return_value = {"sub": "test-user"} + + await authutils.dpop.validate_dpop_request_async( + dpop_header=proof, + access_token=access_token, + request_method="GET", + request_url="https://example.com/api/resource", + issuers=["https://example.com"], + scope=["openid", "user"], + ) + + assert mock_validate_jwt.call_args.kwargs["scope"] == {"openid", "user"} + + @pytest.mark.parametrize( + "non_string", + [pytest.param(12345, id="int"), pytest.param(["a.b.c"], id="list")], + ) + async def test_non_string_access_token_rejected(self, non_string, rsa_key): + """A non-string access token raises ValueError, not AttributeError.""" + proof = authutils.dpop.generate_dpop_proof( + rsa_key, "GET", "https://example.com/api/resource" + ) + + with pytest.raises(ValueError, match="must be a string"): + await authutils.dpop.validate_dpop_request_async( + dpop_header=proof, + access_token=non_string, + request_method="GET", + request_url="https://example.com/api/resource", + issuers=["https://example.com"], + ) + + +@pytest.mark.anyio +class TestValidateDpopRequestAsync: + """ + validate_dpop_request_async must reach the same verdict as the sync + wrapper, and must not hold the event loop while discovering keys. + """ + + @pytest.fixture + def rsa_key(self): + """Generate an RSA key for testing.""" + return jwk.RSAKey.generate_key() + + @patch("authutils.dpop.token_core.validate_jwt") + async def test_matches_the_sync_wrapper_on_a_valid_request( + self, mock_validate_jwt, rsa_key + ): + """A request that the sync wrapper accepts is accepted identically.""" + access_token = _create_signed_access_token(rsa_key) + proof = authutils.dpop.generate_dpop_proof( + rsa_key, "GET", "https://example.com/api/resource", access_token + ) + mock_validate_jwt.return_value = {"sub": "test-user"} + + kwargs = dict( + dpop_header=proof, + access_token=access_token, + request_method="GET", + request_url="https://example.com/api/resource", + issuers=["https://example.com"], + public_key=rsa_key.as_pem(), + ) + + ( + sync_claims, + sync_token, + sync_jwk, + ) = await authutils.dpop.validate_dpop_request_async(**kwargs) + ( + async_claims, + async_token, + async_jwk, + ) = await authutils.dpop.validate_dpop_request_async(**kwargs) + + assert async_claims == sync_claims + assert async_token == sync_token + assert async_jwk.thumbprint() == sync_jwk.thumbprint() + + @patch("authutils.dpop.get_any_public_key_for_token_async") + @patch("authutils.dpop.token_core.validate_jwt") + async def test_awaits_async_key_discovery_when_no_public_key_given( + self, mock_validate_jwt, mock_get_key_async, rsa_key + ): + """Discovery goes through the async fetcher, not the blocking one.""" + access_token = _create_signed_access_token(rsa_key) + proof = authutils.dpop.generate_dpop_proof( + rsa_key, "GET", "https://example.com/api/resource", access_token + ) + + async def fake_get_key(token, allowed_issuers=None): + await anyio.sleep(0) + return rsa_key.as_pem() + + mock_get_key_async.side_effect = fake_get_key + mock_validate_jwt.return_value = {"sub": "test-user"} + + await authutils.dpop.validate_dpop_request_async( + dpop_header=proof, + access_token=access_token, + request_method="GET", + request_url="https://example.com/api/resource", + issuers=["https://example.com"], + ) + + mock_get_key_async.assert_awaited_once() + assert mock_get_key_async.await_args.kwargs["allowed_issuers"] == [ + "https://example.com" + ] + + @patch("authutils.dpop.get_any_public_key_for_token_async") + async def test_proof_failure_rejects_before_key_discovery( + self, mock_get_key_async, rsa_key + ): + """An invalid proof is refused without triggering any key lookup.""" + access_token = _create_signed_access_token(rsa_key) + proof = authutils.dpop.generate_dpop_proof( + rsa_key, "GET", "https://example.com/api/resource", access_token + ) + + with pytest.raises(ValueError, match="htm mismatch"): + await authutils.dpop.validate_dpop_request_async( + dpop_header=proof, + access_token=access_token, + request_method="DELETE", + request_url="https://example.com/api/resource", + issuers=["https://example.com"], + ) + + mock_get_key_async.assert_not_awaited() + + @patch("authutils.dpop.get_any_public_key_for_token_async") + @patch("authutils.dpop.token_core.validate_jwt") + async def test_does_not_block_the_event_loop_during_key_discovery( + self, mock_validate_jwt, mock_get_key_async, rsa_key + ): + """Other coroutines keep running while the request awaits key discovery.""" + access_token = _create_signed_access_token(rsa_key) + proof = authutils.dpop.generate_dpop_proof( + rsa_key, "GET", "https://example.com/api/resource", access_token + ) + + async def slow_get_key(token, allowed_issuers=None): + await anyio.sleep(0.05) + return rsa_key.as_pem() + + mock_get_key_async.side_effect = slow_get_key + mock_validate_jwt.return_value = {"sub": "test-user"} + + ticks = 0 + stop = False + + async def ticker(): + nonlocal ticks + while not stop: + ticks += 1 + await anyio.sleep(0.001) + + async with anyio.create_task_group() as tg: + tg.start_soon(ticker) + await authutils.dpop.validate_dpop_request_async( + dpop_header=proof, + access_token=access_token, + request_method="GET", + request_url="https://example.com/api/resource", + issuers=["https://example.com"], + ) + stop = True + + assert ticks > 5, f"event loop appears to have been blocked (ticks={ticks})" + + +def _create_signed_access_token( + key: jwk.Key, + subject: str = "test-user", + issuer: str = "https://example.com", + audience: str = "test-audience", + scopes: list[str] | None = None, + purpose: str = "access", + additional_claims: dict | None = None, +) -> str: + """ + Create a signed access token with proper claims for testing. + + Args: + key (jwk.Key): The signing key (ECKey or RSAKey) + subject (str): The subject (sub) claim + issuer (str): The issuer (iss) claim + audience (str): The audience (aud) claim + scopes (list[str] | None): List of scopes to include in the token + purpose (str): The purpose (pur) claim + additional_claims (dict | None): Optional additional claims to include + + Returns: + str: Signed JWT access token + """ + now = int(time.time()) + payload: dict[str, Any] = { + "sub": subject, + "iss": issuer, + "aud": audience, + "iat": now, + # 1 hour from now + "exp": now + 3600, + "pur": purpose, + "scope": scopes or ["openid", "user"], + } + if additional_claims: + payload.update(additional_claims) + + if key: + payload["cnf"] = {"jkt": key.thumbprint()} + + # Always use RS256 for access tokens since token validation only supports RS256 + header = {"alg": "RS256", "typ": "JWT"} + return jwt.encode(header, payload, key) + + +def _assert_nonce_error_for_resource_server(err) -> None: + """Assert a nonce error carries the RFC 9449 resource-server response shape.""" + assert err.code == 401 + assert err.json["error"] == "use_dpop_nonce" + assert "DPoP-Nonce" in err.error_headers + assert "WWW-Authenticate" in err.error_headers + assert len(err.error_headers["DPoP-Nonce"]) > 0 + assert len(err.error_headers["WWW-Authenticate"]) > 0 + + +def _assert_nonce_error_for_authorization_server(err) -> None: + """Assert a nonce error carries the RFC 9449 authorization-server shape.""" + assert err.code == 400 + assert err.json["error"] == "use_dpop_nonce" + assert "DPoP-Nonce" in err.error_headers + assert len(err.error_headers["DPoP-Nonce"]) > 0 + + +def _decode_jwt_header(token: str) -> dict: + """Decode JWT header without verification, allowing large RSA headers.""" + obj = jws.extract_compact(token.encode("utf-8"), registry=_large_registry()) + return obj.protected + + +def _decode_jwt_payload(token: str) -> dict: + """Decode JWT payload without verification, allowing large RSA headers.""" + obj = jws.extract_compact(token.encode("utf-8"), registry=_large_registry()) + return json.loads(obj.payload) + + +def _large_registry(): + """Build a JWS registry that accepts DPoP-sized headers and algs.""" + return authutils.dpop._LargeHeaderRegistry( + algorithms=sorted(authutils.dpop.SUPPORTED_DPOP_ALGS) + ) + + +def _replace_header_field( + token: str, field: str, value: Any = None, remove: bool = False +) -> str: + """ + Rebuild a JWT with one header field modified or removed, + re-encoding the header only (payload and signature bytes are left + untouched, matching the tamper pattern already used by + test_algorithm_confusion_injection). Useful for constructing malformed + DPoP proofs to exercise header-validation failure paths. + """ + header_b64, payload_b64, signature = token.split(".") + header_padded = header_b64 + "=" * (4 - len(header_b64) % 4) + header_json = json.loads(base64.urlsafe_b64decode(header_padded).decode("utf-8")) + + if remove: + header_json.pop(field, None) + else: + header_json[field] = value + + new_header_b64 = ( + base64.urlsafe_b64encode(json.dumps(header_json).encode()).rstrip(b"=").decode() + ) + return f"{new_header_b64}.{payload_b64}.{signature}" + + +def _replace_payload_field(token: str, field: str, value: Any) -> str: + """ + Rebuild a JWT with one payload claim modified, leaving the header and + signature bytes untouched, so that the resulting token is well-formed but + no longer matches its signature. + """ + header_b64, payload_b64, signature = token.split(".") + payload_padded = payload_b64 + "=" * (4 - len(payload_b64) % 4) + payload_json = json.loads(base64.urlsafe_b64decode(payload_padded).decode("utf-8")) + payload_json[field] = value + + new_payload_b64 = ( + base64.urlsafe_b64encode(json.dumps(payload_json).encode()) + .rstrip(b"=") + .decode() + ) + return f"{header_b64}.{new_payload_b64}.{signature}" + + +def _b64url(raw: bytes) -> str: + """Base64url-encode bytes without padding, as JOSE compact serialization does.""" + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + + +def _build_unverifiable_proof(header: dict, claims: dict) -> str: + """ + Assemble a compact JWS from arbitrary header and claims with a placeholder + signature. For header-validation tests whose key cannot legitimately sign + the declared alg (e.g. an oct jwk claiming ES256); header validation runs + before signature verification, so the signature is never reached. + """ + return ( + f"{_b64url(json.dumps(header).encode())}." + f"{_b64url(json.dumps(claims).encode())}.AAAA" + ) + + +def _minimal_proof_claims( + method: str = "GET", url: str = "https://example.com/resource" +) -> dict: + """Build the minimum set of valid, fresh DPoP proof claims.""" + return { + "jti": os.urandom(8).hex(), + "htm": method, + "htu": url, + "iat": int(time.time()), + } + + +def _build_raw_dpop_proof(key: jwk.Key, claims: dict, alg: str = None) -> str: + """ + Construct a DPoP proof from arbitrary claims (e.g. a + deliberately stale/future `iat`) rather than going through + generate_dpop_proof, which always stamps `iat` with the current time. + Mirrors the manual jwt.encode pattern already used for the stolen-token + defense test. + """ + resolved_alg = alg or authutils.dpop._resolve_proof_alg(key) + header = { + "typ": "dpop+jwt", + "alg": resolved_alg, + "jwk": key.as_dict(private=False), + } + return jwt.encode(header, claims, key, registry=_large_registry()) diff --git a/tests/test_fastapi.py b/tests/test_fastapi.py index 0761c92..4897a79 100644 --- a/tests/test_fastapi.py +++ b/tests/test_fastapi.py @@ -36,7 +36,7 @@ def whitelist( access_token( *default_scopes, allowed_issuers=["https://right.example.com"], - purpose="access" + purpose="access", ) ) ): @@ -91,6 +91,36 @@ def test_issuers_whitelist(auth_header, async_client): assert async_client.get("/whitelist", headers=dict(auth_header)).status_code == 403 +def test_rejected_token_does_not_poison_later_requests( + async_client, auth_header, claims, token_headers, rsa_private_key +): + """ + Regression Test for authutils.token.fastapi.access_token() + + A rejected token must not affect the next request's issuer resolution. + + The issuer is derived from the incoming token whenever the caller did not + pin one. Previously, the iss value was cached to a global, so the + first request seen would decide the issuer for every later request, and a + single unauthenticated junk `iss` would 403 all subsequent valid tokens for + the life of the process. + """ + bad_claims = claims.copy() + bad_claims["iss"] = "https://attacker.example.com" + bad_token = jwt.encode( + bad_claims, headers=token_headers, key=rsa_private_key, algorithm="RS256" + ) + + assert ( + async_client.get( + "/whoami", headers={"Authorization": f"Bearer {bad_token}"} + ).status_code + == 403 + ) + + assert async_client.get("/whoami", headers=dict(auth_header)).status_code == 200 + + def test_bad_token(async_client, auth_header): headers = {k: v[:-5] for k, v in auth_header} assert async_client.get("/whoami", headers=headers).status_code == 403 diff --git a/tests/test_jwt.py b/tests/test_jwt.py index 4be4af8..3738596 100644 --- a/tests/test_jwt.py +++ b/tests/test_jwt.py @@ -5,7 +5,7 @@ import flask import pytest -import httpx +import httpx2 from authutils.errors import JWTError, JWTAudienceError, JWTExpiredError, JWTScopeError from authutils.token.keys import get_public_key @@ -138,6 +138,261 @@ def test_invalid_iss_rejected( ) +def test_token_without_iss_rejected_as_jwt_error( + claims, token_headers, rsa_private_key, rsa_public_key, default_audience, iss +): + """ + A token carrying no iss at all raises JWTError, not KeyError. + + A caller doing `except JWTError: return 401` would otherwise emit a 500 for + an attacker-supplied token that simply omits the claim. + """ + claims_without_iss = {k: v for k, v in claims.items() if k != "iss"} + encoded = jwt.encode( + claims_without_iss, + headers=token_headers, + key=rsa_private_key, + algorithm="RS256", + ) + + with pytest.raises(JWTError): + validate_jwt( + encoded, + rsa_public_key, + aud=default_audience, + scope=None, + allowed_issuers=[iss], + ) + + +def test_denylist_callback_rejects_token( + claims, encoded_jwt, rsa_public_key, default_audience, default_scopes, iss +): + """ + Test that `validate_jwt` rejects tokens when denylist_callback returns True. + """ + + def denylist_callback(jti): + # Deny all tokens for this test + return True + + with pytest.raises(JWTError) as exc_info: + validate_jwt( + encoded_jwt, + rsa_public_key, + default_audience, + default_scopes, + [iss], + denylist_callback=denylist_callback, + ) + + +def test_denylist_callback_allows_token( + claims, encoded_jwt, rsa_public_key, default_audience, default_scopes, iss +): + """ + Test that `validate_jwt` accepts tokens when denylist_callback returns False. + """ + + def allowlist_callback(jti): + # Allow all tokens for this test + return False + + decoded_token = validate_jwt( + encoded_jwt, + rsa_public_key, + default_audience, + default_scopes, + [iss], + denylist_callback=allowlist_callback, + ) + assert decoded_token + assert decoded_token == claims + + +def test_denylist_callback_receives_jti( + claims, + token_headers, + rsa_private_key, + rsa_public_key, + default_audience, + default_scopes, + iss, +): + """ + Test that denylist_callback receives the correct jti value. + """ + received_jti = None + + def callback(jti): + nonlocal received_jti + received_jti = jti + return False + + claims_with_jti = claims.copy() + claims_with_jti["jti"] = "test-jti-123" + + encoded_token = jwt.encode( + claims_with_jti, + headers=token_headers, + key=rsa_private_key, + algorithm="RS256", + ) + # Verify that callback is called with values from the token + decoded = validate_jwt( + encoded_token, + rsa_public_key, + default_audience, + default_scopes, + [iss], + denylist_callback=callback, + ) + # Check that callback was called with values from the token + assert received_jti == claims_with_jti["jti"] + + +def test_denylist_callback_not_callable_raises_value_error( + encoded_jwt, rsa_public_key, default_audience, default_scopes, iss +): + """ + Test that `validate_jwt` raises ValueError if denylist_callback is not callable. + """ + with pytest.raises(ValueError): + validate_jwt( + encoded_jwt, + rsa_public_key, + default_audience, + default_scopes, + [iss], + denylist_callback="not_callable", + ) + + +def test_validate_jwt_with_purpose( + claims, encoded_jwt, rsa_public_key, default_audience, default_scopes, iss +): + """ + Test that `validate_jwt` validates purpose when provided. + """ + # Token should have correct purpose + decoded_token = validate_jwt( + encoded_jwt, + rsa_public_key, + default_audience, + default_scopes, + [iss], + purpose="access", + ) + assert decoded_token == claims + + +def test_validate_jwt_with_incorrect_purpose( + claims, encoded_jwt, rsa_public_key, default_audience, default_scopes, iss +): + """ + Test that `validate_jwt` rejects tokens with incorrect purpose. + """ + with pytest.raises(JWTError): + validate_jwt( + encoded_jwt, + rsa_public_key, + default_audience, + default_scopes, + [iss], + purpose="refresh", + ) + + +def test_validate_jwt_without_purpose( + claims, encoded_jwt, rsa_public_key, default_audience, default_scopes, iss +): + """ + Test that `validate_jwt` works without purpose parameter (None skips validation). + """ + decoded_token = validate_jwt( + encoded_jwt, + rsa_public_key, + default_audience, + default_scopes, + [iss], + purpose=None, + ) + assert decoded_token == claims + + +def test_validate_jwt_with_scope_as_list( + claims, encoded_jwt, rsa_public_key, default_audience, iss +): + """ + Test that `validate_jwt` accepts scope as list and converts to set internally. + """ + decoded_token = validate_jwt( + encoded_jwt, + rsa_public_key, + default_audience, + # scope as list - using scopes from the token + ["user", "openid"], + [iss], + ) + assert decoded_token == claims + + +def test_validate_jwt_with_options( + claims, encoded_jwt, rsa_public_key, default_audience, default_scopes, iss +): + """ + Test that `validate_jwt` passes options through to PyJWT. + """ + decoded_token = validate_jwt( + encoded_jwt, + rsa_public_key, + "bad_aud", + default_scopes, + [iss], + options={"verify_aud": False}, + ) + assert decoded_token == claims + + +def test_validate_jwt_type_validation(): + """ + Test that `validate_jwt` validates argument types. + """ + with pytest.raises(ValueError): + validate_jwt( + "token", "key", aud=123, scope=None, allowed_issuers=["https://example.com"] + ) # aud must be str/list/None + + +def test_missing_allowed_issuers_is_a_type_error( + encoded_jwt, rsa_public_key, default_audience, default_scopes +): + """ + Omitting `allowed_issuers` fails at call time rather than skipping the check. + + It has no default, so a caller upgrading from a version where the issuer + allowlist was optional cannot silently end up accepting any issuer. + """ + with pytest.raises(TypeError): + validate_jwt(encoded_jwt, rsa_public_key, default_audience, default_scopes) + + +@pytest.mark.parametrize("empty", [[], set()]) +def test_empty_allowed_issuers_rejected( + empty, encoded_jwt, rsa_public_key, default_audience, default_scopes +): + """ + An empty allowlist is rejected rather than read as "any issuer is fine". + + Passing the check with an empty list would mean every issuer is accepted, + which is the opposite of what an empty allowlist reads as. + """ + with pytest.raises(ValueError, match="non-empty"): + validate_jwt( + encoded_jwt, rsa_public_key, default_audience, default_scopes, empty + ) + + def test_get_public_key(app, example_keys_response, mock_get): """ Test the functionality of retrieving the public keys from the keys @@ -148,9 +403,9 @@ def test_get_public_key(app, example_keys_response, mock_get): iss = app.config["USER_API"] expected_jwt_public_keys_dict = {iss: OrderedDict(example_keys_response["keys"])} key = get_public_key(kid=test_kid) - # httpx.get should be called twice: once attempting to get the jwks_uri from + # httpx2.get should be called twice: once attempting to get the jwks_uri from # .well-known/openid-configuration, another to actually hit the jwks_uri - assert httpx.get.call_count == 2 + assert httpx2.get.call_count == 2 assert key assert key == expected_key assert app.jwt_public_keys == expected_jwt_public_keys_dict diff --git a/tests/test_keys.py b/tests/test_keys.py new file mode 100644 index 0000000..14613b7 --- /dev/null +++ b/tests/test_keys.py @@ -0,0 +1,895 @@ +""" +Unit tests for authutils.token.keys module, specifically +for `get_any_public_key_for_token_async` +""" + +import time +from unittest.mock import AsyncMock, Mock, patch + +import anyio +import pytest +from joserfc import jwk, jwt + +import authutils.token.keys as keys_module +from authutils.errors import JWTError + +TEST_ISSUER = "https://example.com/issuer" + + +async def _get_key(token, **kwargs): + """ + Look up a key, defaulting the allowlist to the issuer the test tokens carry. + + allowed_issuers is required, but most tests below are about caching, kid + selection, or JWKS parsing; spelling it out at every call would bury the + assertion each one actually makes. Tests that care about the allowlist call + get_any_public_key_for_token_async directly. + """ + kwargs.setdefault("allowed_issuers", [TEST_ISSUER]) + return await keys_module.get_any_public_key_for_token_async(token, **kwargs) + + +@pytest.fixture +def mock_rsa_key(): + """An RSA JWK, as published in a JWKS document.""" + return { + "kty": "RSA", + "kid": "test-key-id", + "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", # pragma: allowlist secret + "e": "AQAB", + } + + +@pytest.mark.anyio +class TestGetAnyPublicKeyForToken: + """Tests for get_any_public_key_for_token_async.""" + + @pytest.fixture(autouse=True) + def clear_cache(self): + """Clear the cache before and after each test.""" + keys_module.clear_public_key_cache() + yield + keys_module.clear_public_key_cache() + + @pytest.fixture + def mock_jwks_response(self, mock_rsa_key): + """Create a mock JWKS response.""" + return {"keys": [mock_rsa_key]} + + @pytest.fixture + def valid_token(self): + """Create a valid JWT token for testing.""" + hs_key = jwk.OctKey.import_key("test-secret-32chars-minimum-test") + payload = { + "iss": "https://example.com/issuer", + "sub": "test-user", + "kid": "test-key-id", + } + header = {"alg": "HS256", "typ": "JWT", "kid": "test-key-id"} + return jwt.encode(header, payload, hs_key) + + @patch("authutils.token.keys.get_keys_url_async", new_callable=AsyncMock) + @patch("authutils.token.keys._fetch_jwks_async", new_callable=AsyncMock) + async def test_successful_key_retrieval( + self, mock_get, mock_get_keys_url, valid_token, mock_jwks_response + ): + """Test successful retrieval of a public key from a valid token.""" + mock_get_keys_url.return_value = ( + "https://example.com/issuer/.well-known/jwks.json" + ) + mock_get.return_value = mock_jwks_response + + # First call should fetch from network + result = await _get_key(valid_token) + + assert result is not None + assert isinstance(result, (str, bytes)) + mock_get.assert_called_once() + + @patch("authutils.token.keys.get_keys_url_async", new_callable=AsyncMock) + @patch("authutils.token.keys._fetch_jwks_async", new_callable=AsyncMock) + async def test_cache_hit( + self, mock_get, mock_get_keys_url, valid_token, mock_jwks_response + ): + """Test that cache returns the same key on second call.""" + mock_get_keys_url.return_value = ( + "https://example.com/issuer/.well-known/jwks.json" + ) + mock_get.return_value = mock_jwks_response + + # First call + result1 = await _get_key(valid_token) + # Second call + result2 = await _get_key(valid_token) + + # Should have fetched once (second call was served from cache) + assert mock_get.call_count == 1 + assert result1 == result2 + + @patch("authutils.token.keys.get_keys_url_async", new_callable=AsyncMock) + @patch("authutils.token.keys._fetch_jwks_async", new_callable=AsyncMock) + @patch("authutils.token.keys.time.time") + async def test_cache_expiration( + self, mock_time, mock_get, mock_get_keys_url, valid_token, mock_jwks_response + ): + """Test that cache entries expire after TTL.""" + mock_get_keys_url.return_value = ( + "https://example.com/issuer/.well-known/jwks.json" + ) + mock_get.return_value = mock_jwks_response + + # Mock time progression + current_time = 1000.0 + mock_time.return_value = current_time + + # First call + result1 = await _get_key(valid_token, cache_ttl=300) + + # Second call within TTL (should be cached) + result2 = await _get_key(valid_token, cache_ttl=300) + + # Third call after TTL (should fetch again) + mock_time.return_value = current_time + 400 + result3 = await _get_key(valid_token, cache_ttl=300) + + # Should have fetched twice (once initially, once after expiration) + assert mock_get.call_count == 2 + assert result1 == result2 == result3 + + @patch("authutils.token.keys.get_keys_url_async", new_callable=AsyncMock) + @patch("authutils.token.keys._fetch_jwks_async", new_callable=AsyncMock) + async def test_cache_size_limiting( + self, mock_get, mock_get_keys_url, valid_token, mock_jwks_response + ): + """Test that cache size is limited to prevent memory overload.""" + # Mock get_keys_url to return JWKS URL based on issuer + mock_get_keys_url.side_effect = lambda iss: f"{iss}/.well-known/jwks.json" + + # A JWKS carrying a key for every kid the tokens below will ask for. + mock_get.return_value = { + "keys": [ + { + "kty": "RSA", + "kid": f"key-{i}", + "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", # pragma: allowlist secret + "e": "AQAB", + } + for i in range(10) + ] + } + + # Save original cache size limit + original_limit = keys_module._TOKEN_PUBLIC_KEY_CACHE_MAX_SIZE + + try: + # Temporarily set a small cache limit for testing + keys_module._TOKEN_PUBLIC_KEY_CACHE_MAX_SIZE = 5 + + # Generate tokens from different issuers + issuers = [f"https://issuer-{i}.example.com" for i in range(10)] + tokens = [] + for i in range(10): + hs_key = jwk.OctKey.import_key("test-secret-32chars-minimum-test") + payload = { + "iss": issuers[i], + "sub": "test-user", + "kid": f"key-{i}", + } + header = {"alg": "HS256", "typ": "JWT", "kid": f"key-{i}"} + token = jwt.encode(header, payload, hs_key) + tokens.append(token) + + # Fetch keys for all tokens + for token in tokens: + await _get_key(token, allowed_issuers=issuers) + + # Cache should not exceed the limit + assert len(keys_module._token_public_key_cache) <= 5 + + finally: + # Restore original cache size limit + keys_module._TOKEN_PUBLIC_KEY_CACHE_MAX_SIZE = original_limit + + def test_cache_eviction_removes_oldest_entry(self): + """Test that LRU eviction removes the oldest entry when cache is full.""" + # Save original cache size limit + original_limit = keys_module._TOKEN_PUBLIC_KEY_CACHE_MAX_SIZE + + try: + # Set a very small cache limit for testing + keys_module._TOKEN_PUBLIC_KEY_CACHE_MAX_SIZE = 2 + + # Directly populate cache with manually controlled entries + keys_module._token_public_key_cache["issuer-0:key-0"] = { + "key": b"test_key_0", + "expires_at": 100.0, + } + keys_module._token_public_key_cache["issuer-1:key-1"] = { + "key": b"test_key_1", + "expires_at": 200.0, + } + + assert len(keys_module._token_public_key_cache) == 2 + + # Trigger eviction by attempting to save a new entry + # This will call _save_public_key_to_cache which handles eviction + keys_module._save_public_key_to_cache( + "issuer-2:key-2", b"test_key_2", cache_ttl=300 + ) + + # Cache should have exactly 2 entries + assert len(keys_module._token_public_key_cache) == 2 + + # Entry with earliest expiration (100.0) should have been evicted + assert "issuer-0:key-0" not in keys_module._token_public_key_cache + + # Entries with later expirations should still be in cache + assert "issuer-1:key-1" in keys_module._token_public_key_cache + assert "issuer-2:key-2" in keys_module._token_public_key_cache + + finally: + # Restore original cache size limit + keys_module._TOKEN_PUBLIC_KEY_CACHE_MAX_SIZE = original_limit + + async def test_malformed_token_raises_error(self): + """Test that malformed tokens raise JWTError.""" + with pytest.raises(JWTError): + await _get_key("not.a.valid.token") + + async def test_missing_issuer_raises_error(self): + """Test that tokens missing issuer raise JWTError.""" + hs_key = jwk.OctKey.import_key("test-secret-32chars-minimum-test") + payload = { + "sub": "test-user", + "kid": "test-key-id", + # Missing "iss" + } + header = {"alg": "HS256", "typ": "JWT", "kid": "test-key-id"} + token = jwt.encode(header, payload, hs_key) + + with pytest.raises(JWTError): + await _get_key(token) + + @patch("authutils.token.keys.get_keys_url_async", new_callable=AsyncMock) + @patch("authutils.token.keys._fetch_jwks_async", new_callable=AsyncMock) + async def test_network_error_raises_error( + self, mock_get, mock_get_keys_url, valid_token + ): + """A failed JWKS fetch surfaces as JWTError, not the raw transport error.""" + mock_get_keys_url.return_value = _JWKS_URL + mock_get.side_effect = Exception("Network error") + + with pytest.raises(JWTError, match="Could not fetch JWKS"): + await _get_key(valid_token) + + @patch("authutils.token.keys.get_keys_url_async", new_callable=AsyncMock) + @patch("authutils.token.keys._fetch_jwks_async", new_callable=AsyncMock) + async def test_no_matching_key_in_jwks_raises_error( + self, mock_get, mock_get_keys_url, valid_token + ): + """A JWKS document publishing no keys at all raises JWTError.""" + mock_get_keys_url.return_value = _JWKS_URL + mock_get.return_value = {"keys": []} + + with pytest.raises(JWTError, match="Got no keys"): + await _get_key(valid_token) + + @patch("authutils.token.keys.get_keys_url_async", new_callable=AsyncMock) + @patch("authutils.token.keys._fetch_jwks_async", new_callable=AsyncMock) + @patch("authutils.token.keys.time.time") + async def test_custom_cache_ttl( + self, mock_time, mock_get, mock_get_keys_url, valid_token, mock_jwks_response + ): + """Test that custom cache TTL is respected.""" + mock_get_keys_url.return_value = ( + "https://example.com/issuer/.well-known/jwks.json" + ) + mock_get.return_value = mock_jwks_response + + current_time = 1000.0 + mock_time.return_value = current_time + + # First call with custom TTL of 150 seconds + result1 = await _get_key(valid_token, cache_ttl=150) + + # Second call within custom TTL + mock_time.return_value = current_time + 100 + result2 = await _get_key(valid_token, cache_ttl=150) + + # Should have fetched once (second call was served from cache) + assert mock_get.call_count == 1 + assert result1 == result2 + + @patch("authutils.token.keys._fetch_jwks_async", new_callable=AsyncMock) + @patch("authutils.token.keys.get_keys_url_async", new_callable=AsyncMock) + async def test_different_kids_different_cache_entries( + self, mock_get_keys_url, mock_get, mock_jwks_response + ): + """Test that tokens with different kids are cached separately.""" + mock_get_keys_url.return_value = _JWKS_URL + + # A JWKS carrying a key for each kid the tokens below ask for. + mock_get.return_value = { + "keys": [ + { + "kty": "RSA", + "kid": f"key-{i}", + "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", # pragma: allowlist secret + "e": "AQAB", + } + for i in range(2) + ] + } + + # Create two tokens with different kids + tokens = [] + for i in range(2): + hs_key = jwk.OctKey.import_key("test-secret-32chars-minimum-test") + payload = { + "iss": "https://same-issuer.example.com", + "sub": "test-user", + } + header = {"alg": "HS256", "typ": "JWT", "kid": f"key-{i}"} + token = jwt.encode(header, payload, hs_key) + tokens.append(token) + + # Fetch keys for both tokens + issuer = ["https://same-issuer.example.com"] + result1 = await _get_key(tokens[0], allowed_issuers=issuer) + result2 = await _get_key(tokens[1], allowed_issuers=issuer) + + # Should have 2 entries in cache (different kids) + assert len(keys_module._token_public_key_cache) == 2 + + @patch("authutils.token.keys._fetch_jwks_async", new_callable=AsyncMock) + @patch("authutils.token.keys.get_keys_url_async", new_callable=AsyncMock) + async def test_different_issuers_different_cache_entries( + self, mock_get_keys_url, mock_get, mock_jwks_response + ): + """Test that tokens from different issuers are cached separately.""" + mock_get_keys_url.return_value = _JWKS_URL + mock_get.return_value = mock_jwks_response + + # Create two tokens from different issuers + issuers = [f"https://issuer-{i}.example.com" for i in range(2)] + tokens = [] + for i in range(2): + hs_key = jwk.OctKey.import_key("test-secret-32chars-minimum-test") + payload = { + "iss": issuers[i], + "sub": "test-user", + } + header = {"alg": "HS256", "typ": "JWT", "kid": "test-key-id"} + token = jwt.encode(header, payload, hs_key) + tokens.append(token) + + # Fetch keys for both tokens + result1 = await _get_key(tokens[0], allowed_issuers=issuers) + result2 = await _get_key(tokens[1], allowed_issuers=issuers) + + # Should have 2 entries in cache (different issuers) + assert len(keys_module._token_public_key_cache) == 2 + + @patch("authutils.token.keys._fetch_jwks_async", new_callable=AsyncMock) + @patch("authutils.token.keys.get_keys_url_async", new_callable=AsyncMock) + async def test_cache_key_format( + self, mock_get_keys_url, mock_get, valid_token, mock_jwks_response + ): + """Test that cache keys follow the expected format 'issuer:kid'.""" + mock_get_keys_url.return_value = _JWKS_URL + mock_get.return_value = mock_jwks_response + + await _get_key(valid_token) + + # Check cache key format + cache_keys = list(keys_module._token_public_key_cache.keys()) + assert len(cache_keys) == 1 + assert ":" in cache_keys[0] + assert cache_keys[0].startswith("https://example.com/issuer:") + + @patch("authutils.token.keys.get_keys_url_async", new_callable=AsyncMock) + @patch("authutils.token.keys._fetch_jwks_async", new_callable=AsyncMock) + async def test_kid_not_published_by_issuer_raises( + self, mock_get, mock_get_keys_url, valid_token, mock_rsa_key + ): + """ + A token naming a kid the issuer does not publish is refused. + + Falling back to some other published key would defeat kid pinning, key + rotation, and revocation-by-removal: a token signed with a retired key + would keep validating against whatever key happened to be listed first. + """ + mock_get_keys_url.return_value = _JWKS_URL + rotated_key = dict(mock_rsa_key, kid="some-other-kid") + mock_get.return_value = {"keys": [rotated_key]} + + with pytest.raises(JWTError) as exc_info: + await _get_key(valid_token) + + assert "test-key-id" in str(exc_info.value) + assert "some-other-kid" in str(exc_info.value) + + @patch("authutils.token.keys.get_keys_url_async", new_callable=AsyncMock) + @patch("authutils.token.keys._fetch_jwks_async", new_callable=AsyncMock) + async def test_legacy_kid_pem_pair_format_supported( + self, mock_get, mock_get_keys_url, valid_token + ): + """ + A legacy `/jwt/keys` response of [kid, pem] pairs resolves to its PEM. + + Gen3 deployments predating .well-known discovery serve this shape, so + both formats have to work against the same issuer set. + """ + mock_get_keys_url.return_value = "https://example.com/issuer/jwt/keys" + mock_get.return_value = {"keys": [["test-key-id", _PUBLIC_KEY_PEM]]} + + result = await _get_key(valid_token) + + assert result == _PUBLIC_KEY_PEM + + @patch("authutils.token.keys.get_keys_url_async", new_callable=AsyncMock) + @patch("authutils.token.keys._fetch_jwks_async", new_callable=AsyncMock) + async def test_token_without_kid_uses_first_published_key( + self, mock_get, mock_get_keys_url, mock_rsa_key + ): + """A token declaring no kid falls back to the issuer's first key.""" + mock_get_keys_url.return_value = _JWKS_URL + first = dict(mock_rsa_key, kid="first-key") + second = dict(mock_rsa_key, kid="second-key") + mock_get.return_value = {"keys": [first, second]} + + result = await _get_key(_make_token(kid=None)) + + expected = keys_module.get_pem_key(first)[1] + assert result == expected + + @patch("authutils.token.keys.get_keys_url_async", new_callable=AsyncMock) + @patch("authutils.token.keys._fetch_jwks_async", new_callable=AsyncMock) + async def test_unrecognized_jwks_entries_are_skipped( + self, mock_get, mock_get_keys_url, valid_token, mock_rsa_key + ): + """Malformed JWKS entries are ignored rather than aborting discovery.""" + mock_get_keys_url.return_value = _JWKS_URL + mock_get.return_value = { + "keys": ["a-bare-string", {"no_kid": True}, None, mock_rsa_key] + } + + result = await _get_key(valid_token) + + assert result == keys_module.get_pem_key(mock_rsa_key)[1] + + @patch("authutils.token.keys.get_keys_url_async", new_callable=AsyncMock) + @patch("authutils.token.keys._fetch_jwks_async", new_callable=AsyncMock) + async def test_concurrent_lookups_do_not_corrupt_cache( + self, mock_get, mock_get_keys_url, mock_rsa_key + ): + """ + Concurrent lookups that overflow the cache leave it at its size limit. + + Note this does not on its own demonstrate that the cache lock is + load-bearing: the current popitem-based eviction happens to survive + this scenario with the lock removed. It pins the invariant against an + eviction rewrite that iterates the cache (the "dictionary changed size + during iteration" failure the lock exists to prevent), which is what + the lock is actually there for now that only async callers remain. + """ + mock_get_keys_url.return_value = _JWKS_URL + mock_get.return_value = {"keys": [mock_rsa_key]} + + overflow = keys_module._TOKEN_PUBLIC_KEY_CACHE_MAX_SIZE * 3 + issuers = [f"https://issuer-{i}.example.com" for i in range(overflow)] + tokens = [_make_token(iss=iss) for iss in issuers] + errors = [] + + async def fetch(token): + try: + await _get_key(token, allowed_issuers=issuers) + except Exception as exc: + errors.append(exc) + + async with anyio.create_task_group() as tg: + for token in tokens: + tg.start_soon(fetch, token) + + assert not errors + assert ( + len(keys_module._token_public_key_cache) + == keys_module._TOKEN_PUBLIC_KEY_CACHE_MAX_SIZE + ) + + @pytest.mark.parametrize( + "jwks_payload", + [ + pytest.param({}, id="empty_object"), + pytest.param(None, id="null"), + pytest.param("", id="empty_string"), + pytest.param("not json", id="bare_string"), + pytest.param([], id="empty_list"), + ], + ) + @patch("authutils.token.keys.get_keys_url_async", new_callable=AsyncMock) + @patch("authutils.token.keys._fetch_jwks_async", new_callable=AsyncMock) + async def test_unusable_jwks_payload_raises( + self, mock_get, mock_get_keys_url, jwks_payload, valid_token + ): + """A JWKS response that carries no usable keys raises JWTError.""" + mock_get_keys_url.return_value = _JWKS_URL + mock_get.return_value = jwks_payload + + with pytest.raises(JWTError): + await _get_key(valid_token) + + @patch("authutils.token.keys.get_keys_url_async", new_callable=AsyncMock) + @patch("authutils.token.keys._fetch_jwks_async", new_callable=AsyncMock) + async def test_keys_url_discovery_failure_normalized_to_jwt_error( + self, mock_get, mock_get_keys_url, valid_token + ): + """A failure inside keys-URL discovery surfaces as JWTError, not raw.""" + mock_get_keys_url.side_effect = RuntimeError("discovery exploded") + + with pytest.raises(JWTError, match="Could not resolve keys URL"): + await _get_key(valid_token) + + mock_get.assert_not_called() + + @pytest.mark.parametrize( + "modulus", + [ + pytest.param("", id="empty"), + pytest.param("AA", id="too_short"), + pytest.param("_", id="undecodable_length"), + pytest.param("!!!not-base64!!!", id="outside_base64_alphabet"), + pytest.param("0vx7ag oebGcQ", id="embedded_space"), + ], + ) + @patch("authutils.token.keys.get_keys_url_async", new_callable=AsyncMock) + @patch("authutils.token.keys._fetch_jwks_async", new_callable=AsyncMock) + async def test_unserializable_key_material_raises( + self, mock_get, mock_get_keys_url, modulus, valid_token, mock_rsa_key + ): + """A published key whose material will not serialize raises JWTError.""" + mock_get_keys_url.return_value = _JWKS_URL + mock_get.return_value = {"keys": [dict(mock_rsa_key, n=modulus)]} + + with pytest.raises(JWTError, match="Could not serialize public key"): + await _get_key(valid_token) + + +@pytest.mark.anyio +class TestAllowlistIsMandatory: + """ + The allowlist is the only thing constraining which host key discovery + contacts, so it cannot be omitted or empty. + """ + + @pytest.fixture(autouse=True) + def clear_cache(self): + """Reset the module-global key cache around each test.""" + keys_module.clear_public_key_cache() + yield + keys_module.clear_public_key_cache() + + async def test_omitting_the_allowlist_is_a_type_error(self): + """Callers cannot skip the allowlist: it has no default.""" + with pytest.raises(TypeError): + await keys_module.get_any_public_key_for_token_async(_make_token()) + + @pytest.mark.parametrize( + "empty", [pytest.param([], id="list"), pytest.param(set(), id="set")] + ) + async def test_empty_allowlist_is_a_value_error(self, empty): + """An empty allowlist is a config bug, not a token failure.""" + with pytest.raises(ValueError, match="allowed_issuers must be non-empty"): + await keys_module.get_any_public_key_for_token_async( + _make_token(), allowed_issuers=empty + ) + + @patch("authutils.token.keys.get_keys_url_async", new_callable=AsyncMock) + @patch("authutils.token.keys._fetch_jwks_async", new_callable=AsyncMock) + async def test_allowlist_may_be_a_generator( + self, mock_get, mock_get_keys_url, mock_rsa_key + ): + """ + A generator allowlist is materialized before use. + + The membership test would otherwise consume it, leaving an empty + collection that refuses every issuer. + """ + mock_get_keys_url.return_value = _JWKS_URL + mock_get.return_value = {"keys": [mock_rsa_key]} + + result = await keys_module.get_any_public_key_for_token_async( + _make_token(), allowed_issuers=(i for i in [TEST_ISSUER]) + ) + + assert result == keys_module.get_pem_key(mock_rsa_key)[1] + + +@pytest.mark.anyio +class TestPrivateAddressIssuers: + """ + An allowlisted issuer is fetched regardless of where it resolves. Gen3 runs + behind a reverse proxy, in k8s, and through an egress proxy, where the + issuer routinely resolves to a ClusterIP, a loopback sidecar, or an + internal ingress. + """ + + @pytest.fixture(autouse=True) + def clear_cache(self): + """Reset the module-global key cache around each test.""" + keys_module.clear_public_key_cache() + yield + keys_module.clear_public_key_cache() + + @pytest.mark.parametrize( + "issuer", + [ + pytest.param("https://fence-service/user", id="cluster_dns"), + pytest.param("http://10.96.0.11:8000/user", id="cluster_ip"), + pytest.param("http://127.0.0.1:8000/user", id="loopback_sidecar"), + pytest.param("http://172.20.0.10/user", id="eks_cluster_ip"), + ], + ) + @patch("authutils.token.keys.get_keys_url_async", new_callable=AsyncMock) + @patch("authutils.token.keys._fetch_jwks_async", new_callable=AsyncMock) + async def test_allowlisted_private_issuer_is_fetched( + self, mock_get, mock_get_keys_url, mock_rsa_key, issuer + ): + """An allowlisted issuer on a private address resolves normally.""" + mock_get_keys_url.return_value = f"{issuer}/jwt/keys" + mock_get.return_value = {"keys": [mock_rsa_key]} + + result = await keys_module.get_any_public_key_for_token_async( + _make_token(iss=issuer), allowed_issuers=[issuer] + ) + + assert result == keys_module.get_pem_key(mock_rsa_key)[1] + + +@pytest.mark.anyio +class TestTransportRefusesNonHttpUrls: + """ + Non-http(s) URLs are refused by httpx, which is why authutils does not + check schemes itself. + + A compromised allowlisted issuer is the only way such a URL reaches the + fetch, since jwks_uri comes from the discovery response body. httpx raises + UnsupportedProtocol before performing any I/O, so no request is made. + """ + + @pytest.fixture(autouse=True) + def clear_cache(self): + """Reset the module-global key cache around each test.""" + keys_module.clear_public_key_cache() + yield + keys_module.clear_public_key_cache() + + @pytest.mark.parametrize( + "bad_url", + [ + pytest.param("file:///etc/passwd", id="file"), + pytest.param("gopher://169.254.169.254/x", id="gopher"), + pytest.param("169.254.169.254/latest", id="no_scheme"), + pytest.param("", id="empty"), + ], + ) + @patch("authutils.token.keys.get_keys_url_async", new_callable=AsyncMock) + async def test_discovered_non_http_url_is_refused(self, mock_get_keys_url, bad_url): + """A discovered jwks_uri that is not http(s) fails as a JWTError.""" + mock_get_keys_url.return_value = bad_url + + with pytest.raises(JWTError, match="Could not fetch JWKS"): + await _get_key(_make_token()) + + +@pytest.mark.anyio +class TestIssuerAllowlist: + """ + The `iss` claim is unverified at key-discovery time, and discovery turns it + into outbound HTTP. The allowlist is what stops an unauthenticated caller + from choosing where the server connects. + """ + + @pytest.fixture(autouse=True) + def clear_cache(self): + """Reset the module-global key cache around each test.""" + keys_module.clear_public_key_cache() + yield + keys_module.clear_public_key_cache() + + @patch("authutils.token.keys.get_keys_url_async", new_callable=AsyncMock) + @patch("authutils.token.keys._fetch_jwks_async", new_callable=AsyncMock) + async def test_issuer_outside_allowlist_refused_before_any_request( + self, mock_get, mock_get_keys_url + ): + """An issuer outside the allowlist is refused with no network access.""" + token = _make_token(iss="https://evil.example.net") + + with pytest.raises(JWTError, match="not in the allowed issuers list"): + await _get_key(token, allowed_issuers=["https://good.example.com"]) + + mock_get_keys_url.assert_not_called() + mock_get.assert_not_called() + + @patch("authutils.token.keys.get_keys_url_async", new_callable=AsyncMock) + @patch("authutils.token.keys._fetch_jwks_async", new_callable=AsyncMock) + async def test_issuer_inside_allowlist_proceeds( + self, mock_get, mock_get_keys_url, mock_rsa_key + ): + """An allowlisted issuer resolves normally.""" + mock_get_keys_url.return_value = _JWKS_URL + mock_get.return_value = {"keys": [mock_rsa_key]} + token = _make_token(iss="https://good.example.com") + + result = await _get_key(token, allowed_issuers=["https://good.example.com"]) + + assert result == keys_module.get_pem_key(mock_rsa_key)[1] + + @patch("authutils.token.keys.get_keys_url_async", new_callable=AsyncMock) + @patch("authutils.token.keys._fetch_jwks_async", new_callable=AsyncMock) + async def test_allowlist_matching_is_exact_not_prefix( + self, mock_get, mock_get_keys_url + ): + """An issuer that merely prefixes an allowlisted one is still refused.""" + token = _make_token(iss="https://good.example.com.evil.net") + + with pytest.raises(JWTError, match="not in the allowed issuers list"): + await _get_key(token, allowed_issuers=["https://good.example.com"]) + + mock_get_keys_url.assert_not_called() + mock_get.assert_not_called() + + +@pytest.mark.anyio +@pytest.mark.anyio +class TestAsyncKeyDiscovery: + """ + get_any_public_key_for_token_async must enforce every rule the synchronous + version does, share its cache, and leave the event loop free while it waits + on the network. + """ + + @pytest.fixture(autouse=True) + def clear_cache(self): + """Reset the module-global key cache around each test.""" + keys_module.clear_public_key_cache() + yield + keys_module.clear_public_key_cache() + + async def test_second_lookup_is_served_from_cache(self, mock_rsa_key, monkeypatch): + """A repeat lookup reuses the cached key instead of refetching.""" + fetched = _stub_async_jwks(monkeypatch, {"keys": [mock_rsa_key]}) + token = _make_token() + + first = await _get_key(token) + second = await _get_key(token) + + assert first == second + assert len(fetched) == 1, "second lookup should not have refetched" + + async def test_issuer_outside_allowlist_refused_before_any_request( + self, mock_rsa_key, monkeypatch + ): + """The issuer allowlist is enforced on the async path too, with no I/O.""" + calls = _stub_async_jwks(monkeypatch, {"keys": [mock_rsa_key]}) + + with pytest.raises(JWTError, match="not in the allowed issuers list"): + await _get_key( + _make_token(iss="https://evil.example.net"), + allowed_issuers=["https://good.example.com"], + ) + + assert calls == [] + + async def test_kid_not_published_by_issuer_raises(self, mock_rsa_key, monkeypatch): + """Key pinning is enforced on the async path too.""" + _stub_async_jwks( + monkeypatch, {"keys": [dict(mock_rsa_key, kid="some-other-kid")]} + ) + + with pytest.raises(JWTError, match="test-key-id"): + await _get_key(_make_token()) + + async def test_does_not_block_the_event_loop(self, mock_rsa_key, monkeypatch): + """ + Other coroutines keep running while key discovery waits on the network. + + This is the whole point of the async variant: with the synchronous + implementation the loop is held for the duration of a DNS lookup plus + up to two HTTP round trips, stalling every other in-flight request. + """ + _stub_async_jwks(monkeypatch, {"keys": [mock_rsa_key]}, latency=0.05) + + ticks = 0 + stop = False + + async def ticker(): + nonlocal ticks + while not stop: + ticks += 1 + await anyio.sleep(0.001) + + async with anyio.create_task_group() as tg: + tg.start_soon(ticker) + await _get_key(_make_token()) + stop = True + + assert ticks > 5, f"event loop appears to have been blocked (ticks={ticks})" + + +_JWKS_URL = "https://example.com/issuer/.well-known/jwks.json" + +_PUBLIC_KEY_PEM = ( + "-----BEGIN PUBLIC KEY-----\n" + "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEg0HpFHzrLNgQPRs2hJQZSvpMDGpx\n" # pragma: allowlist secret + "MSD3xVAZ0nJ7Xn6E6mHnW0PLGN0kZKN1Z1hkGfQzZ5D5tYWbLQmC8cN0Bg==\n" # pragma: allowlist secret + "-----END PUBLIC KEY-----\n" +) + + +def _make_token( + iss: str = "https://example.com/issuer", kid: str | None = "test-key-id" +) -> str: + """Build an unverified JWT carrying a given iss and kid, for discovery tests.""" + hs_key = jwk.OctKey.import_key("test-secret-32chars-minimum-test") + header = {"alg": "HS256", "typ": "JWT"} + if kid is not None: + header["kid"] = kid + return jwt.encode(header, {"iss": iss, "sub": "test-user"}, hs_key) + + +def _jwks_response(payload: dict) -> Mock: + """Build a mock httpx2 response returning a given JWKS document.""" + response = Mock() + response.json.return_value = payload + return response + + +def _stub_async_jwks( + monkeypatch, + payload: dict, + keys_url: str = _JWKS_URL, + latency: float = 0.0, +) -> list: + """ + Stub the async discovery and JWKS fetch, recording the URLs requested. + + Args: + monkeypatch: pytest monkeypatch fixture. + payload (dict): JWKS document the fetch should return. + keys_url (str): URL that discovery should resolve to. + latency (float): Seconds each awaited step should take, for tests that + need the coroutine to actually yield to the loop. + + Returns: + list: URLs passed to the JWKS fetch, appended as calls happen. + """ + fetched = [] + + async def fake_get_keys_url_async(iss, force_issuer=None): + if latency: + await anyio.sleep(latency) + return keys_url + + class FakeAsyncClient: + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc_info): + return False + + async def get(self, url, *args, **kwargs): + fetched.append(url) + if latency: + await anyio.sleep(latency) + return _jwks_response(payload) + + monkeypatch.setattr( + "authutils.token.keys.get_keys_url_async", fake_get_keys_url_async + ) + monkeypatch.setattr("authutils.token.keys.httpx2.AsyncClient", FakeAsyncClient) + + return fetched