Skip to content

Commit

Permalink
[oidc] expose OIDC as standalone policy
Browse files Browse the repository at this point in the history
  • Loading branch information
mikz committed Oct 1, 2018
1 parent 576fa67 commit 50d698c
Show file tree
Hide file tree
Showing 9 changed files with 497 additions and 72 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
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
Loading

0 comments on commit 50d698c

Please sign in to comment.