Skip to content

Commit

Permalink
Merge pull request #904 from 3scale/oidc-policy
Browse files Browse the repository at this point in the history
Expose OIDC as standalone policy
  • Loading branch information
mikz authored Oct 1, 2018
2 parents 48f7ead + 50d698c commit ab4ce9d
Show file tree
Hide file tree
Showing 14 changed files with 537 additions and 83 deletions.
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

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)

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)

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

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

0 comments on commit ab4ce9d

Please sign in to comment.