You've already forked Php-Template
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m59s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m55s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 3m9s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 3m5s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 3m51s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 3m11s
Reviewed-on: #22 Co-authored-by: Ron Rise <ron@siteworxpro.com> Co-committed-by: Ron Rise <ron@siteworxpro.com>
367 lines
13 KiB
PHP
367 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Siteworxpro\Tests\Http\Middleware;
|
|
|
|
use DateTimeImmutable;
|
|
use Lcobucci\JWT\JwtFacade;
|
|
use Lcobucci\JWT\Signer\Hmac\Sha256;
|
|
use Lcobucci\JWT\Signer\Key\InMemory;
|
|
use Lcobucci\JWT\Token\Builder;
|
|
use League\Route\Dispatcher;
|
|
use Nyholm\Psr7\Response;
|
|
use Nyholm\Psr7\ServerRequest;
|
|
use Siteworxpro\App\Attributes\Guards\Jwt;
|
|
use Siteworxpro\App\Http\Middleware\JwtMiddleware;
|
|
use Siteworxpro\App\Services\Facades\Config;
|
|
use Siteworxpro\App\Services\Facades\Guzzle;
|
|
use Siteworxpro\App\Services\Facades\Redis;
|
|
use Siteworxpro\HttpStatus\CodesEnum;
|
|
|
|
class JwtMiddlewareTest extends Middleware
|
|
{
|
|
private const string TEST_SIGNING_KEY = 'test_signing_key_123456444478901234';
|
|
|
|
private const string TEST_RSA_PRIVATE_KEY = <<<EOD
|
|
-----BEGIN RSA PRIVATE KEY-----
|
|
MIIEpQIBAAKCAQEAqTheAdlelxJL0K15BqUEo0lBzY06P7J0PhMfPlg2fgIJH+ng
|
|
ZmrpYFhBkj2L5Fnvxz0y58eu9WhhokwpS0GzgFIw+KfLV/WLX4PgionsQshrt0Pi
|
|
XvthaSH1xuYtg2N13dVVTv3Au0BBFLUHMrQ+bO5hgvowHBNfFf0GaHLW2m0eZ2Um
|
|
hWbtdv4HxrXBO5gI2N4UevyQ+inczN7RBZR6ZzyNoDO6Up6kS23/58zOruO+PGi7
|
|
q9eb7hU+getpVgA29wEWMgT+N6c5n5AcENgM1sHxZK43GR5vhMGbVJqnrUsMGof7
|
|
rT9Lxey3gjPS2r5nz2PNFcQ1i07QKDzvQHp2wwIDAQABAoIBAFMAC9QaWzP8TGWJ
|
|
gNBKhnDU0MrSl5yAmlWMKYn52JiLxQ/7Ng7mJ5wTDe5986zIlDyEfwCCyAUk8qaZ
|
|
drOsATBSoCSGoM1+6aKq26r4JYNILNVSHal64XegqZ2qbu6ADWMGbXZ2Ll9qD8Hp
|
|
XSN4lxn0/q0wrAJJWh094zO+CDZP+zBbX9oHxb5JAVxjCaNW84sI6/6agXM5zzgK
|
|
wcBt5Y0i8V8f7n9kg+CPNqY6BKg7o2ONFYTEVKuuEnVS/eupHQwBWExPCdxc85Tb
|
|
YqFL0dmgehE0OTQ6FrEN7Xh6jE4GMJtWmTvBNpqhsMZ0i08tAZSPs+Us9rnppKkK
|
|
T1SC2xECgYEA7yOv4C7dtHmFbn0YfnbBEfgvGAubv5jPDtZ5u6tUEhhU3rOcWexM
|
|
Xhj7OFV4I8lbu2t7GY+2BR7Y2ikOLW9MrOGo6qWhsjTQuZs6QaRKObcPvl2s0LYY
|
|
GxD1u84VjHPzID2pKVPqxaQ7KdcIaujAedWwAf4PV/uK2prKdGvzIksCgYEAtSau
|
|
4Ml1UpXvKxiBcVKsHIoEO0g3NL1+wAbdStg8TFi+leCMJoPwZ01t64BTtHF+pgDP
|
|
vn6VEgDSP3J4+W3dVhoajQeKBioT3MpDRP/qKDsImi2zJrg+hh9DMTlZd0Ab3EXv
|
|
ycjw3FWRcpcU/1l261fA/m3QPwZikF2VlO/0cmkCgYEAvtefCuy718RHHObOPlZt
|
|
O/bxNmJFOEEttOyql39iB1LNoDB8bTLruwh6q/lheEXAZDChO8P5gdqdOnUbMF0r
|
|
Nqib0i6+fOYzUHw1oJ8I8UhLUyOUv7ciQ69kPC15+u2psCglMKscp/+pi3lk6VS4
|
|
DkLfRKfI/PDsXgq72O8xSEMCgYEApukSnvngyQxvR1UYB7N19AHTLlA21bh4LjTk
|
|
905QGMR4Lp6sY9yTyIsWabRe69bbK9d5kvsNHX52OpGeF6z8EJaSujklGtLwZDJV
|
|
UyE9vn3OSkkrVdTTfz8U6Sj/XxpJ0Wb7LwCftVR+ZIgCh9kF8ohzwbqq8zdN39jq
|
|
t0V1BWkCgYEA2Mk2gOdYAN8aZgydFYKhogY5UNK/CFpq7hhekEyt73uxzxguVpZn
|
|
AJ9mq2L1CVJ5WqAUk2IzioeR7XAndntesbOafDuR4mhCUJhX+m/YQlKbTrs2dScR
|
|
S88z05AnmQmr5eCbQmVULZGo9xeLDB+GDWvvjpQ+NWcha2uO0O0RTQY=
|
|
-----END RSA PRIVATE KEY-----
|
|
EOD;
|
|
|
|
private const string TEST_JWKS_JSON = <<<EOD
|
|
{
|
|
"keys": [
|
|
{
|
|
"alg": "RS256",
|
|
"e": "AQAB",
|
|
"ext": true,
|
|
"key_ops": [
|
|
"verify"
|
|
],
|
|
"kty": "RSA",
|
|
"n": "qTheAdlelxJL0K15BqUEo0lBzY06P7J0PhMfPlg2fgIJH-ngZmrpYFhBkj2L5Fnvxz0y58eu9WhhokwpS0GzgFIw-KfLV_WLX4PgionsQshrt0PiXvthaSH1xuYtg2N13dVVTv3Au0BBFLUHMrQ-bO5hgvowHBNfFf0GaHLW2m0eZ2UmhWbtdv4HxrXBO5gI2N4UevyQ-inczN7RBZR6ZzyNoDO6Up6kS23_58zOruO-PGi7q9eb7hU-getpVgA29wEWMgT-N6c5n5AcENgM1sHxZK43GR5vhMGbVJqnrUsMGof7rT9Lxey3gjPS2r5nz2PNFcQ1i07QKDzvQHp2ww",
|
|
"kid": "2o5IaHnjxYtkpNWEcdPlwnaRJnaCJ2k2LY2nR4z6cN4=",
|
|
"use": "sig"
|
|
}
|
|
]
|
|
}
|
|
EOD;
|
|
|
|
public function getClass(): object
|
|
{
|
|
return new class {
|
|
public function getCallable(): array
|
|
{
|
|
return [$this, 'index'];
|
|
}
|
|
|
|
#[Jwt]
|
|
public function index()
|
|
{
|
|
// Dummy method for testing
|
|
}
|
|
};
|
|
}
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
Config::set('jwt.signing_key', self::TEST_SIGNING_KEY);
|
|
}
|
|
|
|
/**
|
|
* @throws \JsonException
|
|
*/
|
|
public function testIgnoresNoJwtAttribute()
|
|
{
|
|
$class = new class {
|
|
public function getCallable(): array
|
|
{
|
|
return [$this, 'index'];
|
|
}
|
|
|
|
public function index()
|
|
{
|
|
// Dummy method for testing
|
|
}
|
|
};
|
|
|
|
$handler = \Mockery::mock(Dispatcher::class);
|
|
$handler->shouldReceive('getMiddlewareStack')
|
|
->andReturn([$class]);
|
|
|
|
$handler
|
|
->shouldReceive('handle')
|
|
->once()
|
|
->andReturn(new Response(200));
|
|
|
|
$request = new ServerRequest('GET', '/');
|
|
$middleware = new JwtMiddleware();
|
|
$response = $middleware->process($request, $handler);
|
|
$this->assertEquals(CodesEnum::OK->value, $response->getStatusCode());
|
|
}
|
|
|
|
/**
|
|
* @throws \JsonException
|
|
*/
|
|
public function testIgnoresJwtAttributeButNoToken()
|
|
{
|
|
$class = $this->getClass();
|
|
|
|
$handler = \Mockery::mock(Dispatcher::class);
|
|
$handler->shouldReceive('getMiddlewareStack')
|
|
->andReturn([$class]);
|
|
|
|
$request = new ServerRequest('GET', '/');
|
|
$middleware = new JwtMiddleware();
|
|
$response = $middleware->process($request, $handler);
|
|
$this->assertEquals(CodesEnum::UNAUTHORIZED->value, $response->getStatusCode());
|
|
}
|
|
|
|
/**
|
|
* @throws \JsonException
|
|
*/
|
|
public function testInvalidToken()
|
|
{
|
|
$class = $this->getClass();
|
|
|
|
$handler = \Mockery::mock(Dispatcher::class);
|
|
$handler->shouldReceive('getMiddlewareStack')
|
|
->andReturn([$class]);
|
|
|
|
$request = new ServerRequest('GET', '/');
|
|
$request = $request->withHeader('Authorization', 'Bearer ' . 'invalid_token_string');
|
|
$middleware = new JwtMiddleware();
|
|
$response = $middleware->process($request, $handler);
|
|
$this->assertEquals(CodesEnum::UNAUTHORIZED->value, $response->getStatusCode());
|
|
$this->assertStringContainsString(
|
|
'Unauthorized: Invalid token',
|
|
$response->getBody()->getContents()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @throws \JsonException
|
|
*/
|
|
public function testJwtAttributeWithTokenButWrongAud()
|
|
{
|
|
$class = $this->getClass();
|
|
|
|
$handler = \Mockery::mock(Dispatcher::class);
|
|
$handler->shouldReceive('getMiddlewareStack')
|
|
->andReturn([$class]);
|
|
|
|
$request = new ServerRequest('GET', '/');
|
|
$request = $request->withHeader('Authorization', 'Bearer ' . $this->getJwt());
|
|
$middleware = new JwtMiddleware();
|
|
$response = $middleware->process($request, $handler);
|
|
$this->assertEquals(CodesEnum::UNAUTHORIZED->value, $response->getStatusCode());
|
|
$this->assertStringContainsString(
|
|
'The token is not allowed to be used by this audience',
|
|
$response->getBody()->getContents()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @throws \JsonException
|
|
*/
|
|
public function testJwtAttributeWithTokenButWrongIss()
|
|
{
|
|
Config::set('jwt.audience', 'https://client-app.io');
|
|
|
|
$class = $this->getClass();
|
|
|
|
$handler = \Mockery::mock(Dispatcher::class);
|
|
$handler->shouldReceive('getMiddlewareStack')
|
|
->andReturn([$class]);
|
|
|
|
$request = new ServerRequest('GET', '/');
|
|
$request = $request->withHeader('Authorization', 'Bearer ' . $this->getJwt());
|
|
$middleware = new JwtMiddleware();
|
|
$response = $middleware->process($request, $handler);
|
|
$this->assertEquals(CodesEnum::UNAUTHORIZED->value, $response->getStatusCode());
|
|
$this->assertStringContainsString(
|
|
'The token was not issued by the given issuers',
|
|
$response->getBody()->getContents()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @throws \JsonException
|
|
*/
|
|
public function testJwtAttributeWithTokenWithDiffIssuer()
|
|
{
|
|
Config::set('jwt.audience', 'https://client-app.io');
|
|
Config::set('jwt.issuer', 'https://different-issuer.io');
|
|
|
|
$class = $this->getClass();
|
|
|
|
$handler = \Mockery::mock(Dispatcher::class);
|
|
$handler->shouldReceive('getMiddlewareStack')
|
|
->andReturn([$class]);
|
|
|
|
$request = new ServerRequest('GET', '/');
|
|
$request = $request->withHeader('Authorization', 'Bearer ' . $this->getJwt());
|
|
$middleware = new JwtMiddleware();
|
|
$response = $middleware->process($request, $handler);
|
|
$this->assertEquals(CodesEnum::UNAUTHORIZED->value, $response->getStatusCode());
|
|
$this->assertStringContainsString(
|
|
'The token was not issued by the given issuers',
|
|
$response->getBody()->getContents()
|
|
);
|
|
}
|
|
|
|
public function testJwtAttributeWithToken()
|
|
{
|
|
Config::set('jwt.audience', 'https://client-app.io');
|
|
Config::set('jwt.issuer', 'https://api.my-awesome-app.io');
|
|
|
|
$class = $this->getClass();
|
|
|
|
$handler = \Mockery::mock(Dispatcher::class);
|
|
$handler->shouldReceive('getMiddlewareStack')
|
|
->andReturn([$class]);
|
|
|
|
$handler
|
|
->shouldReceive('handle')
|
|
->once()
|
|
->andReturn(new Response(200));
|
|
|
|
$request = new ServerRequest('GET', '/');
|
|
$request = $request->withHeader('Authorization', 'Bearer ' . $this->getJwt());
|
|
$middleware = new JwtMiddleware();
|
|
$response = $middleware->process($request, $handler);
|
|
$this->assertEquals(CodesEnum::OK->value, $response->getStatusCode());
|
|
}
|
|
|
|
/**
|
|
* @throws \JsonException
|
|
*/
|
|
public function testJwtFromJwkEndpoint()
|
|
{
|
|
Config::set('jwt.audience', 'https://client-app.io');
|
|
Config::set('jwt.issuer', 'https://api.my-awesome-app.io');
|
|
|
|
Redis::partialMock()->shouldReceive('get')->andReturn(null);
|
|
Redis::shouldReceive('set')->andReturn('OK');
|
|
Guzzle::partialMock()->shouldReceive('get')
|
|
->with('https://test.com/.well-known/openid-configuration')
|
|
->andReturn(new Response(200, [], json_encode([
|
|
'jwks_uri' => 'https://test.com/keys'
|
|
], JSON_THROW_ON_ERROR)));
|
|
|
|
Guzzle::shouldReceive('get')
|
|
->with('https://test.com/keys')
|
|
->andReturn(new Response(200, [], self::TEST_JWKS_JSON));
|
|
|
|
Config::set('jwt.signing_key', 'https://test.com/.well-known/openid-configuration');
|
|
|
|
$class = $this->getClass();
|
|
|
|
$handler = \Mockery::mock(Dispatcher::class);
|
|
$handler->shouldReceive('getMiddlewareStack')
|
|
->andReturn([$class]);
|
|
$handler
|
|
->shouldReceive('handle')
|
|
->once()
|
|
->andReturn(new Response(200));
|
|
|
|
$request = new ServerRequest('GET', '/');
|
|
$request = $request->withHeader('Authorization', 'Bearer ' . $this->getJwtRsa());
|
|
$middleware = new JwtMiddleware();
|
|
$response = $middleware->process($request, $handler);
|
|
$this->assertEquals(CodesEnum::OK->value, $response->getStatusCode());
|
|
}
|
|
|
|
/**
|
|
* @throws \JsonException
|
|
*/
|
|
public function testCatchesInvalidJwksUrl()
|
|
{
|
|
Config::set('jwt.signing_key', 'https://test.com/.well-known/openid-configuration');
|
|
Redis::partialMock()->shouldReceive('get')->andReturn(null);
|
|
Redis::shouldReceive('set')->andReturn('OK');
|
|
Guzzle::partialMock()->shouldReceive('get')
|
|
->with('https://test.com/.well-known/openid-configuration')
|
|
->andReturn(new Response(200, [], json_encode([], JSON_THROW_ON_ERROR)));
|
|
|
|
|
|
|
|
$class = $this->getClass();
|
|
|
|
$handler = \Mockery::mock(Dispatcher::class);
|
|
$handler->shouldReceive('getMiddlewareStack')
|
|
->andReturn([$class]);
|
|
|
|
$request = new ServerRequest('GET', '/');
|
|
$request = $request->withHeader('Authorization', 'Bearer ' . $this->getJwtRsa());
|
|
$middleware = new JwtMiddleware();
|
|
$response = $middleware->process($request, $handler);
|
|
$this->assertEquals(CodesEnum::INTERNAL_SERVER_ERROR->value, $response->getStatusCode());
|
|
}
|
|
|
|
private function getJwtRsa(): string
|
|
{
|
|
$key = InMemory::plainText(self::TEST_RSA_PRIVATE_KEY);
|
|
$signer = new \Lcobucci\JWT\Signer\Rsa\Sha256();
|
|
$token = new JwtFacade()->issue(
|
|
$signer,
|
|
$key,
|
|
static fn(
|
|
Builder $builder,
|
|
DateTimeImmutable $issuedAt
|
|
): Builder => $builder
|
|
->issuedBy('https://api.my-awesome-app.io')
|
|
->permittedFor('https://client-app.io')
|
|
->expiresAt($issuedAt->modify('+10 minutes'))
|
|
);
|
|
|
|
return $token->toString();
|
|
}
|
|
|
|
private function getJwt(): string
|
|
{
|
|
$key = InMemory::plainText(self::TEST_SIGNING_KEY);
|
|
$signer = new Sha256();
|
|
|
|
$token = new JwtFacade()->issue(
|
|
$signer,
|
|
$key,
|
|
static fn(
|
|
Builder $builder,
|
|
DateTimeImmutable $issuedAt
|
|
): Builder => $builder
|
|
->issuedBy('https://api.my-awesome-app.io')
|
|
->permittedFor('https://client-app.io')
|
|
->expiresAt($issuedAt->modify('+10 minutes'))
|
|
);
|
|
|
|
return $token->toString();
|
|
}
|
|
}
|