From e2fbe3ff81538aeb87a55129a7addf104226e32b Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Fri, 18 Sep 2026 23:53:48 -0600 Subject: [PATCH 1/3] Preserve authentication parameters with whitespace --- src/util.jl | 1 + test/runtests.jl | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/util.jl b/src/util.jl index 8f5b7c4..76befab 100644 --- a/src/util.jl +++ b/src/util.jl @@ -188,6 +188,7 @@ function parse_www_authenticate(header::AbstractString) idx = after_peek continue end + after_peek = skip_spaces(header, after_peek, stop) if after_peek <= stop && header[after_peek] == '=' idx = peek_idx else diff --git a/test/runtests.jl b/test/runtests.jl index bffd8c6..e89ec1e 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -5,6 +5,22 @@ using OAuth using Sockets using ModelContextProtocol +@testset "Authentication parameter whitespace" begin + for whitespace in (" ", "\t", " \t "), quoted in (false, true) + metadata = "https://example.com/meta" + value = quoted ? string('"', metadata, '"') : metadata + header = "Bearer realm=\"api\", resource_metadata$(whitespace)=$(whitespace)$(value), scope$(whitespace)=\"openid profile\", Basic realm=\"other\"" + challenges = ModelContextProtocol.extract_auth_challenges( + ModelContextProtocol.build_headers(["WWW-Authenticate" => header])) + @test length(challenges) == 2 + @test challenges[1].challenge.scheme == "Bearer" + @test challenges[1].resource_metadata == metadata + @test challenges[1].scopes == ["openid", "profile"] + @test challenges[2].challenge.scheme == "Basic" + @test challenges[2].challenge.params["realm"] == "other" + end +end + mutable struct StubState headers::Vector{Dict{String,String}} cancellations::Vector{Dict{String,Any}} @@ -378,7 +394,7 @@ function start_auth_stub_server() HTTP.register!(router, "GET", "/.well-known/auth-required.json", req -> begin host = HTTP.header(req, "Host") base = string("http://", host) - header = "Bearer resource_metadata=\"$(base)/.well-known/protected-resource\" scope=\"openid profile\"" + header = "Bearer realm=\"api\", resource_metadata = \"$(base)/.well-known/protected-resource\", scope = \"openid profile\"" HTTP.Response(401, ["WWW-Authenticate" => header], "") end) HTTP.register!(router, "GET", "/.well-known/protected-resource", req -> begin From cf07d93c7d622151f5ac4cd5d0d2947d9f2ef95c Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Sat, 19 Sep 2026 01:31:29 -0600 Subject: [PATCH 2/3] Preserve padded authentication challenge tokens --- src/util.jl | 19 +++++++++++++++++-- test/runtests.jl | 17 +++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/util.jl b/src/util.jl index 76befab..fd89338 100644 --- a/src/util.jl +++ b/src/util.jl @@ -197,10 +197,25 @@ function parse_www_authenticate(header::AbstractString) end end key_start = idx - key, idx = read_token(header, idx, stop) + key, after_key = read_token(header, idx, stop) isempty(key) && break - idx = skip_spaces(header, idx, stop) + idx = skip_spaces(header, after_key, stop) if idx <= stop && header[idx] == '=' + # A token68 can end in padding, while auth-param needs a value. + if !seen_param && token === nothing && idx == after_key + padding_end = idx + while padding_end <= stop && header[padding_end] == '=' + padding_end = Base.nextind(header, padding_end) + end + next = skip_spaces(header, padding_end, stop) + if (next > stop || header[next] == ',') && + all(c -> 'A' <= c <= 'Z' || 'a' <= c <= 'z' || + '0' <= c <= '9' || c in ('-', '.', '_', '~', '+', '/'), key) + token = String(SubString(header, key_start, Base.prevind(header, padding_end))) + idx = next + break + end + end idx = Base.nextind(header, idx) idx = skip_spaces(header, idx, stop) value, idx = read_value(header, idx, stop) diff --git a/test/runtests.jl b/test/runtests.jl index e89ec1e..dfbb733 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -21,6 +21,23 @@ using ModelContextProtocol end end +@testset "Padded authentication challenge tokens" begin + for token in ("abc=", "abc==", "azAZ09-._~+/=="), whitespace in ("", " \t") + parsed = ModelContextProtocol.parse_www_authenticate("Negotiate $token$whitespace, Basic realm=\"backup\"") + @test length(parsed) == 2 + @test parsed[1].scheme == "Negotiate" + @test parsed[1].token == token + @test isempty(parsed[1].params) + @test parsed[2].params["realm"] == "backup" + single = only(ModelContextProtocol.parse_www_authenticate("Negotiate $token$whitespace")) + @test single.token == token + @test isempty(single.params) + end + params = only(ModelContextProtocol.parse_www_authenticate("Bearer realm = \"\", error = invalid_token")) + @test params.token === nothing + @test params.params == Dict("realm" => "", "error" => "invalid_token") +end + mutable struct StubState headers::Vector{Dict{String,String}} cancellations::Vector{Dict{String,Any}} From 264bcc5a2dd5e25e98f21ef3abb99e563d75bdcb Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Sat, 19 Sep 2026 01:42:21 -0600 Subject: [PATCH 3/3] Handle case-insensitive authentication parameter names --- src/util.jl | 2 +- test/runtests.jl | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/util.jl b/src/util.jl index fd89338..0159fd4 100644 --- a/src/util.jl +++ b/src/util.jl @@ -219,7 +219,7 @@ function parse_www_authenticate(header::AbstractString) idx = Base.nextind(header, idx) idx = skip_spaces(header, idx, stop) value, idx = read_value(header, idx, stop) - params[String(key)] = value + params[lowercase(key)] = value seen_param = true else if seen_param || token !== nothing diff --git a/test/runtests.jl b/test/runtests.jl index dfbb733..9901dfd 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -38,6 +38,17 @@ end @test params.params == Dict("realm" => "", "error" => "invalid_token") end +@testset "Authentication parameter case" begin + challenges = ModelContextProtocol.extract_auth_challenges( + ModelContextProtocol.build_headers([ + "WWW-Authenticate" => "Bearer RESOURCE_METADATA=\"https://example.test/Mixed\", SCOPE=\"OpenID Profile\", ReAlM=\"MiXeD\"", + ])) + @test length(challenges) == 1 + @test challenges[1].resource_metadata == "https://example.test/Mixed" + @test challenges[1].scopes == ["OpenID", "Profile"] + @test challenges[1].challenge.params["realm"] == "MiXeD" +end + mutable struct StubState headers::Vector{Dict{String,String}} cancellations::Vector{Dict{String,Any}}