diff --git a/changelog/unreleased/kong/jwt_www_authenticate.yml b/changelog/unreleased/kong/jwt_www_authenticate.yml new file mode 100644 index 000000000000..848527418bf9 --- /dev/null +++ b/changelog/unreleased/kong/jwt_www_authenticate.yml @@ -0,0 +1,3 @@ +message: "**jwt**: Add WWW-Authenticate headers to 401 responses." +type: bugfix +scope: Plugin diff --git a/kong/clustering/compat/removed_fields.lua b/kong/clustering/compat/removed_fields.lua index ef370447bc9f..bee80469bf07 100644 --- a/kong/clustering/compat/removed_fields.lua +++ b/kong/clustering/compat/removed_fields.lua @@ -157,5 +157,8 @@ return { hmac_auth = { "realm", }, + jwt = { + "realm", + }, }, } diff --git a/kong/plugins/jwt/handler.lua b/kong/plugins/jwt/handler.lua index 8bb7a954c65e..45af8fd64c85 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, www_auth_content, errors) + return { status = 401, message = message, headers = { ["WWW-Authenticate"] = www_auth_content }, errors = errors } +end + local function do_authentication(conf) local token, err = retrieve_tokens(conf) @@ -153,21 +157,23 @@ local function do_authentication(conf) return error(err) end + local www_authenticate_base = conf.realm and fmt('Bearer realm="%s"', conf.realm) or 'Bearer' + local www_authenticate_with_error = www_authenticate_base .. ' error="invalid_token"' 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", www_authenticate_base) elseif token_type == "table" then - return false, { status = 401, message = "Multiple tokens provided" } + return false, unauthorized("Multiple tokens provided", www_authenticate_with_error) else - return false, { status = 401, message = "Unrecognizable token" } + return false, unauthorized("Unrecognizable token", www_authenticate_with_error) 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), www_authenticate_with_error) end local claims = jwt.claims @@ -175,9 +181,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", www_authenticate_with_error) 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", www_authenticate_with_error) end -- Retrieve the secret @@ -189,14 +195,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 .. "'", www_authenticate_with_error) 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", www_authenticate_with_error) end local jwt_secret_value = algorithm ~= nil and algorithm:sub(1, 2) == "HS" and @@ -207,25 +213,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", www_authenticate_with_error) 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", www_authenticate_with_error) 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, www_authenticate_with_error, 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, www_authenticate_with_error, errors) end end @@ -252,35 +258,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.exit(err.status, err.errors or { message = 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/kong/plugins/jwt/schema.lua b/kong/plugins/jwt/schema.lua index 5eb1cc02e6f6..0bfaef6e1354 100644 --- a/kong/plugins/jwt/schema.lua +++ b/kong/plugins/jwt/schema.lua @@ -44,6 +44,7 @@ return { elements = { type = "string" }, default = { "authorization" }, }, }, + { realm = { description = "When authentication fails the plugin sends `WWW-Authenticate` header with `realm` attribute value.", type = "string", required = false }, }, }, }, }, diff --git a/spec/02-integration/09-hybrid_mode/09-config-compat_spec.lua b/spec/02-integration/09-hybrid_mode/09-config-compat_spec.lua index f7789afb00b1..e97b1e008d53 100644 --- a/spec/02-integration/09-hybrid_mode/09-config-compat_spec.lua +++ b/spec/02-integration/09-hybrid_mode/09-config-compat_spec.lua @@ -690,6 +690,20 @@ describe("CP/DP config compat transformations #" .. strategy, function() -- cleanup admin.plugins:remove({ id = hmac_auth.id }) end) + + it("[jwt] removes realm for versions below 3.8", function() + local jwt = admin.plugins:insert { + name = "jwt", + config = { + realm = "test", + } + } + local expected_jwt_prior_38 = cycle_aware_deep_copy(jwt) + expected_jwt_prior_38.config.realm = nil + do_assert(uuid(), "3.7.0", expected_jwt_prior_38) + -- cleanup + admin.plugins:remove({ id = jwt.id }) + end) end) describe("compatibility test for response-transformer plugin", function() diff --git a/spec/03-plugins/16-jwt/03-access_spec.lua b/spec/03-plugins/16-jwt/03-access_spec.lua index 972749f604e0..e136dd8f50aa 100644 --- a/spec/03-plugins/16-jwt/03-access_spec.lua +++ b/spec/03-plugins/16-jwt/03-access_spec.lua @@ -133,7 +133,10 @@ for _, strategy in helpers.each_strategy() do plugins:insert({ name = "jwt", route = { id = routes[9].id }, - config = { cookie_names = { "silly", "crumble" } }, + config = { + cookie_names = { "silly", "crumble" }, + realm = "test-jwt" + }, }) plugins:insert({ @@ -298,6 +301,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 +318,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 error="invalid_token"', 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 +335,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 error="invalid_token"', res.headers["WWW-Authenticate"]) end) it("returns 401 Unauthorized if the iss does not match a credential", function() PAYLOAD.iss = "123456789" @@ -346,6 +352,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 error="invalid_token"', res.headers["WWW-Authenticate"]) end) it("returns 401 Unauthorized if the signature is invalid", function() PAYLOAD.iss = jwt_secret.key @@ -362,6 +369,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 error="invalid_token"', 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 +386,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 error="invalid_token"', 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 +408,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 +426,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 error="invalid_token"', res.headers["WWW-Authenticate"]) end) it("accepts a JWT token within the maximum allowed expiration limit", function() local payload = { @@ -456,6 +467,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 error="invalid_token"', res.headers["WWW-Authenticate"]) end) end) @@ -595,6 +607,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 realm="test-jwt" error="invalid_token"', 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 +622,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 realm="test-jwt" error="invalid_token"', 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 +646,7 @@ for _, strategy in helpers.each_strategy() do } }) assert.res_status(401, res) + assert.equal('Bearer realm="test-jwt"', 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 +1115,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 error="invalid_token"', res.headers["WWW-Authenticate"]) end) it("checks if the fields are valid: `exp` claim", function() local payload = { @@ -1117,6 +1133,7 @@ for _, strategy in helpers.each_strategy() do }) local body = assert.res_status(401, res) assert.equal('{"exp":"token expired"}', body) + assert.equal('Bearer error="invalid_token"', res.headers["WWW-Authenticate"]) end) it("checks if the fields are valid: `nbf` claim", function() local payload = { @@ -1134,6 +1151,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 error="invalid_token"', res.headers["WWW-Authenticate"]) end) end) @@ -1348,6 +1366,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 +1379,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 +1391,7 @@ for _, strategy in helpers.each_strategy() do } }) assert.response(res).has.status(401) + assert.equal('Bearer', res.headers["WWW-Authenticate"]) end) end)