usefulteam / jwt-auth

WordPress JSON Web Token Authentication
https://wordpress.org/plugins/jwt-auth/
122 stars 48 forks source link

Added refresh token architecture. #33

Closed sun closed 2 years ago

sun commented 3 years ago

Resolves #1

Problem

Goal

Proposed solution

  1. /token generates a new access token.
    • Either by passing username/password combo (as before). This returns a refresh token in a HttpOnly cookie.
    • Or by passing a valid refresh token in a cookie.
    • The refresh token is also a JWT, but has a (very) long TTL.
  2. ~/token/validate validates whether a token is still valid. Token can be an access token or refresh token.~
  3. /token/refresh generates a new refresh token and sends it in a HttpOnly cookie.
    • Requires a valid refresh token to be sent in a cookie.
  4. Reduce the default TTL of the existing (access) tokens to one hour.

Resources

To clarify

Remaining todos


From https://github.com/usefulteam/jwt-auth/issues/1#issuecomment-867050042: The general idea is that the client should not store the username/password credentials themselves. It should only store the refresh token. The common flow looks like this:

Authenticate with actual user login credentials to retrieve refresh token

$ curl -F device=abc-def -F username=user -F password=pass https://example.com/wp-json/jwt-auth/v1/token

Response:

Set-Cookie: refresh_token=1234.3c5ce9df9fac63bfcface7f7bae0db12273c9dc4275cd5554f78d2853eddc3c0; expires=Wed, 01-Jun-2022 12:00:00 GMT; Max-Age=2592000; path=/; domain=.example.com; HttpOnly
{
  "success": true,
  "statusCode": 200,
  "code": "jwt_auth_valid_credential",
  "message": "Credential is valid",
  "data": {
    "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczpcL1wvc2VydmljZS50ZXN0LmJubi5kZSIsImlhdCI6MTYyNDQ1NzA2MSwibmJmIjoxNjI0NDU3MDYxLCJleHAiOjE2MjQ0NjA2NjEsImRhdGEiOnsidXNlciI6eyJpZCI6NTA1NjUsImRldmljZSI6IiIsInBhc3MiOm51bGx9fX0.yYmLkcoF6mYoI6naAUv_qvOgfojm2cTG3HW-CvSkkj0",
  }
}

Authenticate with refresh token to create new access token

$ curl -F device=abc-def -b \
'refresh_token=1234.3c5ce9df9fac63bfcface7f7bae0db12273c9dc4275cd5554f78d2853eddc3c0' \
https://example.com/wp-json/jwt-auth/v1/token

Response:

{
  "success": true,
  "statusCode": 200,
  "code": "jwt_auth_valid_credential",
  "message": "Credential is valid",
  "data": {
    "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczpcL1wvc2VydmljZS50ZXN0LmJubi5kZSIsImlhdCI6MTYyNDQ1NzA2MSwibmJmIjoxNjI0NDU3MDYxLCJleHAiOjE2MjQ0NjA2NjEsImRhdGEiOnsidXNlciI6eyJpZCI6NTA1NjUsImRldmljZSI6IiIsInBhc3MiOm51bGx9fX0.yYmLkcoF6mYoI6naAUv_qvOgfojm2cTG3HW-CvSkkj0",
  }
}

Refresh the refresh token

$ curl -F device=abc-def -b \
'refresh_token=1234.3c5ce9df9fac63bfcface7f7bae0db12273c9dc4275cd5554f78d2853eddc3c0' \
https://example.com/wp-json/jwt-auth/v1/token/refresh

Response:

Set-Cookie: refresh_token=1234.bc4345f6d43611a2988a8cff7e110379f1b32d7c2bd9c6feeacad2f89a7babd4; expires=Thu, 01-Jul-2022 16:10:13 GMT; Max-Age=2592000; path=/; domain=.example.com; HttpOnly
{
  "success": true,
  "statusCode": 200,
  "code": "jwt_auth_valid_credential",
  "message": "Credential is valid",
  "data": {}
}

Note: The /token/refresh response only supplies a new refresh token. It does not generate a new access token.

sun commented 3 years ago

Added the following final changes:

  1. Reduced the expiration of access tokens to 10 minutes.
  2. Changed refresh token to only be accepted and sent as a HttpOnly cookie to the client to meet common security considerations. For instance, see https://hasura.io/blog/best-practices-of-using-jwt-with-graphql/#refresh_token_persistance
  3. Changed refresh endpoint to perform a silent token refresh only.

Therefore, the endpoints changed as follows:

  1. /token generates a new access token. Either by passing username/password combo (as before) or a valid refresh token in a HttpOnly cookie.
  2. /token/validate validates whether a token is still valid. Token can be an access token or refresh token.
  3. /token/refresh generates a new refresh token as HttpOnly cookie. Requires a valid refresh token sent as cookie.
contactjavas commented 3 years ago

Hey @sun , would you mind if I add you as one of the maintainer here? So that you guys don't have to wait for me for updates in GitHub.

The WordPress plugin repository account for this plugin is still on me. I'd be happy to push an update there.

Thanks a lot for this awesome PR.

webgurus commented 3 years ago

@sun this is awesome, @contactjavas can you please review and test and then merge, this is a super important feature, that I think everyone trying to build a headless WP implementation would appreciate.

Thanks

sun commented 3 years ago

Thanks for your feedback! Meanwhile I studied more tutorials and client libraries and server implementations from various services. I think the security needs hardening. I added a list of todos to the PR summary:

You can try and help test this branch on your non-public development sites already. These additional measures are only going to invalidate refresh tokens sooner or more often.

Note that I will be in holidays for the next two weeks. It is unlikely that I'll be able to implement these points before I leave. But I will come back to this directly afterwards – one of our customers needs this to move forward.

contactjavas commented 3 years ago

Wow, thanks for your awesome effort @sun ! I'll try to learn and test it. This is awesome man!

sun commented 2 years ago

Made some major progress. This PR now also introduces REST API tests using the PHPUnit framework. The tests confirm that all requests and responses are meeting the expectations. Especially important for the refresh token rotation, where we expect a past refresh token to no longer be valid.

Run the tests with:

$ composer install
$ composer test
> ./vendor/bin/phpunit
PHPUnit 9.5.13 by Sebastian Bergmann and contributors.

....F.......F                                                     13 / 13 (100%)

Time: 00:20.465, Memory: 6.00 MB

There were 2 failures:

1) UsefulTeam\Tests\JwtAuth\AccessTokenTest::testTokenWithInvalidRefreshToken
Failed asserting that 200 matches expected 401.

/wp-content/plugins/jwt-auth/tests/src/AccessTokenTest.php:117

2) UsefulTeam\Tests\JwtAuth\RefreshTokenTest::testTokenWithRotatedRefreshToken
Failed asserting that 401 matches expected 200.

/wp-content/plugins/jwt-auth/tests/src/RefreshTokenTest.php:200

FAILURES!
Tests: 13, Assertions: 71, Failures: 2.
Script ./vendor/bin/phpunit handling the test event returned with error code 1

This is the actual current test result output, so there are indeed two failures that need closer inspection.

We will be able to automatically run the tests for every PR using GitHub Actions in the future. However, I'll probably defer that to a separate PR.

sun commented 2 years ago

The release packaging script needs to be updated to use the --no-dev flag for composer install.

@contactjavas How do you build the releases currently? I did not see a script in the repo, or did miss it?

contactjavas commented 2 years ago

Hi @sun, this is awesome ... ❤️

I'd like to try this PR on my end. Yes, currently there's neither build script nor GitHub hook provided in this repo. Composer package is there, but hasn't been taken any care. I haven't even tried if the composer package even works/not. About WordPress build, it's still manual.

If you need to, or if you would like to do something about the composer or WP SVN, I've added you:

I'm sorry, I wasn't active here for long time. I'll be back to this repo sometime after Kirki 4 is released. Thanks a lot for your great work!

sun commented 2 years ago

Ah, great, thanks! 👍

The composer.json works fine. The composer.lock file should also be committed though; otherwise every new invocation of composer install will install a different version of the JWT library. We can address that in a separate PR.

I'm using https://github.com/sun/wordpress-git-svn-release to build and publish releases on WordPress.org. Here is an actual example config file: https://github.com/netzstrategen/wordpress-gallerya/blob/master/.wp-release.conf – There are also GitHub actions for releasing, but I haven't used them yet. Maybe we can also look into this in a separate PR.

sun commented 2 years ago

Resolved another todo from the list: Refresh tokens are no longer JWTs now. Various tutorials are also recommending this.

This makes all tests pass now:

$ composer test
> ./vendor/bin/phpunit
PHPUnit 9.5.13 by Sebastian Bergmann and contributors.

............                                                      12 / 12 (100%)

Time: 00:18.611, Memory: 6.00 MB

OK (12 tests, 92 assertions)

I will try to resolve the remaining todos mentioned in the PR summary in the next days.

sun commented 2 years ago

After a long period of testing with iOS, Android, and web app clients, we are currently preparing the launch of this in our project (finally). Therefore, I am going to resolve the remaining todos and would then look into merging this PR, ideally already tomorrow.

I added some commits already. The refresh token rotation by device was not correctly working when a client sends the POST parameters as JSON data instead of form data. This is now fixed for the refresh tokens and I added a test to ensure this remains to work in the future. (Yay to more tests! 🎉 )

I created a separate issue for the Devices class, which still accesses the $_POST array directly instead of using the REST Request object parameters: #58

sun commented 2 years ago

Created a separate follow-up issue for #59

sun commented 2 years ago

All remaining todos have been resolved. Everything is still the same, just cleaned up now.

The major version is bumped to 3.0.0. The changelog explains the new and possibly breaking changes. All tests still pass.

Unless someone raises concerns, I will proceed with merging this tomorrow.