Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Expose OIDC as standalone policy #904

Merged
merged 3 commits into from
Oct 1, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

- Prometheus metrics for the 3scale batching policy [PR #902](https://github.com/3scale/apicast/pull/902)
- Support for path in the upstream URL [PR #905](https://github.com/3scale/apicast/pull/905)
- OIDC Authentication policy (only useable directly by the configuration file) [PR #904](https://github.com/3scale/apicast/pull/904)

## [3.3.0-cr2] - 2018-09-25

Expand Down
2 changes: 1 addition & 1 deletion gateway/src/apicast/configuration/service.lua
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ function _M:oauth()
local authentication = self.authentication_method or self.backend_version

if authentication == 'oidc' then
return oauth.oidc.new(self)
return oauth.oidc.new(self.oidc)
elseif authentication == 'oauth' then
return oauth.apicast.new(self)
else
Expand Down
14 changes: 9 additions & 5 deletions gateway/src/apicast/errors.lua
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
local _M = { }

local function exit()
return ngx.exit(ngx.status)
end

function _M.no_credentials(service)
ngx.log(ngx.INFO, 'no credentials provided for service ', service.id)
ngx.var.cached_key = nil
ngx.status = service.auth_missing_status
ngx.header.content_type = service.auth_missing_headers
ngx.print(service.error_auth_missing)
return ngx.exit(ngx.HTTP_OK)
return exit()
end

function _M.authorization_failed(service)
Expand All @@ -15,7 +19,7 @@ function _M.authorization_failed(service)
ngx.status = service.auth_failed_status
ngx.header.content_type = service.auth_failed_headers
ngx.print(service.error_auth_failed)
return ngx.exit(ngx.HTTP_OK)
return exit()
end

function _M.limits_exceeded(service)
Expand All @@ -24,7 +28,7 @@ function _M.limits_exceeded(service)
ngx.status = service.limits_exceeded_status
ngx.header.content_type = service.limits_exceeded_headers
ngx.print(service.error_limits_exceeded)
return ngx.exit(ngx.HTTP_OK)
return exit()
end

function _M.no_match(service)
Expand All @@ -34,14 +38,14 @@ function _M.no_match(service)
ngx.status = service.no_match_status
ngx.header.content_type = service.no_match_headers
ngx.print(service.error_no_match)
return ngx.exit(ngx.HTTP_OK)
return exit()
end

function _M.service_not_found(host)
ngx.status = 404
ngx.print('')
ngx.log(ngx.WARN, 'could not find service for host: ', host or ngx.var.host)
return ngx.exit(ngx.status)
return exit()
end

return _M
121 changes: 81 additions & 40 deletions gateway/src/apicast/oauth/oidc.lua
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ local setmetatable = setmetatable
local ngx_now = ngx.now
local format = string.format
local type = type
local tostring = tostring
local assert = assert

local _M = {
cache_size = 10000,
Expand All @@ -28,20 +30,18 @@ local mt = {

local empty = {}

function _M.new(service)
local oidc = service.oidc or empty

local issuer = oidc.issuer or ""
function _M.new(oidc_config)
local oidc = oidc_config or empty
local issuer = oidc.issuer
local config = oidc.config or empty
local alg_values = config.id_token_signing_alg_values_supported or empty

local err
if #issuer == 0 or #alg_values == 0 then
if not issuer or #alg_values == 0 then
err = 'missing OIDC configuration'
end

return setmetatable({
service = service,
config = config,
issuer = issuer,
keys = oidc.keys or empty,
Expand All @@ -51,7 +51,7 @@ function _M.new(service)
jwt_claims = {
-- 1. The JWT MUST contain an "iss" (issuer) claim that contains a
-- unique identifier for the entity that issued the JWT.
iss = jwt_validators.equals_any_of({ issuer }),
iss = jwt_validators.chain(jwt_validators.required(), issuer and jwt_validators.equals_any_of({ issuer })),

-- 2. The JWT MUST contain a "sub" (subject) claim identifying the
-- principal that is the subject of the JWT.
Expand All @@ -74,6 +74,9 @@ function _M.new(service)
-- 6. The JWT MAY contain an "iat" (issued at) claim that identifies
-- the time at which the JWT was issued.
iat = jwt_validators.opt_greater_than(0),

-- This is keycloak-specific. Its tokens have a 'typ' and we need to verify
typ = jwt_validators.opt_equals_any_of({ 'Bearer' }),
},
}, mt), err
end
Expand All @@ -92,64 +95,102 @@ end
-- Parses the token - in this case we assume it's a JWT token
-- Here we can extract authenticated user's claims or other information returned in the access_token
-- or id_token by RH SSO
local function parse_and_verify_token(self, jwt_token)
local function parse_and_verify_token(self, namespace, jwt_token)
local cache = self.cache

if not cache then
return nil, 'not initialized'
end
local cache_key = format('%s:%s', self.service.id, jwt_token)
local cache_key = format('%s:%s', namespace or '<empty>', jwt_token)

local jwt = self:parse(jwt_token, cache_key)

if jwt.verified then
return jwt
end

local _, err = self:verify(jwt, cache_key)

return jwt, err
end

function _M:parse_and_verify(access_token, cache_key)
local jwt_obj, err = parse_and_verify_token(self, assert(cache_key, 'missing cache key'), access_token)

if err then
if ngx.config.debug then
ngx.log(ngx.DEBUG, 'JWT object: ', require('inspect')(jwt_obj), ' err: ', err, ' reason: ', jwt_obj.reason)
end
return nil, jwt_obj and jwt_obj.reason or err
end

local jwt_obj = cache:get(cache_key)
return jwt_obj
end

if jwt_obj then
local jwt_mt = {
__tostring = function(jwt)
return jwt.token
end
}

local function load_jwt(token)
local jwt = JWT:load_jwt(tostring(token))

jwt.token = token

return setmetatable(jwt, jwt_mt)
end

function _M:parse(jwt, cache_key)
local cached = cache_key and self.cache:get(cache_key)

if cached then
ngx.log(ngx.DEBUG, 'found JWT in cache for ', cache_key)
return jwt_obj
return cached
end

jwt_obj = JWT:load_jwt(jwt_token)
return load_jwt(jwt)
end

if not jwt_obj.valid then
ngx.log(ngx.WARN, jwt_obj.reason)
return jwt_obj, 'JWT not valid'
function _M:verify(jwt, cache_key)
if not jwt then
return false, 'JWT missing'
end

if not self.alg_whitelist[jwt_obj.header.alg] then
return jwt_obj, '[jwt] invalid alg'
if not jwt.valid then
ngx.log(ngx.WARN, jwt.reason)
return false, 'JWT not valid'
end
-- TODO: this should be able to use DER format instead of PEM
local pubkey = find_public_key(jwt_obj, self.keys)

-- This is keycloak-specific. Its tokens have a 'typ' and we need to verify
-- it's Bearer.
local claims = self.jwt_claims
if jwt_obj.payload and jwt_obj.payload.typ then
claims.typ = jwt_validators.equals('Bearer')
if not self.alg_whitelist[jwt.header.alg] then
return false, '[jwt] invalid alg'
end

jwt_obj = JWT:verify_jwt_obj(pubkey, jwt_obj, self.jwt_claims)
-- TODO: this should be able to use DER format instead of PEM
local pubkey = find_public_key(jwt, self.keys)

jwt = JWT:verify_jwt_obj(pubkey, jwt, self.jwt_claims)

if not jwt_obj.verified then
ngx.log(ngx.DEBUG, "[jwt] failed verification for token, reason: ", jwt_obj.reason)
return jwt_obj, "JWT not verified"
if not jwt.verified then
ngx.log(ngx.DEBUG, "[jwt] failed verification for token, reason: ", jwt.reason)
return false, "JWT not verified"
end

ngx.log(ngx.DEBUG, 'adding JWT to cache ', cache_key)
local ttl = timestamp_to_seconds_from_now(jwt_obj.payload.exp, self.clock)
cache:set(cache_key, jwt_obj, ttl)
if cache_key then
ngx.log(ngx.DEBUG, 'adding JWT to cache ', cache_key)
local ttl = timestamp_to_seconds_from_now(jwt.payload.exp, self.clock)
-- use the JWT itself in case there is no cache key
self.cache:set(cache_key, jwt, ttl)
end

return jwt_obj
return true
end


function _M:transform_credentials(credentials)
local jwt_obj, err = parse_and_verify_token(self, credentials.access_token)
function _M:transform_credentials(credentials, cache_key)
local jwt_obj, err = self:parse_and_verify(credentials.access_token, cache_key or '<shared>')

if err then
if ngx.config.debug then
ngx.log(ngx.DEBUG, 'JWT object: ', require('inspect')(jwt_obj), ' err: ', err, ' reason: ', jwt_obj.reason)
end
return nil, nil, nil, jwt_obj and jwt_obj.reason or err
return nil, nil, nil, err
end

local payload = jwt_obj.payload
Expand Down
1 change: 1 addition & 0 deletions gateway/src/apicast/policy/oidc_authentication/init.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
return require('oidc_authentication')
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
-- OpenID Connect Authentication policy
-- It will verify JWT signature against a list of public keys
-- discovered through OIDC Discovery from the IDP.

local lrucache = require('resty.lrucache')
local OIDC = require('apicast.oauth.oidc')
local oidc_discovery = require('resty.oidc.discovery')
local http_authorization = require('resty.http_authorization')
local resty_url = require('resty.url')
local policy = require('apicast.policy')
local _M = policy.new('oidc_authentication')

local tostring = tostring

_M.cache_size = 100
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if this should be exposed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other modules to this too. I think it is reasonable. If you want to you could write a policy that in the init phase bumps cache sizes of other policies.


function _M.init()
_M.cache = lrucache.new(_M.cache_size)
end

local function valid_issuer_endpoint(endpoint)
return resty_url.parse(endpoint) and endpoint
end

local new = _M.new
--- Initialize a oidc_authentication
-- @tparam[opt] table config Policy configuration.
function _M.new(config)
local self = new(config)

self.issuer_endpoint = valid_issuer_endpoint(config and config.issuer_endpoint)
self.discovery = oidc_discovery.new(self.http_backend)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't self.http_backend always nil at this point?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, unless you set it on the module level. self already has the metatable pointing to _M.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍


self.oidc = (config and config.oidc) or OIDC.new(self.discovery:call(self.issuer_endpoint))

self.required = config and config.required

return self
end

local function bearer_token()
return http_authorization.new(ngx.var.http_authorization).token
end

function _M:rewrite(context)
local access_token = bearer_token()

if access_token or self.required then
local jwt, err = self.oidc:parse(access_token)
mikz marked this conversation as resolved.
Show resolved Hide resolved

if jwt then
context[self] = jwt
context.jwt = jwt
else
ngx.log(ngx.WARN, 'failed to parse access token ', access_token, ' err: ', err)
end
end
end

local function exit_status(status)
ngx.status = status
-- TODO: implement content negotiation to generate proper content with an error
return ngx.exit(status)
end

local function challenge_response()
ngx.header.www_authenticate = 'Bearer'

return exit_status(ngx.HTTP_UNAUTHORIZED)
end

function _M:access(context)
local jwt = context[self]

if not jwt or not jwt.token then
if self.required then
return challenge_response()
else
return
end
end

local ok, err = self.oidc:verify(jwt)

if not ok then
ngx.log(ngx.INFO, 'JWT verification error: ', err, ' token: ', tostring(jwt))

return exit_status(ngx.HTTP_FORBIDDEN)
end

return ok
end

return _M
2 changes: 1 addition & 1 deletion gateway/src/apicast/proxy.lua
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ function _M:rewrite(service, context)

if self.oauth then
local jwt_payload
credentials, ttl, jwt_payload, err = self.oauth:transform_credentials(credentials)
credentials, ttl, jwt_payload, err = self.oauth:transform_credentials(credentials, service.id)

if err then
ngx.log(ngx.DEBUG, 'oauth failed with ', err)
Expand Down
6 changes: 5 additions & 1 deletion gateway/src/resty/http_ng.lua
Original file line number Diff line number Diff line change
Expand Up @@ -243,9 +243,13 @@ local function generate_client_method(client, method_or_format)
return add_http_method(client, method_or_format) or chain_serializer(client, method_or_format)
end

function http.backend()
return resty_backend
end

function http.new(client)
client = client or { }
client.backend = client.backend or resty_backend
client.backend = client.backend or http.backend()

return setmetatable(client, { __index = generate_client_method })
end
Expand Down
3 changes: 3 additions & 0 deletions spec/ngx_helper.lua
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
-- so we can copy ngx object and compare it later to check modifications
require('resty.core')

-- so test can't exit and can verify the return status easily
ngx.exit = function(...) return ... end
mikz marked this conversation as resolved.
Show resolved Hide resolved

local busted = require('busted')
local misc = require('resty.core.misc')
local tablex = require('pl.tablex')
Expand Down
Loading