diff --git a/src/util.jl b/src/util.jl index 8f5b7c4..0159fd4 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 @@ -196,14 +197,29 @@ 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) - 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 bffd8c6..9901dfd 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -5,6 +5,50 @@ 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 + +@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 + +@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}} @@ -378,7 +422,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