diff --git a/.gitignore b/.gitignore index e90b4d509..a7eb03c42 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ phpunit.xml .phpunit.cache/ docker-compose/mysql/config/*.sql docker-compose/mysql/model/*.sql +docker-compose.override.yml package.xml .env.dev rector.php diff --git a/app/Http/Middleware/OAuth2BearerAccessTokenRequestValidator.php b/app/Http/Middleware/OAuth2BearerAccessTokenRequestValidator.php index 502bb64eb..95e589bca 100644 --- a/app/Http/Middleware/OAuth2BearerAccessTokenRequestValidator.php +++ b/app/Http/Middleware/OAuth2BearerAccessTokenRequestValidator.php @@ -171,16 +171,22 @@ public function handle($request, Closure $next) ); throw new InvalidGrantTypeException(OAuth2Protocol::OAuth2Protocol_Error_InvalidToken); } - if ( - $token_info->getApplicationType() === 'JS_CLIENT' - && (is_null($origin) || empty($origin)|| str_contains($token_info->getAllowedOrigins(), $origin) === false ) - ) { + if ($token_info->getApplicationType() === 'JS_CLIENT') { //check origins - throw new OAuth2ResourceServerException( - 403, - OAuth2Protocol::OAuth2Protocol_Error_UnauthorizedClient, - sprintf('invalid origin %s - allowed ones (%s)', $origin, $token_info->getAllowedOrigins()) - ); + $allowedOrigins = array_filter(array_map(function ($o) { + $o = trim($o); + if ($o === '') return ''; + try { $o = (new Normalizer($o))->normalize(); } catch (\Throwable $e) {} + return rtrim($o, '/'); + }, explode(' ', $token_info->getAllowedOrigins() ?? ''))); + + if (is_null($origin) || empty($origin) || !in_array(rtrim($origin, '/'), $allowedOrigins, true)) { + throw new OAuth2ResourceServerException( + 403, + OAuth2Protocol::OAuth2Protocol_Error_UnauthorizedClient, + sprintf('invalid origin %s - allowed ones (%s)', $origin, $token_info->getAllowedOrigins()) + ); + } } //check scopes Log::debug('OAuth2BearerAccessTokenRequestValidator::handle checking token scopes ...'); diff --git a/tests/Unit/OAuth2BearerAccessTokenRequestValidatorTest.php b/tests/Unit/OAuth2BearerAccessTokenRequestValidatorTest.php new file mode 100644 index 000000000..8f44e4561 --- /dev/null +++ b/tests/Unit/OAuth2BearerAccessTokenRequestValidatorTest.php @@ -0,0 +1,174 @@ +getHeaders() immediately. + */ +class TestableBearerValidator extends OAuth2BearerAccessTokenRequestValidator +{ + private array $fixedHeaders; + + public function __construct( + array $fixedHeaders, + IResourceServerContext $context, + IApiEndpointRepository $endpoint_repository, + IAccessTokenService $token_service + ) { + $this->fixedHeaders = $fixedHeaders; + parent::__construct($context, $endpoint_repository, $token_service); + } + + protected function getHeaders(): array + { + return $this->fixedHeaders; + } +} + +/** + * Class OAuth2BearerAccessTokenRequestValidatorTest + * + * Verifies that the JS_CLIENT origin check correctly accepts normalized URLs + * and rejects bare hostnames or requests with no Origin header. + */ +final class OAuth2BearerAccessTokenRequestValidatorTest extends TestCase +{ + private const TOKEN = 'test-bearer-token'; + private const HOST = 'example.com'; + private const ALLOWED_ORIGINS = 'https://example.com https://foo.bar'; + + private IResourceServerContext $context; + private IApiEndpointRepository $endpointRepo; + private IAccessTokenService $tokenService; + + protected function setUp(): void + { + parent::setUp(); + + Route::get('/api/test', fn() => 'ok'); + + $this->context = $this->createMock(IResourceServerContext::class); + + $endpoint = $this->createMock(IApiEndpoint::class); + $endpoint->method('isActive')->willReturn(true); + $endpoint->method('getScopesNames')->willReturn(['openid']); + + $this->endpointRepo = $this->createMock(IApiEndpointRepository::class); + $this->endpointRepo->method('getApiEndpointByUrlAndMethod')->willReturn($endpoint); + + // AccessToken is final, so use an anonymous stub instead of createMock(). + $tokenStub = new class { + public function getLifetime() { return 3600; } + public function getAudience() { return 'example.com'; } + public function getApplicationType() { return 'JS_CLIENT'; } + public function getAllowedOrigins(): ?string { return 'https://example.com https://foo.bar'; } + public function getScope() { return 'openid'; } + public function getClientId() { return 'test-client'; } + public function getUserId(): ?int { return null; } + public function getAllowedReturnUris() { return ''; } + }; + + $this->tokenService = $this->createMock(IAccessTokenService::class); + $this->tokenService->method('get')->willReturn($tokenStub); + + Log::shouldReceive('debug')->zeroOrMoreTimes(); + Log::shouldReceive('warning')->zeroOrMoreTimes(); + Log::shouldReceive('info')->zeroOrMoreTimes(); + Log::shouldReceive('error')->zeroOrMoreTimes(); + } + + // ------------------------------------------------------------------------- + + private function buildValidator(string $originHeader = ''): TestableBearerValidator + { + $headers = ['authorization' => 'Bearer ' . self::TOKEN]; + if ($originHeader !== '') { + $headers['origin'] = $originHeader; + } + return new TestableBearerValidator( + $headers, + $this->context, + $this->endpointRepo, + $this->tokenService + ); + } + + private function buildRequest(string $originHeader = ''): Request + { + $server = ['HTTP_HOST' => self::HOST]; + if ($originHeader !== '') { + $server['HTTP_ORIGIN'] = $originHeader; + } + return Request::create('/api/test', 'GET', [], [], [], $server); + } + + private function next(): \Closure + { + return fn($req) => new JsonResponse(['ok' => true], 200); + } + + // ------------------------------------------------------------------------- + + public function test_exact_origin_url_is_accepted(): void + { + $this->context->expects($this->once())->method('setAuthorizationContext'); + + $response = $this->buildValidator('https://example.com') + ->handle($this->buildRequest('https://example.com'), $this->next()); + + $this->assertEquals(200, $response->getStatusCode()); + } + + public function test_trailing_slash_origin_is_accepted(): void + { + $this->context->expects($this->once())->method('setAuthorizationContext'); + + $response = $this->buildValidator('https://example.com/') + ->handle($this->buildRequest('https://example.com/'), $this->next()); + + $this->assertEquals(200, $response->getStatusCode()); + } + + public function test_bare_hostname_without_scheme_is_rejected(): void + { + $this->context->expects($this->never())->method('setAuthorizationContext'); + + $response = $this->buildValidator('example') + ->handle($this->buildRequest('example'), $this->next()); + + $this->assertEquals(403, $response->getStatusCode()); + } + + public function test_missing_origin_is_rejected(): void + { + $this->context->expects($this->never())->method('setAuthorizationContext'); + + $response = $this->buildValidator() + ->handle($this->buildRequest(), $this->next()); + + $this->assertEquals(403, $response->getStatusCode()); + } +}