From de224f0495af3c9bec4bcae2df55b795efaad4b9 Mon Sep 17 00:00:00 2001 From: avantol Date: Wed, 15 Jul 2026 14:33:45 -0500 Subject: [PATCH 01/28] feat(dpop): initial dpop support --- poetry.lock | 200 +++++------ pyproject.toml | 3 +- src/authutils/dpop.py | 549 ++++++++++++++++++++++++++++++ src/authutils/token/dpop_nonce.py | 91 +++++ src/authutils/token/keys.py | 142 ++++++++ tests/test_dpop.py | 403 ++++++++++++++++++++++ tests/test_keys.py | 395 +++++++++++++++++++++ 7 files changed, 1682 insertions(+), 101 deletions(-) create mode 100644 src/authutils/dpop.py create mode 100644 src/authutils/token/dpop_nonce.py create mode 100644 tests/test_dpop.py create mode 100644 tests/test_keys.py diff --git a/poetry.lock b/poetry.lock index c0ceadf..ad4498c 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] @@ -390,103 +390,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] @@ -952,14 +952,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] @@ -2091,4 +2091,4 @@ flask = ["Flask"] [metadata] lock-version = "2.1" python-versions = ">=3.13, <4.0" -content-hash = "a2c6efc4d455532fbabc7bcc0bcefa72631c88c68dfd5213fec02c80d8cef5ca" +content-hash = "c2a058a6cebe5b5cab6d5873113c863f5c208584d574aaba6259f405936dbce2" diff --git a/pyproject.toml b/pyproject.toml index f221570..d0a9756 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "authutils" -version = "8.0.0" +version = "8.1.0" description = "Gen3 auth utility functions" authors = ["CTDS UChicago "] license = "Apache-2.0" @@ -22,6 +22,7 @@ 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/dpop.py b/src/authutils/dpop.py new file mode 100644 index 0000000..c0e4aab --- /dev/null +++ b/src/authutils/dpop.py @@ -0,0 +1,549 @@ +""" +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 typing import Dict, Any +from urllib.parse import urlparse + +from joserfc import jwt, jwk, jws +from joserfc.errors import JoseError + +from authutils.token.dpop_nonce import verify_stateless_nonce +from authutils.token import core as token_core +from authutils.token.keys import get_any_public_key_for_token + +DPOP_JWT_TYPE = "dpop+jwt" +DEFAULT_DPOP_ALGORITHM = "ES256" +SUPPORTED_DPOP_ALGS = { + # EC + "ES256", + "ES384", + "ES512", + # RSA + "RS256", + "RS384", + "RS512", + "PS256", + "PS384", + "PS512", +} + +# will invalidate any proof older than 5 minutes +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 + + +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. + + Returns: + str: Encoded DPoP proof JWT string. + + 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 not alg: + key_type = (getattr(key, "kty", "") or getattr(key, "key_type", "")).upper() + if key_type == "RSA": + alg = "RS256" + elif key_type == "EC": + alg = "ES256" + else: + alg = DEFAULT_DPOP_ALGORITHM + + 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: + payload["ath"] = _compute_ath(access_token) + + if nonce: + payload["nonce"] = nonce + + return jwt.encode(header, payload, key) + + +def validate_dpop_request( + dpop_header: str, + access_token: str, + request_method: str, + request_url: str, + issuers: list[str], + public_key: str | None = None, + scope: set[str] | list[str] | None = None, + purpose: str | None = None, + aud: str | None = None, + require_nonce: bool = False, +) -> Dict[str, Any]: + """ + 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). + issuers (list[str]): Allowed token issuers whitelist. + public_key (str | 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. + + Returns: + Dict[str, Any]: The validated access token claims dict. + + Raises: + ValueError: If DPoP proof validation fails. + JWTError: If access token validation fails (signature, expiration, issuer, scope, purpose). + + Example: + >>> claims = validate_dpop_request( + ... dpop_header="eyJ0eXAi...", + ... access_token="eyJhbGc...", + ... request_method="GET", + ... request_url="https://api.example.com/ga4gh/tes/v1/jobs", + ... issuers=["https://fence.example.com"], + ... scope={"user", "data"}, + ... purpose="access", + ... ) + """ + validate_dpop_proof( + dpop_header=dpop_header, + request_method=request_method, + request_url=request_url, + unvalidated_access_token=access_token, + require_nonce=require_nonce, + ) + + if public_key is None: + # Fetch public key from issuer's JWKS endpoint if not provided + public_key = get_any_public_key_for_token(access_token) + + # Normalize scope to list/set for validation + if isinstance(scope, list): + scope = set(scope) + + validated_claims = token_core.validate_jwt( + encoded_token=access_token, + public_key=public_key, + aud=aud, + scope=scope, + issuers=issuers, + options={}, + ) + + # Step 3: Validate purpose if specified + if purpose: + token_core.validate_purpose(validated_claims, purpose) + + return validated_claims + + +def validate_dpop_proof( + dpop_header: str, + request_method: str, + request_url: str, + unvalidated_access_token: str | None = None, + require_nonce: bool = False, +) -> Dict[str, Any]: + """ + 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) + - Signature verification + - Time-based validation (iat, exp) + - htm (HTTP method) validation + - htu (URL) validation + - ath (access token hash) validation if provided + - nonce validation if required + + 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). + unvalidated_access_token (str | None): Optional access token to validate ath claim against. + require_nonce (bool): Whether to require and validate a nonce. + + Returns: + Dict[str, Any]: dict with decoded claims dict. + + Raises: + ValueError: If any validation fails. + + Example: + >>> claims = validate_dpop_proof( + ... dpop_header="eyJ0eXAi...", + ... request_method="GET", + ... request_url="https://api.example.com/ga4gh/tes/v1/jobs", + ... ) + """ + 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_or_reject(dpop_claims, require_nonce=require_nonce) + + if unvalidated_access_token: + _validate_ath(dpop_claims, unvalidated_access_token) + _validate_key_binding(client_jwk, unvalidated_access_token) + + return dpop_claims + + +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 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, or uses symmetric key. + """ + try: + unverified_header: dict = jws.extract_compact( + dpop_header.encode("utf-8") + ).protected + except Exception: + 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}") + + if unverified_header.get("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'") + + client_jwk: jwk.Key = jwk.import_key(unverified_header["jwk"]) + + # Check if key is symmetric (oct/HMAC) + # joserfc keys have kty attribute, but if checking fails, check key_type + try: + key_type = getattr(client_jwk, "kty", "") or getattr(client_jwk, "key_type", "") + key_type = key_type.lower() + except Exception: + key_type = None + + 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") + + return client_jwk + + +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: + JoseError: If signature verification or time validation fails. + """ + dpop_claims = jwt.decode(dpop_header, client_jwk) + + claims_dict = dpop_claims.claims + current_time = int(time.time()) + + if "exp" in claims_dict: + if current_time >= claims_dict["exp"]: + raise JoseError("Invalid token: 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 + if claims_dict["iat"] + DPOP_PROOF_MAX_TTL < current_time: + raise JoseError("Invalid token: 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 JoseError("Invalid token: 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") + + htm_value: str = 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: str = dpop_claims.get("htu", "") + actual_url: str = _get_url(request_url) + if actual_url != htu_value: + raise ValueError( + f"htu mismatch: request URL '{actual_url}' != proof htu '{htu_value}'" + ) + + +def _validate_nonce_or_reject(dpop_claims: Dict[str, Any], require_nonce: bool) -> None: + """ + Validate nonce; raise ValueError if missing or expired. + + Args: + dpop_claims (Dict[str, Any]): Decoded DPoP proof claims. + + Raises: + ValueError: If nonce is missing or expired. + """ + client_nonce: str = dpop_claims.get("nonce", "") + + if not client_nonce: + if require_nonce: + raise ValueError("DPoP proof missing required server nonce") + + # Nonce wasn't required and wasn't provided, safe to skip + return + + if not verify_stateless_nonce(client_nonce): + raise ValueError("Invalid or expired DPoP nonce") + + +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 does not match SHA-256 of the token. + """ + expected_ath: str = _compute_ath(access_token) + if dpop_claims.get("ath") != expected_ath: + raise ValueError("ath claim does not match access token hash") + + +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. + """ + 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") + + +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 jkt claim is missing or 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. + + Args: + url (str): The full request URL. + + Returns: + str: The sanitized HTTP target URI (e.g., 'https://example.com/api/v1/resource') + """ + parsed = urlparse(url) + + # Rebuild the URL using only scheme, netloc (host/port), and path + # This automatically drops parsed.query and parsed.fragment + clean_url = f"{parsed.scheme.lower()}://{parsed.netloc.lower()}{parsed.path}" + + return clean_url + + +def _get_unverified_claims(token: str) -> Dict[str, Any]: + """ + Extract the JWT claims payload WITHOUT verifying the signature. + + IMPORTANT: Verify the signature elsewhere! + """ + 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")) + + return claims diff --git a/src/authutils/token/dpop_nonce.py b/src/authutils/token/dpop_nonce.py new file mode 100644 index 0000000..460df02 --- /dev/null +++ b/src/authutils/token/dpop_nonce.py @@ -0,0 +1,91 @@ +""" +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 typing import Optional + +from joserfc import jwt +from joserfc.jwk import OctKey +from joserfc.errors import JoseError + + +def _get_shared_secret() -> Optional[str]: + """Get DPOP_SHARED_SECRET from environment (read at runtime for testability).""" + return 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")) + + +def generate_stateless_nonce() -> 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() + 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) -> bool: + """ + Verify nonce originated from this cluster and hasn't expired. + + Args: + client_nonce (str): Nonce token to verify. + + Returns: + bool: True if nonce is valid and within TTL, False otherwise. + """ + if not isinstance(client_nonce, str): + return False + + shared_secret = _get_shared_secret() + if not client_nonce or not shared_secret: + return False + + try: + key = OctKey.import_key(shared_secret) + token = jwt.decode( + client_nonce, + key, + algorithms=["HS256"], + ) + claims = token.claims + + now = int(time.time()) + iat = claims.get("iat") + exp = claims.get("exp") + + if exp is not None and exp < now: + return False + + if iat is not None and exp is not None and exp < iat: + return False + + return claims.get("purpose") == "dpop_nonce" + except (JoseError, TypeError): + return False diff --git a/src/authutils/token/keys.py b/src/authutils/token/keys.py index dd3dc9c..313a4a9 100644 --- a/src/authutils/token/keys.py +++ b/src/authutils/token/keys.py @@ -24,6 +24,8 @@ import base64 import json from collections import OrderedDict +import time + from cdislogging import get_logger @@ -43,6 +45,12 @@ from authutils.errors import JWTError from .core import get_keys_url, get_kid, get_iss +# In-memory cache for token public key lookups (with TTL) +# Maps "issuer:kid" to {"key": pem_key, "expires_at": timestamp} +_token_public_key_cache = {} +# Maximum number of entries to cache to prevent memory overload +_TOKEN_PUBLIC_KEY_CACHE_MAX_SIZE = 100 + def get_pem_key(key, logger=None): """ @@ -301,3 +309,137 @@ def get_public_key_for_token( pkey_cache=pkey_cache, logger=logger, ) + + +def get_any_public_key_for_token(encoded_token, cache_ttl: int = 300, logger=None): + """ + Get the public key for a token (generalized for any web framework, e.g. without + requiring Flask context like the above function). + + It includes a simple in-memory cache with TTL to avoid excessive network requests. + + Decouples public key fetching from Flask, + enabling token validation in non-Flask environments (FastAPI, async contexts, etc). + + Args: + encoded_token (str): Encoded JWT token. + cache_ttl (int): Cache time-to-live in seconds (default: 300s / 5 minutes). + logger (Optional): Logger instance. Defaults to module logger. + + Returns: + bytes: Public key in PEM format. + + Raises: + JWTError: If the token is malformed or the public key cannot be fetched. + """ + logger = logger or get_logger(__name__, log_level="info") + + try: + iss = get_iss(encoded_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)") + + cache_key = f"{iss}:{kid}" + + # Try to retrieve from cache + cached_key = _get_public_key_from_cache(cache_key, logger) + if cached_key is not None: + return cached_key + + logger.debug(f"cache miss. attempting to get keys URL from iss: {iss}...") + keys_url = get_keys_url(iss) + + # TODO: check keys URL against an allowlist of domains + + try: + # Fetch JWKS from issuer + logger.info(f"hitting keys URL from iss: {iss}, keys_url: {keys_url}...") + response = httpx.get(keys_url) + response.raise_for_status() + jwks_data = response.json() + keys = jwks_data.get("keys", []) + except Exception as e: + raise JWTError(f"Could not fetch JWKS from {keys_url}: {str(e)}") + + if not keys: + raise JWTError(f"Got no keys from {keys_url} for iss: {iss}") + + # Find the key with matching kid or use the first key + for key_data in keys: + if key_data.get("kid") == kid or (kid is None and keys): + _, pem_key = get_pem_key(key_data, logger) + + # Save to cache with TTL and size limiting + _save_public_key_to_cache(cache_key, pem_key, cache_ttl, logger) + + return pem_key + + raise JWTError(f"No public key found for kid={kid} at issuer {iss}") + + +def _get_public_key_from_cache(cache_key: str, logger=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 (Optional): Logger instance. + + Returns: + bytes | None: Public key in PEM format if valid entry exists, None otherwise. + """ + if cache_key not in _token_public_key_cache: + return None + + cached_entry = _token_public_key_cache[cache_key] + if time.time() < cached_entry["expires_at"]: + if logger: + logger.debug(f"Using cached public key for {cache_key}") + return cached_entry["key"] + else: + 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=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 (Optional): Logger instance. + + Side Effects: + - Adds entry to _token_public_key_cache. + - May evict oldest entry if cache is at capacity. + """ + # Enforce cache size limit: remove oldest entry if at capacity + if len(_token_public_key_cache) >= _TOKEN_PUBLIC_KEY_CACHE_MAX_SIZE: + # Find and remove the oldest entry (earliest expires_at) + oldest_key = None + oldest_expiration = float("inf") + for cached_key, cached_entry in _token_public_key_cache.items(): + if cached_entry["expires_at"] < oldest_expiration: + oldest_expiration = cached_entry["expires_at"] + oldest_key = cached_key + if oldest_key is not None: + del _token_public_key_cache[oldest_key] + if logger: + logger.debug( + f"Cache at capacity ({_TOKEN_PUBLIC_KEY_CACHE_MAX_SIZE}), " + f"evicted oldest entry: {oldest_key}" + ) + + # Cache the key + _token_public_key_cache[cache_key] = { + "key": pem_key, + "expires_at": time.time() + cache_ttl, + } diff --git a/tests/test_dpop.py b/tests/test_dpop.py new file mode 100644 index 0000000..21d3fa9 --- /dev/null +++ b/tests/test_dpop.py @@ -0,0 +1,403 @@ +""" +Test DPoP functionality in authutils.dpop module. +""" + +import base64 +import json +import os +import time +from unittest.mock import patch + +import pytest +from joserfc import jwk, jwt + +import authutils.dpop +from authutils.token import dpop_nonce +from authutils.dpop import DPOP_PROOF_MAX_TTL, DPOP_PROOF_CLOCK_SKEW_LEEWAY + + +@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 TestBidirectionalBinding: + """Bidirectional Binding (Stolen Token Defense)""" + + def test_stolen_token_defense(self): + """ + Verify that an attacker cannot sign a valid DPoP proof + with their own key but bind it to a victim's stolen access token. + """ + # Generate Alice's key and create a mock access token containing Alice's key thumbprint (cnf.jkt) + alice_key = jwk.ECKey.generate_key(crv="P-256") + alice_thumbprint = alice_key.thumbprint() + + # Create a valid access token with Alice's thumbprint using a proper header with alg + 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, + ) + + # Generate the Attacker's key + attacker_key = jwk.ECKey.generate_key(crv="P-256") + + # Call generate_dpop_proof() using the Attacker's key, providing Alice's access token to generate the ath claim + attacker_proof = authutils.dpop.generate_dpop_proof( + attacker_key, "GET", "https://example.com/resource", alice_token + ) + + # Run validate_dpop_proof() passing the Attacker's proof header and Alice's access token + # This should raise ValueError because attacker's key thumbprint doesn't match Alice's thumbprint + with pytest.raises(ValueError): + authutils.dpop.validate_dpop_proof( + attacker_proof, "GET", "https://example.com/resource", alice_token + ) + + +class TestStrictConditionalNonceValidation: + """Strict Conditional Nonce Validation""" + + def test_missing_required_nonce(self): + """ + Test Case A (Missing & Required): Generate a DPoP proof without a nonce. + Call validate_dpop_proof(..., require_nonce=True). + """ + # Generate a DPoP proof without a nonce + key = jwk.ECKey.generate_key(crv="P-256") + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource" + ) + + # Call validate_dpop_proof(..., require_nonce=True) + with pytest.raises(ValueError): + authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource", require_nonce=True + ) + + def test_provided_unexpectedly_invalid_nonce(self): + """ + Test Case B (Provided unexpectedly & Invalid): Generate a DPoP proof + containing an expired or garbage nonce string. Call validate_dpop_proof(..., require_nonce=False). + """ + # Generate a DPoP proof with invalid nonce + 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 + ) + + # Mock the verify_stateless_nonce function to return False for invalid nonce + with patch( + "authutils.token.dpop_nonce.verify_stateless_nonce", return_value=False + ): + # Call validate_dpop_proof(..., require_nonce=False) + # Even though require_nonce=False, an invalid nonce should still be rejected + with pytest.raises(ValueError): + authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource", require_nonce=False + ) + + +class TestAlgorithmWhitelisting: + """Algorithm Whitelisting & Key Cross-Compatibility""" + + def test_rsa_verification(self): + """ + Test Case A (RSA Verification): Generate an RSA private key. + Generate a proof using generate_dpop_proof(rsa_key, ...) and verify + the header resolves automatically to RS256. + """ + # Generate an RSA private key + rsa_key = jwk.RSAKey.generate_key() + + # Generate a proof using generate_dpop_proof(rsa_key, ...) + proof = authutils.dpop.generate_dpop_proof( + rsa_key, "GET", "https://example.com/resource" + ) + + # Verify the header resolves automatically to RS256 + 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): + """ + Test Case B (Algorithm Confusion Injection): Generate a valid EC proof, + but manually intercept and edit the unverified header parameter "alg": "HS256" + (e.g. not allowed). Pass it to validate_dpop_proof(). + Expected Assertion: Raises ValueError + """ + # Generate a valid EC proof + ec_key = jwk.ECKey.generate_key(crv="P-256") + proof = authutils.dpop.generate_dpop_proof( + ec_key, "GET", "https://example.com/resource" + ) + + # Manually intercept and edit the unverified header parameter "alg": "HS256" + header_b64, payload_b64, signature = proof.split(".") + + # Decode and modify header + 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() + ) + + # Create a new proof with the modified header + new_proof = f"{new_header_b64}.{payload_b64}.{signature}" + + # Pass it to validate_dpop_proof() + with pytest.raises(ValueError): + authutils.dpop.validate_dpop_proof( + new_proof, "GET", "https://example.com/resource" + ) + + +class TestGenerateStatelessNonce: + """Tests for generate_stateless_nonce.""" + + 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 + + +class TestVerifyStatelessNonce: + """Tests for verify_stateless_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_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 + + +class TestVerifyStatelessNonceEdgeCases: + """Edge case tests for nonce verification.""" + + 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_with_malformed_base64(self): + """Returns False for JWT with malformed base64 encoding.""" + # JWT with invalid base64 characters in each part + assert dpop_nonce.verify_stateless_nonce("abc.def.ghi") is False + assert dpop_nonce.verify_stateless_nonce("!!!.!!!.!!!") is False + assert dpop_nonce.verify_stateless_nonce("a.") is False + + def test_verify_nonce_with_empty_jwt_parts(self): + """Returns False for JWT with empty parts.""" + assert dpop_nonce.verify_stateless_nonce("..") is False + assert dpop_nonce.verify_stateless_nonce("a..") is False + assert dpop_nonce.verify_stateless_nonce("..c") is False + + def test_verify_nonce_with_only_header(self): + """Returns False for incomplete JWT (only header).""" + # Generate a valid JWT and truncate it + nonce = dpop_nonce.generate_stateless_nonce() + parts = nonce.split(".") + assert dpop_nonce.verify_stateless_nonce(parts[0]) is False + assert dpop_nonce.verify_stateless_nonce(parts[0] + "." + parts[1]) is False + + def test_verify_nonce_with_invalid_json_in_header(self): + """Returns False for JWT with invalid JSON in header.""" + # Create a JWT with invalid JSON in header + invalid_header = ( + base64.urlsafe_b64encode(b"{invalid json}").rstrip(b"=").decode() + ) + payload = base64.urlsafe_b64encode(b"{}").rstrip(b"=").decode() + signature = "sig" + malformed = f"{invalid_header}.{payload}.{signature}" + assert dpop_nonce.verify_stateless_nonce(malformed) is False + + def test_verify_nonce_with_invalid_json_in_payload(self): + """Returns False for JWT with invalid JSON in payload.""" + header = base64.urlsafe_b64encode(b'{"typ":"JWT"}').rstrip(b"=").decode() + invalid_payload = ( + base64.urlsafe_b64encode(b"{invalid json}").rstrip(b"=").decode() + ) + signature = "sig" + malformed = f"{header}.{invalid_payload}.{signature}" + assert dpop_nonce.verify_stateless_nonce(malformed) is False + + def test_verify_nonce_with_whitespace_variations(self): + """Returns False for JWT with unexpected whitespace.""" + nonce = dpop_nonce.generate_stateless_nonce() + # Add various whitespace characters + assert dpop_nonce.verify_stateless_nonce(" " + nonce) is False + assert dpop_nonce.verify_stateless_nonce(nonce + " ") is False + assert dpop_nonce.verify_stateless_nonce(nonce.replace(".", ". ")) is False + + def test_verify_nonce_with_numeric_input(self): + """Returns False for numeric input.""" + assert dpop_nonce.verify_stateless_nonce(12345) is False + assert dpop_nonce.verify_stateless_nonce(0) is False + assert dpop_nonce.verify_stateless_nonce(-1) is False + + def test_verify_nonce_with_list_input(self): + """Returns False for list input.""" + assert dpop_nonce.verify_stateless_nonce(["a", "b", "c"]) is False + assert dpop_nonce.verify_stateless_nonce([]) is False + + def test_verify_nonce_with_dict_input(self): + """Returns False for dict input.""" + assert dpop_nonce.verify_stateless_nonce({"a": "b"}) is False + assert dpop_nonce.verify_stateless_nonce({}) 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 diff --git a/tests/test_keys.py b/tests/test_keys.py new file mode 100644 index 0000000..6e51948 --- /dev/null +++ b/tests/test_keys.py @@ -0,0 +1,395 @@ +""" +Unit tests for authutils.token.keys module, specifically +for `get_any_public_key_for_token` +""" + +import time +from unittest.mock import Mock, patch + +import pytest +from joserfc import jwk, jwt + +import authutils.token.keys as keys_module +from authutils.errors import JWTError + + +class TestGetAnyPublicKeyForToken: + """Tests for get_any_public_key_for_token function.""" + + @pytest.fixture(autouse=True) + def clear_cache(self): + """Clear the cache before and after each test.""" + keys_module._token_public_key_cache.clear() + yield + keys_module._token_public_key_cache.clear() + + @pytest.fixture + def mock_rsa_key(self): + """Create a mock RSA key for testing.""" + 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.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") + @patch("authutils.token.keys.httpx.get") + 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_response = Mock() + mock_response.json.return_value = mock_jwks_response + mock_get.return_value = mock_response + + # First call should fetch from network + result = keys_module.get_any_public_key_for_token(valid_token) + + assert result is not None + assert isinstance(result, (str, bytes)) + mock_get.assert_called_once() + + @patch("authutils.token.keys.get_keys_url") + @patch("authutils.token.keys.httpx.get") + 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_response = Mock() + mock_response.json.return_value = mock_jwks_response + mock_get.return_value = mock_response + + # First call + result1 = keys_module.get_any_public_key_for_token(valid_token) + # Second call + result2 = keys_module.get_any_public_key_for_token(valid_token) + + # Should have only called httpx.get once (second call was cached) + assert mock_get.call_count == 1 + assert result1 == result2 + + @patch("authutils.token.keys.get_keys_url") + @patch("authutils.token.keys.httpx.get") + @patch("authutils.token.keys.time.time") + 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_response = Mock() + mock_response.json.return_value = mock_jwks_response + mock_get.return_value = mock_response + + # Mock time progression + current_time = 1000.0 + mock_time.side_effect = [ + # First cache write + current_time, + # First cache check (hit) + current_time, + # Second cache check (expired) + current_time + 400, + # Second cache write (after expiration) + current_time + 400, + ] + + # First call + result1 = keys_module.get_any_public_key_for_token(valid_token, cache_ttl=300) + + # Second call within TTL (should be cached) + result2 = keys_module.get_any_public_key_for_token(valid_token, cache_ttl=300) + + # Third call after TTL (should fetch again) + result3 = keys_module.get_any_public_key_for_token(valid_token, cache_ttl=300) + + # Should have called httpx.get twice (once for initial, once after expiration) + assert mock_get.call_count == 2 + assert result1 == result2 == result3 + + @patch("authutils.token.keys.get_keys_url") + @patch("authutils.token.keys.httpx.get") + 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" + + # Create a mock that returns JWKS with matching kid + def mock_get_response(url): + response = Mock() + # Extract kid from the URL or create one that matches tokens + # For this test, we create a JWKS that has keys for all kids + response.json.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) + ] + } + return response + + mock_get.side_effect = mock_get_response + + # 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 + tokens = [] + for i in range(10): + hs_key = jwk.OctKey.import_key("test-secret-32chars-minimum-test") + payload = { + "iss": f"https://issuer-{i}.example.com", + "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: + keys_module.get_any_public_key_for_token(token) + + # 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 + + def test_malformed_token_raises_error(self): + """Test that malformed tokens raise JWTError.""" + with pytest.raises(JWTError): + keys_module.get_any_public_key_for_token("not.a.valid.token") + + 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): + keys_module.get_any_public_key_for_token(token) + + @patch("authutils.token.keys.httpx.get") + def test_network_error_raises_error(self, mock_get, valid_token): + """Test that network errors are properly handled.""" + mock_get.side_effect = Exception("Network error") + + with pytest.raises(JWTError) as exc_info: + keys_module.get_any_public_key_for_token(valid_token) + + @patch("authutils.token.keys.httpx.get") + def test_no_matching_key_in_jwks_raises_error(self, mock_get, valid_token): + """Test that missing key in JWKS raises JWTError.""" + mock_jwks_response = { + "keys": [ + { + "kty": "RSA", + "kid": "different-key-id", + "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", # pragma: allowlist secret + "e": "AQAB", + } + ] + } + + mock_response = Mock() + mock_response.json.return_value = mock_jwks_response + mock_get.return_value = mock_response + + with pytest.raises(JWTError) as exc_info: + keys_module.get_any_public_key_for_token(valid_token) + + @patch("authutils.token.keys.get_keys_url") + @patch("authutils.token.keys.httpx.get") + @patch("authutils.token.keys.time.time") + 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_response = Mock() + mock_response.json.return_value = mock_jwks_response + mock_get.return_value = mock_response + + current_time = 1000.0 + mock_time.side_effect = [ + current_time, # First cache write + current_time + 100, # Check within custom TTL (150 seconds) + ] + + # First call with custom TTL of 150 seconds + result1 = keys_module.get_any_public_key_for_token(valid_token, cache_ttl=150) + + # Second call within custom TTL + result2 = keys_module.get_any_public_key_for_token(valid_token, cache_ttl=150) + + # Should have only called httpx.get once (second call was cached) + assert mock_get.call_count == 1 + assert result1 == result2 + + @patch("authutils.token.keys.httpx.get") + def test_different_kids_different_cache_entries(self, mock_get, mock_jwks_response): + """Test that tokens with different kids are cached separately.""" + # Create a mock that returns JWKS with matching kids + def mock_get_response(url): + response = Mock() + response.json.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) + ] + } + return response + + mock_get.side_effect = mock_get_response + + # 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 + result1 = keys_module.get_any_public_key_for_token(tokens[0]) + result2 = keys_module.get_any_public_key_for_token(tokens[1]) + + # Should have 2 entries in cache (different kids) + assert len(keys_module._token_public_key_cache) == 2 + + @patch("authutils.token.keys.httpx.get") + def test_different_issuers_different_cache_entries( + self, mock_get, mock_jwks_response + ): + """Test that tokens from different issuers are cached separately.""" + mock_response = Mock() + mock_response.json.return_value = mock_jwks_response + mock_get.return_value = mock_response + + # Create two tokens from different issuers + tokens = [] + for i in range(2): + hs_key = jwk.OctKey.import_key("test-secret-32chars-minimum-test") + payload = { + "iss": f"https://issuer-{i}.example.com", + "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 = keys_module.get_any_public_key_for_token(tokens[0]) + result2 = keys_module.get_any_public_key_for_token(tokens[1]) + + # Should have 2 entries in cache (different issuers) + assert len(keys_module._token_public_key_cache) == 2 + + @patch("authutils.token.keys.httpx.get") + def test_cache_key_format(self, mock_get, valid_token, mock_jwks_response): + """Test that cache keys follow the expected format 'issuer:kid'.""" + mock_response = Mock() + mock_response.json.return_value = mock_jwks_response + mock_get.return_value = mock_response + + keys_module.get_any_public_key_for_token(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:") From 7df2e93e1a7a657199bcf9e9b0e468cd737c848a Mon Sep 17 00:00:00 2001 From: avantol Date: Thu, 16 Jul 2026 15:45:21 -0500 Subject: [PATCH 02/28] feat(dpop): return more info in validate call --- src/authutils/dpop.py | 11 ++++--- tests/test_dpop.py | 67 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/src/authutils/dpop.py b/src/authutils/dpop.py index c0e4aab..b18fecd 100644 --- a/src/authutils/dpop.py +++ b/src/authutils/dpop.py @@ -24,7 +24,7 @@ import os import base64 import time -from typing import Dict, Any +from typing import Dict, Any, Tuple from urllib.parse import urlparse from joserfc import jwt, jwk, jws @@ -213,7 +213,7 @@ def validate_dpop_proof( request_url: str, unvalidated_access_token: str | None = None, require_nonce: bool = False, -) -> Dict[str, Any]: +) -> Tuple[Dict[str, Any], jwk.Key]: """ Validate a DPoP proof JWT for a resource server request. @@ -238,7 +238,7 @@ def validate_dpop_proof( require_nonce (bool): Whether to require and validate a nonce. Returns: - Dict[str, Any]: dict with decoded claims dict. + Dict[str, Any], jwk.Key: dict with decoded claims dict, validated client_jwk from dpop header Raises: ValueError: If any validation fails. @@ -250,6 +250,9 @@ def validate_dpop_proof( ... request_url="https://api.example.com/ga4gh/tes/v1/jobs", ... ) """ + if not dpop_header: + raise ValueError("Invalid DPoP proof: Empty string / None provided") + client_jwk = extract_and_validate_jwk(dpop_header) dpop_claims = _verify_signature_and_claims(dpop_header, client_jwk) @@ -263,7 +266,7 @@ def validate_dpop_proof( _validate_ath(dpop_claims, unvalidated_access_token) _validate_key_binding(client_jwk, unvalidated_access_token) - return dpop_claims + return dpop_claims, client_jwk def _validate_key_binding( diff --git a/tests/test_dpop.py b/tests/test_dpop.py index 21d3fa9..aa3c75e 100644 --- a/tests/test_dpop.py +++ b/tests/test_dpop.py @@ -13,7 +13,7 @@ import authutils.dpop from authutils.token import dpop_nonce -from authutils.dpop import DPOP_PROOF_MAX_TTL, DPOP_PROOF_CLOCK_SKEW_LEEWAY +from authutils.dpop import DPOP_PROOF_MAX_TTL @pytest.fixture(autouse=True) @@ -26,6 +26,71 @@ def set_shared_secret(): os.environ.pop("DPOP_SHARED_SECRET", None) +class TestValidateDpopProofSuccess: + """Tests for successful validate_dpop_proof calls that verify return values.""" + + def test_returns_dpop_claims_and_client_jwk_tuple(self): + """ + Test that validate_dpop_proof returns a tuple of (dpop_claims, client_jwk). + This allows callers to get both values in a single call without needing + to call both validate_dpop_proof and extract_and_validate_jwk separately. + """ + key = jwk.ECKey.generate_key(crv="P-256") + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource" + ) + + # Call validate_dpop_proof and verify it returns a tuple + result = authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource" + ) + + # Verify return type is a tuple + assert isinstance(result, tuple), "validate_dpop_proof should return a tuple" + + # Verify tuple has exactly 2 elements + assert len(result) == 2, "validate_dpop_proof should return a 2-element tuple" + + # Verify first element is the dpop claims dict + dpop_claims, client_jwk = result + assert isinstance( + dpop_claims, dict + ), "First element should be a dict (dpop_claims)" + 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 dpop_claims["htm"] == "GET", "htm should match request method" + + # Verify second element is the client jwk + assert client_jwk is not None, "client_jwk should not be None" + # client_jwk can be an ECKey or RSAKey, check for key attributes + 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"] == "EC", "client_jwk should be EC type" + + +class TestNoneDpopHeaderValidation: + """Tests for None or empty dpop_header validation.""" + + @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) + + class TestBidirectionalBinding: """Bidirectional Binding (Stolen Token Defense)""" From af233c8b2fbf9da0b75261fe97eb7c9e93dd522a Mon Sep 17 00:00:00 2001 From: avantol Date: Tue, 21 Jul 2026 12:34:32 -0500 Subject: [PATCH 03/28] feat(jwt): validate handles pur, and denylist, more closely match typical non-general flask validation. more tests --- src/authutils/dpop.py | 29 ++--- src/authutils/token/core.py | 114 +++++++++++------- src/authutils/token/validate.py | 12 +- tests/test_jwt.py | 199 ++++++++++++++++++++++++++++++++ 4 files changed, 294 insertions(+), 60 deletions(-) diff --git a/src/authutils/dpop.py b/src/authutils/dpop.py index b18fecd..1733d96 100644 --- a/src/authutils/dpop.py +++ b/src/authutils/dpop.py @@ -24,7 +24,7 @@ import os import base64 import time -from typing import Dict, Any, Tuple +from typing import Callable, Dict, Any, Tuple from urllib.parse import urlparse from joserfc import jwt, jwk, jws @@ -132,6 +132,8 @@ def validate_dpop_request( purpose: str | None = None, aud: str | None = None, require_nonce: bool = False, + options: dict | None = None, + denylist_callback: Callable | None = None, ) -> Dict[str, Any]: """ Validate both the DPoP proof AND the access token in one operation. @@ -156,6 +158,10 @@ def validate_dpop_request( 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. Returns: Dict[str, Any]: The validated access token claims dict. @@ -163,17 +169,6 @@ def validate_dpop_request( Raises: ValueError: If DPoP proof validation fails. JWTError: If access token validation fails (signature, expiration, issuer, scope, purpose). - - Example: - >>> claims = validate_dpop_request( - ... dpop_header="eyJ0eXAi...", - ... access_token="eyJhbGc...", - ... request_method="GET", - ... request_url="https://api.example.com/ga4gh/tes/v1/jobs", - ... issuers=["https://fence.example.com"], - ... scope={"user", "data"}, - ... purpose="access", - ... ) """ validate_dpop_proof( dpop_header=dpop_header, @@ -196,14 +191,12 @@ def validate_dpop_request( public_key=public_key, aud=aud, scope=scope, - issuers=issuers, - options={}, + allowed_issuers=issuers, + purpose=purpose, + options=options, + denylist_callback=denylist_callback, ) - # Step 3: Validate purpose if specified - if purpose: - token_core.validate_purpose(validated_claims, purpose) - return validated_claims diff --git a/src/authutils/token/core.py b/src/authutils/token/core.py index d0b2ecd..cc29ee9 100644 --- a/src/authutils/token/core.py +++ b/src/authutils/token/core.py @@ -1,5 +1,8 @@ import httpx import jwt +from collections.abc import Callable + +from jwt.types import Options from ..errors import ( JWTAudienceError, @@ -61,18 +64,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, + aud: str | list[str] | None = None, + scope: set[str] | list[str] | None = None, + allowed_issuers: set[str] | list[str] | None = None, + 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,25 +95,33 @@ 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]): + 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 - - Return: + allowed_issuers (set[str] | list[str] | None): allowed allowed_issuers whitelist. If None, this will + SKIP ISSUER VALIDATION. NOTE: THIS IS THE DEFAULT. + 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. + + Returns: dict: the decoded and validated JWT Raises: @@ -112,28 +129,31 @@ def validate_jwt( 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 {} + allowed_issuers = allowed_issuers 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 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,23 +161,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) - raise JWTError(msg) + if allowed_issuers: + if token["iss"] not in allowed_issuers: + msg = f"invalid issuer {token['iss']}; expected one of: {allowed_issuers}" + raise JWTError(msg) # scope # Check that if scope arg was non-empty then the token includes each given scope in its scope claim @@ -167,14 +191,26 @@ 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 diff --git a/src/authutils/token/validate.py b/src/authutils/token/validate.py index bf40249..4f3cdbc 100644 --- a/src/authutils/token/validate.py +++ b/src/authutils/token/validate.py @@ -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 diff --git a/tests/test_jwt.py b/tests/test_jwt.py index 4be4af8..5a7b1ee 100644 --- a/tests/test_jwt.py +++ b/tests/test_jwt.py @@ -138,6 +138,205 @@ def test_invalid_iss_rejected( ) +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=[] + ) # aud must be str/list/None + + def test_get_public_key(app, example_keys_response, mock_get): """ Test the functionality of retrieving the public keys from the keys From a8ae22934b78622b0b275b82c1bfe587c11a43b6 Mon Sep 17 00:00:00 2001 From: avantol Date: Wed, 22 Jul 2026 10:36:32 -0500 Subject: [PATCH 04/28] fix(dpop): handle rsa keys, improve return of dpop functions, more tests --- src/authutils/dpop.py | 38 ++- src/authutils/token/core.py | 4 +- src/authutils/token/keys.py | 6 +- tests/test_dpop.py | 453 +++++++++++++++++++++++++++++++++++- 4 files changed, 476 insertions(+), 25 deletions(-) diff --git a/src/authutils/dpop.py b/src/authutils/dpop.py index 1733d96..0c6feef 100644 --- a/src/authutils/dpop.py +++ b/src/authutils/dpop.py @@ -62,6 +62,15 @@ MAX_JTI_LENGTH = 256 +class _LargeHeaderRegistry(jws.JWSRegistry): + """ + Custom JWS registry with increased header size limit for DPoP proofs. + This is required to support RSA-s + """ + + max_header_length = 4096 + + def generate_dpop_proof( key: jwk.Key, method: str, @@ -127,14 +136,14 @@ def validate_dpop_request( request_method: str, request_url: str, issuers: list[str], - public_key: str | None = None, + 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, -) -> Dict[str, Any]: +) -> Tuple[Dict[str, Any], Dict[str, Any], jwk.Key]: """ Validate both the DPoP proof AND the access token in one operation. @@ -152,7 +161,7 @@ def validate_dpop_request( request_method (str): The HTTP method of the incoming request. request_url (str): The full URL of the incoming request (scheme + host + path). issuers (list[str]): Allowed token issuers whitelist. - public_key (str | None): Optional public key for token validation. If None, will be + 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. @@ -164,13 +173,16 @@ def validate_dpop_request( The callback is called after basic JWT validation. Returns: - Dict[str, Any]: The validated access token claims dict. + 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. JWTError: If access token validation fails (signature, expiration, issuer, scope, purpose). """ - validate_dpop_proof( + dpop_claims, client_jwk = validate_dpop_proof( dpop_header=dpop_header, request_method=request_method, request_url=request_url, @@ -178,7 +190,7 @@ def validate_dpop_request( require_nonce=require_nonce, ) - if public_key is None: + if not public_key: # Fetch public key from issuer's JWKS endpoint if not provided public_key = get_any_public_key_for_token(access_token) @@ -186,7 +198,7 @@ def validate_dpop_request( if isinstance(scope, list): scope = set(scope) - validated_claims = token_core.validate_jwt( + validated_access_token_claims = token_core.validate_jwt( encoded_token=access_token, public_key=public_key, aud=aud, @@ -197,7 +209,7 @@ def validate_dpop_request( denylist_callback=denylist_callback, ) - return validated_claims + return dpop_claims, validated_access_token_claims, client_jwk def validate_dpop_proof( @@ -299,9 +311,13 @@ def extract_and_validate_jwk(dpop_header: str) -> jwk.Key: Raises: ValueError: If header is malformed, missing jwk, or uses symmetric key. """ + # Use custom registry with increased header size limit for DPoP proofs + # containing full JWKs (especially RSA keys which have large public keys) + registry = _LargeHeaderRegistry() + try: unverified_header: dict = jws.extract_compact( - dpop_header.encode("utf-8") + dpop_header.encode("utf-8"), registry=registry ).protected except Exception: raise ValueError("Invalid DPoP proof: malformed compact JWS structure") @@ -351,7 +367,9 @@ def _verify_signature_and_claims( Raises: JoseError: If signature verification or time validation fails. """ - dpop_claims = jwt.decode(dpop_header, client_jwk) + # Use custom registry with increased header size limit for DPoP proofs + registry = _LargeHeaderRegistry() + dpop_claims = jwt.decode(dpop_header, client_jwk, registry=registry) claims_dict = dpop_claims.claims current_time = int(time.time()) diff --git a/src/authutils/token/core.py b/src/authutils/token/core.py index cc29ee9..f31b691 100644 --- a/src/authutils/token/core.py +++ b/src/authutils/token/core.py @@ -73,7 +73,7 @@ def validate_purpose(claims, pur): def validate_jwt( encoded_token: str, - public_key: str, + public_key: str | bytes, aud: str | list[str] | None = None, scope: set[str] | list[str] | None = None, allowed_issuers: set[str] | list[str] | None = None, @@ -104,7 +104,7 @@ def validate_jwt( Args: encoded_token (str): encoded JWT - public_key (str): public key to validate the JWT signature + 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 diff --git a/src/authutils/token/keys.py b/src/authutils/token/keys.py index 313a4a9..70cbc85 100644 --- a/src/authutils/token/keys.py +++ b/src/authutils/token/keys.py @@ -311,7 +311,9 @@ def get_public_key_for_token( ) -def get_any_public_key_for_token(encoded_token, cache_ttl: int = 300, logger=None): +def get_any_public_key_for_token( + encoded_token, cache_ttl: int = 300, logger=None +) -> bytes: """ Get the public key for a token (generalized for any web framework, e.g. without requiring Flask context like the above function). @@ -347,7 +349,7 @@ def get_any_public_key_for_token(encoded_token, cache_ttl: int = 300, logger=Non # Try to retrieve from cache cached_key = _get_public_key_from_cache(cache_key, logger) - if cached_key is not None: + if cached_key: return cached_key logger.debug(f"cache miss. attempting to get keys URL from iss: {iss}...") diff --git a/tests/test_dpop.py b/tests/test_dpop.py index aa3c75e..dc8198a 100644 --- a/tests/test_dpop.py +++ b/tests/test_dpop.py @@ -6,6 +6,7 @@ import json import os import time +from typing import Any from unittest.mock import patch import pytest @@ -14,6 +15,53 @@ import authutils.dpop from authutils.token import dpop_nonce from authutils.dpop import DPOP_PROOF_MAX_TTL +from authutils.errors import JWTScopeError, JWTPurposeError + + +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) @pytest.fixture(autouse=True) @@ -148,7 +196,8 @@ def test_missing_required_nonce(self): proof, "GET", "https://example.com/resource", require_nonce=True ) - def test_provided_unexpectedly_invalid_nonce(self): + @patch("authutils.token.dpop_nonce.verify_stateless_nonce", return_value=False) + def test_provided_unexpectedly_invalid_nonce(self, mock_verify_nonce): """ Test Case B (Provided unexpectedly & Invalid): Generate a DPoP proof containing an expired or garbage nonce string. Call validate_dpop_proof(..., require_nonce=False). @@ -161,16 +210,12 @@ def test_provided_unexpectedly_invalid_nonce(self): key, "GET", "https://example.com/resource", nonce=invalid_nonce ) - # Mock the verify_stateless_nonce function to return False for invalid nonce - with patch( - "authutils.token.dpop_nonce.verify_stateless_nonce", return_value=False - ): - # Call validate_dpop_proof(..., require_nonce=False) - # Even though require_nonce=False, an invalid nonce should still be rejected - with pytest.raises(ValueError): - authutils.dpop.validate_dpop_proof( - proof, "GET", "https://example.com/resource", require_nonce=False - ) + # Call validate_dpop_proof(..., require_nonce=False) + # Even though require_nonce=False, an invalid nonce should still be rejected + with pytest.raises(ValueError): + authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource", require_nonce=False + ) class TestAlgorithmWhitelisting: @@ -466,3 +511,389 @@ def test_verify_nonce_with_extremely_short_ttl(self): header={"alg": "HS256", "typ": "JWT"}, claims=payload, key=hs_key ) assert dpop_nonce.verify_stateless_nonce(short_ttl) is False + + +class TestValidateDpopRequest: + """Tests for validate_dpop_request.""" + + @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") + @patch("authutils.dpop.token_core.validate_jwt") + 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 = authutils.dpop.validate_dpop_request( + 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") + @patch("authutils.dpop.token_core.validate_jwt") + 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 = authutils.dpop.validate_dpop_request( + 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") + @patch("authutils.dpop.token_core.validate_jwt") + 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 = authutils.dpop.validate_dpop_request( + 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") + @patch("authutils.dpop.token_core.validate_jwt") + 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 = authutils.dpop.validate_dpop_request( + 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") + @patch("authutils.dpop.token_core.validate_jwt") + 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): + authutils.dpop.validate_dpop_request( + 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") + @patch("authutils.dpop.token_core.validate_jwt") + 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 = authutils.dpop.validate_dpop_request( + 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") + @patch("authutils.dpop.token_core.validate_jwt") + 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): + authutils.dpop.validate_dpop_request( + 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") + @patch("authutils.dpop.token_core.validate_jwt") + 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"): + authutils.dpop.validate_dpop_request( + 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") + @patch("authutils.dpop.token_core.validate_jwt") + 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 = authutils.dpop.validate_dpop_request( + 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" From 74a968270bebdbc0abb216b3c3c0184540d69000 Mon Sep 17 00:00:00 2001 From: avantol Date: Wed, 22 Jul 2026 13:28:35 -0500 Subject: [PATCH 05/28] feat(secret): allow passing through shared secret for nonce (so it can be passed from config) --- src/authutils/dpop.py | 27 ++++-- src/authutils/token/dpop_nonce.py | 12 +-- tests/test_dpop.py | 143 ++++++++++++++++++++++++++++++ 3 files changed, 171 insertions(+), 11 deletions(-) diff --git a/src/authutils/dpop.py b/src/authutils/dpop.py index 0c6feef..9c63d28 100644 --- a/src/authutils/dpop.py +++ b/src/authutils/dpop.py @@ -143,6 +143,7 @@ def validate_dpop_request( require_nonce: bool = False, options: dict | None = None, denylist_callback: Callable | None = None, + secret: str | None = None, ) -> Tuple[Dict[str, Any], Dict[str, Any], jwk.Key]: """ Validate both the DPoP proof AND the access token in one operation. @@ -171,6 +172,8 @@ def validate_dpop_request( 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. + secret (str | None): Optional secret key for stateless nonce verification. + If None, defaults to the environment-configured secret. Returns: Dict[str, Any], Dict[str, Any], jwk.Key: @@ -188,6 +191,7 @@ def validate_dpop_request( request_url=request_url, unvalidated_access_token=access_token, require_nonce=require_nonce, + secret=secret, ) if not public_key: @@ -218,6 +222,7 @@ def validate_dpop_proof( request_url: str, unvalidated_access_token: str | None = None, require_nonce: bool = False, + secret: str | None = None, ) -> Tuple[Dict[str, Any], jwk.Key]: """ Validate a DPoP proof JWT for a resource server request. @@ -241,6 +246,8 @@ def validate_dpop_proof( request_url (str): The full URL of the incoming request (scheme + host + path). 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. Returns: Dict[str, Any], jwk.Key: dict with decoded claims dict, validated client_jwk from dpop header @@ -265,7 +272,9 @@ def validate_dpop_proof( _validate_proof_claims(dpop_claims, request_method, request_url) if require_nonce or "nonce" in dpop_claims: - _validate_nonce_or_reject(dpop_claims, require_nonce=require_nonce) + _validate_nonce_or_reject( + dpop_claims, require_nonce=require_nonce, secret=secret + ) if unvalidated_access_token: _validate_ath(dpop_claims, unvalidated_access_token) @@ -431,15 +440,23 @@ def _validate_proof_claims( ) -def _validate_nonce_or_reject(dpop_claims: Dict[str, Any], require_nonce: bool) -> None: +def _validate_nonce_or_reject( + dpop_claims: Dict[str, Any], require_nonce: bool, secret: str | None = None +) -> None: """ - Validate nonce; raise ValueError if missing or expired. + Validate DPoP nonce; raise ValueError if missing or expired. 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 ValueError. 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. Raises: - ValueError: If nonce is missing or expired. + ValueError: If nonce is missing when required, or if the nonce is + invalid or expired according to verify_stateless_nonce. """ client_nonce: str = dpop_claims.get("nonce", "") @@ -450,7 +467,7 @@ def _validate_nonce_or_reject(dpop_claims: Dict[str, Any], require_nonce: bool) # Nonce wasn't required and wasn't provided, safe to skip return - if not verify_stateless_nonce(client_nonce): + if not verify_stateless_nonce(client_nonce, secret=secret): raise ValueError("Invalid or expired DPoP nonce") diff --git a/src/authutils/token/dpop_nonce.py b/src/authutils/token/dpop_nonce.py index 460df02..d976dec 100644 --- a/src/authutils/token/dpop_nonce.py +++ b/src/authutils/token/dpop_nonce.py @@ -13,9 +13,9 @@ from joserfc.errors import JoseError -def _get_shared_secret() -> Optional[str]: +def _get_shared_secret(secret: str | None = None) -> Optional[str]: """Get DPOP_SHARED_SECRET from environment (read at runtime for testability).""" - return os.getenv("DPOP_SHARED_SECRET") + return secret or os.getenv("DPOP_SHARED_SECRET") def _get_nonce_ttl() -> int: @@ -23,7 +23,7 @@ def _get_nonce_ttl() -> int: return int(os.getenv("DPOP_NONCE_TTL", "300")) -def generate_stateless_nonce() -> str: +def generate_stateless_nonce(secret: str | None = None) -> str: """ Mint a symmetric nonce token valid for DPOP_NONCE_TTL_SECONDS. @@ -33,7 +33,7 @@ def generate_stateless_nonce() -> str: Raises: RuntimeError: If DPOP_SHARED_SECRET environment variable not set. """ - shared_secret = _get_shared_secret() + shared_secret = _get_shared_secret(secret=secret) if not shared_secret: raise RuntimeError("DPOP_SHARED_SECRET environment variable not set") @@ -50,7 +50,7 @@ def generate_stateless_nonce() -> str: return jwt.encode(header, claims, key) -def verify_stateless_nonce(client_nonce: str) -> bool: +def verify_stateless_nonce(client_nonce: str, secret: str | None = None) -> bool: """ Verify nonce originated from this cluster and hasn't expired. @@ -63,7 +63,7 @@ def verify_stateless_nonce(client_nonce: str) -> bool: if not isinstance(client_nonce, str): return False - shared_secret = _get_shared_secret() + shared_secret = _get_shared_secret(secret=secret) if not client_nonce or not shared_secret: return False diff --git a/tests/test_dpop.py b/tests/test_dpop.py index dc8198a..765e0fc 100644 --- a/tests/test_dpop.py +++ b/tests/test_dpop.py @@ -718,6 +718,149 @@ def test_access_token_scopes_validation( dpop_claims, token_claims, client_jwk = result assert dpop_claims["htm"] == "GET" + 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 + + @patch("authutils.dpop.get_any_public_key_for_token") + @patch("authutils.dpop.token_core.validate_jwt") + 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, + ) = authutils.dpop.validate_dpop_request( + 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) + @patch("authutils.dpop.get_any_public_key_for_token") @patch("authutils.dpop.token_core.validate_jwt") def test_access_token_scope_missing_raises( From a6690eba529606a4d3869e44302384a65b829d26 Mon Sep 17 00:00:00 2001 From: avantol Date: Thu, 23 Jul 2026 12:56:52 -0500 Subject: [PATCH 06/28] chore(deps): increase min version of pyjwt for required types --- poetry.lock | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/poetry.lock b/poetry.lock index ad4498c..bea6404 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2091,4 +2091,4 @@ flask = ["Flask"] [metadata] lock-version = "2.1" python-versions = ">=3.13, <4.0" -content-hash = "c2a058a6cebe5b5cab6d5873113c863f5c208584d574aaba6259f405936dbce2" +content-hash = "c4a6697c338443f379e09e7b17a467a3ae0d9ad4b55e92a0574555441455d85d" diff --git a/pyproject.toml b/pyproject.toml index d0a9756..973d240 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ xmltodict = "~=0.9" authlib = ">=1.6.6" httpx = ">=0.23.0,<1.0.0" -pyjwt = {version = ">=2.4.0,<3.0", extras = ["crypto"]} +pyjwt = {version = ">=2.11.0,<3.0", extras = ["crypto"]} cryptography = ">=48.0.1" Flask = {version = "*", optional = true} From fd68324d2829ed5c656b040f6055ccd3c0a51e53 Mon Sep 17 00:00:00 2001 From: avantol Date: Thu, 23 Jul 2026 14:46:01 -0500 Subject: [PATCH 07/28] chore(dpop): make compute_ath public --- src/authutils/dpop.py | 6 +++--- tests/test_keys.py | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/authutils/dpop.py b/src/authutils/dpop.py index 9c63d28..f77239c 100644 --- a/src/authutils/dpop.py +++ b/src/authutils/dpop.py @@ -122,7 +122,7 @@ def generate_dpop_proof( } if access_token: - payload["ath"] = _compute_ath(access_token) + payload["ath"] = compute_ath(access_token) if nonce: payload["nonce"] = nonce @@ -482,12 +482,12 @@ def _validate_ath(dpop_claims: Dict[str, Any], access_token: str) -> None: Raises: ValueError: If ath does not match SHA-256 of the token. """ - expected_ath: str = _compute_ath(access_token) + expected_ath: str = compute_ath(access_token) if dpop_claims.get("ath") != expected_ath: raise ValueError("ath claim does not match access token hash") -def _compute_ath(access_token: str | bytes) -> str: +def compute_ath(access_token: str | bytes) -> str: """ Compute base64url(SHA-256(token)) per RFC 9449 4.2. diff --git a/tests/test_keys.py b/tests/test_keys.py index 6e51948..b319838 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -314,6 +314,7 @@ def test_custom_cache_ttl( @patch("authutils.token.keys.httpx.get") def test_different_kids_different_cache_entries(self, mock_get, mock_jwks_response): """Test that tokens with different kids are cached separately.""" + # Create a mock that returns JWKS with matching kids def mock_get_response(url): response = Mock() From b6ef43dfc31ee554078b7f56af65e97bb2b9e171 Mon Sep 17 00:00:00 2001 From: avantol Date: Fri, 24 Jul 2026 10:25:50 -0500 Subject: [PATCH 08/28] fix(nonce): return new nonce on error --- src/authutils/dpop.py | 24 +++++--- src/authutils/errors.py | 23 +++++++ tests/test_dpop.py | 132 +++++++++++++++++++++++++++++++++++++--- 3 files changed, 161 insertions(+), 18 deletions(-) diff --git a/src/authutils/dpop.py b/src/authutils/dpop.py index f77239c..b8d792e 100644 --- a/src/authutils/dpop.py +++ b/src/authutils/dpop.py @@ -30,9 +30,10 @@ from joserfc import jwt, jwk, jws from joserfc.errors import JoseError -from authutils.token.dpop_nonce import verify_stateless_nonce +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 +from authutils.errors import InvalidNonceError DPOP_JWT_TYPE = "dpop+jwt" DEFAULT_DPOP_ALGORITHM = "ES256" @@ -254,6 +255,9 @@ def validate_dpop_proof( Raises: ValueError: If any validation fails. + 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( @@ -272,9 +276,7 @@ def validate_dpop_proof( _validate_proof_claims(dpop_claims, request_method, request_url) if require_nonce or "nonce" in dpop_claims: - _validate_nonce_or_reject( - dpop_claims, require_nonce=require_nonce, secret=secret - ) + _validate_nonce(dpop_claims, require_nonce=require_nonce, secret=secret) if unvalidated_access_token: _validate_ath(dpop_claims, unvalidated_access_token) @@ -440,35 +442,37 @@ def _validate_proof_claims( ) -def _validate_nonce_or_reject( +def _validate_nonce( dpop_claims: Dict[str, Any], require_nonce: bool, secret: str | None = None ) -> None: """ - Validate DPoP nonce; raise ValueError if missing or expired. + 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 ValueError. If False and no nonce is present, + 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. Raises: - ValueError: If nonce is missing when required, or if the nonce is + InvalidNonceError: If nonce is missing when required, or if the nonce is invalid or expired according to verify_stateless_nonce. """ client_nonce: str = dpop_claims.get("nonce", "") if not client_nonce: if require_nonce: - raise ValueError("DPoP proof missing required server nonce") + raise InvalidNonceError(new_nonce=generate_stateless_nonce(secret=secret)) # Nonce wasn't required and wasn't provided, safe to skip return if not verify_stateless_nonce(client_nonce, secret=secret): - raise ValueError("Invalid or expired DPoP nonce") + raise InvalidNonceError(new_nonce=generate_stateless_nonce(secret=secret)) def _validate_ath(dpop_claims: Dict[str, Any], access_token: str) -> None: diff --git a/src/authutils/errors.py b/src/authutils/errors.py index 6ce5d97..5dc6066 100644 --- a/src/authutils/errors.py +++ b/src/authutils/errors.py @@ -1,6 +1,29 @@ from cdiserrors import AuthNError as CDISAuthNError +class InvalidNonceError(CDISAuthNError): + """ + Handle DPoP Nonce per the spec RFC9449. + + This error contains the necessary information for a 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 = { + "error": "use_dpop_nonce", + "error_description": "Authorization server requires nonce in DPoP proof", + }, + ): + super().__init__(message, code=code, json=json) + self.error_headers = {"DPoP-Nonce": new_nonce} + + class AuthError(CDISAuthNError): pass diff --git a/tests/test_dpop.py b/tests/test_dpop.py index 765e0fc..ad37c81 100644 --- a/tests/test_dpop.py +++ b/tests/test_dpop.py @@ -15,7 +15,7 @@ import authutils.dpop from authutils.token import dpop_nonce from authutils.dpop import DPOP_PROOF_MAX_TTL -from authutils.errors import JWTScopeError, JWTPurposeError +from authutils.errors import InvalidNonceError, JWTScopeError, JWTPurposeError def _create_signed_access_token( @@ -177,12 +177,13 @@ def test_stolen_token_defense(self): class TestStrictConditionalNonceValidation: - """Strict Conditional Nonce Validation""" + """Strict Conditional Nonce Validation per RFC 9449""" def test_missing_required_nonce(self): """ Test Case A (Missing & Required): Generate a DPoP proof without a nonce. Call validate_dpop_proof(..., require_nonce=True). + Expects InvalidNonceError containing new nonce response headers. """ # Generate a DPoP proof without a nonce key = jwk.ECKey.generate_key(crv="P-256") @@ -190,17 +191,24 @@ def test_missing_required_nonce(self): key, "GET", "https://example.com/resource" ) - # Call validate_dpop_proof(..., require_nonce=True) - with pytest.raises(ValueError): + with pytest.raises(InvalidNonceError) as exc_info: authutils.dpop.validate_dpop_proof( proof, "GET", "https://example.com/resource", require_nonce=True ) - @patch("authutils.token.dpop_nonce.verify_stateless_nonce", return_value=False) + err = exc_info.value + assert err.code == 400 + assert err.json["error"] == "use_dpop_nonce" + assert "DPoP-Nonce" in err.error_headers + assert isinstance(err.error_headers["DPoP-Nonce"], str) + assert len(err.error_headers["DPoP-Nonce"]) > 0 + + @patch("authutils.dpop.verify_stateless_nonce", return_value=False) def test_provided_unexpectedly_invalid_nonce(self, mock_verify_nonce): """ Test Case B (Provided unexpectedly & Invalid): Generate a DPoP proof containing an expired or garbage nonce string. Call validate_dpop_proof(..., require_nonce=False). + Even though require_nonce=False, an invalid nonce should still raise InvalidNonceError. """ # Generate a DPoP proof with invalid nonce key = jwk.ECKey.generate_key(crv="P-256") @@ -210,13 +218,121 @@ def test_provided_unexpectedly_invalid_nonce(self, mock_verify_nonce): key, "GET", "https://example.com/resource", nonce=invalid_nonce ) - # Call validate_dpop_proof(..., require_nonce=False) - # Even though require_nonce=False, an invalid nonce should still be rejected - with pytest.raises(ValueError): + with pytest.raises(InvalidNonceError) as exc_info: + authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource", require_nonce=False + ) + + err = exc_info.value + assert err.code == 400 + assert "DPoP-Nonce" in err.error_headers + + +class TestNonceValidationAndInvalidNonceError: + """Tests for DPoP nonce validation and InvalidNonceError handling per RFC 9449.""" + + def test_missing_required_nonce_raises_invalid_nonce_error(self): + """ + Test that validate_dpop_proof raises InvalidNonceError when require_nonce=True + and the DPoP proof lacks a nonce claim. + """ + key = jwk.ECKey.generate_key(crv="P-256") + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource" + ) + + with pytest.raises(InvalidNonceError) as exc_info: + authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource", require_nonce=True + ) + + err = exc_info.value + + assert err.code == 400 + assert err.json == { + "error": "use_dpop_nonce", + "error_description": "Authorization server requires nonce in DPoP proof", + } + + # Verify new nonce header was generated for client resubmission + assert "DPoP-Nonce" in err.error_headers + assert isinstance(err.error_headers["DPoP-Nonce"], str) + assert len(err.error_headers["DPoP-Nonce"]) > 0 + + @patch("authutils.token.dpop_nonce.verify_stateless_nonce", return_value=False) + def test_invalid_nonce_raises_invalid_nonce_error(self, mock_verify_nonce): + """ + Test that validate_dpop_proof raises InvalidNonceError when an invalid/expired + nonce is provided, even if require_nonce=False. + """ + key = jwk.ECKey.generate_key(crv="P-256") + invalid_nonce = "garbage-or-expired-nonce" + proof = authutils.dpop.generate_dpop_proof( + key, "GET", "https://example.com/resource", nonce=invalid_nonce + ) + + with pytest.raises(InvalidNonceError) as exc_info: authutils.dpop.validate_dpop_proof( proof, "GET", "https://example.com/resource", require_nonce=False ) + err = exc_info.value + + assert err.code == 400 + assert "DPoP-Nonce" in err.error_headers + assert isinstance(err.error_headers["DPoP-Nonce"], str) + assert len(err.error_headers["DPoP-Nonce"]) > 0 + + 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_custom_secret_passed_to_nonce_validation(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 InvalidNonceError + with pytest.raises(InvalidNonceError): + authutils.dpop.validate_dpop_proof( + proof, + "GET", + "https://example.com/resource", + require_nonce=True, + secret="wrong-secret-key-32-chars-long!!", # pragma: allowlist secret + ) + class TestAlgorithmWhitelisting: """Algorithm Whitelisting & Key Cross-Compatibility""" From 33e7e9b75c9ff207ffe8dbf7178c22aa463fd02d Mon Sep 17 00:00:00 2001 From: avantol Date: Mon, 27 Jul 2026 11:30:18 -0500 Subject: [PATCH 09/28] chore(logs): debug logs --- src/authutils/dpop.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/authutils/dpop.py b/src/authutils/dpop.py index b8d792e..6813527 100644 --- a/src/authutils/dpop.py +++ b/src/authutils/dpop.py @@ -27,6 +27,7 @@ from typing import Callable, Dict, Any, Tuple from urllib.parse import urlparse +from cdislogging import get_logger from joserfc import jwt, jwk, jws from joserfc.errors import JoseError @@ -62,6 +63,8 @@ # want to cache these in the future MAX_JTI_LENGTH = 256 +logging = get_logger(__name__) + class _LargeHeaderRegistry(jws.JWSRegistry): """ @@ -185,6 +188,9 @@ def validate_dpop_request( Raises: ValueError: If DPoP proof validation fails. 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. """ dpop_claims, client_jwk = validate_dpop_proof( dpop_header=dpop_header, @@ -487,6 +493,9 @@ def _validate_ath(dpop_claims: Dict[str, Any], access_token: str) -> None: ValueError: If ath does not match SHA-256 of the token. """ expected_ath: str = compute_ath(access_token) + logging.info( + f"expected_ath: {expected_ath}. dpop_claims.auth: {dpop_claims.get('ath')}" + ) if dpop_claims.get("ath") != expected_ath: raise ValueError("ath claim does not match access token hash") From 04f5df528c6364214f9b17ff018427bb8920e808 Mon Sep 17 00:00:00 2001 From: avantol Date: Mon, 27 Jul 2026 12:03:56 -0500 Subject: [PATCH 10/28] fix(dpop): helpful error with invalid token info passed --- src/authutils/dpop.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/authutils/dpop.py b/src/authutils/dpop.py index 6813527..0a5fa01 100644 --- a/src/authutils/dpop.py +++ b/src/authutils/dpop.py @@ -126,6 +126,13 @@ def generate_dpop_proof( } if access_token: + if access_token.lower().startswith("bearer "): + raise ValueError( + "The provided access_token contains the 'Bearer ' prefix. " + "DPoP ath claims must be computed using only the raw JWT/access token." + "Ensure you are not accidentally providing an Authorization header's full contents " + "(which would contain the bearer prefix)." + ) payload["ath"] = compute_ath(access_token) if nonce: @@ -493,7 +500,7 @@ def _validate_ath(dpop_claims: Dict[str, Any], access_token: str) -> None: ValueError: If ath does not match SHA-256 of the token. """ expected_ath: str = compute_ath(access_token) - logging.info( + logging.debug( f"expected_ath: {expected_ath}. dpop_claims.auth: {dpop_claims.get('ath')}" ) if dpop_claims.get("ath") != expected_ath: From a3e272774cbcd65cee9d2d47bb1eaff09810d996 Mon Sep 17 00:00:00 2001 From: avantol Date: Mon, 27 Jul 2026 14:49:28 -0500 Subject: [PATCH 11/28] fix(dpop): correct nonce error handling per the spec --- src/authutils/dpop.py | 42 ++++++++++++++++++++++++++++++++++++----- src/authutils/errors.py | 32 +++++++++++++++++++++++++++++-- 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/src/authutils/dpop.py b/src/authutils/dpop.py index 0a5fa01..c5a4c43 100644 --- a/src/authutils/dpop.py +++ b/src/authutils/dpop.py @@ -34,7 +34,10 @@ 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 -from authutils.errors import InvalidNonceError +from authutils.errors import ( + InvalidNonceErrorResourceServer, + InvalidNonceErrorAuthorizationServer, +) DPOP_JWT_TYPE = "dpop+jwt" DEFAULT_DPOP_ALGORITHM = "ES256" @@ -155,6 +158,7 @@ def validate_dpop_request( options: dict | None = None, denylist_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. @@ -185,6 +189,10 @@ def validate_dpop_request( The callback is called after basic JWT validation. secret (str | None): Optional secret key for stateless nonce verification. If None, defaults to the environment-configured secret. + TODO: add unit test(s) for as_resource_server + 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: @@ -206,6 +214,7 @@ def validate_dpop_request( unvalidated_access_token=access_token, require_nonce=require_nonce, secret=secret, + as_resource_server=as_resource_server, ) if not public_key: @@ -237,6 +246,7 @@ def validate_dpop_proof( unvalidated_access_token: str | None = None, require_nonce: bool = False, secret: str | None = None, + as_resource_server: bool = True, ) -> Tuple[Dict[str, Any], jwk.Key]: """ Validate a DPoP proof JWT for a resource server request. @@ -289,7 +299,12 @@ def validate_dpop_proof( _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) + _validate_nonce( + dpop_claims, + require_nonce=require_nonce, + secret=secret, + as_resource_server=as_resource_server, + ) if unvalidated_access_token: _validate_ath(dpop_claims, unvalidated_access_token) @@ -456,7 +471,10 @@ def _validate_proof_claims( def _validate_nonce( - dpop_claims: Dict[str, Any], require_nonce: bool, secret: str | None = None + 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. @@ -479,13 +497,27 @@ def _validate_nonce( if not client_nonce: if require_nonce: - raise InvalidNonceError(new_nonce=generate_stateless_nonce(secret=secret)) + if as_resource_server: + raise InvalidNonceErrorResourceServer( + new_nonce=generate_stateless_nonce(secret=secret) + ) + else: + raise InvalidNonceErrorAuthorizationServer( + new_nonce=generate_stateless_nonce(secret=secret) + ) # Nonce wasn't required and wasn't provided, safe to skip return if not verify_stateless_nonce(client_nonce, secret=secret): - raise InvalidNonceError(new_nonce=generate_stateless_nonce(secret=secret)) + if as_resource_server: + raise InvalidNonceErrorResourceServer( + new_nonce=generate_stateless_nonce(secret=secret) + ) + else: + raise InvalidNonceErrorAuthorizationServer( + new_nonce=generate_stateless_nonce(secret=secret) + ) def _validate_ath(dpop_claims: Dict[str, Any], access_token: str) -> None: diff --git a/src/authutils/errors.py b/src/authutils/errors.py index 5dc6066..4504a52 100644 --- a/src/authutils/errors.py +++ b/src/authutils/errors.py @@ -1,11 +1,12 @@ from cdiserrors import AuthNError as CDISAuthNError -class InvalidNonceError(CDISAuthNError): +class InvalidNonceErrorAuthorizationServer(CDISAuthNError): """ Handle DPoP Nonce per the spec RFC9449. - This error contains the necessary information for a server to + 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). """ @@ -24,6 +25,33 @@ def __init__( self.error_headers = {"DPoP-Nonce": new_nonce} +class InvalidNonceErrorResourceServer(CDISAuthNError): + """ + 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 = { + "error": "use_dpop_nonce", + "error_description": "Authorization server requires nonce in DPoP proof", + }, + ): + super().__init__(message, code=code, json=json) + 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 From ca83abaabf2c7767e4c8c4f991794c176aef881e Mon Sep 17 00:00:00 2001 From: avantol Date: Mon, 27 Jul 2026 15:46:00 -0500 Subject: [PATCH 12/28] chore(dpop): debug lines --- src/authutils/dpop.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/authutils/dpop.py b/src/authutils/dpop.py index c5a4c43..fb43e75 100644 --- a/src/authutils/dpop.py +++ b/src/authutils/dpop.py @@ -532,7 +532,8 @@ def _validate_ath(dpop_claims: Dict[str, Any], access_token: str) -> None: ValueError: If ath does not match SHA-256 of the token. """ expected_ath: str = compute_ath(access_token) - logging.debug( + # TODO: remove or make debug. FIXME: debug logs not showing up + logging.info( f"expected_ath: {expected_ath}. dpop_claims.auth: {dpop_claims.get('ath')}" ) if dpop_claims.get("ath") != expected_ath: From 5f3b0c341c96f6b66766afe3654f2ec330de2f61 Mon Sep 17 00:00:00 2001 From: avantol Date: Mon, 27 Jul 2026 16:02:49 -0500 Subject: [PATCH 13/28] chore(dpop): update error --- src/authutils/dpop.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/authutils/dpop.py b/src/authutils/dpop.py index fb43e75..12c47be 100644 --- a/src/authutils/dpop.py +++ b/src/authutils/dpop.py @@ -532,12 +532,12 @@ def _validate_ath(dpop_claims: Dict[str, Any], access_token: str) -> None: ValueError: If ath does not match SHA-256 of the token. """ expected_ath: str = compute_ath(access_token) - # TODO: remove or make debug. FIXME: debug logs not showing up - logging.info( - f"expected_ath: {expected_ath}. dpop_claims.auth: {dpop_claims.get('ath')}" - ) if dpop_claims.get("ath") != expected_ath: - raise ValueError("ath claim does not match access token hash") + raise ValueError( + f"ath claim does not match access token hash. " + f" expected_ath: {expected_ath}" + f" dpop_claims.ath: {dpop_claims.get('ath')}" + ) def compute_ath(access_token: str | bytes) -> str: From ce2c4906fa5a6d07c5fb1e1dfd902215b770b52f Mon Sep 17 00:00:00 2001 From: avantol Date: Tue, 28 Jul 2026 09:06:36 -0500 Subject: [PATCH 14/28] temp(dpop): debug lines --- src/authutils/dpop.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/authutils/dpop.py b/src/authutils/dpop.py index 12c47be..4ed511a 100644 --- a/src/authutils/dpop.py +++ b/src/authutils/dpop.py @@ -555,6 +555,9 @@ def compute_ath(access_token: str | bytes) -> str: Returns: str: Base64url-encoded SHA-256 hash. """ + # TODO FIXME REMOVE + print(f"access_token from compute_ath in authutils: {access_token}") + if isinstance(access_token, str): token_bytes = access_token.encode("ascii") elif isinstance(access_token, bytes): From 779448588813563c8d52f69b8fa977f03aa34071 Mon Sep 17 00:00:00 2001 From: avantol Date: Tue, 28 Jul 2026 09:23:39 -0500 Subject: [PATCH 15/28] fix(dpop): handle dpop prefix in header --- src/authutils/dpop.py | 69 ++++++++++++++++++++++++------------------- 1 file changed, 38 insertions(+), 31 deletions(-) diff --git a/src/authutils/dpop.py b/src/authutils/dpop.py index 4ed511a..dd6112d 100644 --- a/src/authutils/dpop.py +++ b/src/authutils/dpop.py @@ -207,6 +207,10 @@ def validate_dpop_request( 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. """ + # TODO unit tests for this + # get rid of any prefixed 'DPoP ' / 'dpop ' + dpop_header = "".join(dpop_header.split(" ")[1:]) + dpop_claims, client_jwk = validate_dpop_proof( dpop_header=dpop_header, request_method=request_method, @@ -292,6 +296,9 @@ def validate_dpop_proof( if not dpop_header: raise ValueError("Invalid DPoP proof: Empty string / None provided") + # get rid of any prefixed 'DPoP ' / 'dpop ' + dpop_header = "".join(dpop_header.split(" ")[1:]) + client_jwk = extract_and_validate_jwk(dpop_header) dpop_claims = _verify_signature_and_claims(dpop_header, client_jwk) @@ -350,6 +357,9 @@ def extract_and_validate_jwk(dpop_header: str) -> jwk.Key: Raises: ValueError: If header is malformed, missing jwk, or uses symmetric key. """ + # get rid of any prefixed 'DPoP ' / 'dpop ' + dpop_header = "".join(dpop_header.split(" ")[1:]) + # Use custom registry with increased header size limit for DPoP proofs # containing full JWKs (especially RSA keys which have large public keys) registry = _LargeHeaderRegistry() @@ -390,6 +400,34 @@ def extract_and_validate_jwk(dpop_header: str) -> jwk.Key: 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. + """ + 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") + + def _verify_signature_and_claims( dpop_header: str, client_jwk: jwk.Key ) -> Dict[str, Any]: @@ -540,37 +578,6 @@ def _validate_ath(dpop_claims: Dict[str, Any], access_token: str) -> None: ) -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. - """ - # TODO FIXME REMOVE - print(f"access_token from compute_ath in authutils: {access_token}") - - 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") - - def _get_token_jkt(access_token: str) -> str: """ Extract the jkt (key thumbprint) from a token's cnf claim. From 745711644dcf460133b8a1bd3f6d8bb78ca77fca Mon Sep 17 00:00:00 2001 From: avantol Date: Tue, 28 Jul 2026 09:40:05 -0500 Subject: [PATCH 16/28] chore(dpop): debug lines --- src/authutils/dpop.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/authutils/dpop.py b/src/authutils/dpop.py index dd6112d..8657a8e 100644 --- a/src/authutils/dpop.py +++ b/src/authutils/dpop.py @@ -360,6 +360,9 @@ def extract_and_validate_jwk(dpop_header: str) -> jwk.Key: # get rid of any prefixed 'DPoP ' / 'dpop ' dpop_header = "".join(dpop_header.split(" ")[1:]) + # TODO FIXME REMOVE + logging.info(f"Extracting and validating jwk from dpop header: '{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 = _LargeHeaderRegistry() @@ -368,7 +371,8 @@ def extract_and_validate_jwk(dpop_header: str) -> jwk.Key: unverified_header: dict = jws.extract_compact( dpop_header.encode("utf-8"), registry=registry ).protected - except Exception: + 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") From b2120bc9e8d90ab6e1e55bfe86f00fd3dadf6e51 Mon Sep 17 00:00:00 2001 From: avantol Date: Tue, 28 Jul 2026 10:28:29 -0500 Subject: [PATCH 17/28] fix(dpop): safely handle dpop header --- src/authutils/dpop.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/authutils/dpop.py b/src/authutils/dpop.py index 8657a8e..ffb490b 100644 --- a/src/authutils/dpop.py +++ b/src/authutils/dpop.py @@ -209,7 +209,7 @@ def validate_dpop_request( """ # TODO unit tests for this # get rid of any prefixed 'DPoP ' / 'dpop ' - dpop_header = "".join(dpop_header.split(" ")[1:]) + dpop_header = dpop_header.split(" ", 1)[-1] dpop_claims, client_jwk = validate_dpop_proof( dpop_header=dpop_header, @@ -297,7 +297,7 @@ def validate_dpop_proof( raise ValueError("Invalid DPoP proof: Empty string / None provided") # get rid of any prefixed 'DPoP ' / 'dpop ' - dpop_header = "".join(dpop_header.split(" ")[1:]) + dpop_header = dpop_header.split(" ", 1)[-1] client_jwk = extract_and_validate_jwk(dpop_header) @@ -358,10 +358,7 @@ def extract_and_validate_jwk(dpop_header: str) -> jwk.Key: ValueError: If header is malformed, missing jwk, or uses symmetric key. """ # get rid of any prefixed 'DPoP ' / 'dpop ' - dpop_header = "".join(dpop_header.split(" ")[1:]) - - # TODO FIXME REMOVE - logging.info(f"Extracting and validating jwk from dpop header: '{dpop_header}'") + dpop_header = dpop_header.split(" ", 1)[-1] # Use custom registry with increased header size limit for DPoP proofs # containing full JWKs (especially RSA keys which have large public keys) From babc33f3ff1cab52e00debfb5d4c269b3fbffea0 Mon Sep 17 00:00:00 2001 From: avantol Date: Tue, 28 Jul 2026 10:45:06 -0500 Subject: [PATCH 18/28] chore(dpop): log ath --- src/authutils/dpop.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/authutils/dpop.py b/src/authutils/dpop.py index ffb490b..2bd3b9d 100644 --- a/src/authutils/dpop.py +++ b/src/authutils/dpop.py @@ -425,6 +425,8 @@ def compute_ath(access_token: str | bytes) -> str: f"access_token must be str or bytes, not {type(access_token).__name__}" ) + logging.info(f"Computing ath for access token: {access_token[:50]}...TRUNCATED") + digest = hashlib.sha256(token_bytes).digest() return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") From 5f2ca2219fc2e71d6ebe5610612728ba17657a32 Mon Sep 17 00:00:00 2001 From: avantol Date: Tue, 28 Jul 2026 10:56:33 -0500 Subject: [PATCH 19/28] chore(dpop): more logs --- src/authutils/dpop.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/authutils/dpop.py b/src/authutils/dpop.py index 2bd3b9d..749d1e6 100644 --- a/src/authutils/dpop.py +++ b/src/authutils/dpop.py @@ -576,7 +576,8 @@ def _validate_ath(dpop_claims: Dict[str, Any], access_token: str) -> None: if dpop_claims.get("ath") != expected_ath: raise ValueError( f"ath claim does not match access token hash. " - f" expected_ath: {expected_ath}" + f" access_token: {access_token[:50]}...TRUNCATED" + f" expected_ath (computed from access_token): {expected_ath}" f" dpop_claims.ath: {dpop_claims.get('ath')}" ) From 54a1d71a8d69fbe6df83cbf4fb677c024d6165fa Mon Sep 17 00:00:00 2001 From: avantol Date: Tue, 28 Jul 2026 11:37:40 -0500 Subject: [PATCH 20/28] chore(dpop): increase timeout and more logs on public key fetching --- src/authutils/token/keys.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/authutils/token/keys.py b/src/authutils/token/keys.py index 70cbc85..51e33b6 100644 --- a/src/authutils/token/keys.py +++ b/src/authutils/token/keys.py @@ -360,12 +360,13 @@ def get_any_public_key_for_token( try: # Fetch JWKS from issuer logger.info(f"hitting keys URL from iss: {iss}, keys_url: {keys_url}...") - response = httpx.get(keys_url) + response = httpx.get(keys_url, timeout=10) response.raise_for_status() jwks_data = response.json() keys = jwks_data.get("keys", []) - except Exception as e: - raise JWTError(f"Could not fetch JWKS from {keys_url}: {str(e)}") + 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)}") if not keys: raise JWTError(f"Got no keys from {keys_url} for iss: {iss}") From 68b32d9ee62288d34c39a8ef4edddcdbe84a2c29 Mon Sep 17 00:00:00 2001 From: avantol Date: Tue, 28 Jul 2026 12:02:55 -0500 Subject: [PATCH 21/28] fix(keys): handle custom and official public keys endpoints --- src/authutils/token/core.py | 11 +++++++++-- src/authutils/token/keys.py | 36 +++++++++++++++++++++++++++--------- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/authutils/token/core.py b/src/authutils/token/core.py index f31b691..383e948 100644 --- a/src/authutils/token/core.py +++ b/src/authutils/token/core.py @@ -12,6 +12,10 @@ JWTError, ) +from cdislogging import get_logger + +logging = get_logger(__name__) + def get_keys_url(issuer, force_issuer=None): """ @@ -25,9 +29,12 @@ def get_keys_url(issuer, force_issuer=None): [issuer.strip("/"), ".well-known", "openid-configuration"] ) try: - jwks_uri = httpx.get(openid_cfg_path).json().get("jwks_uri", "") + jwks_uri = httpx.get(openid_cfg_path, timeout=10).json().get("jwks_uri", "") return jwks_uri - except Exception: + except Exception as exc: + logging.info( + f"Could not get public keys from: {openid_cfg_path}. Falling back to iss: {jwt_keys_url}. Exception: {exc}" + ) return jwt_keys_url diff --git a/src/authutils/token/keys.py b/src/authutils/token/keys.py index 51e33b6..b04541c 100644 --- a/src/authutils/token/keys.py +++ b/src/authutils/token/keys.py @@ -139,7 +139,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. @@ -239,7 +239,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 ( @@ -290,7 +290,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 = ( @@ -334,7 +334,7 @@ def get_any_public_key_for_token( Raises: JWTError: If the token is malformed or the public key cannot be fetched. """ - logger = logger or get_logger(__name__, log_level="info") + logger = logger or get_logger(__name__) try: iss = get_iss(encoded_token) @@ -355,7 +355,7 @@ def get_any_public_key_for_token( logger.debug(f"cache miss. attempting to get keys URL from iss: {iss}...") keys_url = get_keys_url(iss) - # TODO: check keys URL against an allowlist of domains + # TODO: check keys URL against an allowlist of domains? try: # Fetch JWKS from issuer @@ -363,17 +363,35 @@ def get_any_public_key_for_token( response = httpx.get(keys_url, timeout=10) response.raise_for_status() jwks_data = response.json() - keys = jwks_data.get("keys", []) + + # Safely extract keys array (handles root dict or fallback list) + raw_keys = ( + jwks_data.get("keys", []) if isinstance(jwks_data, dict) else jwks_data + ) + + # Map kid -> key object (either PEM string or JWK dict) + keys_by_id = {} + + for item in raw_keys: + # Custom format: ["kid", "-----BEGIN PUBLIC KEY..."] + if isinstance(item, (list, tuple)) and len(item) >= 2: + kid, key_content = item[0], item[1] + keys_by_id[kid] = key_content + + # Official .well-known JWKS format: {"kid": "...", "kty": "RSA", ...} + elif isinstance(item, dict) and "kid" in item: + keys_by_id[item["kid"]] = item + 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)}") - if not keys: + if not keys_by_id: raise JWTError(f"Got no keys from {keys_url} for iss: {iss}") # Find the key with matching kid or use the first key - for key_data in keys: - if key_data.get("kid") == kid or (kid is None and keys): + for kid, key_data in keys_by_id.items(): + if key_data.get("kid") == kid or (kid is None and keys_by_id): _, pem_key = get_pem_key(key_data, logger) # Save to cache with TTL and size limiting From ea5257aba67e7420def218831bd9b96d66414915 Mon Sep 17 00:00:00 2001 From: avantol Date: Tue, 28 Jul 2026 13:18:28 -0500 Subject: [PATCH 22/28] chore(logs): update log levels --- src/authutils/dpop.py | 2 +- src/authutils/token/validate.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/authutils/dpop.py b/src/authutils/dpop.py index 749d1e6..7a29c12 100644 --- a/src/authutils/dpop.py +++ b/src/authutils/dpop.py @@ -425,7 +425,7 @@ def compute_ath(access_token: str | bytes) -> str: f"access_token must be str or bytes, not {type(access_token).__name__}" ) - logging.info(f"Computing ath for access token: {access_token[:50]}...TRUNCATED") + logging.debug(f"Computing ath for access token: {access_token[:50]}...TRUNCATED") digest = hashlib.sha256(token_bytes).digest() return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") diff --git a/src/authutils/token/validate.py b/src/authutils/token/validate.py index 4f3cdbc..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 = [] @@ -139,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] @@ -172,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): """ From a5aefff80f4c929dc73fbc79ee7c1018614d2138 Mon Sep 17 00:00:00 2001 From: avantol Date: Wed, 29 Jul 2026 10:08:18 -0500 Subject: [PATCH 23/28] fix(dpop): increase public keys timeout --- src/authutils/token/keys.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/authutils/token/keys.py b/src/authutils/token/keys.py index b04541c..dbb31d9 100644 --- a/src/authutils/token/keys.py +++ b/src/authutils/token/keys.py @@ -360,7 +360,7 @@ def get_any_public_key_for_token( try: # Fetch JWKS from issuer logger.info(f"hitting keys URL from iss: {iss}, keys_url: {keys_url}...") - response = httpx.get(keys_url, timeout=10) + response = httpx.get(keys_url, timeout=httpx.Timeout(timeout=10.0)) response.raise_for_status() jwks_data = response.json() From 9e9db826f891e3a8b1e298baa82bb15ae57480a6 Mon Sep 17 00:00:00 2001 From: avantol Date: Wed, 29 Jul 2026 13:22:28 -0500 Subject: [PATCH 24/28] chore(dpop): more logs --- src/authutils/token/keys.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/authutils/token/keys.py b/src/authutils/token/keys.py index dbb31d9..532f898 100644 --- a/src/authutils/token/keys.py +++ b/src/authutils/token/keys.py @@ -364,6 +364,12 @@ def get_any_public_key_for_token( response.raise_for_status() jwks_data = response.json() + 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 @@ -381,8 +387,9 @@ def get_any_public_key_for_token( # Official .well-known JWKS format: {"kid": "...", "kty": "RSA", ...} elif isinstance(item, dict) and "kid" in item: keys_by_id[item["kid"]] = item - except Exception as exc: + # broadly catch all errors so we can log them and return a general JWTError + # to caller logger.error(exc, stack_info=True, exc_info=True) raise JWTError(f"Could not fetch JWKS from {keys_url}: {str(exc)}") From 62e0d669a5256fe879df5025d1e3bee810b6707c Mon Sep 17 00:00:00 2001 From: avantol Date: Wed, 29 Jul 2026 16:11:54 -0500 Subject: [PATCH 25/28] chore(dpop): more logging --- src/authutils/token/dpop_nonce.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/authutils/token/dpop_nonce.py b/src/authutils/token/dpop_nonce.py index d976dec..b2f75b6 100644 --- a/src/authutils/token/dpop_nonce.py +++ b/src/authutils/token/dpop_nonce.py @@ -8,9 +8,13 @@ import os from typing import Optional +from cdislogging import get_logger + from joserfc import jwt from joserfc.jwk import OctKey -from joserfc.errors import JoseError +from joserfc.errors import BadSignatureError, InvalidPayloadError, JoseError + +logging = get_logger(__name__) def _get_shared_secret(secret: str | None = None) -> Optional[str]: @@ -87,5 +91,11 @@ def verify_stateless_nonce(client_nonce: str, secret: str | None = None) -> bool return False return claims.get("purpose") == "dpop_nonce" - except (JoseError, TypeError): + except (JoseError, TypeError, BadSignatureError, InvalidPayloadError) as exc: + logging.debug(f"invalid nonce", exc_info=True) + return False + except Exception as exc: + logging.exception( + f"unknown error when attempting to verify nonce. Returning False / invalid." + ) return False From ac55e55a3e8cc8a2acf392f014da802769eae89a Mon Sep 17 00:00:00 2001 From: avantol Date: Mon, 10 Aug 2026 13:22:24 -0500 Subject: [PATCH 26/28] chore(tools): update and small black fixes --- .pre-commit-config.yaml | 6 +-- .secrets.baseline | 2 +- poetry.lock | 52 +++++++++++++++--------- pyproject.toml | 4 +- src/authutils/globals.py | 1 - src/authutils/oauth2/client/blueprint.py | 1 - src/authutils/oauth2/client/client.py | 1 - 7 files changed, 39 insertions(+), 28 deletions(-) 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 bea6404..7aa847f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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" @@ -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" @@ -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 = "c4a6697c338443f379e09e7b17a467a3ae0d9ad4b55e92a0574555441455d85d" +content-hash = "db885ac72040f149127c4d02ae0a25adc863f0b79e76341bce560a2f3fe187ad" diff --git a/pyproject.toml b/pyproject.toml index 973d240..affc064 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "authutils" -version = "8.1.0" +version = "9.0.0" description = "Gen3 auth utility functions" authors = ["CTDS UChicago "] license = "Apache-2.0" @@ -15,7 +15,7 @@ werkzeug = ">=3.1.5" xmltodict = "~=0.9" authlib = ">=1.6.6" -httpx = ">=0.23.0,<1.0.0" +httpx2 = ">=2.9.1" pyjwt = {version = ">=2.11.0,<3.0", extras = ["crypto"]} cryptography = ">=48.0.1" 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"] From 86cdf9a8df69edefcc70be1fd302dfd59305292d Mon Sep 17 00:00:00 2001 From: avantol Date: Tue, 11 Aug 2026 10:35:15 -0500 Subject: [PATCH 27/28] feat(dpop): migrate to httpx2, edge cases, cleanup, tests --- src/authutils/dbgap.py | 10 +- src/authutils/dpop.py | 723 ++++-- src/authutils/errors.py | 81 +- src/authutils/token/__init__.py | 6 +- src/authutils/token/core.py | 93 +- src/authutils/token/dpop_nonce.py | 46 +- src/authutils/token/fastapi.py | 40 +- src/authutils/token/keys.py | 437 +++- tests/conftest.py | 33 +- tests/test_dpop.py | 3839 ++++++++++++++++++++++------- tests/test_fastapi.py | 32 +- tests/test_jwt.py | 32 +- tests/test_keys.py | 803 ++++-- 13 files changed, 4836 insertions(+), 1339 deletions(-) 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 index 7a29c12..aefb606 100644 --- a/src/authutils/dpop.py +++ b/src/authutils/dpop.py @@ -24,8 +24,9 @@ import os import base64 import time -from typing import Callable, Dict, Any, Tuple -from urllib.parse import urlparse +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 @@ -33,49 +34,79 @@ 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 +from authutils.token.keys import get_any_public_key_for_token_async from authutils.errors import ( InvalidNonceErrorResourceServer, InvalidNonceErrorAuthorizationServer, ) DPOP_JWT_TYPE = "dpop+jwt" -DEFAULT_DPOP_ALGORITHM = "ES256" -SUPPORTED_DPOP_ALGS = { - # EC - "ES256", - "ES384", - "ES512", - # RSA - "RS256", - "RS384", - "RS512", - "PS256", - "PS384", - "PS512", + +# 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", } -# will invalidate any proof older than 5 minutes + +# 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 +# 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 +# To avoid unnecessarilly large JTIs since we may # want to cache these in the future MAX_JTI_LENGTH = 256 -logging = get_logger(__name__) +# 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"} -class _LargeHeaderRegistry(jws.JWSRegistry): - """ - Custom JWS registry with increased header size limit for DPoP proofs. - This is required to support RSA-s - """ - - max_header_length = 4096 +logging = get_logger(__name__) def generate_dpop_proof( @@ -95,10 +126,16 @@ def generate_dpop_proof( 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( @@ -107,21 +144,23 @@ def generate_dpop_proof( ... "https://fence.example.com/credentials/api/access_token", ... ) """ - if not alg: - key_type = (getattr(key, "kty", "") or getattr(key, "key_type", "")).upper() - if key_type == "RSA": - alg = "RS256" - elif key_type == "EC": - alg = "ES256" - else: - alg = DEFAULT_DPOP_ALGORITHM + 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] = { + payload: dict[str, Any] = { "jti": os.urandom(16).hex(), "htm": method.upper(), "htu": _get_url(url), @@ -129,22 +168,16 @@ def generate_dpop_proof( } if access_token: - if access_token.lower().startswith("bearer "): - raise ValueError( - "The provided access_token contains the 'Bearer ' prefix. " - "DPoP ath claims must be computed using only the raw JWT/access token." - "Ensure you are not accidentally providing an Authorization header's full contents " - "(which would contain the bearer prefix)." - ) + _reject_bearer_prefixed_token(access_token) payload["ath"] = compute_ath(access_token) if nonce: payload["nonce"] = nonce - return jwt.encode(header, payload, key) + return jwt.encode(header, payload, key, registry=_new_registry()) -def validate_dpop_request( +async def validate_dpop_request_async( dpop_header: str, access_token: str, request_method: str, @@ -157,9 +190,10 @@ def validate_dpop_request( 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]: +) -> tuple[dict[str, Any], dict[str, Any], jwk.Key]: """ Validate both the DPoP proof AND the access token in one operation. @@ -175,8 +209,12 @@ def validate_dpop_request( 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). - issuers (list[str]): Allowed token issuers whitelist. + 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. @@ -187,29 +225,35 @@ def validate_dpop_request( 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. - TODO: add unit test(s) for as_resource_server 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[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. + 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. """ - # TODO unit tests for this - # get rid of any prefixed 'DPoP ' / 'dpop ' - dpop_header = dpop_header.split(" ", 1)[-1] + 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") + + _reject_bearer_prefixed_token(access_token) dpop_claims, client_jwk = validate_dpop_proof( dpop_header=dpop_header, @@ -219,13 +263,16 @@ def validate_dpop_request( require_nonce=require_nonce, secret=secret, as_resource_server=as_resource_server, + jti_seen_callback=jti_seen_callback, ) if not public_key: - # Fetch public key from issuer's JWKS endpoint if not provided - public_key = get_any_public_key_for_token(access_token) + # 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 + ) - # Normalize scope to list/set for validation if isinstance(scope, list): scope = set(scope) @@ -251,7 +298,8 @@ def validate_dpop_proof( require_nonce: bool = False, secret: str | None = None, as_resource_server: bool = True, -) -> Tuple[Dict[str, Any], jwk.Key]: + jti_seen_callback: Callable | None = None, +) -> tuple[dict[str, Any], jwk.Key]: """ Validate a DPoP proof JWT for a resource server request. @@ -260,28 +308,38 @@ def validate_dpop_proof( 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) - - Signature verification + - 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). + 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 + dict[str, Any], jwk.Key: dict with decoded claims dict, validated client_jwk from dpop header Raises: - ValueError: If any validation fails. + 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. @@ -296,8 +354,18 @@ def validate_dpop_proof( 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 = dpop_header.split(" ", 1)[-1] + dpop_header = _strip_auth_scheme(dpop_header) client_jwk = extract_and_validate_jwk(dpop_header) @@ -313,37 +381,21 @@ def validate_dpop_proof( 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 _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 extract_and_validate_jwk(dpop_header: str) -> jwk.Key: """ Extract and validate embedded JWK from DPoP proof header. @@ -355,16 +407,20 @@ def extract_and_validate_jwk(dpop_header: str) -> jwk.Key: jwk.Key: Validated asymmetric jwk.Key. Raises: - ValueError: If header is malformed, missing jwk, or uses symmetric key. + 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 = dpop_header.split(" ", 1)[-1] + 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 = _LargeHeaderRegistry() + # 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 @@ -376,21 +432,27 @@ def extract_and_validate_jwk(dpop_header: str) -> jwk.Key: if alg not in SUPPORTED_DPOP_ALGS: raise ValueError(f"Unsupported or unpermitted DPoP signature algorithm: {alg}") - if unverified_header.get("typ", "").lower() != DPOP_JWT_TYPE: + 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'") - client_jwk: jwk.Key = jwk.import_key(unverified_header["jwk"]) + if not isinstance(unverified_header["jwk"], dict): + raise ValueError("DPoP proof header 'jwk' must be a JSON object") - # Check if key is symmetric (oct/HMAC) - # joserfc keys have kty attribute, but if checking fails, check key_type try: - key_type = getattr(client_jwk, "kty", "") or getattr(client_jwk, "key_type", "") - key_type = key_type.lower() - except Exception: - key_type = None + 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}`") @@ -398,6 +460,8 @@ def extract_and_validate_jwk(dpop_header: str) -> jwk.Key: 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 @@ -415,6 +479,11 @@ def compute_ath(access_token: str | bytes) -> str: 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") @@ -425,15 +494,269 @@ def compute_ath(access_token: str | bytes) -> str: f"access_token must be str or bytes, not {type(access_token).__name__}" ) - logging.debug(f"Computing ath for access token: {access_token[:50]}...TRUNCATED") - 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]: +) -> dict[str, Any]: """ Verify JWS signature and decode claims with leeway. @@ -442,44 +765,59 @@ def _verify_signature_and_claims( client_jwk (jwk.Key): Public key extracted from the proof header. Returns: - Dict[str, Any]: Decoded claims dict. + dict[str, Any]: Decoded claims dict. Raises: - JoseError: If signature verification or time validation fails. + 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. """ - # Use custom registry with increased header size limit for DPoP proofs - registry = _LargeHeaderRegistry() - dpop_claims = jwt.decode(dpop_header, client_jwk, registry=registry) + 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 JoseError("Invalid token: expired") + 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 + # Reject if proof is older than DPOP_PROOF_MAX_TTL if claims_dict["iat"] + DPOP_PROOF_MAX_TTL < current_time: - raise JoseError("Invalid token: proof created too far in the past") + 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 JoseError("Invalid token: proof issued in the future") + 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 + 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. + 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. @@ -497,22 +835,39 @@ def _validate_proof_claims( if not isinstance(jti, str) or len(jti) > MAX_JTI_LENGTH: raise ValueError("DPoP proof jti is malformed or unnecessarily large") - htm_value: str = dpop_claims.get("htm", "") + 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: str = dpop_claims.get("htu", "") - actual_url: str = _get_url(request_url) - if actual_url != htu_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], + dpop_claims: dict[str, Any], require_nonce: bool, secret: str | None = None, as_resource_server: bool = True, @@ -523,64 +878,69 @@ def _validate_nonce( required per the spec. Args: - dpop_claims (Dict[str, Any]): Decoded DPoP proof claims. + 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: str = dpop_claims.get("nonce", "") + client_nonce = dpop_claims.get("nonce", "") if not client_nonce: if require_nonce: - if as_resource_server: - raise InvalidNonceErrorResourceServer( - new_nonce=generate_stateless_nonce(secret=secret) - ) - else: - raise InvalidNonceErrorAuthorizationServer( - new_nonce=generate_stateless_nonce(secret=secret) - ) + _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): - if as_resource_server: - raise InvalidNonceErrorResourceServer( - new_nonce=generate_stateless_nonce(secret=secret) - ) - else: - raise InvalidNonceErrorAuthorizationServer( - new_nonce=generate_stateless_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: +def _validate_ath(dpop_claims: dict[str, Any], access_token: str) -> None: """ - Validate access token hash per RFC 9449 ยง4.2. + Validate access token hash per RFC 9449 4.2. Args: - dpop_claims (Dict[str, Any]): Decoded DPoP proof claims. + dpop_claims (dict[str, Any]): Decoded DPoP proof claims. access_token (str): The access token string. Raises: - ValueError: If ath does not match SHA-256 of the token. + ValueError: If ath is absent, or does not match SHA-256 of the token. """ - expected_ath: str = compute_ath(access_token) - if dpop_claims.get("ath") != expected_ath: + if "ath" not in dpop_claims: raise ValueError( - f"ath claim does not match access token hash. " - f" access_token: {access_token[:50]}...TRUNCATED" - f" expected_ath (computed from access_token): {expected_ath}" - f" dpop_claims.ath: {dpop_claims.get('ath')}" + "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: """ @@ -593,7 +953,7 @@ def _get_token_jkt(access_token: str) -> str: str: The key thumbprint string. Raises: - ValueError: If jkt claim is missing or invalid. + ValueError: If the token is malformed or the jkt claim is missing/invalid. """ claims = _get_unverified_claims(access_token) cnf = claims.get("cnf", {}) @@ -613,7 +973,10 @@ def _get_token_jkt(access_token: str) -> str: 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. + + 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. @@ -621,27 +984,67 @@ def _get_url(url: str) -> str: Returns: str: The sanitized HTTP target URI (e.g., 'https://example.com/api/v1/resource') """ - parsed = urlparse(url) + if not url: + logging.warning( + "No URL provided for DPoP htu claim. Using empty string instead." + ) + return "" - # Rebuild the URL using only scheme, netloc (host/port), and path - # This automatically drops parsed.query and parsed.fragment - clean_url = f"{parsed.scheme.lower()}://{parsed.netloc.lower()}{parsed.path}" + # 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() - return clean_url + # 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]: + +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. """ - token_bytes = token.encode("utf-8") + 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) + # (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") - # The payload is returned as bytes, so we decode and parse the JSON - claims = json.loads(obj.payload.decode("utf-8")) + 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 4504a52..c3c2e51 100644 --- a/src/authutils/errors.py +++ b/src/authutils/errors.py @@ -1,7 +1,20 @@ from cdiserrors import AuthNError as CDISAuthNError -class InvalidNonceErrorAuthorizationServer(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. @@ -16,16 +29,36 @@ def __init__( new_nonce: str, message: str = "use_dpop_nonce", code: int = 400, - json: dict = { - "error": "use_dpop_nonce", - "error_description": "Authorization server requires nonce in DPoP proof", - }, - ): - super().__init__(message, code=code, json=json) + 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(CDISAuthNError): +class InvalidNonceErrorResourceServer(InvalidNonceError): """ Handle DPoP Nonce per the spec RFC9449. @@ -40,12 +73,32 @@ def __init__( new_nonce: str, message: str = "use_dpop_nonce", code: int = 401, - json: dict = { - "error": "use_dpop_nonce", - "error_description": "Authorization server requires nonce in DPoP proof", - }, - ): - super().__init__(message, code=code, json=json) + 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"', 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 383e948..3f87ad1 100644 --- a/src/authutils/token/core.py +++ b/src/authutils/token/core.py @@ -1,4 +1,4 @@ -import httpx +import httpx2 import jwt from collections.abc import Callable @@ -16,25 +16,60 @@ 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, timeout=10).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 as exc: - logging.info( - f"Could not get public keys from: {openid_cfg_path}. Falling back to iss: {jwt_keys_url}. Exception: {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 @@ -185,9 +220,11 @@ def validate_jwt( # iss # Check that the issuer of the token has the expected hostname. + # Read with .get: a token carrying no iss at all must fail as a JWTError if allowed_issuers: - if token["iss"] not in allowed_issuers: - msg = f"invalid issuer {token['iss']}; expected one of: {allowed_issuers}" + 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 @@ -221,3 +258,35 @@ def validate_jwt( 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 index b2f75b6..8dbad4b 100644 --- a/src/authutils/token/dpop_nonce.py +++ b/src/authutils/token/dpop_nonce.py @@ -6,30 +6,19 @@ import time import os -from typing import Optional from cdislogging import get_logger from joserfc import jwt from joserfc.jwk import OctKey -from joserfc.errors import BadSignatureError, InvalidPayloadError, JoseError +from joserfc.errors import JoseError logging = get_logger(__name__) -def _get_shared_secret(secret: str | None = None) -> Optional[str]: - """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")) - - def generate_stateless_nonce(secret: str | None = None) -> str: """ - Mint a symmetric nonce token valid for DPOP_NONCE_TTL_SECONDS. + Mint a symmetric nonce token valid for DPOP_NONCE_TTL seconds. Returns: str: HS256-signed JWT nonce token. @@ -60,9 +49,12 @@ def verify_stateless_nonce(client_nonce: str, secret: str | None = None) -> bool 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. + 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 @@ -76,6 +68,8 @@ def verify_stateless_nonce(client_nonce: str, secret: str | None = None) -> bool 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 @@ -84,18 +78,30 @@ def verify_stateless_nonce(client_nonce: str, secret: str | None = None) -> bool iat = claims.get("iat") exp = claims.get("exp") - if exp is not None and exp < now: + # exp is required, not just checked when present + if exp is None or exp < now: return False - if iat is not None and exp is not None and exp < iat: + if iat is not None and exp < iat: return False return claims.get("purpose") == "dpop_nonce" - except (JoseError, TypeError, BadSignatureError, InvalidPayloadError) as exc: - logging.debug(f"invalid nonce", exc_info=True) + except (JoseError, TypeError): + # BadSignatureError and InvalidPayloadError are JoseError subclasses. + logging.debug("invalid nonce", exc_info=True) return False - except Exception as exc: + except Exception: logging.exception( - f"unknown error when attempting to verify nonce. Returning False / invalid." + "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 532f898..5f5047b 100644 --- a/src/authutils/token/keys.py +++ b/src/authutils/token/keys.py @@ -22,9 +22,13 @@ """ import base64 +import binascii import json -from collections import OrderedDict +import threading import time +from collections import OrderedDict +from collections.abc import Iterable +from typing import Any from cdislogging import get_logger @@ -35,22 +39,130 @@ 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} -_token_public_key_cache = {} +# 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): """ @@ -64,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 @@ -168,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 {}," @@ -311,130 +419,247 @@ def get_public_key_for_token( ) -def get_any_public_key_for_token( - encoded_token, cache_ttl: int = 300, logger=None -) -> bytes: +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. """ - Get the public key for a token (generalized for any web framework, e.g. without - requiring Flask context like the above function). + logger = logger or get_logger(__name__) - It includes a simple in-memory cache with TTL to avoid excessive network requests. + 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." + ) - Decouples public key fetching from Flask, - enabling token validation in non-Flask environments (FastAPI, async contexts, etc). + +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: - encoded_token (str): Encoded JWT token. - cache_ttl (int): Cache time-to-live in seconds (default: 300s / 5 minutes). - logger (Optional): Logger instance. Defaults to module logger. + 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 token is malformed or the public key cannot be fetched. + JWTError: If the document carries no usable keys, does not publish the + token's kid, or the selected key cannot be serialized. """ - logger = logger or get_logger(__name__) + 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: - iss = get_iss(encoded_token) - kid = get_kid(encoded_token) - except JWTError as e: - raise JWTError(f"Could not extract issuer/kid from token: {str(e)}") + _, 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 iss: - raise JWTError("Token is missing issuer (iss claim)") + if not pem_key: + raise JWTError( + f"Public key for kid={selected_kid} at issuer {iss} is empty or unusable" + ) - cache_key = f"{iss}:{kid}" + return pem_key - # Try to retrieve from cache - 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}...") - keys_url = get_keys_url(iss) +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. - # TODO: check keys URL against an allowlist of domains? + 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. - try: - # Fetch JWKS from issuer - logger.info(f"hitting keys URL from iss: {iss}, keys_url: {keys_url}...") - response = httpx.get(keys_url, timeout=httpx.Timeout(timeout=10.0)) - response.raise_for_status() - jwks_data = response.json() + 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 - if not jwks_data or isinstance(jwks_data, str): - logger.error(f"invalid jwks data: {jwks_data}") + 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"Could not fetch JWKS from {keys_url}. Response JSON is empty or a string." + 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] - # Safely extract keys array (handles root dict or fallback list) - raw_keys = ( - jwks_data.get("keys", []) if isinstance(jwks_data, dict) else jwks_data - ) - - # Map kid -> key object (either PEM string or JWK dict) - keys_by_id = {} + 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 - for item in raw_keys: - # Custom format: ["kid", "-----BEGIN PUBLIC KEY..."] - if isinstance(item, (list, tuple)) and len(item) >= 2: - kid, key_content = item[0], item[1] - keys_by_id[kid] = key_content - # Official .well-known JWKS format: {"kid": "...", "kty": "RSA", ...} - elif isinstance(item, dict) and "kid" in item: - keys_by_id[item["kid"]] = item - except Exception as exc: - # broadly catch all errors so we can log them and return a general JWTError - # to caller - logger.error(exc, stack_info=True, exc_info=True) - raise JWTError(f"Could not fetch JWKS from {keys_url}: {str(exc)}") +def _strict_b64url_decode(value: str, param: str, kid: Any = None) -> bytes: + """ + Base64url-decode a JWK parameter, rejecting characters outside the alphabet. - if not keys_by_id: - raise JWTError(f"Got no keys from {keys_url} for iss: {iss}") + 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". - # Find the key with matching kid or use the first key - for kid, key_data in keys_by_id.items(): - if key_data.get("kid") == kid or (kid is None and keys_by_id): - _, pem_key = get_pem_key(key_data, logger) + Args: + value (str): The base64url-encoded parameter value. + param (str): Parameter name, for the error message. + kid (Any): Key id, for the error message. - # Save to cache with TTL and size limiting - _save_public_key_to_cache(cache_key, pem_key, cache_ttl, logger) + Returns: + bytes: The decoded bytes. - return pem_key + 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") - raise JWTError(f"No public key found for kid={kid} at issuer {iss}") + 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=None) -> bytes | None: +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 (Optional): Logger instance. + logger (Any): Logger instance. Returns: bytes | None: Public key in PEM format if valid entry exists, None otherwise. """ - if cache_key not in _token_public_key_cache: - return None + 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"] - cached_entry = _token_public_key_cache[cache_key] - if time.time() < cached_entry["expires_at"]: - if logger: - logger.debug(f"Using cached public key for {cache_key}") - return cached_entry["key"] - else: 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=None + 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. @@ -443,31 +668,25 @@ def _save_public_key_to_cache( 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 (Optional): Logger instance. + logger (Any): Logger instance. Side Effects: - Adds entry to _token_public_key_cache. - - May evict oldest entry if cache is at capacity. + - May evict the least-recently-used entry if the cache is at capacity. """ - # Enforce cache size limit: remove oldest entry if at capacity - if len(_token_public_key_cache) >= _TOKEN_PUBLIC_KEY_CACHE_MAX_SIZE: - # Find and remove the oldest entry (earliest expires_at) - oldest_key = None - oldest_expiration = float("inf") - for cached_key, cached_entry in _token_public_key_cache.items(): - if cached_entry["expires_at"] < oldest_expiration: - oldest_expiration = cached_entry["expires_at"] - oldest_key = cached_key - if oldest_key is not None: - del _token_public_key_cache[oldest_key] + 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 oldest entry: {oldest_key}" + f"evicted least-recently-used entry: {evicted_key}" ) - - # Cache the key - _token_public_key_cache[cache_key] = { - "key": pem_key, - "expires_at": time.time() + cache_ttl, - } 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 index ad37c81..371b00b 100644 --- a/tests/test_dpop.py +++ b/tests/test_dpop.py @@ -1,357 +1,337 @@ """ 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 patch +from unittest.mock import AsyncMock, patch +import anyio import pytest -from joserfc import jwk, jwt +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_MAX_TTL -from authutils.errors import InvalidNonceError, JWTScopeError, JWTPurposeError - - -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) +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 + os.environ["DPOP_SHARED_SECRET"] = ( + "test-secret-32chars-minimum-test" # pragma: allowlist secret + ) yield os.environ.pop("DPOP_SHARED_SECRET", None) -class TestValidateDpopProofSuccess: - """Tests for successful validate_dpop_proof calls that verify return values.""" +class TestGenerateDpopProof: + """Direct unit tests for generate_dpop_proof behavior and edge cases.""" - def test_returns_dpop_claims_and_client_jwk_tuple(self): - """ - Test that validate_dpop_proof returns a tuple of (dpop_claims, client_jwk). - This allows callers to get both values in a single call without needing - to call both validate_dpop_proof and extract_and_validate_jwk separately. - """ - key = jwk.ECKey.generate_key(crv="P-256") - proof = authutils.dpop.generate_dpop_proof( - key, "GET", "https://example.com/resource" + 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, ) - # Call validate_dpop_proof and verify it returns a tuple - result = authutils.dpop.validate_dpop_proof( - proof, "GET", "https://example.com/resource" + 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, ) - # Verify return type is a tuple - assert isinstance(result, tuple), "validate_dpop_proof should return a tuple" + payload = _decode_jwt_payload(proof) + assert payload["htu"] == "https://gen3.example.com/api/v1/resource" - # Verify tuple has exactly 2 elements - assert len(result) == 2, "validate_dpop_proof should return a 2-element tuple" + 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" - # Verify first element is the dpop claims dict - dpop_claims, client_jwk = result - assert isinstance( - dpop_claims, dict - ), "First element should be a dict (dpop_claims)" - 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 dpop_claims["htm"] == "GET", "htm should match request method" + # 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" + ) - # Verify second element is the client jwk - assert client_jwk is not None, "client_jwk should not be None" - # client_jwk can be an ECKey or RSAKey, check for key attributes - 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"] == "EC", "client_jwk should be EC type" + # 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") -class TestNoneDpopHeaderValidation: - """Tests for None or empty dpop_header validation.""" + # 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( - "invalid_header", - [ - pytest.param(None, id="none_header"), - pytest.param("", id="empty_string_header"), - ], + "curve, expected_alg", + [("P-256", "ES256"), ("P-384", "ES384"), ("P-521", "ES512")], ) - 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) - - -class TestBidirectionalBinding: - """Bidirectional Binding (Stolen Token Defense)""" - - def test_stolen_token_defense(self): - """ - Verify that an attacker cannot sign a valid DPoP proof - with their own key but bind it to a victim's stolen access token. - """ - # Generate Alice's key and create a mock access token containing Alice's key thumbprint (cnf.jkt) - alice_key = jwk.ECKey.generate_key(crv="P-256") - alice_thumbprint = alice_key.thumbprint() - - # Create a valid access token with Alice's thumbprint using a proper header with alg - 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, + 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" ) - - # Generate the Attacker's key - attacker_key = jwk.ECKey.generate_key(crv="P-256") - - # Call generate_dpop_proof() using the Attacker's key, providing Alice's access token to generate the ath claim - attacker_proof = authutils.dpop.generate_dpop_proof( - attacker_key, "GET", "https://example.com/resource", alice_token - ) - - # Run validate_dpop_proof() passing the Attacker's proof header and Alice's access token - # This should raise ValueError because attacker's key thumbprint doesn't match Alice's thumbprint - with pytest.raises(ValueError): - authutils.dpop.validate_dpop_proof( - attacker_proof, "GET", "https://example.com/resource", alice_token + 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 ) - -class TestStrictConditionalNonceValidation: - """Strict Conditional Nonce Validation per RFC 9449""" - - def test_missing_required_nonce(self): + def test_explicit_alg_matches_key_default(self): """ - Test Case A (Missing & Required): Generate a DPoP proof without a nonce. - Call validate_dpop_proof(..., require_nonce=True). - Expects InvalidNonceError containing new nonce response headers. - """ - # Generate a DPoP proof without a nonce - key = jwk.ECKey.generate_key(crv="P-256") - proof = authutils.dpop.generate_dpop_proof( - key, "GET", "https://example.com/resource" - ) - - with pytest.raises(InvalidNonceError) as exc_info: - authutils.dpop.validate_dpop_proof( - proof, "GET", "https://example.com/resource", require_nonce=True - ) - - err = exc_info.value - assert err.code == 400 - assert err.json["error"] == "use_dpop_nonce" - assert "DPoP-Nonce" in err.error_headers - assert isinstance(err.error_headers["DPoP-Nonce"], str) - assert len(err.error_headers["DPoP-Nonce"]) > 0 + An explicit `alg` matching the key's auto-selected one is a no-op. - @patch("authutils.dpop.verify_stateless_nonce", return_value=False) - def test_provided_unexpectedly_invalid_nonce(self, mock_verify_nonce): + The override path and the auto-resolution path have to agree, or a + caller pinning the algorithm it already expects would change behavior. """ - Test Case B (Provided unexpectedly & Invalid): Generate a DPoP proof - containing an expired or garbage nonce string. Call validate_dpop_proof(..., require_nonce=False). - Even though require_nonce=False, an invalid nonce should still raise InvalidNonceError. - """ - # Generate a DPoP proof with invalid nonce - 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 + 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" - with pytest.raises(InvalidNonceError) as exc_info: - authutils.dpop.validate_dpop_proof( - proof, "GET", "https://example.com/resource", require_nonce=False - ) - - err = exc_info.value - assert err.code == 400 - assert "DPoP-Nonce" in err.error_headers - - -class TestNonceValidationAndInvalidNonceError: - """Tests for DPoP nonce validation and InvalidNonceError handling per RFC 9449.""" - - def test_missing_required_nonce_raises_invalid_nonce_error(self): + def test_explicit_unsupported_alg_rejected(self): """ - Test that validate_dpop_proof raises InvalidNonceError when require_nonce=True - and the DPoP proof lacks a nonce claim. + A caller-supplied alg outside SUPPORTED_DPOP_ALGS is rejected, so + "none" and symmetric algs can never produce a usable proof. """ - key = jwk.ECKey.generate_key(crv="P-256") - proof = authutils.dpop.generate_dpop_proof( - key, "GET", "https://example.com/resource" - ) - - with pytest.raises(InvalidNonceError) as exc_info: - authutils.dpop.validate_dpop_proof( - proof, "GET", "https://example.com/resource", require_nonce=True + 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", ) - err = exc_info.value - - assert err.code == 400 - assert err.json == { - "error": "use_dpop_nonce", - "error_description": "Authorization server requires nonce in DPoP proof", - } - - # Verify new nonce header was generated for client resubmission - assert "DPoP-Nonce" in err.error_headers - assert isinstance(err.error_headers["DPoP-Nonce"], str) - assert len(err.error_headers["DPoP-Nonce"]) > 0 - - @patch("authutils.token.dpop_nonce.verify_stateless_nonce", return_value=False) - def test_invalid_nonce_raises_invalid_nonce_error(self, mock_verify_nonce): - """ - Test that validate_dpop_proof raises InvalidNonceError when an invalid/expired - nonce is provided, even if require_nonce=False. - """ - key = jwk.ECKey.generate_key(crv="P-256") - invalid_nonce = "garbage-or-expired-nonce" - proof = authutils.dpop.generate_dpop_proof( - key, "GET", "https://example.com/resource", nonce=invalid_nonce - ) - with pytest.raises(InvalidNonceError) as exc_info: - authutils.dpop.validate_dpop_proof( - proof, "GET", "https://example.com/resource", require_nonce=False - ) +class TestComputeAth: + """Direct unit tests for RFC 9449 access token hash computation.""" - err = exc_info.value + 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 err.code == 400 - assert "DPoP-Nonce" in err.error_headers - assert isinstance(err.error_headers["DPoP-Nonce"], str) - assert len(err.error_headers["DPoP-Nonce"]) > 0 + assert len(ath) == 43 + assert "=" not in ath - 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 + expected_ath = ( + base64.urlsafe_b64encode(hashlib.sha256(token.encode("ascii")).digest()) + .rstrip(b"=") + .decode("ascii") ) + assert ath == expected_ath - dpop_claims, client_jwk = authutils.dpop.validate_dpop_proof( - proof, "GET", "https://example.com/resource", require_nonce=True - ) + 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 - assert dpop_claims["nonce"] == valid_nonce - assert client_jwk is not None + # 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_custom_secret_passed_to_nonce_validation(self): + def test_bytes_input(self): """ - Test that custom secrets passed to validate_dpop_proof correctly validate nonces. + compute_ath is typed to accept `str | bytes`. Confirm bytes + input produces the same digest as the equivalent str input. """ - 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 + token_str = "K945938459384593845" + token_bytes = token_str.encode("ascii") + assert compute_ath(token_bytes) == compute_ath(token_str) - # Verification with mismatched secret should fail and raise InvalidNonceError - with pytest.raises(InvalidNonceError): - authutils.dpop.validate_dpop_proof( - proof, - "GET", - "https://example.com/resource", - require_nonce=True, - secret="wrong-secret-key-32-chars-long!!", # pragma: allowlist secret - ) + @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 TestAlgorithmWhitelisting: - """Algorithm Whitelisting & Key Cross-Compatibility""" +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): - """ - Test Case A (RSA Verification): Generate an RSA private key. - Generate a proof using generate_dpop_proof(rsa_key, ...) and verify - the header resolves automatically to RS256. - """ - # Generate an RSA private key + """An RSA proof key resolves to RS256 without the caller naming an alg.""" rsa_key = jwk.RSAKey.generate_key() - # Generate a proof using generate_dpop_proof(rsa_key, ...) proof = authutils.dpop.generate_dpop_proof( rsa_key, "GET", "https://example.com/resource" ) - # Verify the header resolves automatically to RS256 header_b64, _, _ = proof.split(".") header_padded = header_b64 + "=" * (4 - len(header_b64) % 4) header_json = json.loads( @@ -362,22 +342,14 @@ def test_rsa_verification(self): assert "jwk" in header_json def test_algorithm_confusion_injection(self): - """ - Test Case B (Algorithm Confusion Injection): Generate a valid EC proof, - but manually intercept and edit the unverified header parameter "alg": "HS256" - (e.g. not allowed). Pass it to validate_dpop_proof(). - Expected Assertion: Raises ValueError - """ - # Generate a valid EC proof + """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" ) - # Manually intercept and edit the unverified header parameter "alg": "HS256" header_b64, payload_b64, signature = proof.split(".") - # Decode and modify header header_padded = header_b64 + "=" * (4 - len(header_b64) % 4) header_json = json.loads( base64.urlsafe_b64decode(header_padded).decode("utf-8") @@ -390,272 +362,2208 @@ def test_algorithm_confusion_injection(self): base64.urlsafe_b64encode(new_header_json.encode()).rstrip(b"=").decode() ) - # Create a new proof with the modified header new_proof = f"{new_header_b64}.{payload_b64}.{signature}" - # Pass it to validate_dpop_proof() 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. -class TestGenerateStatelessNonce: - """Tests for generate_stateless_nonce.""" - - def test_generate_nonce_success(self): - """Returns valid HS256 JWT with purpose=dpop_nonce and correct exp.""" - nonce = dpop_nonce.generate_stateless_nonce() + 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, + ) - # 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 + with pytest.raises(ValueError): + authutils.dpop.validate_dpop_proof( + proof, "GET", "https://example.com/resource" + ) - # Check TTL is approximately correct - now = int(time.time()) - ttl = decoded["exp"] - decoded["iat"] - assert abs(ttl - DPOP_PROOF_MAX_TTL) < 3 + 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. - 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 + 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, + ) - # Remove the environment variable - old_secret = os.environ.pop("DPOP_SHARED_SECRET", None) + with pytest.raises(JoseError): + jwt.decode(proof, hs_key, registry=authutils.dpop._new_registry()) - try: - with pytest.raises(RuntimeError): - dpop_nonce.generate_stateless_nonce() - finally: - os.environ["DPOP_SHARED_SECRET"] = old_secret + 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" + ) -class TestVerifyStatelessNonce: - """Tests for verify_stateless_nonce.""" + 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 + ) - 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 + 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"], + } - 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"] + 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"], + ) - # Generate nonce with original secret - nonce = dpop_nonce.generate_stateless_nonce() + assert len(result) == 3, "validate_dpop_request should return a 3-element tuple" + dpop_claims, token_claims, client_jwk = result - # Change the secret - os.environ[ - "DPOP_SHARED_SECRET" - ] = "completely-different-secret-key-123" # pragma: allowlist secret + 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" - try: - # Should return False with different secret - assert dpop_nonce.verify_stateless_nonce(nonce) is False - finally: - os.environ["DPOP_SHARED_SECRET"] = old_secret + 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" - 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 + 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" - 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", + @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"], } - 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, + + 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 ) - assert dpop_nonce.verify_stateless_nonce(expired_nonce) 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", + 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"], } - 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 - -class TestVerifyStatelessNonceEdgeCases: - """Edge case tests for nonce verification.""" - - 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_with_malformed_base64(self): - """Returns False for JWT with malformed base64 encoding.""" - # JWT with invalid base64 characters in each part - assert dpop_nonce.verify_stateless_nonce("abc.def.ghi") is False - assert dpop_nonce.verify_stateless_nonce("!!!.!!!.!!!") is False - assert dpop_nonce.verify_stateless_nonce("a.") is False - - def test_verify_nonce_with_empty_jwt_parts(self): - """Returns False for JWT with empty parts.""" - assert dpop_nonce.verify_stateless_nonce("..") is False - assert dpop_nonce.verify_stateless_nonce("a..") is False - assert dpop_nonce.verify_stateless_nonce("..c") is False - - def test_verify_nonce_with_only_header(self): - """Returns False for incomplete JWT (only header).""" - # Generate a valid JWT and truncate it - nonce = dpop_nonce.generate_stateless_nonce() - parts = nonce.split(".") - assert dpop_nonce.verify_stateless_nonce(parts[0]) is False - assert dpop_nonce.verify_stateless_nonce(parts[0] + "." + parts[1]) is False - - def test_verify_nonce_with_invalid_json_in_header(self): - """Returns False for JWT with invalid JSON in header.""" - # Create a JWT with invalid JSON in header - invalid_header = ( - base64.urlsafe_b64encode(b"{invalid json}").rstrip(b"=").decode() - ) - payload = base64.urlsafe_b64encode(b"{}").rstrip(b"=").decode() - signature = "sig" - malformed = f"{invalid_header}.{payload}.{signature}" - assert dpop_nonce.verify_stateless_nonce(malformed) is False + 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, + ) - def test_verify_nonce_with_invalid_json_in_payload(self): - """Returns False for JWT with invalid JSON in payload.""" - header = base64.urlsafe_b64encode(b'{"typ":"JWT"}').rstrip(b"=").decode() - invalid_payload = ( - base64.urlsafe_b64encode(b"{invalid json}").rstrip(b"=").decode() + @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", ) - signature = "sig" - malformed = f"{header}.{invalid_payload}.{signature}" - assert dpop_nonce.verify_stateless_nonce(malformed) is False - - def test_verify_nonce_with_whitespace_variations(self): - """Returns False for JWT with unexpected whitespace.""" - nonce = dpop_nonce.generate_stateless_nonce() - # Add various whitespace characters - assert dpop_nonce.verify_stateless_nonce(" " + nonce) is False - assert dpop_nonce.verify_stateless_nonce(nonce + " ") is False - assert dpop_nonce.verify_stateless_nonce(nonce.replace(".", ". ")) is False - - def test_verify_nonce_with_numeric_input(self): - """Returns False for numeric input.""" - assert dpop_nonce.verify_stateless_nonce(12345) is False - assert dpop_nonce.verify_stateless_nonce(0) is False - assert dpop_nonce.verify_stateless_nonce(-1) is False - - def test_verify_nonce_with_list_input(self): - """Returns False for list input.""" - assert dpop_nonce.verify_stateless_nonce(["a", "b", "c"]) is False - assert dpop_nonce.verify_stateless_nonce([]) is False - - def test_verify_nonce_with_dict_input(self): - """Returns False for dict input.""" - assert dpop_nonce.verify_stateless_nonce({"a": "b"}) is False - assert dpop_nonce.verify_stateless_nonce({}) 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", + 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"], } - 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 + 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, + ) - 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 + @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 ) - assert dpop_nonce.verify_stateless_nonce(short_ttl) is False + mock_validate_jwt.return_value = { + "sub": "test-user", + "iss": "https://example.com", + "aud": "test-audience", + "pur": "access", + "scope": ["openid", "user"], + } -class TestValidateDpopRequest: - """Tests for validate_dpop_request.""" - - @pytest.fixture - def rsa_key(self): - """Generate an RSA key for testing.""" - return jwk.RSAKey.generate_key() + 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(), + ) - @pytest.fixture - def ec_key(self): - """Generate an EC key for DPoP proof testing.""" - return jwk.ECKey.generate_key(crv="P-256") + mock_get_public_key.assert_not_called() - @patch("authutils.dpop.get_any_public_key_for_token") + @patch("authutils.dpop.get_any_public_key_for_token_async", new_callable=AsyncMock) @patch("authutils.dpop.token_core.validate_jwt") - def test_validate_dpop_request_return( + async def test_options_param_forwarded_to_validate_jwt( 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). + The `options` dict should be passed through to the underlying + token_core.validate_jwt call (e.g. for pyjwt-style decode options). """ - # 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 + 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() @@ -666,57 +2574,64 @@ def test_validate_dpop_request_return( "pur": "access", "scope": ["openid", "user"], } + custom_options = {"verify_aud": False} - result = authutils.dpop.validate_dpop_request( - dpop_header=dpop_proof, + await authutils.dpop.validate_dpop_request_async( + dpop_header=proof, access_token=access_token, - request_method="POST", + request_method="GET", request_url="https://example.com/api/resource", issuers=["https://example.com"], + options=custom_options, ) - assert len(result) == 3, "validate_dpop_request should return a 3-element tuple" - dpop_claims, token_claims, client_jwk = result + _, call_kwargs = mock_validate_jwt.call_args + assert call_kwargs.get("options") == custom_options - 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" + @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"]) - 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" + proof = authutils.dpop.generate_dpop_proof( + dpop_key, "GET", "https://example.com/api/resource", access_token + ) - 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" + # 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") - @pytest.mark.parametrize("http_method", ["GET", "POST", "PUT", "DELETE", "PATCH"]) - @patch("authutils.dpop.get_any_public_key_for_token") + 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") - def test_different_http_methods( - self, mock_validate_jwt, mock_get_public_key, http_method + async def test_access_token_purpose_validation( + self, mock_validate_jwt, mock_get_public_key ): """ - Test that validate_dpop_request works with different HTTP methods. + Test that validate_dpop_request validates the required purpose. """ - # Use RSA key since access token validation only supports RS256 + # Use RSA key for access token (RS256 only) dpop_key = jwk.RSAKey.generate_key() - access_token = _create_signed_access_token(dpop_key) + access_token = _create_signed_access_token(dpop_key, purpose="access") + proof = authutils.dpop.generate_dpop_proof( - dpop_key, http_method, "https://example.com/api/resource", access_token + dpop_key, "GET", "https://example.com/api/resource", access_token ) mock_get_public_key.return_value = dpop_key.as_pem() @@ -728,431 +2643,695 @@ def test_different_http_methods( "scope": ["openid", "user"], } - result = authutils.dpop.validate_dpop_request( + result = await authutils.dpop.validate_dpop_request_async( dpop_header=proof, access_token=access_token, - request_method=http_method, + request_method="GET", request_url="https://example.com/api/resource", issuers=["https://example.com"], + purpose="access", ) - dpop_claims, _, _ = result - assert dpop_claims["htm"] == http_method + dpop_claims, token_claims, client_jwk = result + assert token_claims["pur"] == "access" - @pytest.mark.parametrize("key_type", ["EC", "RSA"]) - @patch("authutils.dpop.get_any_public_key_for_token") + @patch("authutils.dpop.get_any_public_key_for_token_async", new_callable=AsyncMock) @patch("authutils.dpop.token_core.validate_jwt") - def test_different_key_types( - self, mock_validate_jwt, mock_get_public_key, key_type + 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 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. + Test that validate_dpop_request raises JWTError when the token is denylisted. """ - 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() + from authutils.token import core as token_core - access_token = _create_signed_access_token(dpop_key) - proof = authutils.dpop.generate_dpop_proof( - dpop_key, "GET", "https://example.com/api/resource", access_token + access_token = _create_signed_access_token( + rsa_key, additional_claims={"jti": "test-jti-123"} ) - 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 = authutils.dpop.validate_dpop_request( - dpop_header=proof, - access_token=access_token, - request_method="GET", - request_url="https://example.com/api/resource", - issuers=["https://example.com"], + proof = authutils.dpop.generate_dpop_proof( + rsa_key, "GET", "https://example.com/api/resource", access_token ) - dpop_claims, token_claims, client_jwk = result + mock_get_public_key.return_value = rsa_key.as_pem() - assert dpop_claims["htm"] == "GET" - assert dpop_claims["htu"] == "https://example.com/api/resource" + def denylist_callback(jti): + # This jti is in the token, so it should return True + return jti == "test-jti-123" - assert token_claims["sub"] == "test-user" + # Set up mock to raise JWTError - simulating denylist behavior + mock_validate_jwt.side_effect = token_core.JWTError("token is denylisted") - jwk_dict = client_jwk.as_dict(private=False) - assert ( - jwk_dict["kty"] == "RSA" - ), "All tests use RSA keys due to RS256 requirement" + # 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") + @patch("authutils.dpop.get_any_public_key_for_token_async", new_callable=AsyncMock) @patch("authutils.dpop.token_core.validate_jwt") - def test_access_token_scopes_validation( - self, mock_validate_jwt, mock_get_public_key + async def test_denylist_callback_not_denylisted( + self, mock_validate_jwt, mock_get_public_key, rsa_key ): """ - Test that validate_dpop_request validates the required scopes. + Test that validate_dpop_request succeeds when the token is not denylisted. """ - # 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"] + rsa_key, additional_claims={"jti": "test-jti-123"} ) proof = authutils.dpop.generate_dpop_proof( - dpop_key, "GET", "https://example.com/api/resource", access_token + rsa_key, "GET", "https://example.com/api/resource", access_token ) - mock_get_public_key.return_value = dpop_key.as_pem() + 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", "data"], + "scope": ["openid", "user"], + "jti": "test-jti-123", } - # Should succeed with matching scopes - result = authutils.dpop.validate_dpop_request( + 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"}, + denylist_callback=denylist_callback, ) dpop_claims, token_claims, client_jwk = result assert dpop_claims["htm"] == "GET" - def test_secret_parameter_fallback_to_env(self): - """ - Test that when secret=None is passed, it defaults to the environment variable. + @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): """ - 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 + A None/empty DPoP header raises ValueError, not AttributeError. - def test_secret_parameter_explicit_value(self): - """ - Test that when an explicit secret is passed, it uses that secret. + validate_dpop_request strips an auth scheme prefix from the header, so + without an explicit guard a None header would crash on .split(). """ - key = jwk.ECKey.generate_key(crv="P-256") - nonce = dpop_nonce.generate_stateless_nonce() + 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( - key, "GET", "https://example.com/resource", nonce=nonce + rsa_key, "GET", "https://example.com/api/resource" ) - # 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 + 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"], + ) - 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 + 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 ) - # 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 + 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"], ) - 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 - @patch("authutils.dpop.get_any_public_key_for_token") + @patch("authutils.dpop.get_any_public_key_for_token_async", new_callable=AsyncMock) @patch("authutils.dpop.token_core.validate_jwt") - def test_validate_dpop_request_secret_parameter( - self, mock_validate_jwt, mock_get_public_key + async def test_issuer_allowlist_passed_to_key_lookup( + self, mock_validate_jwt, mock_get_public_key, rsa_key ): """ - Test that secret parameter is properly passed through from validate_dpop_request. + 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. """ - key = jwk.RSAKey.generate_key() - custom_secret = ( - "custom-request-secret-32chars-minimum-test" # pragma: allowlist secret + access_token = _create_signed_access_token(rsa_key) + proof = authutils.dpop.generate_dpop_proof( + rsa_key, "GET", "https://example.com/api/resource", access_token ) - # 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() + 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"], + } - # Create access token with key binding - access_token = _create_signed_access_token( - key, additional_claims={"cnf": {"jkt": key.thumbprint()}} - ) + 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"], + ) - proof = authutils.dpop.generate_dpop_proof( - key, - "POST", - "https://example.com/api/resource", - access_token, - nonce=nonce, - ) + _, 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" + ) - 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"], - } + @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 + ) - ( - dpop_claims, - token_claims, - client_jwk, - ) = authutils.dpop.validate_dpop_request( - 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) + 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) - @patch("authutils.dpop.get_any_public_key_for_token") + 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") - def test_access_token_scope_missing_raises( - self, mock_validate_jwt, mock_get_public_key + async def test_token_signature_failure_still_rejects_after_key_binding_passes( + self, mock_validate_jwt, mock_get_public_key, rsa_key ): """ - Test that validate_dpop_request raises JWTScopeError when required scope is missing. + 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. """ - # 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"]) + from authutils.token import core as token_core + access_token = _create_signed_access_token(rsa_key) proof = authutils.dpop.generate_dpop_proof( - dpop_key, "GET", "https://example.com/api/resource", access_token + rsa_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") + mock_get_public_key.return_value = rsa_key.as_pem() + mock_validate_jwt.side_effect = token_core.JWTError("bad signature") - with pytest.raises(JWTScopeError): - authutils.dpop.validate_dpop_request( + 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"], - scope={"openid", "user", "data"}, ) - @patch("authutils.dpop.get_any_public_key_for_token") + @patch("authutils.dpop.get_any_public_key_for_token_async", new_callable=AsyncMock) @patch("authutils.dpop.token_core.validate_jwt") - def test_access_token_purpose_validation( - self, mock_validate_jwt, mock_get_public_key + async def test_denylist_callback_missing_jti_claim( + self, mock_validate_jwt, mock_get_public_key, rsa_key ): """ - Test that validate_dpop_request validates the required purpose. + 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. """ - # Use RSA key for access token (RS256 only) - dpop_key = jwk.RSAKey.generate_key() - access_token = _create_signed_access_token(dpop_key, purpose="access") - + access_token = _create_signed_access_token(rsa_key) proof = authutils.dpop.generate_dpop_proof( - dpop_key, "GET", "https://example.com/api/resource", access_token + rsa_key, "GET", "https://example.com/api/resource", access_token ) - mock_get_public_key.return_value = dpop_key.as_pem() + 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" } - result = authutils.dpop.validate_dpop_request( + 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"], - purpose="access", + denylist_callback=denylist_callback, ) - dpop_claims, token_claims, client_jwk = result - assert token_claims["pur"] == "access" + dpop_claims, _, _ = result + assert dpop_claims["htm"] == "GET" - @patch("authutils.dpop.get_any_public_key_for_token") + @patch("authutils.dpop.get_any_public_key_for_token_async", new_callable=AsyncMock) @patch("authutils.dpop.token_core.validate_jwt") - def test_access_token_purpose_mismatch_raises( - self, mock_validate_jwt, mock_get_public_key + async def test_scope_list_is_normalized_to_a_set( + self, mock_validate_jwt, mock_get_public_key, rsa_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") - + """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( - dpop_key, "GET", "https://example.com/api/resource", access_token + rsa_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" + 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"], ) - with pytest.raises(JWTPurposeError): - authutils.dpop.validate_dpop_request( + 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=access_token, + access_token=non_string, 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") + +@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") - def test_denylist_callback_denylisted( - self, mock_validate_jwt, mock_get_public_key, rsa_key + async def test_matches_the_sync_wrapper_on_a_valid_request( + self, mock_validate_jwt, rsa_key ): - """ - Test that validate_dpop_request raises JWTError when the token is denylisted. - """ - from authutils.token import core as token_core + """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"} - access_token = _create_signed_access_token( - rsa_key, additional_claims={"jti": "test-jti-123"} + 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 ) - mock_get_public_key.return_value = rsa_key.as_pem() + async def fake_get_key(token, allowed_issuers=None): + await anyio.sleep(0) + return rsa_key.as_pem() - def denylist_callback(jti): - # This jti is in the token, so it should return True - return jti == "test-jti-123" + mock_get_key_async.side_effect = fake_get_key + mock_validate_jwt.return_value = {"sub": "test-user"} - # Set up mock to raise JWTError - simulating denylist behavior - mock_validate_jwt.side_effect = token_core.JWTError("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"], + ) - # Should raise JWTError when token is denylisted - with pytest.raises(token_core.JWTError, match="token is denylisted"): - authutils.dpop.validate_dpop_request( + 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="GET", + request_method="DELETE", request_url="https://example.com/api/resource", issuers=["https://example.com"], - denylist_callback=denylist_callback, ) - @patch("authutils.dpop.get_any_public_key_for_token") + mock_get_key_async.assert_not_awaited() + + @patch("authutils.dpop.get_any_public_key_for_token_async") @patch("authutils.dpop.token_core.validate_jwt") - def test_denylist_callback_not_denylisted( - self, mock_validate_jwt, mock_get_public_key, rsa_key + async def test_does_not_block_the_event_loop_during_key_discovery( + self, mock_validate_jwt, mock_get_key_async, 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"} - ) - + """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 ) - mock_get_public_key.return_value = rsa_key.as_pem() + async def slow_get_key(token, allowed_issuers=None): + await anyio.sleep(0.05) + return 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_get_key_async.side_effect = slow_get_key + mock_validate_jwt.return_value = {"sub": "test-user"} - mock_validate_jwt.return_value = { - "sub": "test-user", - "iss": "https://example.com", - "aud": "test-audience", - "pur": "access", - "scope": ["openid", "user"], - "jti": "test-jti-123", - } + ticks = 0 + stop = False - result = authutils.dpop.validate_dpop_request( - 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, - ) + async def ticker(): + nonlocal ticks + while not stop: + ticks += 1 + await anyio.sleep(0.001) - dpop_claims, token_claims, client_jwk = result - assert dpop_claims["htm"] == "GET" + 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 5a7b1ee..0e1aa63 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,32 @@ 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, + allowed_issuers=[iss], + ) + + def test_denylist_callback_rejects_token( claims, encoded_jwt, rsa_public_key, default_audience, default_scopes, iss ): @@ -347,9 +373,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 index b319838..14613b7 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -1,37 +1,55 @@ """ Unit tests for authutils.token.keys module, specifically -for `get_any_public_key_for_token` +for `get_any_public_key_for_token_async` """ import time -from unittest.mock import Mock, patch +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 function.""" + """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._token_public_key_cache.clear() + keys_module.clear_public_key_cache() yield - keys_module._token_public_key_cache.clear() - - @pytest.fixture - def mock_rsa_key(self): - """Create a mock RSA key for testing.""" - 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", - } + keys_module.clear_public_key_cache() @pytest.fixture def mock_jwks_response(self, mock_rsa_key): @@ -50,116 +68,95 @@ def valid_token(self): header = {"alg": "HS256", "typ": "JWT", "kid": "test-key-id"} return jwt.encode(header, payload, hs_key) - @patch("authutils.token.keys.get_keys_url") - @patch("authutils.token.keys.httpx.get") - def test_successful_key_retrieval( + @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_response = Mock() - mock_response.json.return_value = mock_jwks_response - mock_get.return_value = mock_response + mock_get.return_value = mock_jwks_response # First call should fetch from network - result = keys_module.get_any_public_key_for_token(valid_token) + 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") - @patch("authutils.token.keys.httpx.get") - def test_cache_hit( + @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_response = Mock() - mock_response.json.return_value = mock_jwks_response - mock_get.return_value = mock_response + mock_get.return_value = mock_jwks_response # First call - result1 = keys_module.get_any_public_key_for_token(valid_token) + result1 = await _get_key(valid_token) # Second call - result2 = keys_module.get_any_public_key_for_token(valid_token) + result2 = await _get_key(valid_token) - # Should have only called httpx.get once (second call was cached) + # 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") - @patch("authutils.token.keys.httpx.get") + @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") - def test_cache_expiration( + 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_response = Mock() - mock_response.json.return_value = mock_jwks_response - mock_get.return_value = mock_response + mock_get.return_value = mock_jwks_response # Mock time progression current_time = 1000.0 - mock_time.side_effect = [ - # First cache write - current_time, - # First cache check (hit) - current_time, - # Second cache check (expired) - current_time + 400, - # Second cache write (after expiration) - current_time + 400, - ] + mock_time.return_value = current_time # First call - result1 = keys_module.get_any_public_key_for_token(valid_token, cache_ttl=300) + result1 = await _get_key(valid_token, cache_ttl=300) # Second call within TTL (should be cached) - result2 = keys_module.get_any_public_key_for_token(valid_token, cache_ttl=300) + result2 = await _get_key(valid_token, cache_ttl=300) # Third call after TTL (should fetch again) - result3 = keys_module.get_any_public_key_for_token(valid_token, cache_ttl=300) + mock_time.return_value = current_time + 400 + result3 = await _get_key(valid_token, cache_ttl=300) - # Should have called httpx.get twice (once for initial, once after expiration) + # 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") - @patch("authutils.token.keys.httpx.get") - def test_cache_size_limiting( + @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" - # Create a mock that returns JWKS with matching kid - def mock_get_response(url): - response = Mock() - # Extract kid from the URL or create one that matches tokens - # For this test, we create a JWKS that has keys for all kids - response.json.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) - ] - } - return response - - mock_get.side_effect = mock_get_response + # 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 @@ -169,11 +166,12 @@ def mock_get_response(url): 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": f"https://issuer-{i}.example.com", + "iss": issuers[i], "sub": "test-user", "kid": f"key-{i}", } @@ -183,7 +181,7 @@ def mock_get_response(url): # Fetch keys for all tokens for token in tokens: - keys_module.get_any_public_key_for_token(token) + await _get_key(token, allowed_issuers=issuers) # Cache should not exceed the limit assert len(keys_module._token_public_key_cache) <= 5 @@ -233,12 +231,12 @@ def test_cache_eviction_removes_oldest_entry(self): # Restore original cache size limit keys_module._TOKEN_PUBLIC_KEY_CACHE_MAX_SIZE = original_limit - def test_malformed_token_raises_error(self): + async def test_malformed_token_raises_error(self): """Test that malformed tokens raise JWTError.""" with pytest.raises(JWTError): - keys_module.get_any_public_key_for_token("not.a.valid.token") + await _get_key("not.a.valid.token") - def test_missing_issuer_raises_error(self): + 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 = { @@ -250,88 +248,78 @@ def test_missing_issuer_raises_error(self): token = jwt.encode(header, payload, hs_key) with pytest.raises(JWTError): - keys_module.get_any_public_key_for_token(token) + await _get_key(token) - @patch("authutils.token.keys.httpx.get") - def test_network_error_raises_error(self, mock_get, valid_token): - """Test that network errors are properly handled.""" + @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) as exc_info: - keys_module.get_any_public_key_for_token(valid_token) - - @patch("authutils.token.keys.httpx.get") - def test_no_matching_key_in_jwks_raises_error(self, mock_get, valid_token): - """Test that missing key in JWKS raises JWTError.""" - mock_jwks_response = { - "keys": [ - { - "kty": "RSA", - "kid": "different-key-id", - "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", # pragma: allowlist secret - "e": "AQAB", - } - ] - } + with pytest.raises(JWTError, match="Could not fetch JWKS"): + await _get_key(valid_token) - mock_response = Mock() - mock_response.json.return_value = mock_jwks_response - mock_get.return_value = mock_response + @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) as exc_info: - keys_module.get_any_public_key_for_token(valid_token) + with pytest.raises(JWTError, match="Got no keys"): + await _get_key(valid_token) - @patch("authutils.token.keys.get_keys_url") - @patch("authutils.token.keys.httpx.get") + @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") - def test_custom_cache_ttl( + 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_response = Mock() - mock_response.json.return_value = mock_jwks_response - mock_get.return_value = mock_response + mock_get.return_value = mock_jwks_response current_time = 1000.0 - mock_time.side_effect = [ - current_time, # First cache write - current_time + 100, # Check within custom TTL (150 seconds) - ] + mock_time.return_value = current_time # First call with custom TTL of 150 seconds - result1 = keys_module.get_any_public_key_for_token(valid_token, cache_ttl=150) + result1 = await _get_key(valid_token, cache_ttl=150) # Second call within custom TTL - result2 = keys_module.get_any_public_key_for_token(valid_token, cache_ttl=150) + mock_time.return_value = current_time + 100 + result2 = await _get_key(valid_token, cache_ttl=150) - # Should have only called httpx.get once (second call was cached) + # Should have fetched once (second call was served from cache) assert mock_get.call_count == 1 assert result1 == result2 - @patch("authutils.token.keys.httpx.get") - def test_different_kids_different_cache_entries(self, mock_get, mock_jwks_response): + @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 - # Create a mock that returns JWKS with matching kids - def mock_get_response(url): - response = Mock() - response.json.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) - ] - } - return response - - mock_get.side_effect = mock_get_response + # 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 = [] @@ -346,27 +334,29 @@ def mock_get_response(url): tokens.append(token) # Fetch keys for both tokens - result1 = keys_module.get_any_public_key_for_token(tokens[0]) - result2 = keys_module.get_any_public_key_for_token(tokens[1]) + 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.httpx.get") - def test_different_issuers_different_cache_entries( - self, mock_get, mock_jwks_response + @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_response = Mock() - mock_response.json.return_value = mock_jwks_response - mock_get.return_value = mock_response + 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": f"https://issuer-{i}.example.com", + "iss": issuers[i], "sub": "test-user", } header = {"alg": "HS256", "typ": "JWT", "kid": "test-key-id"} @@ -374,23 +364,532 @@ def test_different_issuers_different_cache_entries( tokens.append(token) # Fetch keys for both tokens - result1 = keys_module.get_any_public_key_for_token(tokens[0]) - result2 = keys_module.get_any_public_key_for_token(tokens[1]) + 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.httpx.get") - def test_cache_key_format(self, mock_get, valid_token, mock_jwks_response): + @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_response = Mock() - mock_response.json.return_value = mock_jwks_response - mock_get.return_value = mock_response + mock_get_keys_url.return_value = _JWKS_URL + mock_get.return_value = mock_jwks_response - keys_module.get_any_public_key_for_token(valid_token) + 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 From fbdd95bf769c83c6b2ab21270c9f521c2b91c887 Mon Sep 17 00:00:00 2001 From: avantol Date: Fri, 21 Aug 2026 10:28:08 -0500 Subject: [PATCH 28/28] fix(issuers): require allowed_iss, don't blindly allow all --- src/authutils/dpop.py | 8 ++++++++ src/authutils/token/core.py | 32 +++++++++++++++++++------------- tests/test_dpop.py | 24 ++++++++++++++++++++++++ tests/test_jwt.py | 32 +++++++++++++++++++++++++++++++- 4 files changed, 82 insertions(+), 14 deletions(-) diff --git a/src/authutils/dpop.py b/src/authutils/dpop.py index aefb606..624e16b 100644 --- a/src/authutils/dpop.py +++ b/src/authutils/dpop.py @@ -252,6 +252,14 @@ async def validate_dpop_request_async( 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) diff --git a/src/authutils/token/core.py b/src/authutils/token/core.py index 3f87ad1..69bfd96 100644 --- a/src/authutils/token/core.py +++ b/src/authutils/token/core.py @@ -116,9 +116,9 @@ def validate_purpose(claims, pur): def validate_jwt( encoded_token: str, public_key: str | bytes, - aud: str | list[str] | None = None, - scope: set[str] | list[str] | None = None, - allowed_issuers: set[str] | list[str] | None = None, + 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, @@ -153,9 +153,10 @@ def validate_jwt( the token not have an aud field. scope (set[str] | list[str] | None): set of scopes, each of which the JWT must satisfy in its - ``scope`` claim. Optional. - allowed_issuers (set[str] | list[str] | None): allowed allowed_issuers whitelist. If None, this will - SKIP ISSUER VALIDATION. NOTE: THIS IS THE DEFAULT. + ``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. @@ -167,7 +168,8 @@ def validate_jwt( 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 @@ -176,7 +178,6 @@ def validate_jwt( the denylist_callback indicates the token is denylisted """ options = options or {} - allowed_issuers = allowed_issuers or [] if not isinstance(aud, str) and not isinstance(aud, list) and aud is not None: raise ValueError( @@ -190,6 +191,12 @@ def validate_jwt( raise ValueError( 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}" @@ -221,11 +228,10 @@ def validate_jwt( # iss # Check that the issuer of the token has the expected hostname. # Read with .get: a token carrying no iss at all must fail as a JWTError - if allowed_issuers: - 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) + 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 # Check that if scope arg was non-empty then the token includes each given scope in its scope claim diff --git a/tests/test_dpop.py b/tests/test_dpop.py index 371b00b..658107d 100644 --- a/tests/test_dpop.py +++ b/tests/test_dpop.py @@ -2823,6 +2823,30 @@ async def test_bearer_prefixed_access_token_raises_value_error(self, rsa_key): 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( diff --git a/tests/test_jwt.py b/tests/test_jwt.py index 0e1aa63..3738596 100644 --- a/tests/test_jwt.py +++ b/tests/test_jwt.py @@ -160,6 +160,7 @@ def test_token_without_iss_rejected_as_jwt_error( encoded, rsa_public_key, aud=default_audience, + scope=None, allowed_issuers=[iss], ) @@ -359,10 +360,39 @@ def test_validate_jwt_type_validation(): """ with pytest.raises(ValueError): validate_jwt( - "token", "key", aud=123, scope=None, allowed_issuers=[] + "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