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

Add custom JWKS path and kid check to JWKFetcher + tests #287

Merged
merged 2 commits into from
Sep 26, 2018

Conversation

joshcanhelp
Copy link
Contributor

@joshcanhelp joshcanhelp commented Sep 7, 2018

Add a custom path to a JWKS and kid checking (this will be added to the ID token validation in another PR).

  • Add $kid and $path parameters to \Auth0\SDK\Helpers\JWKFetcher::fetchKeys
  • Add \Auth0\SDK\Helpers\JWKFetcher::getJwks to call the JWKS URL
  • Add \Auth0\SDK\Helpers\JWKFetcher::getProp to get a key from the JWKS

Closes #276

@joshcanhelp joshcanhelp added this to the v5-Next milestone Sep 7, 2018
@joshcanhelp joshcanhelp force-pushed the add-custom-jwks-path-and-kid branch 2 times, most recently from 94dad65 to 5fba1de Compare September 11, 2018 17:30
Copy link
Contributor

@lbalmaceda lbalmaceda left a comment

Choose a reason for hiding this comment

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

Is there any chance that you could keep the existing [method that returns an array instance with "kid" as keys] as deprecated and add a new one that just returns the key for a given kid? Making the code from now on use the new one and keeping the deprecated one just in case someone was using it directly.

src/Helpers/JWKFetcher.php Show resolved Hide resolved
src/Helpers/JWKFetcher.php Outdated Show resolved Hide resolved

if (($secret = $this->cache->get($url)) === null) {
$secret = [];
// No kid passed so get the kid of the first JWK.
Copy link
Contributor

Choose a reason for hiding this comment

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

This assumption is wrong. kid is not required by the spec, so if the array contains a single element (one key) and kid==null then yes you might return that key directly. But no need to be picking the kid value here just "to use it later"

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You'll see further down in this file that the kid is used in the returned value:

$secret[$kid] = $this->convertCertToPem($x5c);

That's how it was stored before so we can't change that.

// Need the kid and x5c for a well-formed return value.
if (! is_null($kid) && ! is_null($x5c)) {
$secret[$kid] = $this->convertCertToPem($x5c);
$this->cache->set($jwks_url, $secret);
Copy link
Contributor

Choose a reason for hiding this comment

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

this cache is a little bit of a lie 😛 you're saving under the same key every time an array with only 1 key (the one that matches the "kid" passed). So if your app requires a second kid, the previous one would be overwritten. Not longer useful as a "cache", is it? ⚡️

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Totally agree but legacy 🤷‍♂️

public function getProp(array $jwks, $prop, $kid = null)
{
$r_key = null;
if (! $kid) {
Copy link
Contributor

Choose a reason for hiding this comment

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

shouldn't this be "is null or empty" or that's what this is already doing?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That's what this is already doing. There would be a problem if $kid was not defined but it always is.

}

/**
* Get a specific property from a JWKS using a key, if provided.
Copy link
Contributor

Choose a reason for hiding this comment

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

using a "key id" if provided. Or something that makes clearer that "key" refers to the JWK and not the prop name.

*
* @return null|string
*/
public function getProp(array $jwks, $prop, $kid = null)
Copy link
Contributor

Choose a reason for hiding this comment

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

No need to keep this public. Also, I think it would be more useful having a method that given a JWKS returns the array for the given "kid" value. Then you could do result["prop"] to obtain it. Looks clearer than having a "get prop" method that is also checking for "kid" IMO.

}

return $secret;
// If the value is an array, get the first element.
return is_array( $r_key[$prop] ) ? $r_key[$prop][0] : $r_key[$prop];
Copy link
Contributor

Choose a reason for hiding this comment

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

If I'm iterating over props of a file I know, I'd like to receive it as is, not being handled as non-array stuff. So I expect this method behaves the same on "any type of prop". e.g. make this return the array or null, and you later could do result[0] on the base of knowing how the JWKS is constructed.

* Get a JWKS given a domain and path to call.
*
* @param string $domain Base domain for the JWKS, including scheme.
* @param string $path Path to the JWKS from the $domain.
Copy link
Contributor

Choose a reason for hiding this comment

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

how can this fail and how can the dev know the reason? Is this "catchable"?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Can fail with an Exception in the HTTP client, I'll add docs.

@joshcanhelp
Copy link
Contributor Author

@lbalmaceda - RE: re-writing and deprecating ... that's not a bad idea, TBH. I'll make the 👍 changes above and move that to it's own method.

*
* @throws \Exception If the Guzzle HTTP client cannot complete the request.
* @codeCoverageIgnore
Copy link
Contributor

Choose a reason for hiding this comment

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

👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀 👀

Copy link
Contributor Author

Choose a reason for hiding this comment

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

🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈 🙈

Copy link
Contributor Author

Choose a reason for hiding this comment

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

/**
  * @luchoIngore
  */

* @param string $prop Property to retrieve.
* @param null|string $kid Kid to check.
* @param string $jwks_url URL to the JWKS.
* @param string|null $kid Key ID.
Copy link
Contributor

Choose a reason for hiding this comment

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

"if not provided and a single JSON Web Key exists on the set, that one will be returned"

// If a key was not found or the property does not exist, return.
if (is_null($r_key) || ! isset($r_key[$prop])) {
$jwks = $this->getJwks($jwks_url);
$jwk = $this->getJwk($jwks, $kid);
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd call this findKeyById or findJwk

break;
}
}
$cache_key = $jwks_url.'|'.(string) $kid;
Copy link
Contributor

Choose a reason for hiding this comment

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

think of the case that kid is null (not passed as arg). This will also break the cache impl below ;) I personally would skip cache in this particular case

Copy link
Contributor Author

Choose a reason for hiding this comment

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

null is technically a value we can handle (first key) so why not cache for that value?

Copy link
Contributor

Choose a reason for hiding this comment

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

if null get's converted to "null" (note the quotes) then I guess it's a valid string and you will end up having a valid key, so yes, you will be able to handle just fine null kids (pun intended)

*/
public function getProp(array $jwks, $prop, $kid = null)
public function getJwksX5c($jwks_url, $kid = null)
Copy link
Contributor

Choose a reason for hiding this comment

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

this is a key's certificate. So it would be JWK not JWKS

}

/**
* Check if an array within an array has at least one element.
Copy link
Contributor

Choose a reason for hiding this comment

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

method is called subArrayHasOneElement, not HasAtLeastOne ⚡️ . Choose either but be concise

*
* @return boolean
*
* @codeCoverageIgnore
Copy link
Contributor

Choose a reason for hiding this comment

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

why? you could easily unit-test this :D

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's private, why would I do that? Also:

Please note that final, private, protected, and static methods cannot be stubbed or mocked.

@@ -125,7 +131,7 @@ public function verifyAndDecode($jwt)
throw new CoreException("We can't trust on a token issued by: `{$body->iss}`.");
}

$secret = $this->JWKFetcher->fetchKeys($body->iss);
$secret = $this->JWKFetcher->fetchKeys($body->iss.$this->jwks_path);
Copy link
Contributor

Choose a reason for hiding this comment

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

let's move the $body->iss.$this->jwks_path one line above to something like jwks_url for clarity

*/
protected function getJwks($domain, $path)
protected function getJwks($jwks_url)
Copy link
Contributor

Choose a reason for hiding this comment

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

suggestion: I'd call this "fetch" or "request" jwks. Since it's performing an external query

Copy link
Contributor Author

Choose a reason for hiding this comment

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

So does getJwkX5c ... change that too?

Copy link
Contributor

Choose a reason for hiding this comment

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

If you feel like it, go ahead. Remember that's the method you want to expose to the user (the public method). These other in turn are internal (protected methods)

src/Helpers/JWKFetcher.php Show resolved Hide resolved
@joshcanhelp joshcanhelp force-pushed the add-custom-jwks-path-and-kid branch 2 times, most recently from 8a6b3d8 to a339af8 Compare September 20, 2018 20:54
@@ -24,10 +34,10 @@ class JWKFetcher
/**
* JWKFetcher constructor.
*
* @param CacheHandler|null $cache
* @param array $guzzleOptions
* @param CacheHandler|null $cache Cache handler or null for no caching.
Copy link
Contributor

Choose a reason for hiding this comment

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

But above you say "NoCacheHandler" as well. Should people pass NoCacheHandler or null for this to have no cache?

/**
* TODO: Deprecate, use $this->getJwksX5c() instead.
*
* @param string $iss
Copy link
Contributor

Choose a reason for hiding this comment

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

I know this is going to get deprecated. Anyway, I'd document this parameter like: "The auth0 issuer which will get appended the /.well-known/jwks.json path to fetch the jwks."

*/
private function getJwk(array $jwks, $kid = null)
{
if (! $this->subArrayHasNonEmptyFirstItem($jwks, 'keys')) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Method is negated ("..has NON empty..") but you're also negating it on every use. What about making it return the final result you want?

@joshcanhelp joshcanhelp force-pushed the add-custom-jwks-path-and-kid branch 2 times, most recently from 7da85e7 to 3fa2637 Compare September 25, 2018 17:35
Copy link
Contributor

@lbalmaceda lbalmaceda left a comment

Choose a reason for hiding this comment

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

Usually we don't add a doc block to the test cases. That confused me because then I found a few "test helper functions". We just make sure the name of the test is clear enough to communicate the intent of the test. (e.g. using "shouldDoThis" or "shouldReturnThis" or "shouldThrowWhenThisHappened".

This is not a blocker, but for future tests you might want to implement this.

tests/Helpers/JWKFetcherTest.php Show resolved Hide resolved
tests/Helpers/JWKFetcherTest.php Show resolved Hide resolved
$jwks_url = 'https://localhost/.well-known/jwks.json';
$kid = '__test_kid_2__';

$pem = $jwksFetcher->getJwksX5c( $jwks_url, $kid );
Copy link
Contributor

Choose a reason for hiding this comment

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

before this line I'd make sure that cache for that key is not defined so test is fully independent:

    `$cache_handler->set( $jwks_url.'|'.$kid, null );`

$this->assertEquals( '__new_value__', $pem );
}

/**
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd add a comment line above this saying that from now on, below you can find "test helper functions".


public function testCacheReturn()
{
$cache_handler = new TestCacheHandler();
Copy link
Contributor

Choose a reason for hiding this comment

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

Testing the CacheHandler this way is sort of wrong, since this TestCacheHandler is not the implementation that the user will use, right? wink. Instead, you should mock a CacheHandler and assert that get/set/remove methods get called N times with the given XYZ arguments. You can then remove the TestCacheHandler class introduced in this PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Well, we don't know the implementation being used, right? So, in this case, it's testing that "some" CacheHandler implementation will work, as long as it's following the right interface. I could add this as a MemoryCacheHandler "official" implementation?

Copy link
Contributor

@lbalmaceda lbalmaceda Sep 25, 2018

Choose a reason for hiding this comment

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

Well, we don't know the implementation being used, right?

No, but you know the interface and you know you're going to call it to
a) store
b) retrieve
c) delete

Adding a subclass for testing purposes is not wrong but it's not the right way either. IMO, you should create a new mock from the interface definition and assert the calls are being made correctly.

I could add this as a MemoryCacheHandler "official" implementation

If you want, go ahead. But remember whatever you ship can't be undone 💃 and unless you have a "best practice in storage/cache" to recommend users to begin using this class, I'd stay away

Copy link
Contributor

@lbalmaceda lbalmaceda left a comment

Choose a reason for hiding this comment

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

LGTM. Left a few comments via DM 👍

@joshcanhelp joshcanhelp merged commit 20038ed into master Sep 26, 2018
@joshcanhelp joshcanhelp deleted the add-custom-jwks-path-and-kid branch September 26, 2018 14:43
@github-actions
Copy link
Contributor

This pull request has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs.

@github-actions github-actions bot locked as resolved and limited conversation to collaborators Aug 24, 2022
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants