Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion code/Entities/AccessTokenEntity.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
use DateTimeImmutable;
use Exception;
use IanSimpson\OAuth2\OauthServerController;
use Lcobucci\JWT\Token;
use League\OAuth2\Server\CryptKey;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Entities\ScopeEntityInterface;
Expand All @@ -33,7 +35,9 @@
*/
class AccessTokenEntity extends DataObject implements AccessTokenEntityInterface
{
use AccessTokenTrait;
use AccessTokenTrait {
convertToJWT as defaultConvertToJWT;
}
use TokenEntityTrait;
use EntityTrait;

Expand Down Expand Up @@ -82,6 +86,34 @@ class AccessTokenEntity extends DataObject implements AccessTokenEntityInterface
'Code',
];

/**
* TODO: Update to CryptkeyInterface once `league/oauth2-server` is updated to `^9`
*/
public function getPrivateKey(): ?CryptKey
{
return $this->privateKey;
}

/**
* Generate a JWT from the access token
*
* @return Token
*/
public function convertToJWT(): Token
{
$token = null;

// Get token from extension (in case of different implementation than the default)
$this->extend('updateJWT', $token);
Copy link

Choose a reason for hiding this comment

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

i'm wondering about this change. Seems that most of the parts of convertToJWT are able to be changed in other public methods eg getScopes or you can override the public initJwtConfiguration and use the setters on https://lcobucci-jwt.readthedocs.io/en/stable/configuration/ ? Do you have an example of an extension? I'm thinking eddsa is probably a better default it could be good to include that change but allow updating it with injector or similar

Copy link
Author

Choose a reason for hiding this comment

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

Copy link

Choose a reason for hiding this comment

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

yeah looking at that implementation it seems it would be better to override the public initJwtConfiguration rather than the private convertToJWT as that detail may change in future non breaking releases given its a private method. It also looks like that implementation could be part of the module? It could be configured using yaml instead of the default sha256 version as an option?

https://github.com/thephpleague/oauth2-server/blob/8.5.x/src/Entities/Traits/AccessTokenTrait.php#L44

Copy link
Author

Choose a reason for hiding this comment

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

It also looks like that implementation could be part of the module?

@blueo Double checking, Are you thinking about adding the eddsa config but still use RSA as default?

Given the amount of changes requested, I might need to ask for more time (budget) than we initially estimated this for so I might revisit it again some time in the future. Appreciate the feedback!

Copy link

Choose a reason for hiding this comment

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

yeah I'd suggest making eddsa the default and bringing that implementation from the internal module in here as rsa getting a bit old now AFAIK. but could also leave it as default with just docs on how to change. Yeah can leave the implementation to when you can get to it :)


if ($token) {
return $token;
}

// Default token generated
return $this->defaultConvertToJWT();
}

public function getIdentifier(): string
{
return (string) $this->Code;
Expand Down
35 changes: 27 additions & 8 deletions code/OauthServerController.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,17 @@
use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Psr7\ServerRequest;
use GuzzleHttp\Psr7\Utils;
use IanSimpson\OAuth2\Entities\UserEntity;
use IanSimpson\OAuth2\AuthorizationValidators\BearerTokenValidatorEddsa;
use IanSimpson\OAuth2\Entities\ClientEntity;
use IanSimpson\OAuth2\Entities\ScopeEntity;
use IanSimpson\OAuth2\Entities\UserEntity;
use IanSimpson\OAuth2\Repositories\AccessTokenRepository;
use IanSimpson\OAuth2\Repositories\AuthCodeRepository;
use IanSimpson\OAuth2\Repositories\ClientRepository;
use IanSimpson\OAuth2\Repositories\RefreshTokenRepository;
use IanSimpson\OAuth2\Repositories\ScopeRepository;
use League\OAuth2\Server\AuthorizationServer;
use League\OAuth2\Server\AuthorizationValidators\AuthorizationValidatorInterface;
use League\OAuth2\Server\Entities\ScopeEntityInterface;
use League\OAuth2\Server\Exception\OAuthServerException;
use League\OAuth2\Server\Grant\AuthCodeGrant;
Expand Down Expand Up @@ -71,6 +73,11 @@ class OauthServerController extends Controller
*/
protected $logger;

/**
* @var null|AuthorizationValidatorInterface
*/
protected $authorizationValidator = null;

private readonly string $privateKey;

private readonly string $publicKey;
Expand Down Expand Up @@ -185,12 +192,22 @@ public function __construct()
parent::__construct();
}

public static function getGrantTypeExpiryInterval(): string
public static function getGrantTypeExpiryInterval(): mixed
{
return self::config()->grant_expiry_interval ?? self::$grant_expiry_interval;
}

public function handleRequest(HTTPRequest $request)
public function getAuthorizationValidator(): ?AuthorizationValidatorInterface
{
return $this->authorizationValidator;
}

public function setAuthorizationValidator(?AuthorizationValidatorInterface $value): void
Copy link

Choose a reason for hiding this comment

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

is there a use case for setting this to null? If not then you could probably remove ? from the type

Copy link
Author

@mikenuguid mikenuguid Sep 4, 2025

Choose a reason for hiding this comment

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

Yes. If null is passed, the ResourceServer will use BearerTokenValidator (tied up to RSA) by default.

{
$this->authorizationValidator = $value;
}

public function handleRequest(HTTPRequest $request): HTTPResponse
{
$this->myRequestAdapter = new HttpRequestAdapter();
$this->myRequest = $this->myRequestAdapter->toPsr7($request);
Expand Down Expand Up @@ -284,13 +301,14 @@ public function accessToken(): HTTPResponse
/**
* @param mixed $controller
*/
public static function authenticateRequest($controller): ?ServerRequestInterface
public function authenticateRequest($controller): ?ServerRequestInterface
{
$publicKey = self::getKey('OAUTH_PUBLIC_KEY_PATH');

$server = new ResourceServer(
new AccessTokenRepository(),
$publicKey
$publicKey,
$this->getAuthorizationValidator()
);

$request = ServerRequest::fromGlobals();
Expand All @@ -311,9 +329,9 @@ public static function authenticateRequest($controller): ?ServerRequestInterface
/**
* @param mixed $controller
*/
public static function getMember($controller): ?Member
public function getMember($controller): ?Member
Copy link

Choose a reason for hiding this comment

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

this is a breaking change (which is fine so long as you're planning to publish a new version). But its also referenced on the main readme.md as how to use the module so it'd be good to update it there too

Copy link
Author

Choose a reason for hiding this comment

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

I'll double check this one too. But thanks. I'll make sure to update the doc, it's mentioned in there.

{
$request = self::authenticateRequest($controller);
$request = $this->authenticateRequest($controller);

if (!$request instanceof ServerRequestInterface) {
return null;
Expand All @@ -331,7 +349,8 @@ public function validateClientGrant(HTTPRequest $request): HTTPResponse
{
$server = new ResourceServer(
new AccessTokenRepository(),
$this->publicKey
$this->publicKey,
$this->getAuthorizationValidator()
);

$this->handleRequest($request);
Expand Down
8 changes: 4 additions & 4 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@
"php": "^8.1",
"guzzlehttp/psr7": "^2.5",
"league/oauth2-server": "^8.2",
"monolog/monolog": "^1.2",
"monolog/monolog": "^3",
"robbie/psr7-adapters": "^1",
"silverstripe/framework": "^4.13",
"silverstripe/siteconfig": "^4.13"
"silverstripe/framework": "^5",
"silverstripe/siteconfig": "^5"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "^1.3",
Expand All @@ -34,7 +34,7 @@
"phpstan/phpstan-strict-rules": "^1.5",
"phpunit/phpunit": "^9.5",
"squizlabs/php_codesniffer": "^3.9",
"syntro/silverstripe-phpstan": "^1.0"
"syntro/silverstripe-phpstan": "^5.0"
},
"minimum-stability": "dev",
"prefer-stable": true,
Expand Down
45 changes: 45 additions & 0 deletions docs/eddsa.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# EdDSA

One of the latest digital signature scheme that is deemed faster, more secure and simpler to implement compare with its counterpart e.g. RSA.

## Ed25519

Specific implemenation of EdDSA. You may need to use

## Steps

### Generate a private/public key pair using Ed25519 algorithm

```shell
openssl genpkey -algorithm Ed25519 -out private.key
Copy link

Choose a reason for hiding this comment

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

looks like we should probably put this as the recommended way on the main Readme.md? it is a better default than RSA

Copy link
Author

Choose a reason for hiding this comment

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

A recommentdation yes, but probably leave RSA as default since not most application use the new algorithm (more so support it)

openssl pkey -in private.key -pubout -out public.key
```

### Add authorization validator

You may extend the [Bearer token validator](https://github.com/thephpleague/oauth2-server/blob/master/src/AuthorizationValidators/BearerTokenValidator.php) if majority of the logic is almost identical or a totally different implementation as long as it implements `AuthorizationValidatorInterface`.

### Add yaml config

Add a custom yaml config on your project to set the custom authorization validator as `OauthServerController` dependency. You may need to pass other constructor parameters if needed.

```yaml
---
Name: oauth_server_controller_dependencies
---
IanSimpson\OAuth2\Entities\AccessTokenEntity:
extensions:
- App\DAM\OauthServer\AccessTokenEntityExtension

SilverStripe\Core\Injector\Injector:
App\DAM\OauthServer\BearerTokenValidatorEddsa:
constructor:
- '%$IanSimpson\OAuth2\Repositories\AccessTokenRepository'
IanSimpson\OAuth2\OauthServerController:
properties:
AuthorizationValidator: '%$App\DAM\OauthServer\BearerTokenValidatorEddsa'
```
### Update JWT Token
A public method is added to allow updating the generated access token in case the chosen algorithm does not match the token generator. To do so, add an extension to the `AccessTokenEntity` in your project and use the `updateJWT` hook. You may also refer to the [default token generator](https://github.com/thephpleague/oauth2-server/blob/master/src/Entities/Traits/AccessTokenTrait.php#L60), on the token format.
35 changes: 35 additions & 0 deletions tests/AccessTokenEntityTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

namespace IanSimpson\Tests;

use IanSimpson\OAuth2\Entities\AccessTokenEntity;
use IanSimpson\Tests\Fixtures\AccessTokenEntityExtensionFake;
use Lcobucci\JWT\Token\Plain;
use League\OAuth2\Server\CryptKey;
use SilverStripe\Dev\SapphireTest;

class AccessTokenEntityTest extends SapphireTest
{
protected static $required_extensions = [
AccessTokenEntity::class => [
AccessTokenEntityExtensionFake::class,
]
];

public function testGetPrivateKey(): void
{
$cryptKeyFake = $this->createMock(CryptKey::class);
$entity = AccessTokenEntity::create();

$this->assertNull($entity->getPrivateKey());

$entity->setPrivateKey($cryptKeyFake);
$this->assertEquals($cryptKeyFake, $entity->getPrivateKey());
}

public function testConvertToJWT(): void
{
$entity = AccessTokenEntity::create();
$this->assertInstanceOf(Plain::class, $entity->convertToJWT());
}
}
17 changes: 14 additions & 3 deletions tests/OauthServerControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use Lcobucci\JWT\Validation\Constraint\LooseValidAt;
use Lcobucci\JWT\Validation\Constraint\PermittedFor;
use Lcobucci\JWT\Validation\Constraint\RelatedTo;
use League\OAuth2\Server\AuthorizationValidators\AuthorizationValidatorInterface;
use League\OAuth2\Server\CryptKey;
use League\OAuth2\Server\CryptTrait;
use Monolog\Logger;
Expand All @@ -28,6 +29,7 @@
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Dev\FunctionalTest;
use SilverStripe\Security\Member;
use IanSimpson\Tests\Fixtures\BearerTokenValidatorFake;

/**
* @internal
Expand Down Expand Up @@ -241,7 +243,7 @@ public function now(): DateTimeImmutable

$_SERVER['AUTHORIZATION'] = sprintf('Bearer %s', $at->__toString());

$request = OauthServerController::authenticateRequest(null);
$request = OauthServerController::singleton()->authenticateRequest(null);

$this->assertInstanceOf(ServerRequestInterface::class, $request);
$this->assertSame($request->getAttribute('oauth_access_token_id'), $at->Code);
Expand Down Expand Up @@ -322,7 +324,16 @@ public function now(): DateTimeImmutable
public function testGetGrantTypeExpiryInterval(): void
{
$oauthController = OauthServerController::singleton();
OauthServerController::config()->merge('grant_expiry_interval', 'PT1H');
$this->assertSame('PT1H', $oauthController::getGrantTypeExpiryInterval());
OauthServerController::config()->merge('grant_expiry_interval', ['PT1H']);
$this->assertSame('PT1H', $oauthController::getGrantTypeExpiryInterval()[0]);
Copy link

Choose a reason for hiding this comment

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

what's the rationale for loosening the type on getGrantTypeExpiryInterval and allowing an array? Seems a string is what you're using here anyway and its changing the API

Copy link
Author

Choose a reason for hiding this comment

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

Can't recall unfortunately, probably a failing test. I'll have a look. Thanks.

}

public function testGetAuthorizationValidator(): void
{
$oauthController = OauthServerController::singleton();
$this->assertNull($oauthController->getAuthorizationValidator());

$oauthController->setAuthorizationValidator(new BearerTokenValidatorFake());
$this->assertInstanceOf(BearerTokenValidatorFake::class, $oauthController->getAuthorizationValidator());
}
}
21 changes: 21 additions & 0 deletions tests/fixtures/AccessTokenEntityExtensionFake.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace IanSimpson\Tests\Fixtures;

use Lcobucci\JWT\Token;
use Lcobucci\JWT\Token\DataSet;
use Lcobucci\JWT\Token\Plain;
use Lcobucci\JWT\Token\Signature;
use SilverStripe\Core\Extension;
use SilverStripe\Dev\TestOnly;

class AccessTokenEntityExtensionFake extends Extension implements TestOnly
{
public function updateJWT(&$token)
{
$headers = new DataSet(['alg' => 'none'], 'headers');
$claims = new DataSet([], 'claims');
$signature = new Signature('hash', 'signature');
$token = new Plain($headers, $claims, $signature);
}
}
19 changes: 19 additions & 0 deletions tests/fixtures/BearerTokenValidatorFake.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace IanSimpson\Tests\Fixtures;

use League\OAuth2\Server\AuthorizationValidators\AuthorizationValidatorInterface;
use Psr\Http\Message\ServerRequestInterface;
use SilverStripe\Dev\TestOnly;

class BearerTokenValidatorFake implements AuthorizationValidatorInterface, TestOnly
{

/**
* @inheritDoc
*/
public function validateAuthorization(ServerRequestInterface $request)
{
return null;
}
}