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

Create new validation API #129

Merged
merged 11 commits into from
Jan 7, 2017
Merged

Create new validation API #129

merged 11 commits into from
Jan 7, 2017

Conversation

lcobucci
Copy link
Owner

@lcobucci lcobucci commented Oct 24, 2016

Yes @Ocramius @henriquemoody @schnittstabil @dannydorfel, after a looong time we finally have something to solve the validation API 🎉

Brace yourself because this is huge PR but after merging it we'll haz:

  • interfaces for the things that the library exposes
  • fix for the SRP violation
  • private claim validation (custom constraints)

@lcobucci
Copy link
Owner Author

There're things that can still be improved (especially naming) and still need to update the documentation, but would be amazing to have your comments!

* @return array
*
* @throws InvalidArgumentException When an invalid header is informed
* @throws InvalidArgumentException
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can we use an exception from this package?

Copy link
Owner Author

Choose a reason for hiding this comment

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

Planned for #128

public function __construct(Parsing\Encoder $encoder)
{
$this->encoder = $encoder;
$this->headers = ['typ'=> 'JWT', 'alg' => 'none'];
Copy link
Collaborator

Choose a reason for hiding this comment

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

Move this to defaults

Copy link
Owner Author

Choose a reason for hiding this comment

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

What you mean? Move to the property definition?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Correct.

{
$this->encoder = $encoder;
$this->headers = ['typ'=> 'JWT', 'alg' => 'none'];
$this->claims = [];
Copy link
Collaborator

Choose a reason for hiding this comment

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

Move this to defaults

*/
public function canOnlyBeUsedBy(string $audience, bool $addHeader = false): BuilderInterface
{
$audiences = $this->claims['aud'] ?? [];
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is it possible to use private constants for things like 'aud' and such? I know they are in the spec, but the verbosity is annoyingly low...

Copy link
Owner Author

Choose a reason for hiding this comment

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

v4 is meant for php 7.0...
But the constant idea is interesting... RegisteredClaims::AUDIENCE sounds good...

Copy link
Owner Author

Choose a reason for hiding this comment

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

However I think we should add it on a different PR

Copy link
Collaborator

Choose a reason for hiding this comment

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

Issue please?

Copy link
Owner Author

Choose a reason for hiding this comment

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


return $this->setRegisteredClaim(
'aud',
array_values(array_map('strval', $audiences)),
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should there be an array_unique() here?

Copy link
Owner Author

Choose a reason for hiding this comment

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

It could be added indeed (although 100% unrelated with the changeset AHAHAHH)

Copy link
Collaborator

Choose a reason for hiding this comment

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

New issue then?

Copy link
Owner Author

Choose a reason for hiding this comment

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

Copy link
Owner Author

@lcobucci lcobucci Oct 26, 2016

Choose a reason for hiding this comment

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

Actually I'll solve it here, that array_map() + array_values() is useless now... the type hint already forces it to be string.

Copy link
Owner Author

Choose a reason for hiding this comment

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

(and it was hurting my eyes HAHAHA)

*
* @since 4.0.0
*/
final class InvalidTokenException extends Exception
Copy link
Collaborator

Choose a reason for hiding this comment

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

extends InvalidArgumentException? extends UnexpectedValueException?

Copy link
Owner Author

Choose a reason for hiding this comment

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

Exception is Lcobucci\JWT\Exception... an initial work of #128.
Didn't want to come to a big inheritance tree now.

public static function fromViolations(ConstraintViolationException ...$violations): self
{
$exception = new self('The token violates some mandatory constraints');
$exception->violations = $violations;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Move the violation messages also into the message?

*
* @since 4.0.0
*/
final class Validator implements \Lcobucci\JWT\Validator
Copy link
Collaborator

Choose a reason for hiding this comment

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

More than a validator, this looks like a composite assertion. A validator simply returns true/false, this thing is actually throwing a ConstraintViolationException containing multiple previous ConstraintViolationException instances.

Copy link
Owner Author

Choose a reason for hiding this comment

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

It's throwing an InvalidTokenException with multiple ConstraintViolationException instances.

What would be the best name in your opinion?

Copy link
Collaborator

Choose a reason for hiding this comment

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

AggregateAssertion or MultiAssertion works too... It would still throw a subtype of ConstraintViolationException

Copy link
Owner Author

Choose a reason for hiding this comment

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

InvalidTokenException is not a ConstraintViolationException and my idea is that users should only know about InvalidTokenException.

Of course we could turn these into a composite specification (which is closer to what you're describing IMO) but I don't think we need that complexity...

A validator simply returns true/false

I'm a bit skeptical about it... not saying that's not true but it sounds over simplified.
Maybe we can offer both things:

interface Validator
{
    /**
     * @throws InvalidTokenException
     */
    public function assert(Token $token, Constraint ...$constraints): void;
    public function validate(Token $token, Constraint ...$constraints): bool;
}

One would use assert() when is looking for a "detailed report" or validate() when is just caring about the end result.

Choose a reason for hiding this comment

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

In my opinion, validate should return an array of ConstraintViolation (not ConstraintViolationException!), thus assert may look like:

public function assert(Token $token, Constraint ...$constraints)
{
    if ($violations = $this->validate($token, ...$constraints)) {
        throw new InvalidTokenException($violations);
    }
}

Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't think that the validate() method name indicates that it should return a boolean. However it's not in accordance with the rest of the changes since you have assert() methods throwing exceptions while here you have a validation() method doing the same.

With that in mind I would either call this class *Assertion or change the implementation of validate() method. But, throwing an exception every time a token is validated doesn't look very nice, IMO. You may or may not want details about the validation.

Split it into two methods may be a good idea but also looks too much responsibility for the Validator. I still keep my ide of having a Result object with the violations as the result of the validate() method, which in the end will tell you if the validation is okay or not, something like:

class Result
{
    private $violations;

    public function __construct(array $violations)
    {
        $this->violations = $violations
    }

    public function isValid(): bool
    {
        return empty($violations);
    }

    public function getViolations(): array
    {
        return $this->violations;
    }
}

Copy link
Owner Author

@lcobucci lcobucci Nov 1, 2016

Choose a reason for hiding this comment

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

@henriquemoody what I dislike about having a result object is requiring users to do this when they just care about the result:

if (!$validator->validate($token, ...$constraints)->isValid()) { // or assign a variable for the result ofc
}

Split it into two methods may be a good idea but also looks too much responsibility for the Validator.

Both methods are related to the same thing, so the reason to change is the same.

@dannydorfel @henriquemoody @schnittstabil @Ocramius thanks for your suggestions regarding this. I think the best option for now is splitting in two methods.

Copy link

@schnittstabil schnittstabil Nov 3, 2016

Choose a reason for hiding this comment

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

$validator = new Validator();
$validator->validate($token, ...$constraints)->isValid()

Eek, 3 times valid on a single line. Wait – 3 times?

The Validator class is final and doesn't have any state, thus it is only a container or the namespace for the validate and assert functions, isn't it? For consumers, this is very inconvenient, I think they should use those functions directly:

use function Lcobucci\JWT\assert;
use function Lcobucci\JWT\validate;

assert($token, ...$constraints);
$result = validate($token, ...$constraints); // whatever result will be

Btw, because of this, I don't know if the Validator class and the Validator interface are need at all. Interop? – Questionable. Of course, they can be kept for internal usage, but I think providing only the functions is much more appropriate.

Choose a reason for hiding this comment

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

Another suggestion:

use function Lcobucci\JWT\assert;   // clearly throws Exceptions
use function Lcobucci\JWT\isValid;  // clearly returns boolean values
use function Lcobucci\JWT\validate; // returns ValidationResult objects, IMO acceptable

$violations = $this->assert($violations, $constraint, $token);
}

if (!empty($violations)) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

if ($violations) ;-)

$violations = [];

foreach ($constraints as $constraint) {
$violations = $this->assert($violations, $constraint, $token);
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is overwriting the previous $violations. Some confusion around this code.

Copy link
Owner Author

Choose a reason for hiding this comment

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

Didn't want to use &$violations there...

@dannydorfel
Copy link

Nice! And now I understand the "case of the Mondays" tweet.
I have got nothing to add 😁

Copy link

@schnittstabil schnittstabil left a comment

Choose a reason for hiding this comment

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

ConstraintViolation instead of ConstraintViolationException – Looks like github falsely belives the comments below are outdated, please click on Show outdated manually.

I don't think we need a ConstraintViolationException class. I've seen this in many assertion/validation libraries, but just because it's sometimes convenient doesn't mean it's the right thing.

  1. Exceptions implement getFile, getLine and getTrace, but these don't describe the location/path (e.g. nbf) within the token, it's the location within the jwt source code – rather useless for a consumer.
  2. Exceptions are thrown, this means a ConstraintViolationExceptions describes single violation (wo composite pattern or similar) - see my ValidAt::assert comment

Instead we need a ConstraintViolation class, which fits our needs, e.g. with a specialized __toString().

*
* @since 4.0.0
*/
final class Validator implements \Lcobucci\JWT\Validator

Choose a reason for hiding this comment

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

In my opinion, validate should return an array of ConstraintViolation (not ConstraintViolationException!), thus assert may look like:

public function assert(Token $token, Constraint ...$constraints)
{
    if ($violations = $this->validate($token, ...$constraints)) {
        throw new InvalidTokenException($violations);
    }
}

$this->assertExpiration($token);
$this->assertBeforeThanNow($claimSet, 'nbf', 'The token cannot be used yet');
$this->assertBeforeThanNow($claimSet, 'iat', 'The token was issued in the future');
}

Choose a reason for hiding this comment

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

The first ConstraintValidationException wins – In my opinion, we should instead implement validate:

    /**
     * @return ConstraintValidation[]
     */
    public function validate(Token $token)
    {
        $claimSet = $token->claims();
        return array_merge(
            $this->validateExpiration($token),
            $this->validateBeforeThanNow($claimSet, 'nbf', 'The token cannot be used yet'),
            $this->validateBeforeThanNow($claimSet, 'iat', 'The token was issued in the future')
        );
    }

    /**
     * @return ConstraintValidation[]
     */
    private function validateExpiration(Token $token)
    …

Choose a reason for hiding this comment

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

This also means, we can drop the ugly try-catch at Validator::assert.

*
* @since 4.0.0
*/
interface Validator

Choose a reason for hiding this comment

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

Sorry, I don't know if this was already discussed, but how do we want to use Validators?
Currently(?):

$config = new Configuration();
$validator = $config->getValidator();

$validator->validate($token, new ValidAt(new DateTimeImmutable()));
$validator->validate($token, new IssuedBy('test.com'));

As Configuration serves like a small DI container, there are also other possibilities, e.g.:

// during bootstrapping
$jwtConfig = new Configuration();
$jwtConfig->getValidator()
    ->addConstraint(new ValidAt(new DateTimeImmutable()))
    ->addConstraint(new IssuedBy('test.com'));

// somewhere else
$jwtConfig->getValidator()->validate($token);

Copy link
Owner Author

Choose a reason for hiding this comment

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

Sorry, I don't know if this was already discussed, but how do we want to use Validators?

$config = new Configuration();
$validator = $config->getValidator();

$validator->validate(
     $token,
     new ValidAt(new DateTimeImmutable()),
     new IssuedBy('test.com')
);

// or simply
$constraints = [new ValidAt(new DateTimeImmutable()), new IssuedBy('test.com')];
$validator->validate($token, ...$constraints);

As Configuration serves like a small DI container, there are also other possibilities, e.g.:

I don't see changing the state of the service as an option, IMO that's not a good practice. Also, new ValidAt(new DateTimeImmutable()) might lead to some issues due to the creation and the validation time.

Choose a reason for hiding this comment

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

That was some food for thought – changing the state is one of many possibilities, but you're right the splat operator offers great options for code reuse. The more I think about it, the more I love it.

@lcobucci lcobucci force-pushed the refactor/validator-extraction branch from 525f3dc to 2373e57 Compare October 28, 2016 12:35

return $this;
}
public function with(string $name, $value): Builder;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Only with() looks a little bit vague to me, wouldn't be better to call it withClaim instead?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Indeed, change needed

Copy link
Owner Author

Choose a reason for hiding this comment

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

Those names were defined on #113 we're just extracting an interface. Let's define on #150 and change them on a different PR.

$this->with($name, $value);

if ($addHeader) {
$this->headers[$name] = $this->claims[$name];
Copy link
Collaborator

Choose a reason for hiding this comment

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

Although they're private properties and you know what with() does, I would create some method to get the claims by name, that you could also use on canOnlyBeUsedBy(), and I would also use withHeader() instead of setting $this->headers manually.

return $this;
}

private function encode(array $items): string
Copy link
Collaborator

Choose a reason for hiding this comment

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

Isn't this useful on the Encoder itself?

Copy link
Owner Author

Choose a reason for hiding this comment

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

Not sure... sometimes you don't want to encode with JSON+Base64Url (binary data is just base64url for example)


public function violations(): array
{
return $this->violations;
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is going to break if someone use the exception with the constructor. Just to be more defensive I would either define it as an array when declaring the property or pass the violations in the constructor.

Copy link
Owner Author

Choose a reason for hiding this comment

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

Solved, thanks 😉

$addHeader
);
}
public function canOnlyBeUsedBy(string $audience, bool $addHeader = false): Builder;

Choose a reason for hiding this comment

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

belongsTo?

Copy link
Owner Author

Choose a reason for hiding this comment

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

Not really, audience is about who can consume the token.
I wouldn't change it.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Docblock specifies "appends", method name suggests "only". :confus:

Copy link
Owner Author

Choose a reason for hiding this comment

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

Those names were defined on #113 we're just extracting an interface. Let's define on #150 and change them on a different PR.

@lcobucci lcobucci force-pushed the refactor/validator-extraction branch 2 times, most recently from 33a81ec to 87c7fda Compare November 16, 2016 23:29
@lcobucci lcobucci mentioned this pull request Dec 9, 2016
@lcobucci lcobucci dismissed Ocramius’s stale review January 7, 2017 11:07

Code changed... @Ocramius are you fine with merging this as is now and implement the exception improvements in another PR?

@lcobucci lcobucci force-pushed the refactor/validator-extraction branch from 87c7fda to d848c69 Compare January 7, 2017 11:44
@lcobucci lcobucci changed the title [WIP] Create new validation API Create new validation API Jan 7, 2017
@lcobucci lcobucci assigned lcobucci and unassigned Ocramius Jan 7, 2017
$addHeader
);
}
public function canOnlyBeUsedBy(string $audience, bool $addHeader = false): Builder;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Docblock specifies "appends", method name suggests "only". :confus:


return $this;
}
public function with(string $name, $value): Builder;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Indeed, change needed

* Returns the resultant token
*
* @return Token
* Returns an unsecured token (not recommended)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Wouldn't it make more sense to use the previous API with a no-op signer?

Copy link
Owner Author

Choose a reason for hiding this comment

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

What you mean here?

Copy link
Collaborator

@Ocramius Ocramius Jan 7, 2017

Choose a reason for hiding this comment

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

Have always a token, let a NoneSignerdeal with it (validates only empty signature, generates only empty signature)

Copy link
Owner Author

Choose a reason for hiding this comment

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

That's a good idea, it would simplify things indeed.

Copy link
Owner Author

Choose a reason for hiding this comment

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

But it can be done in a different PR... I really want to have this merged instead of adding things to the stack

Copy link
Owner Author

Choose a reason for hiding this comment

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

#152 is there for that.

return $this->validator;
}

public function setValidator(Validator $validator)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should have a @return void docblock

Copy link
Owner Author

Choose a reason for hiding this comment

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

Wouldn't bother with that now since I want to merge it and work on #146

* @return mixed
*
* @throws OutOfBoundsException
* Returns if the token minimum time is before than given time
Copy link
Collaborator

Choose a reason for hiding this comment

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

Minimum time of what?

Copy link
Owner Author

Choose a reason for hiding this comment

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

It validates the "not before" claim, so in theory it's the minimum time of usage but I didn't have a better name... any suggestion?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Ah, that's confusing because of the not in not before :-\

I guess that's what the spec is - can't fix the naming then.

*/
public function __toString(): string
{
return implode('.', get_object_vars($this));
Copy link
Collaborator

Choose a reason for hiding this comment

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

Consider getting rid of @get_object_vars() here, as no order is guaranteed

*
* @throws ConstraintViolationException
*/
public function assert(Token $token);
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍

/**
* @param Token $token
*
* @throws ConstraintViolationException
Copy link
Collaborator

Choose a reason for hiding this comment

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

Add @return void

Copy link
Owner Author

Choose a reason for hiding this comment

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

Wouldn't bother with that now since I want to merge it and work on #146

$violations
);

$message = 'The token violates some mandatory constraints, details:' . PHP_EOL;
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'd just use \n

);

$message = 'The token violates some mandatory constraints, details:' . PHP_EOL;
$message .= implode(PHP_EOL, $violations);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Samee: \n

@lcobucci lcobucci force-pushed the refactor/validator-extraction branch from d848c69 to 42ab523 Compare January 7, 2017 16:44
Simplifying the whole thing and making it easier to create new signers.
Yes this is a big change... brace yourself!
The ideia here is to make things more extensible and decoupled.

So what I did was:

- Extract interfaces for the important stuff to isolate the namespaces
- Create the DataSet class (definitely not best name)  to remove duplication
 on headers and claims manipulation
- Make (almost) everything final
- Group token related stuff under a namespace
- Create methods to encapsulate token verifications

Fixes: #117
And so remove all the the existing mutations.
Fixing the SRP violation and adding an extensible way to validate
tokens.

Fixes #29 and #72.
And remove things that are not useful now that we have scalar type
hints.

Fixes #131
@lcobucci lcobucci force-pushed the refactor/validator-extraction branch from 42ab523 to 4fb4d79 Compare January 7, 2017 17:02
@lcobucci lcobucci dismissed Ocramius’s stale review January 7, 2017 17:03

Things changed 😉

@lcobucci lcobucci merged commit cfe0888 into master Jan 7, 2017
@lcobucci lcobucci deleted the refactor/validator-extraction branch January 7, 2017 17:13
@lcobucci lcobucci mentioned this pull request Jan 8, 2017
patrickunic added a commit to patrickunic/jwt that referenced this pull request Jan 10, 2017
@lcobucci lcobucci added this to the 4.0.0 milestone Mar 16, 2017
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants