-
Notifications
You must be signed in to change notification settings - Fork 170
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
Add token introspection feature as a policy. [THREESCALE-312] #619
Conversation
local introspection_url = config.introspection_url | ||
local client = config.client_id | ||
local secret = config.client_secret | ||
local credential = 'Basic ' .. ngx.encode_base64(table.concat({ client or '', secret or '' }, ':')) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This header can be moved to the _M.new, right?
if self.config.introspection_url then | ||
local authorization = http_authorization.new(ngx.var.http_authorization) | ||
local access_token = authorization.token | ||
if not introspect_token(self, access_token) then |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it would be a bit nicer for introspect_token
to return a table so this would look like:
if introspect_token(self, access_token).active then
...
else
...
end
Or rename introspect_token
to active_token
because it actually returns true/false if the token is active or not.
|
||
if res.status == 200 then | ||
local token_info = cjson.decode(res.body) | ||
return token_info.active |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this active
field defined in some RFC? Could we link to the relevant RFC section in a comment?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Came by to look at your PR review automation, stayed to help out w/ a little PR doc pointing. :-) Seems the appropriate RFC doc entry point is: https://tools.ietf.org/html/rfc7662#section-2.2 Where active
is the only REQUIRED member of the returned JSON introspection result.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@reedstrm very nice. Thanks! This looks like the right doc.
@tmogi001 for cases where is RFC available I try to implement it as:
https://github.com/3scale/apicast/blob/5168008db2abb2e217d23f925599fd33da5248cb/gateway/src/resty/http_ng/cache_store.lua#L85-L110
Because the RFC is immutable the comments won't get out of sync and act as relevant context with links to the other parts of the RFC. In this case just the relevant parts to the code in question to document the requests and responses for example.
BTW it is pity OpenID Connect Discovery does not define token introspection endpoint. Some IDPs provide it (like Keycloak as token_introspection_endpoint
), but I don't see it being part of the spec.
} | ||
} | ||
|
||
local res, err = self.http_client.post(introspection_url , { token = token, token_type_hint = 'access_token'}, opts) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would be good to link to the RFC section describing that introspection endpoint receives POST and what parameters.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
https://tools.ietf.org/html/rfc7662#section-2.1 - only REQUIRED param is the token
local token_policy = require('apicast.policy.token_introspection').new(policy_config) | ||
token_policy:access(context) | ||
|
||
test_backend.verify_no_outstanding_expectations() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This line would be better in after_each
block so it is not repeated in every test.
client_secret = "secret" | ||
} | ||
|
||
test_backend.expect{ url = introspection_url }. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It probably should expect right http method (POST) too ? And some other parameters like token_type_hint ? And possibly the headers?
local introspection_url = config.introspection_url | ||
local client = config.client_id | ||
local secret = config.client_secret | ||
local credential = 'Basic ' .. ngx.encode_base64(table.concat({ client or '', secret or '' }, ':')) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@davidor I think we should provide a way to do this in http_ng constructor. Because it is done one several places IIRC.
backend = config.client, | ||
options = { | ||
header = { ['User-Agent'] = user_agent() }, | ||
ssl = { verify = resty_env.enabled('OPENSSL_VERIFY') } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@davidor we probably should provide a default value for these options/headers in the http_ng constructor to simplify its initialization.
@tmogi001 this is very good! 👍 🥇 My comments will be just nitpicks. This should be just minimal effort to be mergeable. |
@mikz - As a random drive-by developer, with a group working on making our PR review policies more explicit (and automating as much as possible :-) - I landed here following examples of use of the CHANGELOG github app) I just wanted to say that I liked your review style here - not only do you review the code-as-presented, you call out issues w/ the shared testing infrastructure, to make future coding easier. |
@reedstrm thank you very much! Means a lot. Regarding the Changelog app (if you mean https://github.com/mikz/probot-changelog). I'm quite obsessed with automation and other approaches I was considering were:
But a changelog is very simple approach and can scale up to some level. Even some huge projects like Ruby on Rails still use one changelog file per component and it works. |
self.http_client = http_ng.new{ | ||
backend = config.client, | ||
options = { | ||
header = { ['User-Agent'] = user_agent() }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@tmogi001 any reason why not to add the 'Authorization' header here too?
--- Parameters for the token introspection endpoint. | ||
-- https://tools.ietf.org/html/rfc7662#section-2.1 | ||
local res, err = self.http_client.post(self.introspection_url , { token = token, token_type_hint = 'access_token'}, opts) | ||
if res and err then |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is the if res
really necessary? Shouldn't this be just if err
?
|
||
if res.status == 200 then | ||
local token_info = cjson.decode(res.body) | ||
return token_info |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This can return nil
when cjson.decode
returns nil, err
. That can happen for example when the response body is not valid json (html usually).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just when fixing #626 I realised there is plenty of error cases.
If the API would return null
then this would be ngx.null
and return the same error as in #626.
This function should always return a table even when the json response can be a string, number, null, ...
And it should normalize active
because if ngx.null
is truthy so { "active": null }
would evaluate to true.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is one potential crash that should be fixed:
https://github.com/3scale/apicast/pull/619/files#r170911776
The rest is just nitpicks and should be easy to fix.
And we will need a changelog entry.
"type": "string" | ||
}, | ||
"clientId": { | ||
"description": "Clilent ID for the Token Introspection Endpoint", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A typo here. Should be Client ID.
"description": "Introspection Endpoint URL", | ||
"type": "string" | ||
}, | ||
"clientId": { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks like this name is not the same as in the tests/code. Everywhere it uses client_id
.
"description": "Clilent ID for the Token Introspection Endpoint", | ||
"type": "string" | ||
}, | ||
"clientSecret": { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The same issue as https://github.com/3scale/apicast/pull/619/files#r170914144.
I fixed my source code and added test cases (unit, integration) of invalid response from IdP. |
"configuration": { | ||
"type": "object", | ||
"properties": { | ||
"introspectionUrl": { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This does not match the use in the policy. Here it is introspectionUrl
but there it is introspection_url
.
We plan to have JSON schema validation during Policy initialization to verify those cases in CI, but right now it is just manual process, sorry.
local access_token = authorization.token | ||
--- Introspection Response must have an "active" boolean value. | ||
-- https://tools.ietf.org/html/rfc7662#section-2.2 | ||
if not introspect_token(self, access_token).active then |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This will incorrectly validate case when the response is { "active": null }
. It should check for active == true
.
ngx.log(ngx.ERR, 'failed to parse token introspection response:', decode_err) | ||
return { active = false } | ||
end | ||
return token_info |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This will return whatever the JSON was. So if the JSON is null
this would crash a bit later.
I think it should verify type(token_info) == 'table'
and return the error otherwise instead of checking just the decode_err
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In my opinion, checking the decode_err
is enough. Because it must be an error when token_info
is null.
So, cjson.decode()
always return error
and decode_err
must not be null when token_info
JSON is null
.
I checked cjson.decode("")
,cjson.decode("{}")
, cjson.decode(nil)
and cjson.decode(ngx.null)
returned decode_err
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the response body is null
then the JSON will be correctly decoded to ngx.null
like:
cjson.decode('null')
That will later crash on trying to access .active
(attempt to index a userdata value):
cjson.decode('null').active
The same can happen when a number is returned like:
cjson.decode('42').active
What I'm saying is introspect_token
always has to return a table, because later it tries to get the key .access
from it. And the only way to verify it always returns table is to check the type.
Because the response body is basically user input it can be any valid JSON, including just string "null" or numbers.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you for information. I missed 'null' and numbers.
Do you think it is good as following? This worked fine with test code.
if decode_err or not (type(token_info) == 'table') then
ngx.log(ngx.ERR, 'failed to parse token introspection response:', decode_err)
return { active = false }
end
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it could be easier to read as:
if type(token_info) == 'table' then
return token_info
else
ngx.log(ngx.ERR, 'failed to parse token introspection response: ', decode_err or 'not a table')
return { active = false }
end
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you! I think this is nice.
Thank you @tmogi001 for this excellent PR! 🎉 You did a really good job. |
Hi team,
I created a new policy to enable RFC7662 OAuth2.0 Token Introspection.
This PR is a rework of the previous request (#435) as a policy.
And Also added sample configuration in the
examples/policies
directory.