| -- |
| -- Licensed to the Apache Software Foundation (ASF) under one or more |
| -- contributor license agreements. See the NOTICE file distributed with |
| -- this work for additional information regarding copyright ownership. |
| -- The ASF licenses this file to You under the Apache License, Version 2.0 |
| -- (the "License"); you may not use this file except in compliance with |
| -- the License. You may obtain a copy of the License at |
| -- |
| -- http://www.apache.org/licenses/LICENSE-2.0 |
| -- |
| -- Unless required by applicable law or agreed to in writing, software |
| -- distributed under the License is distributed on an "AS IS" BASIS, |
| -- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| -- See the License for the specific language governing permissions and |
| -- limitations under the License. |
| -- |
| local core = require("apisix.core") |
| local http = require "resty.http" |
| local sub_str = string.sub |
| local type = type |
| local ngx = ngx |
| local plugin_name = "authz-keycloak" |
| local fetch_secrets = require("apisix.secret").fetch_secrets |
| |
| local log = core.log |
| local pairs = pairs |
| |
| local schema = { |
| type = "object", |
| properties = { |
| discovery = {type = "string", minLength = 1, maxLength = 4096}, |
| token_endpoint = {type = "string", minLength = 1, maxLength = 4096}, |
| resource_registration_endpoint = {type = "string", minLength = 1, maxLength = 4096}, |
| client_id = {type = "string", minLength = 1, maxLength = 100}, |
| client_secret = {type = "string", minLength = 1, maxLength = 100}, |
| grant_type = { |
| type = "string", |
| default="urn:ietf:params:oauth:grant-type:uma-ticket", |
| enum = {"urn:ietf:params:oauth:grant-type:uma-ticket"}, |
| minLength = 1, maxLength = 100 |
| }, |
| policy_enforcement_mode = { |
| type = "string", |
| enum = {"ENFORCING", "PERMISSIVE"}, |
| default = "ENFORCING" |
| }, |
| permissions = { |
| type = "array", |
| items = { |
| type = "string", |
| minLength = 1, maxLength = 100 |
| }, |
| uniqueItems = true, |
| default = {} |
| }, |
| lazy_load_paths = {type = "boolean", default = false}, |
| http_method_as_scope = {type = "boolean", default = false}, |
| timeout = {type = "integer", minimum = 1000, default = 3000}, |
| ssl_verify = {type = "boolean", default = true}, |
| cache_ttl_seconds = {type = "integer", minimum = 1, default = 24 * 60 * 60}, |
| keepalive = {type = "boolean", default = true}, |
| keepalive_timeout = {type = "integer", minimum = 1000, default = 60000}, |
| keepalive_pool = {type = "integer", minimum = 1, default = 5}, |
| access_denied_redirect_uri = {type = "string", minLength = 1, maxLength = 2048}, |
| access_token_expires_in = {type = "integer", minimum = 1, default = 300}, |
| access_token_expires_leeway = {type = "integer", minimum = 0, default = 0}, |
| refresh_token_expires_in = {type = "integer", minimum = 1, default = 3600}, |
| refresh_token_expires_leeway = {type = "integer", minimum = 0, default = 0}, |
| password_grant_token_generation_incoming_uri = { |
| type = "string", |
| minLength = 1, |
| maxLength = 4096 |
| }, |
| }, |
| encrypt_fields = {"client_secret"}, |
| required = {"client_id"}, |
| allOf = { |
| -- Require discovery or token endpoint. |
| { |
| anyOf = { |
| {required = {"discovery"}}, |
| {required = {"token_endpoint"}} |
| } |
| }, |
| -- If lazy_load_paths is true, require discovery or resource registration endpoint. |
| { |
| anyOf = { |
| { |
| properties = { |
| lazy_load_paths = {enum = {false}}, |
| } |
| }, |
| { |
| properties = { |
| lazy_load_paths = {enum = {true}}, |
| }, |
| anyOf = { |
| {required = {"discovery"}}, |
| {required = {"resource_registration_endpoint"}} |
| } |
| } |
| } |
| } |
| } |
| } |
| |
| |
| local _M = { |
| version = 0.1, |
| priority = 2000, |
| name = plugin_name, |
| schema = schema, |
| } |
| |
| |
| function _M.check_schema(conf) |
| return core.schema.check(schema, conf) |
| end |
| |
| |
| -- Some auxiliary functions below heavily inspired by the excellent |
| -- lua-resty-openidc module; see https://github.com/zmartzone/lua-resty-openidc |
| |
| |
| -- Retrieve value from server-wide cache, if available. |
| local function authz_keycloak_cache_get(type, key) |
| local dict = ngx.shared[type] |
| local value |
| if dict then |
| value = dict:get(key) |
| if value then log.debug("cache hit: type=", type, " key=", key) end |
| end |
| return value |
| end |
| |
| |
| -- Set value in server-wide cache, if available. |
| local function authz_keycloak_cache_set(type, key, value, exp) |
| local dict = ngx.shared[type] |
| if dict and (exp > 0) then |
| local success, err, forcible = dict:set(key, value, exp) |
| if err then |
| log.error("cache set: success=", success, " err=", err, " forcible=", forcible) |
| else |
| log.debug("cache set: success=", success, " err=", err, " forcible=", forcible) |
| end |
| end |
| end |
| |
| |
| -- Configure request parameters. |
| local function authz_keycloak_configure_params(params, conf) |
| -- Keepalive options. |
| if conf.keepalive then |
| params.keepalive_timeout = conf.keepalive_timeout |
| params.keepalive_pool = conf.keepalive_pool |
| else |
| params.keepalive = conf.keepalive |
| end |
| |
| -- TLS verification. |
| params.ssl_verify = conf.ssl_verify |
| |
| -- Decorate parameters, maybe, and return. |
| return conf.http_request_decorator and conf.http_request_decorator(params) or params |
| end |
| |
| |
| -- Configure timeouts. |
| local function authz_keycloak_configure_timeouts(httpc, timeout) |
| if timeout then |
| if type(timeout) == "table" then |
| httpc:set_timeouts(timeout.connect or 0, timeout.send or 0, timeout.read or 0) |
| else |
| httpc:set_timeout(timeout) |
| end |
| end |
| end |
| |
| |
| -- Set outgoing proxy options. |
| local function authz_keycloak_configure_proxy(httpc, proxy_opts) |
| if httpc and proxy_opts and type(proxy_opts) == "table" then |
| log.debug("authz_keycloak_configure_proxy : use http proxy") |
| httpc:set_proxy_options(proxy_opts) |
| else |
| log.debug("authz_keycloak_configure_proxy : don't use http proxy") |
| end |
| end |
| |
| |
| -- Get and configure HTTP client. |
| local function authz_keycloak_get_http_client(conf) |
| local httpc = http.new() |
| authz_keycloak_configure_timeouts(httpc, conf.timeout) |
| authz_keycloak_configure_proxy(httpc, conf.proxy_opts) |
| return httpc |
| end |
| |
| |
| -- Parse the JSON result from a call to the OP. |
| local function authz_keycloak_parse_json_response(response) |
| local err |
| local res |
| |
| -- Check the response from the OP. |
| if response.status ~= 200 then |
| err = "response indicates failure, status=" .. response.status .. ", body=" .. response.body |
| else |
| -- Decode the response and extract the JSON object. |
| res, err = core.json.decode(response.body) |
| |
| if not res then |
| err = "JSON decoding failed: " .. err |
| end |
| end |
| |
| return res, err |
| end |
| |
| |
| -- Get the Discovery metadata from the specified URL. |
| local function authz_keycloak_discover(conf) |
| log.debug("authz_keycloak_discover: URL is: " .. conf.discovery) |
| |
| local json, err |
| local v = authz_keycloak_cache_get("discovery", conf.discovery) |
| |
| if not v then |
| log.debug("Discovery data not in cache, making call to discovery endpoint.") |
| |
| -- Make the call to the discovery endpoint. |
| local httpc = authz_keycloak_get_http_client(conf) |
| |
| local params = authz_keycloak_configure_params({}, conf) |
| |
| local res, error = httpc:request_uri(conf.discovery, params) |
| |
| if not res then |
| err = "Accessing discovery URL (" .. conf.discovery .. ") failed: " .. error |
| log.error(err) |
| else |
| log.debug("Response data: " .. res.body) |
| json, err = authz_keycloak_parse_json_response(res) |
| if json then |
| authz_keycloak_cache_set("discovery", conf.discovery, core.json.encode(json), |
| conf.cache_ttl_seconds) |
| else |
| err = "could not decode JSON from Discovery data" .. (err and (": " .. err) or '') |
| log.error(err) |
| end |
| end |
| else |
| json = core.json.decode(v) |
| end |
| |
| return json, err |
| end |
| |
| |
| -- Turn a discovery url set in the conf dictionary into the discovered information. |
| local function authz_keycloak_ensure_discovered_data(conf) |
| local err |
| if type(conf.discovery) == "string" then |
| local discovery |
| discovery, err = authz_keycloak_discover(conf) |
| if not err then |
| conf.discovery = discovery |
| end |
| end |
| return err |
| end |
| |
| |
| -- Get an endpoint from the configuration. |
| local function authz_keycloak_get_endpoint(conf, endpoint) |
| if conf and conf[endpoint] then |
| -- Use explicit entry. |
| return conf[endpoint] |
| elseif conf and conf.discovery and type(conf.discovery) == "table" then |
| -- Use discovery data. |
| return conf.discovery[endpoint] |
| end |
| |
| -- Unable to obtain endpoint. |
| return nil |
| end |
| |
| |
| -- Return the token endpoint from the configuration. |
| local function authz_keycloak_get_token_endpoint(conf) |
| return authz_keycloak_get_endpoint(conf, "token_endpoint") |
| end |
| |
| |
| -- Return the resource registration endpoint from the configuration. |
| local function authz_keycloak_get_resource_registration_endpoint(conf) |
| return authz_keycloak_get_endpoint(conf, "resource_registration_endpoint") |
| end |
| |
| |
| -- Return access_token expires_in value (in seconds). |
| local function authz_keycloak_access_token_expires_in(conf, expires_in) |
| return (expires_in or conf.access_token_expires_in) |
| - 1 - conf.access_token_expires_leeway |
| end |
| |
| |
| -- Return refresh_token expires_in value (in seconds). |
| local function authz_keycloak_refresh_token_expires_in(conf, expires_in) |
| return (expires_in or conf.refresh_token_expires_in) |
| - 1 - conf.refresh_token_expires_leeway |
| end |
| |
| |
| -- Ensure a valid service account access token is available for the configured client. |
| local function authz_keycloak_ensure_sa_access_token(conf) |
| local client_id = conf.client_id |
| local ttl = conf.cache_ttl_seconds |
| local token_endpoint = authz_keycloak_get_token_endpoint(conf) |
| |
| if not token_endpoint then |
| log.error("Unable to determine token endpoint.") |
| return 503, "Unable to determine token endpoint." |
| end |
| |
| local session = authz_keycloak_cache_get("access-tokens", token_endpoint .. ":" |
| .. client_id) |
| |
| if session then |
| -- Decode session string. |
| local err |
| session, err = core.json.decode(session) |
| |
| if not session then |
| -- Should never happen. |
| return 500, err |
| end |
| |
| local current_time = ngx.time() |
| |
| if current_time < session.access_token_expiration then |
| -- Access token is still valid. |
| log.debug("Access token is still valid.") |
| return session.access_token |
| else |
| -- Access token has expired. |
| log.debug("Access token has expired.") |
| if session.refresh_token |
| and (not session.refresh_token_expiration |
| or current_time < session.refresh_token_expiration) then |
| -- Try to get a new access token, using the refresh token. |
| log.debug("Trying to get new access token using refresh token.") |
| |
| local httpc = authz_keycloak_get_http_client(conf) |
| |
| local params = { |
| method = "POST", |
| body = ngx.encode_args({ |
| grant_type = "refresh_token", |
| client_id = client_id, |
| client_secret = conf.client_secret, |
| refresh_token = session.refresh_token, |
| }), |
| headers = { |
| ["Content-Type"] = "application/x-www-form-urlencoded" |
| } |
| } |
| |
| params = authz_keycloak_configure_params(params, conf) |
| |
| local res, err = httpc:request_uri(token_endpoint, params) |
| |
| if not res then |
| err = "Accessing token endpoint URL (" .. token_endpoint |
| .. ") failed: " .. err |
| log.error(err) |
| return nil, err |
| end |
| |
| log.debug("Response data: " .. res.body) |
| local json, err = authz_keycloak_parse_json_response(res) |
| |
| if not json then |
| err = "Could not decode JSON from token endpoint" |
| .. (err and (": " .. err) or '.') |
| log.error(err) |
| return nil, err |
| end |
| |
| if not json.access_token then |
| -- Clear session. |
| log.debug("Answer didn't contain a new access token. Clearing session.") |
| session = nil |
| else |
| log.debug("Got new access token.") |
| -- Save access token. |
| session.access_token = json.access_token |
| |
| -- Calculate and save access token expiry time. |
| session.access_token_expiration = current_time |
| + authz_keycloak_access_token_expires_in(conf, json.expires_in) |
| |
| -- Save refresh token, maybe. |
| if json.refresh_token ~= nil then |
| log.debug("Got new refresh token.") |
| session.refresh_token = json.refresh_token |
| |
| -- Calculate and save refresh token expiry time. |
| session.refresh_token_expiration = current_time |
| + authz_keycloak_refresh_token_expires_in(conf, |
| json.refresh_expires_in) |
| end |
| |
| authz_keycloak_cache_set("access-tokens", |
| token_endpoint .. ":" .. client_id, |
| core.json.encode(session), ttl) |
| end |
| else |
| -- No refresh token available, or it has expired. Clear session. |
| log.debug("No or expired refresh token. Clearing session.") |
| session = nil |
| end |
| end |
| end |
| |
| if not session then |
| -- No session available. Create a new one. |
| |
| log.debug("Getting access token for Protection API from token endpoint.") |
| local httpc = authz_keycloak_get_http_client(conf) |
| |
| local params = { |
| method = "POST", |
| body = ngx.encode_args({ |
| grant_type = "client_credentials", |
| client_id = client_id, |
| client_secret = conf.client_secret, |
| }), |
| headers = { |
| ["Content-Type"] = "application/x-www-form-urlencoded" |
| } |
| } |
| |
| params = authz_keycloak_configure_params(params, conf) |
| |
| local current_time = ngx.time() |
| |
| local res, err = httpc:request_uri(token_endpoint, params) |
| |
| if not res then |
| err = "Accessing token endpoint URL (" .. token_endpoint .. ") failed: " .. err |
| log.error(err) |
| return nil, err |
| end |
| |
| log.debug("Response data: " .. res.body) |
| local json, err = authz_keycloak_parse_json_response(res) |
| |
| if not json then |
| err = "Could not decode JSON from token endpoint" .. (err and (": " .. err) or '.') |
| log.error(err) |
| return nil, err |
| end |
| |
| if not json.access_token then |
| err = "Response does not contain access_token field." |
| log.error(err) |
| return nil, err |
| end |
| |
| session = {} |
| |
| -- Save access token. |
| session.access_token = json.access_token |
| |
| -- Calculate and save access token expiry time. |
| session.access_token_expiration = current_time |
| + authz_keycloak_access_token_expires_in(conf, json.expires_in) |
| |
| -- Save refresh token, maybe. |
| if json.refresh_token ~= nil then |
| session.refresh_token = json.refresh_token |
| |
| -- Calculate and save refresh token expiry time. |
| session.refresh_token_expiration = current_time |
| + authz_keycloak_refresh_token_expires_in(conf, json.refresh_expires_in) |
| end |
| |
| authz_keycloak_cache_set("access-tokens", token_endpoint .. ":" .. client_id, |
| core.json.encode(session), ttl) |
| end |
| |
| return session.access_token |
| end |
| |
| |
| -- Resolve a URI to one or more resource IDs. |
| local function authz_keycloak_resolve_resource(conf, uri, sa_access_token) |
| -- Get resource registration endpoint URL. |
| local resource_registration_endpoint = authz_keycloak_get_resource_registration_endpoint(conf) |
| |
| if not resource_registration_endpoint then |
| local err = "Unable to determine registration endpoint." |
| log.error(err) |
| return nil, err |
| end |
| |
| log.debug("Resource registration endpoint: ", resource_registration_endpoint) |
| |
| local httpc = authz_keycloak_get_http_client(conf) |
| |
| local params = { |
| method = "GET", |
| query = {uri = uri, matchingUri = "true"}, |
| headers = { |
| ["Authorization"] = "Bearer " .. sa_access_token |
| } |
| } |
| |
| params = authz_keycloak_configure_params(params, conf) |
| |
| local res, err = httpc:request_uri(resource_registration_endpoint, params) |
| |
| if not res then |
| err = "Accessing resource registration endpoint URL (" .. resource_registration_endpoint |
| .. ") failed: " .. err |
| log.error(err) |
| return nil, err |
| end |
| |
| log.debug("Response data: " .. res.body) |
| res.body = '{"resources": ' .. res.body .. '}' |
| local json, err = authz_keycloak_parse_json_response(res) |
| |
| if not json then |
| err = "Could not decode JSON from resource registration endpoint" |
| .. (err and (": " .. err) or '.') |
| log.error(err) |
| return nil, err |
| end |
| |
| return json.resources |
| end |
| |
| |
| local function evaluate_permissions(conf, ctx, token) |
| -- Ensure discovered data. |
| local err = authz_keycloak_ensure_discovered_data(conf) |
| if err then |
| return 503, err |
| end |
| |
| local permission |
| |
| if conf.lazy_load_paths then |
| -- Ensure service account access token. |
| local sa_access_token, err = authz_keycloak_ensure_sa_access_token(conf) |
| if err then |
| log.error(err) |
| return 503, err |
| end |
| |
| -- Resolve URI to resource(s). |
| permission, err = authz_keycloak_resolve_resource(conf, ctx.var.request_uri, |
| sa_access_token) |
| |
| -- Check result. |
| if permission == nil then |
| -- No result back from resource registration endpoint. |
| log.error(err) |
| return 503, err |
| end |
| else |
| -- Use statically configured permissions. |
| permission = conf.permissions |
| end |
| |
| -- Return 403 or 307 if permission is empty and enforcement mode is "ENFORCING". |
| if #permission == 0 and conf.policy_enforcement_mode == "ENFORCING" then |
| -- Return Keycloak-style message for consistency. |
| if conf.access_denied_redirect_uri then |
| core.response.set_header("Location", conf.access_denied_redirect_uri) |
| return 307 |
| end |
| return 403, '{"error":"access_denied","error_description":"not_authorized"}' |
| end |
| |
| -- Determine scope from HTTP method, maybe. |
| local scope |
| if conf.http_method_as_scope then |
| scope = ctx.var.request_method |
| end |
| |
| if scope then |
| -- Loop over permissions and add scope. |
| for k, v in pairs(permission) do |
| if v:find("#", 1, true) then |
| -- Already contains scope. |
| permission[k] = v .. ", " .. scope |
| else |
| -- Doesn't contain scope yet. |
| permission[k] = v .. "#" .. scope |
| end |
| end |
| end |
| |
| for k, v in pairs(permission) do |
| log.debug("Requesting permission ", v, ".") |
| end |
| |
| -- Get token endpoint URL. |
| local token_endpoint = authz_keycloak_get_token_endpoint(conf) |
| if not token_endpoint then |
| err = "Unable to determine token endpoint." |
| log.error(err) |
| return 503, err |
| end |
| log.debug("Token endpoint: ", token_endpoint) |
| |
| local httpc = authz_keycloak_get_http_client(conf) |
| |
| local params = { |
| method = "POST", |
| body = ngx.encode_args({ |
| grant_type = conf.grant_type, |
| audience = conf.client_id, |
| response_mode = "decision", |
| permission = permission |
| }), |
| headers = { |
| ["Content-Type"] = "application/x-www-form-urlencoded", |
| ["Authorization"] = token |
| } |
| } |
| |
| params = authz_keycloak_configure_params(params, conf) |
| |
| local res, err = httpc:request_uri(token_endpoint, params) |
| |
| if not res then |
| err = "Error while sending authz request to " .. token_endpoint .. ": " .. err |
| log.error(err) |
| return 503 |
| end |
| |
| log.debug("Response status: ", res.status, ", data: ", res.body) |
| |
| if res.status == 403 then |
| -- Request permanently denied, e.g. due to lacking permissions. |
| log.debug('Request denied: HTTP 403 Forbidden. Body: ', res.body) |
| if conf.access_denied_redirect_uri then |
| core.response.set_header("Location", conf.access_denied_redirect_uri) |
| return 307 |
| end |
| |
| return res.status, res.body |
| elseif res.status == 401 then |
| -- Request temporarily denied, e.g access token not valid. |
| log.debug('Request denied: HTTP 401 Unauthorized. Body: ', res.body) |
| return res.status, res.body |
| elseif res.status >= 400 then |
| -- Some other error. Log full response. |
| log.error('Request denied: Token endpoint returned an error (status: ', |
| res.status, ', body: ', res.body, ').') |
| return res.status, res.body |
| end |
| |
| -- Request accepted. |
| end |
| |
| |
| local function fetch_jwt_token(ctx) |
| local token = core.request.header(ctx, "Authorization") |
| if not token then |
| return nil, "authorization header not available" |
| end |
| |
| local prefix = sub_str(token, 1, 7) |
| if prefix ~= 'Bearer ' and prefix ~= 'bearer ' then |
| return "Bearer " .. token |
| end |
| return token |
| end |
| |
| -- To get new access token by calling get token api |
| local function generate_token_using_password_grant(conf,ctx) |
| log.debug("generate_token_using_password_grant Function Called") |
| |
| local body, err = core.request.get_body() |
| if err or not body then |
| log.error("Failed to get request body: ", err) |
| return 503 |
| end |
| local parameters = core.string.decode_args(body) |
| |
| local username = parameters["username"] |
| local password = parameters["password"] |
| |
| if not username then |
| local err = "username is missing." |
| log.warn(err) |
| return 422, {message = err} |
| end |
| if not password then |
| local err = "password is missing." |
| log.warn(err) |
| return 422, {message = err} |
| end |
| |
| local client_id = conf.client_id |
| |
| local token_endpoint = authz_keycloak_get_token_endpoint(conf) |
| |
| if not token_endpoint then |
| local err = "Unable to determine token endpoint." |
| log.error(err) |
| return 503, {message = err} |
| end |
| local httpc = authz_keycloak_get_http_client(conf) |
| |
| local params = { |
| method = "POST", |
| body = ngx.encode_args({ |
| grant_type = "password", |
| client_id = client_id, |
| client_secret = conf.client_secret, |
| username = username, |
| password = password |
| }), |
| headers = { |
| ["Content-Type"] = "application/x-www-form-urlencoded" |
| } |
| } |
| |
| params = authz_keycloak_configure_params(params, conf) |
| |
| local res, err = httpc:request_uri(token_endpoint, params) |
| |
| if not res then |
| err = "Accessing token endpoint URL (" .. token_endpoint |
| .. ") failed: " .. err |
| log.error(err) |
| return 401, {message = "Accessing token endpoint URL failed."} |
| end |
| |
| log.debug("Response data: " .. res.body) |
| local json, err = authz_keycloak_parse_json_response(res) |
| |
| if not json then |
| err = "Could not decode JSON from response" |
| .. (err and (": " .. err) or '.') |
| log.error(err) |
| return 401, {message = "Could not decode JSON from response."} |
| end |
| |
| return res.status, res.body |
| end |
| |
| function _M.access(conf, ctx) |
| -- resolve secrets |
| conf = fetch_secrets(conf, true, conf, "") |
| local headers = core.request.headers(ctx) |
| local need_grant_token = conf.password_grant_token_generation_incoming_uri and |
| ctx.var.request_uri == conf.password_grant_token_generation_incoming_uri and |
| headers["content-type"] == "application/x-www-form-urlencoded" and |
| core.request.get_method() == "POST" |
| if need_grant_token then |
| return generate_token_using_password_grant(conf,ctx) |
| end |
| log.debug("hit keycloak-auth access") |
| local jwt_token, err = fetch_jwt_token(ctx) |
| if not jwt_token then |
| log.error("failed to fetch JWT token: ", err) |
| return 401, {message = "Missing JWT token in request"} |
| end |
| |
| local status, body = evaluate_permissions(conf, ctx, jwt_token) |
| if status then |
| return status, body |
| end |
| end |
| |
| |
| return _M |