Skip to content

Commit

Permalink
magento-jwt-refresh-service 1.0.0
Browse files Browse the repository at this point in the history
  • Loading branch information
maaarghk committed Jul 14, 2023
0 parents commit f615492
Show file tree
Hide file tree
Showing 10 changed files with 321 additions and 0 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
vendor
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# magento-jwt-refresh-service

Adds an API endpoint `POST /V1/integration/admin/token/refresh` to refresh the
JWT from the Authorization header. Does not currently do customer tokens but PRs
are welcome.

e.g.:

```
$ curl --json '{"username": "username", "password": "password"}' http://mysite.docker/rest/V1/integration/admin/token/
"eyJraWQiOiIxIiwiYWxnIjoiSFMyNTYifQ.eyJ1aWQiOjY4LCJ1dHlwaWQiOjIsImlhdCI6MTY4OTM1MDYxOCwiZXhwIjoxNjg5MzU0MjE4fQ.Y2J6BVDFzBjSWP8MtfEievFplU21YyG40h56CLDIo9c"
$ curl -XPOST --data "" -H "Authorization: Bearer eyJraWQiO<etc>" http://mysite.docker/rest/V1/integration/admin/token/refresh/
"eyJraWQiOiIxIiwiYWxnIjoiSFMyNTYifQ.eyJ1aWQiOjY4LCJ1dHlwaWQiOjIsImlhdCI6MTY4OTM1MDYxOCwiZXhwIjoxNjg5MzU0MjQ3fQ.7TM1LlZ-1ONAQroFO_HVJWCqdl-ig8CCV1Sl-D3eCoA"
```

## Shouldn't JWT be short lived and only extended using refresh tokens?

I guess. A typical recommendation is that refresh tokens should be valid for
seven days, so I will accept a PR which validates that the provided JWT was
not issued over 7 days ago. This module goes to the effort of ensuring that
refreshing the token does not update the issue date, so it should not be too
difficult to enforce a maximum age.

(You can use base64 -d on the above curl example to verify that the iat claims
of the two tokens are the same, but the expt claim is extended.)

## BYOT

If you aren't using bearer tokens but you are using JWT, you can provide a token to be refreshed like this:

```php
class Whatever
{
public function __construct(
private \FTS\JwtRefreshService\Api\JwtRefreshServiceInterfaceFactory $jwtRefreshServiceFactory
) {}

public function refreshToken(string $token)
{
$jwtRefreshService = $this->jwtRefreshServiceFactory->create([
'token' => $token
]);
return $jwtRefreshService->refreshAdminToken();
}
}
```

## Why so complicated

https://github.com/maaarghk/magento-jwt-refresh-service/blob/7827d6b2c5d227bc8f5cedd45615ef0e1b451ec2/src/Model/Api/JwtRefreshService.php#L48-L58


## Help

I am unlikely to fulfil any feature requests, so please provide a merge request
alongside any that you have.

MRs with tests also welcome - it should be straightforward enough to verify the
token returned has the same claims and user context, and that the refreshed
token still validates.
17 changes: 17 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "maaarghk/magento-jwt-refresh-service",
"description": "Add an API service for refreshing Magento JWTs",
"version": "1.0.0",
"type": "magento2-module",
"license": "MIT",
"autoload": {
"files": ["src/registration.php"],
"psr-4": {
"FTS\\JwtRefreshService\\": "src/"
}
},
"require": {
"magento/module-jwt-framework-adapter": "^100.4.2",
"magento/module-jwt-user-token": "^100.4.1"
}
}
15 changes: 15 additions & 0 deletions src/Api/JwtRefreshServiceInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php
namespace FTS\JwtRefreshService\Api;

use Magento\Framework\Exception\AuthorizationException;
use Magento\Framework\Exception\InvalidArgumentException;

interface JwtRefreshServiceInterface
{
/**
* @return string JWT with updated expiry time
* @throws AuthorizationException when current request has no or invalid bearer token
* @throws InvalidArgumentException when current request bearer token is not a JWT
*/
public function refreshAdminToken() : string;
}
115 changes: 115 additions & 0 deletions src/Model/Api/JwtRefreshService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<?php
namespace FTS\JwtRefreshService\Model\Api;

use FTS\JwtRefreshService\Api\JwtRefreshServiceInterface;
use FTS\JwtRefreshService\Plugin\IssuedAtDateOverride;
use Magento\Authorization\Model\UserContextInterface;
use Magento\Framework\Exception\AuthorizationException;
use Magento\Framework\Exception\InvalidArgumentException;
use Magento\Framework\Jwt\Claim\IssuedAtFactory;
use Magento\Framework\Webapi\Request;
use Magento\Integration\Api\Data\UserToken;
use Magento\Integration\Api\Exception\UserTokenException;
use Magento\Integration\Api\UserTokenReaderInterface;
use Magento\Integration\Api\UserTokenValidatorInterface;
use Magento\Integration\Model\UserToken\UserTokenParametersFactory;
use Magento\JwtUserToken\Model\Data\JwtUserContext;
use Magento\JwtUserToken\Model\Issuer;

class JwtRefreshService implements JwtRefreshServiceInterface
{
/**
* @param ?string $token If provided, this token will be refreshed. Otherwise, the current request's bearer token will be used
*/
public function __construct(
private readonly UserTokenReaderInterface $userTokenReader,
private readonly UserTokenValidatorInterface $userTokenValidator,
private readonly Request $request,
private readonly UserTokenParametersFactory $userTokenParametersFactory,
private readonly IssuedAtFactory $issuedAtClaimFactory,
private readonly Issuer $tokenIssuer,
private readonly IssuedAtDateOverride $issuedAtDateOverride,
private readonly ?string $token = null
) {}

public function refreshAdminToken() : string
{
$token = $this->getUserToken();
if (!$token->getUserContext() instanceof JwtUserContext) {
throw new InvalidArgumentException(__("This endpoint can only be used to refresh JSON Web Tokens"));
}

// This may seem redundant since webapi.xml specifies that access to the 'Magento_Backend::admin' resource is
// required - but it allows other modules to use the service class directly.
if ($token->getUserContext()->getUserType() !== UserContextInterface::USER_TYPE_ADMIN) {
throw new AuthorizationException(__("This endpoint can only be used to refresh admin user tokens"));
}

// We don't want the "issued at" time of the new token to change
// compared to the original token, because the new token should be
// affected by revocations based on the initial login time.
//
// Although the UserTokenParameters class takes an "issued" parameter,
// we can't simply use that as it is always used to calculate the
// expiry date (i.e. if we set it to the original issue time then the
// token does not have its expiry time refreshed.)
//
// So, we use a plugin on the JWT manager class to rewrite the JWT
// payload that it receives.
$iat = $this->issuedAtClaimFactory->create([
'value' => $token->getData()->getIssued(),
'duplicate' => true
]);
$this->issuedAtDateOverride->setNewIssuedAt($iat);

// Issue a new token based on the existing context
$userTokenParams = $this->userTokenParametersFactory->create();
return $this->tokenIssuer->create($token->getUserContext(), $userTokenParams);
}

/**
* @throws AuthorizationException
*/
private function getUserToken() : UserToken
{
if ($this->token) {
$token = $this->token;
} else {
$token = $this->getBearerTokenFromRequest();
}
try {
$userToken = $this->userTokenReader->read($token);
$this->userTokenValidator->validate($userToken);
return $userToken;
} catch (UserTokenException $e) {
$this->bail();
}
}

/**
* @throws AuthorizationException
*/
private function getBearerTokenFromRequest() : string
{
// This code is from Magento\Webapi\Model\Authorization\UserTokenContext
// which we would ideally just use directly, but it does not expose the
// parsed token to us, only the user ID and type.
$authorizationHeaderValue = $this->request->getHeader('Authorization');
if (!$authorizationHeaderValue) {
$this->bail();
}
$headerPieces = explode(' ', $authorizationHeaderValue);
if (count($headerPieces) !== 2 || strtolower($headerPieces[0]) !== 'bearer') {
$this->bail();
}
return $headerPieces[1];
}

/**
* @throws AuthorizationException
*/
private function bail()
{
throw new AuthorizationException(__('Invalid bearer token'));
}
}
75 changes: 75 additions & 0 deletions src/Plugin/IssuedAtDateOverride.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php
namespace FTS\JwtRefreshService\Plugin;

use Magento\Framework\Exception\RuntimeException;
use Magento\Framework\Jwt\Claim\IssuedAt;
use Magento\Framework\Jwt\EncryptionSettingsInterface;
use Magento\Framework\Jwt\Jwe\Jwe;
use Magento\Framework\Jwt\Jwe\JweInterface;
use Magento\Framework\Jwt\Jws\Jws;
use Magento\Framework\Jwt\Jws\JwsInterface;
use Magento\Framework\Jwt\JwtInterface;
use Magento\Framework\Jwt\Payload\ClaimsPayload;
use Magento\Framework\Jwt\Payload\ClaimsPayloadInterface;
use Magento\JwtFrameworkAdapter\Model\JwtManager;

class IssuedAtDateOverride
{
private IssuedAt $newIssuedAt;

public function setNewIssuedAt(IssuedAt $issuedAt) : self
{
$this->newIssuedAt = $issuedAt;
return $this;
}

/**
* If we're in the middle of refreshing a JWT, we want to override the
* incoming JWT parameters to have a different issued at date.
*
* @param JwtManager $subject
* @param JwtInterface $jwt
* @param EncryptionSettingsInterface $encryptionSettings
* @return array
* @throws RuntimeException Unknown type of JWT
*/
public function beforeCreate(
JwtManager $subject,
JwtInterface $jwt,
EncryptionSettingsInterface $encryptionSettings
): array {
// Proceed as normal if no override has been set
if (!isset($this->newIssuedAt)) {
return [$jwt, $encryptionSettings];
}
if (!$jwt instanceof JwsInterface && !$jwt instanceof JweInterface) {
throw new RuntimeException(__("Unable to override issue date claim of JWT - unhandled token type (JWS and JWE are supported)"));
}
// We need to rebuild $jwt but with updated claims. This code for making
// a JwtInterface is taken from Magento\JwtUserToken\Model\Issuer.
$claimsPayload = $jwt->getPayload();
if (!$claimsPayload instanceof ClaimsPayloadInterface) {
throw new RuntimeException(__("Unable to override issue date claim of JWT - JWT payload does not contain claims"));
}
// Override the auto-generated iat claim with the one provided
$claims = $claimsPayload->getClaims();
$claims['iat'] = $this->newIssuedAt;
$newClaimsPayload = new ClaimsPayload($claims);
// create a new JWT with the updated claims and continue with the build
if ($jwt instanceof JwsInterface) {
$newJwt = new Jws(
$jwt->getProtectedHeaders(),
$newClaimsPayload,
$jwt->getUnprotectedHeaders()
);
} else {
$newJwt = new Jwe(
$jwt->getProtectedHeader(),
$jwt->getSharedUnprotectedHeader(),
$jwt->getPerRecipientUnprotectedHeaders(),
$newClaimsPayload
);
}
return [$newJwt, $encryptionSettings];
}
}
10 changes: 10 additions & 0 deletions src/etc/di.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<preference for="FTS\JwtRefreshService\Api\JwtRefreshServiceInterface" type="FTS\JwtRefreshService\Model\Api\JwtRefreshService" />
<type name="Magento\JwtFrameworkAdapter\Model\JwtManager">
<plugin
name="fts_jwt_refresh_service_issued_at_date_override"
type="FTS\JwtRefreshService\Plugin\IssuedAtDateOverride" />
</type>
</config>
11 changes: 11 additions & 0 deletions src/etc/module.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
<module name="FTS_JwtRefreshService">
<sequence>
<module name="Magento_Integration" />
<module name="Magento_JwtFrameworkAdapter" />
<module name="Magento_JwtUserToken" />
<module name="Magento_Webapi" />
</sequence>
</module>
</config>
10 changes: 10 additions & 0 deletions src/etc/webapi.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0"?>
<routes xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Webapi:etc/webapi.xsd">
<route url="/V1/integration/admin/token/refresh" method="POST">
<service class="FTS\JwtRefreshService\Api\JwtRefreshServiceInterface" method="refreshAdminToken" />
<resources>
<resource ref="Magento_Backend::admin" />
</resources>
</route>
</routes>
6 changes: 6 additions & 0 deletions src/registration.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?php
\Magento\Framework\Component\ComponentRegistrar::register(
\Magento\Framework\Component\ComponentRegistrar::MODULE,
'FTS_JwtRefreshService',
__DIR__
);

0 comments on commit f615492

Please sign in to comment.