diff --git a/changelog/unreleased/kong/jwt_www_authenticate.yml b/changelog/unreleased/kong/jwt_www_authenticate.yml new file mode 100644 index 000000000000..f77ad83c2ce1 --- /dev/null +++ b/changelog/unreleased/kong/jwt_www_authenticate.yml @@ -0,0 +1,3 @@ +message: Add WWW-Authenticate headers to 401 response in jwt auth plugin. +type: bugfix +scope: Plugin diff --git a/kong/plugins/jwt/handler.lua b/kong/plugins/jwt/handler.lua index 8bb7a954c65e..c2a4b0624a48 100644 --- a/kong/plugins/jwt/handler.lua +++ b/kong/plugins/jwt/handler.lua @@ -146,6 +146,10 @@ local function set_consumer(consumer, credential, token) kong.ctx.shared.authenticated_jwt_token = token -- TODO: wrap in a PDK function? end +local function unauthorized(message, errors) + return { status = 401, message = message, headers = { ["WWW-Authenticate"] = 'Bearer' }, errors = errors } +end + local function do_authentication(conf) local token, err = retrieve_tokens(conf) @@ -156,18 +160,18 @@ local function do_authentication(conf) local token_type = type(token) if token_type ~= "string" then if token_type == "nil" then - return false, { status = 401, message = "Unauthorized" } + return false, unauthorized("Unauthorized") elseif token_type == "table" then - return false, { status = 401, message = "Multiple tokens provided" } + return false, unauthorized("Multiple tokens provided") else - return false, { status = 401, message = "Unrecognizable token" } + return false, unauthorized("Unrecognizable token") end end -- Decode token to find out who the consumer is local jwt, err = jwt_decoder:new(token) if err then - return false, { status = 401, message = "Bad token; " .. tostring(err) } + return false, unauthorized("Bad token; " .. tostring(err)) end local claims = jwt.claims @@ -175,9 +179,9 @@ local function do_authentication(conf) local jwt_secret_key = claims[conf.key_claim_name] or header[conf.key_claim_name] if not jwt_secret_key then - return false, { status = 401, message = "No mandatory '" .. conf.key_claim_name .. "' in claims" } + return false, unauthorized("No mandatory '" .. conf.key_claim_name .. "' in claims") elseif jwt_secret_key == "" then - return false, { status = 401, message = "Invalid '" .. conf.key_claim_name .. "' in claims" } + return false, unauthorized("Invalid '" .. conf.key_claim_name .. "' in claims") end -- Retrieve the secret @@ -189,14 +193,14 @@ local function do_authentication(conf) end if not jwt_secret then - return false, { status = 401, message = "No credentials found for given '" .. conf.key_claim_name .. "'" } + return false, unauthorized("No credentials found for given '" .. conf.key_claim_name .. "'") end local algorithm = jwt_secret.algorithm or "HS256" -- Verify "alg" if jwt.header.alg ~= algorithm then - return false, { status = 401, message = "Invalid algorithm" } + return false, unauthorized("Invalid algorithm") end local jwt_secret_value = algorithm ~= nil and algorithm:sub(1, 2) == "HS" and @@ -207,25 +211,25 @@ local function do_authentication(conf) end if not jwt_secret_value then - return false, { status = 401, message = "Invalid key/secret" } + return false, unauthorized("Invalid key/secret") end -- Now verify the JWT signature if not jwt:verify_signature(jwt_secret_value) then - return false, { status = 401, message = "Invalid signature" } + return false, unauthorized("Invalid signature") end -- Verify the JWT registered claims local ok_claims, errors = jwt:verify_registered_claims(conf.claims_to_verify) if not ok_claims then - return false, { status = 401, errors = errors } + return false, unauthorized(nil, errors) end -- Verify the JWT registered claims if conf.maximum_expiration ~= nil and conf.maximum_expiration > 0 then local ok, errors = jwt:check_maximum_expiration(conf.maximum_expiration) if not ok then - return false, { status = 401, errors = errors } + return false, unauthorized(nil, errors) end end @@ -252,35 +256,55 @@ local function do_authentication(conf) end -function JwtHandler:access(conf) - -- check if preflight request and whether it should be authenticated - if not conf.run_on_preflight and kong.request.get_method() == "OPTIONS" then - return +local function set_anonymous_consumer(anonymous) + local consumer_cache_key = kong.db.consumers:cache_key(anonymous) + local consumer, err = kong.cache:get(consumer_cache_key, nil, + kong.client.load_consumer, + anonymous, true) + if err then + return error(err) end - if conf.anonymous and kong.client.get_credential() then - -- we're already authenticated, and we're configured for using anonymous, - -- hence we're in a logical OR between auth methods and we're already done. + set_consumer(consumer) +end + + +--- When conf.anonymous is enabled we are in "logical OR" authentication flow. +--- Meaning - either anonymous consumer is enabled or there are multiple auth plugins +--- and we need to passthrough on failed authentication. +local function logical_OR_authentication(conf) + if kong.client.get_credential() then + -- we're already authenticated and in "logical OR" between auth methods -- early exit return end + local ok, _ = do_authentication(conf) + if not ok then + set_anonymous_consumer(conf.anonymous) + end +end + +--- When conf.anonymous is not set we are in "logical AND" authentication flow. +--- Meaning - if this authentication fails the request should not be authorized +--- even though other auth plugins might have successfully authorized user. +local function logical_AND_authentication(conf) local ok, err = do_authentication(conf) if not ok then - if conf.anonymous then - -- get anonymous user - local consumer_cache_key = kong.db.consumers:cache_key(conf.anonymous) - local consumer, err = kong.cache:get(consumer_cache_key, nil, - kong.client.load_consumer, - conf.anonymous, true) - if err then - return error(err) - end + return kong.response.error(err.status, err.message, err.headers) + end +end - set_consumer(consumer) - else - return kong.response.exit(err.status, err.errors or { message = err.message }) - end +function JwtHandler:access(conf) + -- check if preflight request and whether it should be authenticated + if not conf.run_on_preflight and kong.request.get_method() == "OPTIONS" then + return + end + + if conf.anonymous then + return logical_OR_authentication(conf) + else + return logical_AND_authentication(conf) end end diff --git a/spec/03-plugins/16-jwt/03-access_spec.lua b/spec/03-plugins/16-jwt/03-access_spec.lua index d091fb8e478d..7318151f325e 100644 --- a/spec/03-plugins/16-jwt/03-access_spec.lua +++ b/spec/03-plugins/16-jwt/03-access_spec.lua @@ -298,6 +298,7 @@ for _, strategy in helpers.each_strategy() do } }) assert.res_status(401, res) + assert.equal('Bearer', res.headers["WWW-Authenticate"]) end) it("returns 401 if the claims do not contain the key to identify a secret", function() PAYLOAD.iss = nil @@ -314,6 +315,7 @@ for _, strategy in helpers.each_strategy() do local body = assert.res_status(401, res) local json = cjson.decode(body) assert.same({ message = "No mandatory 'iss' in claims" }, json) + assert.equal('Bearer', res.headers["WWW-Authenticate"]) end) it("returns 401 if the claims do not contain a valid key to identify a secret", function() PAYLOAD.iss = "" @@ -330,6 +332,7 @@ for _, strategy in helpers.each_strategy() do local body = assert.res_status(401, res) local json = cjson.decode(body) assert.same({ message = "Invalid 'iss' in claims" }, json) + assert.equal('Bearer', res.headers["WWW-Authenticate"]) end) it("returns 401 Unauthorized if the iss does not match a credential", function() PAYLOAD.iss = "123456789" @@ -346,6 +349,7 @@ for _, strategy in helpers.each_strategy() do local body = assert.res_status(401, res) local json = cjson.decode(body) assert.same({ message = "No credentials found for given 'iss'" }, json) + assert.equal('Bearer', res.headers["WWW-Authenticate"]) end) it("returns 401 Unauthorized if the signature is invalid", function() PAYLOAD.iss = jwt_secret.key @@ -362,6 +366,7 @@ for _, strategy in helpers.each_strategy() do local body = assert.res_status(401, res) local json = cjson.decode(body) assert.same({ message = "Invalid signature" }, json) + assert.equal('Bearer', res.headers["WWW-Authenticate"]) end) it("returns 401 Unauthorized if the alg does not match the credential", function() local header = {typ = "JWT", alg = 'RS256'} @@ -378,6 +383,7 @@ for _, strategy in helpers.each_strategy() do local body = assert.res_status(401, res) local json = cjson.decode(body) assert.same({ message = "Invalid algorithm" }, json) + assert.equal('Bearer', res.headers["WWW-Authenticate"]) end) it("returns 200 on OPTIONS requests if run_on_preflight is false", function() local res = assert(proxy_client:send { @@ -399,6 +405,7 @@ for _, strategy in helpers.each_strategy() do }) local body = assert.res_status(401, res) assert.equal([[{"message":"Unauthorized"}]], body) + assert.equal('Bearer', res.headers["WWW-Authenticate"]) end) it("returns 401 if the token exceeds the maximum allowed expiration limit", function() local payload = { @@ -416,6 +423,7 @@ for _, strategy in helpers.each_strategy() do }) local body = assert.res_status(401, res) assert.equal('{"exp":"exceeds maximum allowed expiration"}', body) + assert.equal('Bearer', res.headers["WWW-Authenticate"]) end) it("accepts a JWT token within the maximum allowed expiration limit", function() local payload = { @@ -456,6 +464,7 @@ for _, strategy in helpers.each_strategy() do }) local body = cjson.decode(assert.res_status(401, res)) assert.same({ message = "Multiple tokens provided" }, body) + assert.equal('Bearer', res.headers["WWW-Authenticate"]) end) end) @@ -595,6 +604,7 @@ for _, strategy in helpers.each_strategy() do local body = assert.res_status(401, res) local json = cjson.decode(body) assert.same({ message = "No credentials found for given 'iss'" }, json) + assert.equal('Bearer', res.headers["WWW-Authenticate"]) end) it("returns a 401 if the JWT in the cookie is corrupted", function() PAYLOAD.iss = jwt_secret.key @@ -609,6 +619,7 @@ for _, strategy in helpers.each_strategy() do }) local body = assert.res_status(401, res) assert.equal([[{"message":"Bad token; invalid JSON"}]], body) + assert.equal('Bearer', res.headers["WWW-Authenticate"]) end) it("reports a 200 without cookies but with a JWT token in the Authorization header", function() PAYLOAD.iss = jwt_secret.key @@ -632,6 +643,7 @@ for _, strategy in helpers.each_strategy() do } }) assert.res_status(401, res) + assert.equal('Bearer', res.headers["WWW-Authenticate"]) end) it("returns 200 without cookies but with a JWT token in the CustomAuthorization header", function() PAYLOAD.iss = jwt_secret.key @@ -1100,6 +1112,7 @@ for _, strategy in helpers.each_strategy() do }) local body = cjson.decode(assert.res_status(401, res)) assert.same({ nbf="must be a number", exp="must be a number" }, body) + assert.equal('Bearer', res.headers["WWW-Authenticate"]) end) it("checks if the fields are valid: `exp` claim", function() local payload = { @@ -1117,6 +1130,7 @@ for _, strategy in helpers.each_strategy() do }) local body = assert.res_status(401, res) assert.equal('{"exp":"token expired"}', body) + assert.equal('Bearer', res.headers["WWW-Authenticate"]) end) it("checks if the fields are valid: `nbf` claim", function() local payload = { @@ -1134,6 +1148,7 @@ for _, strategy in helpers.each_strategy() do }) local body = assert.res_status(401, res) assert.equal('{"nbf":"token not valid yet"}', body) + assert.equal('Bearer', res.headers["WWW-Authenticate"]) end) end) @@ -1348,6 +1363,7 @@ for _, strategy in helpers.each_strategy() do } }) assert.response(res).has.status(401) + assert.equal('Bearer', res.headers["WWW-Authenticate"]) end) it("fails 401, with only the second credential provided", function() @@ -1360,6 +1376,7 @@ for _, strategy in helpers.each_strategy() do } }) assert.response(res).has.status(401) + assert.equal('Key', res.headers["WWW-Authenticate"]) end) it("fails 401, with no credential provided", function() @@ -1371,6 +1388,7 @@ for _, strategy in helpers.each_strategy() do } }) assert.response(res).has.status(401) + assert.equal('Bearer', res.headers["WWW-Authenticate"]) end) end)