mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-08-01 14:11:47 +00:00
Added missing features
This commit is contained in:
parent
37108dbf56
commit
fb2759a5b5
21 changed files with 1747 additions and 1 deletions
10
config/packages/rate_limiter.yaml
Normal file
10
config/packages/rate_limiter.yaml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
framework:
|
||||||
|
rate_limiter:
|
||||||
|
# Guards the open RFC 7591 Dynamic Client Registration endpoint (App\Controller\OAuth\ClientRegistrationController)
|
||||||
|
# against abuse (mass client creation) - registration itself requires no authentication by design
|
||||||
|
# (any client may self-register; the real gate is the per-user consent screen at /authorize), so
|
||||||
|
# this is the only throttle standing between it and the internet.
|
||||||
|
oauth_client_registration:
|
||||||
|
policy: 'sliding_window'
|
||||||
|
limit: 20
|
||||||
|
interval: '1 hour'
|
||||||
|
|
@ -75,6 +75,12 @@ security:
|
||||||
# authentication here (rather than relying on is_authenticated()) triggers Symfony's normal
|
# authentication here (rather than relying on is_authenticated()) triggers Symfony's normal
|
||||||
# redirect-to-login-then-back flow for anonymous requests, same as the user settings pages.
|
# redirect-to-login-then-back flow for anonymous requests, same as the user settings pages.
|
||||||
- { path: ^/authorize, role: IS_AUTHENTICATED_FULLY }
|
- { path: ^/authorize, role: IS_AUTHENTICATED_FULLY }
|
||||||
|
# RFC 7591 Dynamic Client Registration (App\Controller\OAuth\ClientRegistrationController) is
|
||||||
|
# deliberately open/unauthenticated - see that class's docblock. Rate-limited, not access-controlled.
|
||||||
|
- { path: ^/oauth/register, role: PUBLIC_ACCESS }
|
||||||
|
# Note: RFC 8414/9728 discovery metadata under /.well-known/ needs no entry here - the "dev"
|
||||||
|
# firewall above already matches ^/\.well-known/ with security:false (fully public, no
|
||||||
|
# AccessListener at all), which is where those requests actually get handled.
|
||||||
# Restrict access to API to users, which has the API access permission
|
# Restrict access to API to users, which has the API access permission
|
||||||
- { path: "^/api", allow_if: 'is_granted("@api.access_api") and is_authenticated()' }
|
- { path: "^/api", allow_if: 'is_granted("@api.access_api") and is_authenticated()' }
|
||||||
# Restrict access to KICAD to users, which has API access permission
|
# Restrict access to KICAD to users, which has API access permission
|
||||||
|
|
|
||||||
16
config/packages/test/rate_limiter.yaml
Normal file
16
config/packages/test/rate_limiter.yaml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
framework:
|
||||||
|
# The default "oauth_client_registration" limiter (config/packages/rate_limiter.yaml) stores its state
|
||||||
|
# in the "cache.rate_limiter" pool, which is filesystem-backed and therefore persists across separate
|
||||||
|
# phpunit invocations (unlike the DB, which DAMADoctrineTestBundle wraps in a rolled-back transaction
|
||||||
|
# per test). Tests hitting POST /oauth/register for real (tests/Controller/OAuth/ClientRegistrationControllerTest.php)
|
||||||
|
# all share the same client IP (127.0.0.1) as the limiter key, so without this override, repeated test
|
||||||
|
# runs within the same hour would eventually start getting real 429s instead of the responses the
|
||||||
|
# tests actually assert on. An array-adapter pool is fresh for every kernel boot, so it never persists
|
||||||
|
# between test runs.
|
||||||
|
cache:
|
||||||
|
pools:
|
||||||
|
test.rate_limiter.cache:
|
||||||
|
adapter: cache.adapter.array
|
||||||
|
rate_limiter:
|
||||||
|
oauth_client_registration:
|
||||||
|
cache_pool: test.rate_limiter.cache
|
||||||
|
|
@ -297,6 +297,9 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
|
||||||
manage_oauth_tokens:
|
manage_oauth_tokens:
|
||||||
label: "Manage OAuth tokens"
|
label: "Manage OAuth tokens"
|
||||||
apiTokenRole: ROLE_API_ADMIN
|
apiTokenRole: ROLE_API_ADMIN
|
||||||
|
manage_oauth_clients:
|
||||||
|
label: "perm.system.manage_oauth_clients"
|
||||||
|
apiTokenRole: ROLE_API_ADMIN
|
||||||
show_updates:
|
show_updates:
|
||||||
label: "perm.system.show_available_updates"
|
label: "perm.system.show_available_updates"
|
||||||
apiTokenRole: ROLE_API_ADMIN
|
apiTokenRole: ROLE_API_ADMIN
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ controllers:
|
||||||
namespace: App\Controller
|
namespace: App\Controller
|
||||||
type: attribute
|
type: attribute
|
||||||
prefix: '{_locale}'
|
prefix: '{_locale}'
|
||||||
|
# OAuth2 protocol endpoints (client registration, discovery metadata) must live at fixed,
|
||||||
|
# spec-mandated paths with no locale prefix - see config/routes/oauth_server_controllers.yaml.
|
||||||
|
exclude: '../../src/Controller/OAuth/*'
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
_locale: '%kernel.default_locale%'
|
_locale: '%kernel.default_locale%'
|
||||||
|
|
@ -11,6 +14,12 @@ controllers:
|
||||||
# Match only locales like de_DE or de
|
# Match only locales like de_DE or de
|
||||||
_locale: "^[a-z]{2}(_[A-Z]{2})?$"
|
_locale: "^[a-z]{2}(_[A-Z]{2})?$"
|
||||||
|
|
||||||
|
oauth_server_controllers:
|
||||||
|
resource:
|
||||||
|
path: ../../src/Controller/OAuth/
|
||||||
|
namespace: App\Controller\OAuth
|
||||||
|
type: attribute
|
||||||
|
|
||||||
kernel:
|
kernel:
|
||||||
resource: ../../src/Kernel.php
|
resource: ../../src/Kernel.php
|
||||||
type: attribute
|
type: attribute
|
||||||
|
|
|
||||||
234
src/Controller/OAuth/ClientRegistrationController.php
Normal file
234
src/Controller/OAuth/ClientRegistrationController.php
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\OAuth;
|
||||||
|
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Manager\ScopeManagerInterface;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Model\Client;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\ValueObject\Grant;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\ValueObject\RedirectUri;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\ValueObject\Scope;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\RateLimiter\RateLimiterFactory;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC 7591 Dynamic Client Registration. Deliberately open/unauthenticated - any client may self-register
|
||||||
|
* a public (secret-less) OAuth2 client, since the actual access-control gate for the whole OAuth server
|
||||||
|
* is the per-user consent screen at /authorize (see App\EventListener\OAuth\AuthorizationConsentListener),
|
||||||
|
* not client registration. Only Authorization Code + PKCE / refresh_token are ever issued to registered
|
||||||
|
* clients (config/packages/league_oauth2_server.yaml), so registering a client grants no access by itself.
|
||||||
|
*
|
||||||
|
* Rate-limited per client IP (config/packages/rate_limiter.yaml, "oauth_client_registration") since this
|
||||||
|
* endpoint has no other gate protecting it from mass-registration abuse.
|
||||||
|
*/
|
||||||
|
#[Route('/oauth')]
|
||||||
|
class ClientRegistrationController extends AbstractController
|
||||||
|
{
|
||||||
|
/** @var list<string> */
|
||||||
|
private const ALLOWED_GRANT_TYPES = ['authorization_code', 'refresh_token'];
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
private const ALLOWED_RESPONSE_TYPES = ['code'];
|
||||||
|
|
||||||
|
private const MAX_REDIRECT_URIS = 10;
|
||||||
|
private const MAX_REDIRECT_URI_LENGTH = 2000;
|
||||||
|
private const MAX_CLIENT_NAME_LENGTH = 128;
|
||||||
|
private const MAX_BODY_LENGTH = 65536;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $defaultScopes
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly ClientManagerInterface $clientManager,
|
||||||
|
private readonly ScopeManagerInterface $scopeManager,
|
||||||
|
#[Autowire(service: 'limiter.oauth_client_registration')]
|
||||||
|
private readonly RateLimiterFactory $registrationLimiter,
|
||||||
|
#[Autowire('%league.oauth2_server.scopes.default%')]
|
||||||
|
private readonly array $defaultScopes,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/register', name: 'oauth2_client_register', methods: ['POST'])]
|
||||||
|
public function register(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$limit = $this->registrationLimiter->create($request->getClientIp() ?? 'unknown')->consume();
|
||||||
|
if (!$limit->isAccepted()) {
|
||||||
|
$retryAfterSeconds = max(0, $limit->getRetryAfter()->getTimestamp() - time());
|
||||||
|
|
||||||
|
return new JsonResponse(
|
||||||
|
['error' => 'temporarily_unavailable', 'error_description' => 'Too many client registration requests, please try again later.'],
|
||||||
|
Response::HTTP_TOO_MANY_REQUESTS,
|
||||||
|
['Retry-After' => (string) $retryAfterSeconds]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = $request->getContent();
|
||||||
|
if (\strlen($content) > self::MAX_BODY_LENGTH) {
|
||||||
|
return $this->error('invalid_client_metadata', 'Request body is too large.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($content, true);
|
||||||
|
if (!\is_array($data)) {
|
||||||
|
return $this->error('invalid_client_metadata', 'Request body must be a JSON object.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$redirectUris = $this->validateRedirectUris($data['redirect_uris'] ?? null);
|
||||||
|
if ($redirectUris instanceof JsonResponse) {
|
||||||
|
return $redirectUris;
|
||||||
|
}
|
||||||
|
|
||||||
|
$authMethod = $data['token_endpoint_auth_method'] ?? 'none';
|
||||||
|
if ('none' !== $authMethod) {
|
||||||
|
return $this->error('invalid_client_metadata', 'Only public clients are supported; token_endpoint_auth_method must be "none".');
|
||||||
|
}
|
||||||
|
|
||||||
|
$grantTypes = $data['grant_types'] ?? self::ALLOWED_GRANT_TYPES;
|
||||||
|
if (!\is_array($grantTypes) || [] === $grantTypes || [] !== array_diff($grantTypes, self::ALLOWED_GRANT_TYPES)) {
|
||||||
|
return $this->error('invalid_client_metadata', 'grant_types may only contain "authorization_code" and "refresh_token".');
|
||||||
|
}
|
||||||
|
|
||||||
|
$responseTypes = $data['response_types'] ?? self::ALLOWED_RESPONSE_TYPES;
|
||||||
|
if (!\is_array($responseTypes) || [] === $responseTypes || [] !== array_diff($responseTypes, self::ALLOWED_RESPONSE_TYPES)) {
|
||||||
|
return $this->error('invalid_client_metadata', 'response_types may only contain "code".');
|
||||||
|
}
|
||||||
|
|
||||||
|
$clientName = $data['client_name'] ?? 'Unnamed OAuth client';
|
||||||
|
if (!\is_string($clientName) || '' === $clientName) {
|
||||||
|
return $this->error('invalid_client_metadata', 'client_name must be a non-empty string.');
|
||||||
|
}
|
||||||
|
$clientName = mb_substr($clientName, 0, self::MAX_CLIENT_NAME_LENGTH);
|
||||||
|
|
||||||
|
$scopes = $this->validateScopes($data['scope'] ?? null);
|
||||||
|
if ($scopes instanceof JsonResponse) {
|
||||||
|
return $scopes;
|
||||||
|
}
|
||||||
|
|
||||||
|
$identifier = bin2hex(random_bytes(16));
|
||||||
|
|
||||||
|
$client = new Client($clientName, $identifier, null);
|
||||||
|
$client->setRedirectUris(...array_map(static fn (string $uri): RedirectUri => new RedirectUri($uri), $redirectUris));
|
||||||
|
$client->setGrants(...array_map(static fn (string $grant): Grant => new Grant($grant), $grantTypes));
|
||||||
|
$client->setScopes(...array_map(static fn (string $scope): Scope => new Scope($scope), $scopes));
|
||||||
|
$client->setActive(true);
|
||||||
|
|
||||||
|
$this->clientManager->save($client);
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'client_id' => $identifier,
|
||||||
|
'client_id_issued_at' => time(),
|
||||||
|
'client_name' => $clientName,
|
||||||
|
'redirect_uris' => $redirectUris,
|
||||||
|
'grant_types' => array_values($grantTypes),
|
||||||
|
'response_types' => array_values($responseTypes),
|
||||||
|
'token_endpoint_auth_method' => 'none',
|
||||||
|
'scope' => implode(' ', $scopes),
|
||||||
|
], Response::HTTP_CREATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>|JsonResponse
|
||||||
|
*/
|
||||||
|
private function validateRedirectUris(mixed $redirectUrisRaw): array|JsonResponse
|
||||||
|
{
|
||||||
|
if (!\is_array($redirectUrisRaw) || [] === $redirectUrisRaw || \count($redirectUrisRaw) > self::MAX_REDIRECT_URIS) {
|
||||||
|
return $this->error('invalid_redirect_uri', 'redirect_uris must be a non-empty array of at most '.self::MAX_REDIRECT_URIS.' URIs.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$redirectUris = [];
|
||||||
|
foreach ($redirectUrisRaw as $uri) {
|
||||||
|
if (!\is_string($uri) || \strlen($uri) > self::MAX_REDIRECT_URI_LENGTH || !$this->isAllowedRedirectUri($uri)) {
|
||||||
|
return $this->error('invalid_redirect_uri', \sprintf('"%s" is not an allowed redirect URI.', \is_string($uri) ? $uri : get_debug_type($uri)));
|
||||||
|
}
|
||||||
|
$redirectUris[] = $uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $redirectUris;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accepts https:// URIs, loopback http:// URIs (127.0.0.1/localhost/[::1], any port), and private-use
|
||||||
|
* URI schemes containing a dot (e.g. "com.example.app:/callback") - the three redirect URI shapes
|
||||||
|
* RFC 8252 recommends for native/CLI apps like MCP clients. Plain http:// to a non-loopback host is
|
||||||
|
* rejected (token/code leakage over an insecure channel), as is any URI carrying a fragment.
|
||||||
|
*/
|
||||||
|
private function isAllowedRedirectUri(string $uri): bool
|
||||||
|
{
|
||||||
|
$parts = parse_url($uri);
|
||||||
|
if (false === $parts || !isset($parts['scheme']) || isset($parts['fragment'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scheme = strtolower($parts['scheme']);
|
||||||
|
|
||||||
|
if ('https' === $scheme) {
|
||||||
|
return isset($parts['host']) && '' !== $parts['host'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('http' === $scheme) {
|
||||||
|
$host = strtolower($parts['host'] ?? '');
|
||||||
|
|
||||||
|
return \in_array($host, ['127.0.0.1', 'localhost', '::1', '[::1]'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private-use URI scheme (RFC 8252 §7.1): require a dot to match the recommended reverse-DNS
|
||||||
|
// style (e.g. "com.example.app"), reducing collisions with generic/likely-preregistered schemes.
|
||||||
|
return 1 === preg_match('/^[a-z][a-z0-9+.-]*\.[a-z0-9+.-]*[a-z0-9]$/', $scheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>|JsonResponse
|
||||||
|
*/
|
||||||
|
private function validateScopes(mixed $scopeString): array|JsonResponse
|
||||||
|
{
|
||||||
|
if (null === $scopeString) {
|
||||||
|
return $this->defaultScopes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!\is_string($scopeString)) {
|
||||||
|
return $this->error('invalid_client_metadata', 'scope must be a space-delimited string.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$scopes = array_values(array_filter(explode(' ', $scopeString), static fn (string $s): bool => '' !== $s));
|
||||||
|
if ([] === $scopes) {
|
||||||
|
return $this->defaultScopes;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($scopes as $scope) {
|
||||||
|
if (null === $this->scopeManager->find($scope)) {
|
||||||
|
return $this->error('invalid_client_metadata', \sprintf('Unknown scope "%s".', $scope));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $scopes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function error(string $error, string $description): JsonResponse
|
||||||
|
{
|
||||||
|
return new JsonResponse(['error' => $error, 'error_description' => $description], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/Controller/OAuth/DiscoveryController.php
Normal file
89
src/Controller/OAuth/DiscoveryController.php
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\OAuth;
|
||||||
|
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC 8414 (OAuth 2.0 Authorization Server Metadata) and RFC 9728 (OAuth 2.0 Protected Resource Metadata)
|
||||||
|
* discovery endpoints, so OAuth/MCP clients can locate /authorize, /token and the RFC 7591 registration
|
||||||
|
* endpoint (App\Controller\OAuth\ClientRegistrationController) without any hardcoded configuration.
|
||||||
|
*
|
||||||
|
* Lives at the fixed /.well-known/... paths both RFCs mandate - unprefixed by locale (see
|
||||||
|
* config/routes/oauth_server_controllers.yaml) and, in practice, served by the "dev" firewall's
|
||||||
|
* ^/\.well-known/ pattern (config/packages/security.yaml), which is fully public (security: false).
|
||||||
|
*
|
||||||
|
* The issuer/resource identifier is derived from the current request's scheme+host rather than a fixed
|
||||||
|
* config value, so this keeps working unchanged behind any hostname/reverse proxy the instance is reached
|
||||||
|
* through.
|
||||||
|
*/
|
||||||
|
#[Route('/.well-known')]
|
||||||
|
class DiscoveryController extends AbstractController
|
||||||
|
{
|
||||||
|
#[Route('/oauth-authorization-server', name: 'oauth2_discovery_authorization_server', methods: ['GET'])]
|
||||||
|
public function authorizationServerMetadata(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$issuer = $request->getSchemeAndHttpHost();
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'issuer' => $issuer,
|
||||||
|
'authorization_endpoint' => $this->generateUrl('oauth2_authorize', [], UrlGeneratorInterface::ABSOLUTE_URL),
|
||||||
|
'token_endpoint' => $this->generateUrl('oauth2_token', [], UrlGeneratorInterface::ABSOLUTE_URL),
|
||||||
|
'registration_endpoint' => $this->generateUrl('oauth2_client_register', [], UrlGeneratorInterface::ABSOLUTE_URL),
|
||||||
|
'scopes_supported' => self::availableScopes(),
|
||||||
|
'response_types_supported' => ['code'],
|
||||||
|
'response_modes_supported' => ['query'],
|
||||||
|
'grant_types_supported' => ['authorization_code', 'refresh_token'],
|
||||||
|
'token_endpoint_auth_methods_supported' => ['none'],
|
||||||
|
'code_challenge_methods_supported' => ['S256'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/oauth-protected-resource', name: 'oauth2_discovery_protected_resource', methods: ['GET'])]
|
||||||
|
public function protectedResourceMetadata(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$issuer = $request->getSchemeAndHttpHost();
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'resource' => $issuer,
|
||||||
|
'authorization_servers' => [$issuer],
|
||||||
|
'bearer_methods_supported' => ['header'],
|
||||||
|
'scopes_supported' => self::availableScopes(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private static function availableScopes(): array
|
||||||
|
{
|
||||||
|
// Kept in sync with config/packages/league_oauth2_server.yaml's scopes.available - the bundle
|
||||||
|
// does not expose the configured "available" scope list as a service/parameter (only "default" is,
|
||||||
|
// via league.oauth2_server.scopes.default), so this one list is hand-maintained.
|
||||||
|
return ['read_only', 'edit', 'admin', 'full'];
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/Controller/OAuthClientAdminController.php
Normal file
73
src/Controller/OAuthClientAdminController.php
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Services\OAuth\OAuthClientAdminManager;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-only overview of every OAuth2 client (League\Bundle\OAuth2ServerBundle) registered against this
|
||||||
|
* instance - almost always RFC 7591 self-registered (App\Controller\OAuth\ClientRegistrationController),
|
||||||
|
* since that endpoint is open by design. This page is the moderation surface for that openness: an admin
|
||||||
|
* can see what has registered and permanently remove a client (and everything it was ever granted) if it
|
||||||
|
* looks abusive or abandoned.
|
||||||
|
*/
|
||||||
|
#[Route(path: '/tools/oauth_clients')]
|
||||||
|
class OAuthClientAdminController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(private readonly OAuthClientAdminManager $manager)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route(path: '', name: 'oauth_clients_list', methods: ['GET'])]
|
||||||
|
public function list(): Response
|
||||||
|
{
|
||||||
|
$this->denyAccessUnlessGranted('@system.manage_oauth_clients');
|
||||||
|
|
||||||
|
return $this->render('tools/oauth_clients/oauth_clients.html.twig', [
|
||||||
|
'clients' => $this->manager->listClientsWithLiveTokenCounts(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route(path: '/{identifier}/delete', name: 'oauth_clients_delete', methods: ['DELETE'])]
|
||||||
|
public function delete(string $identifier, Request $request): Response
|
||||||
|
{
|
||||||
|
$this->denyAccessUnlessGranted('@system.manage_oauth_clients');
|
||||||
|
|
||||||
|
if (!$this->isCsrfTokenValid('oauth_client_delete'.$identifier, $request->request->get('_token'))) {
|
||||||
|
$this->addFlash('error', 'csfr_invalid');
|
||||||
|
return $this->redirectToRoute('oauth_clients_list');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->manager->deleteClient($identifier)) {
|
||||||
|
$this->addFlash('error', 'tfa_u2f.u2f_delete.not_existing');
|
||||||
|
return $this->redirectToRoute('oauth_clients_list');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->addFlash('success', 'oauth_clients.deleted');
|
||||||
|
return $this->redirectToRoute('oauth_clients_list');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -32,6 +32,7 @@ use App\Events\SecurityEvent;
|
||||||
use App\Events\SecurityEvents;
|
use App\Events\SecurityEvents;
|
||||||
use App\Form\TFAGoogleSettingsType;
|
use App\Form\TFAGoogleSettingsType;
|
||||||
use App\Form\UserSettingsType;
|
use App\Form\UserSettingsType;
|
||||||
|
use App\Services\OAuth\ConnectedAppManager;
|
||||||
use App\Services\UserSystem\TFA\BackupCodeManager;
|
use App\Services\UserSystem\TFA\BackupCodeManager;
|
||||||
use App\Services\UserSystem\UserAvatarHelper;
|
use App\Services\UserSystem\UserAvatarHelper;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
@ -58,7 +59,7 @@ use Symfony\Component\Validator\Constraints\Length;
|
||||||
#[Route(path: '/user')]
|
#[Route(path: '/user')]
|
||||||
class UserSettingsController extends AbstractController
|
class UserSettingsController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(protected bool $demo_mode, protected EventDispatcherInterface $eventDispatcher)
|
public function __construct(protected bool $demo_mode, protected EventDispatcherInterface $eventDispatcher, private readonly ConnectedAppManager $connectedAppManager)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -380,6 +381,7 @@ class UserSettingsController extends AbstractController
|
||||||
'settings_form' => $form,
|
'settings_form' => $form,
|
||||||
'pw_form' => $pw_form,
|
'pw_form' => $pw_form,
|
||||||
'global_reload_needed' => $page_need_reload,
|
'global_reload_needed' => $page_need_reload,
|
||||||
|
'connected_apps' => $this->connectedAppManager->listConnectedClients($user->getUserIdentifier()),
|
||||||
|
|
||||||
'google_form' => $google_form,
|
'google_form' => $google_form,
|
||||||
'backup_form' => $backup_form,
|
'backup_form' => $backup_form,
|
||||||
|
|
@ -486,4 +488,35 @@ class UserSettingsController extends AbstractController
|
||||||
$this->addFlash('success', 'api_tokens.deleted');
|
$this->addFlash('success', 'api_tokens.deleted');
|
||||||
return $this->redirectToRoute('user_settings');
|
return $this->redirectToRoute('user_settings');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Route(path: '/oauth_connected_apps/revoke', name: 'user_oauth_connected_apps_revoke', methods: ['DELETE'])]
|
||||||
|
public function revokeConnectedApp(Request $request): Response
|
||||||
|
{
|
||||||
|
$this->denyAccessUnlessGranted('@api.manage_tokens');
|
||||||
|
//When user change its settings, he should be logged in fully.
|
||||||
|
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||||
|
|
||||||
|
$user = $this->getUser();
|
||||||
|
if (!$user instanceof User) {
|
||||||
|
throw new RuntimeException('This controller only works only for Part-DB User objects!');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->isCsrfTokenValid('delete'.$user->getID(), $request->request->get('_token'))) {
|
||||||
|
$this->addFlash('error', 'csfr_invalid');
|
||||||
|
return $this->redirectToRoute('user_settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
$clientId = $request->request->getString('client_id');
|
||||||
|
if ('' === $clientId) {
|
||||||
|
$this->addFlash('error', 'tfa_u2f.u2f_delete.not_existing');
|
||||||
|
return $this->redirectToRoute('user_settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
//Scoped to this user's own tokens by userIdentifier, so there is nothing to revoke (and no harm
|
||||||
|
//done) if $clientId does not actually belong to one of this user's connected apps.
|
||||||
|
$this->connectedAppManager->revokeForUserAndClient($user->getUserIdentifier(), $clientId);
|
||||||
|
|
||||||
|
$this->addFlash('success', 'user.settings.oauth_connected_apps.revoked');
|
||||||
|
return $this->redirectToRoute('user_settings');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
149
src/Services/OAuth/ConnectedAppManager.php
Normal file
149
src/Services/OAuth/ConnectedAppManager.php
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\OAuth;
|
||||||
|
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Model\AccessToken;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Model\AuthorizationCode;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Model\ClientInterface;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Model\RefreshToken;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists and revokes the OAuth2 clients (League\Bundle\OAuth2ServerBundle) a given user has authorized -
|
||||||
|
* the "connected apps" shown in user settings (templates/users/_oauth_connected_apps.html.twig).
|
||||||
|
*
|
||||||
|
* "Connected" means the client currently holds a live (non-revoked, non-expired) access token or refresh
|
||||||
|
* token for that user; a client the user approved but whose grant has since fully expired or been revoked
|
||||||
|
* no longer shows up here (there's nothing left for the user to revoke).
|
||||||
|
*/
|
||||||
|
class ConnectedAppManager
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly ClientManagerInterface $clientManager,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{client: ClientInterface, expiry: \DateTimeInterface}>
|
||||||
|
*/
|
||||||
|
public function listConnectedClients(string $userIdentifier): array
|
||||||
|
{
|
||||||
|
$now = new \DateTimeImmutable();
|
||||||
|
|
||||||
|
$fromAccessTokens = $this->entityManager->createQueryBuilder()
|
||||||
|
->select('IDENTITY(at.client) as clientId', 'MAX(at.expiry) as expiry')
|
||||||
|
->from(AccessToken::class, 'at')
|
||||||
|
->where('at.userIdentifier = :userIdentifier')
|
||||||
|
->andWhere('at.revoked = false')
|
||||||
|
->andWhere('at.expiry > :now')
|
||||||
|
->groupBy('at.client')
|
||||||
|
->setParameter('userIdentifier', $userIdentifier)
|
||||||
|
->setParameter('now', $now)
|
||||||
|
->getQuery()
|
||||||
|
->getResult();
|
||||||
|
|
||||||
|
$fromRefreshTokens = $this->entityManager->createQueryBuilder()
|
||||||
|
->select('IDENTITY(at.client) as clientId', 'MAX(rt.expiry) as expiry')
|
||||||
|
->from(RefreshToken::class, 'rt')
|
||||||
|
->join('rt.accessToken', 'at')
|
||||||
|
->where('at.userIdentifier = :userIdentifier')
|
||||||
|
->andWhere('rt.revoked = false')
|
||||||
|
->andWhere('rt.expiry > :now')
|
||||||
|
->groupBy('at.client')
|
||||||
|
->setParameter('userIdentifier', $userIdentifier)
|
||||||
|
->setParameter('now', $now)
|
||||||
|
->getQuery()
|
||||||
|
->getResult();
|
||||||
|
|
||||||
|
/** @var array<string, \DateTimeInterface> $expiryByClientId */
|
||||||
|
$expiryByClientId = [];
|
||||||
|
foreach ([...$fromAccessTokens, ...$fromRefreshTokens] as $row) {
|
||||||
|
$clientId = $row['clientId'];
|
||||||
|
$expiry = $row['expiry'];
|
||||||
|
if (!isset($expiryByClientId[$clientId]) || $expiry > $expiryByClientId[$clientId]) {
|
||||||
|
$expiryByClientId[$clientId] = $expiry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
foreach ($expiryByClientId as $clientId => $expiry) {
|
||||||
|
$client = $this->clientManager->find($clientId);
|
||||||
|
if (null === $client) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$result[] = ['client' => $client, 'expiry' => $expiry];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revokes every access token, refresh token and authorization code the given client holds for the
|
||||||
|
* given user - bulk DQL UPDATEs (mirroring League\Bundle\OAuth2ServerBundle\Service\CredentialsRevoker\DoctrineCredentialsRevoker,
|
||||||
|
* which does the same thing but scoped to "all of a user" / "all of a client" rather than one pair),
|
||||||
|
* not entity-load-then-flush, since this can touch many rows at once.
|
||||||
|
*/
|
||||||
|
public function revokeForUserAndClient(string $userIdentifier, string $clientIdentifier): void
|
||||||
|
{
|
||||||
|
$this->entityManager->createQueryBuilder()
|
||||||
|
->update(AccessToken::class, 'at')
|
||||||
|
->set('at.revoked', ':revoked')
|
||||||
|
->where('at.userIdentifier = :userIdentifier')
|
||||||
|
->andWhere('at.client = :client')
|
||||||
|
->setParameter('revoked', true)
|
||||||
|
->setParameter('userIdentifier', $userIdentifier)
|
||||||
|
->setParameter('client', $clientIdentifier)
|
||||||
|
->getQuery()
|
||||||
|
->execute();
|
||||||
|
|
||||||
|
$accessTokenIdsSubQuery = $this->entityManager->createQueryBuilder()
|
||||||
|
->select('at2.identifier')
|
||||||
|
->from(AccessToken::class, 'at2')
|
||||||
|
->where('at2.userIdentifier = :userIdentifier')
|
||||||
|
->andWhere('at2.client = :client')
|
||||||
|
->getDQL();
|
||||||
|
|
||||||
|
$refreshTokenUpdate = $this->entityManager->createQueryBuilder();
|
||||||
|
$refreshTokenUpdate->update(RefreshToken::class, 'rt')
|
||||||
|
->set('rt.revoked', ':revoked')
|
||||||
|
->where($refreshTokenUpdate->expr()->in('rt.accessToken', $accessTokenIdsSubQuery))
|
||||||
|
->setParameter('revoked', true)
|
||||||
|
->setParameter('userIdentifier', $userIdentifier)
|
||||||
|
->setParameter('client', $clientIdentifier)
|
||||||
|
->getQuery()
|
||||||
|
->execute();
|
||||||
|
|
||||||
|
$this->entityManager->createQueryBuilder()
|
||||||
|
->update(AuthorizationCode::class, 'ac')
|
||||||
|
->set('ac.revoked', ':revoked')
|
||||||
|
->where('ac.userIdentifier = :userIdentifier')
|
||||||
|
->andWhere('ac.client = :client')
|
||||||
|
->setParameter('revoked', true)
|
||||||
|
->setParameter('userIdentifier', $userIdentifier)
|
||||||
|
->setParameter('client', $clientIdentifier)
|
||||||
|
->getQuery()
|
||||||
|
->execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
133
src/Services/OAuth/OAuthClientAdminManager.php
Normal file
133
src/Services/OAuth/OAuthClientAdminManager.php
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\OAuth;
|
||||||
|
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Model\AbstractClient;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Model\AccessToken;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Model\AuthorizationCode;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Model\ClientInterface;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Model\RefreshToken;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backs the admin OAuth2 client overview (App\Controller\OAuthClientAdminController) - listing every
|
||||||
|
* registered client (almost always RFC 7591 self-registered, see that controller's docblock) with how
|
||||||
|
* many currently-live access tokens each one holds, and permanently removing a client plus everything it
|
||||||
|
* was ever granted.
|
||||||
|
*
|
||||||
|
* Deliberately does NOT use League\Bundle\OAuth2ServerBundle\Service\CredentialsRevokerInterface's
|
||||||
|
* DoctrineCredentialsRevoker: it unconditionally also issues an UPDATE against the DeviceCode model/table,
|
||||||
|
* which does not exist in our schema (config/packages/league_oauth2_server.yaml disables the device code
|
||||||
|
* grant, so migrations/Version20260726180454.php never created oauth2_device_code) - that call would
|
||||||
|
* throw "no such table" here. The access/refresh tokens and authorization codes (the 3 credential types
|
||||||
|
* we actually persist) are bulk-deleted directly instead, before removing the client itself - the
|
||||||
|
* "client" FK columns are mapped ON DELETE CASCADE (see League's Persistence\Mapping\Driver), but SQLite
|
||||||
|
* only enforces that when "PRAGMA foreign_keys = ON" is set on the connection, which is not guaranteed
|
||||||
|
* here, so this does not rely on it.
|
||||||
|
*/
|
||||||
|
class OAuthClientAdminManager
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly ClientManagerInterface $clientManager,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{client: ClientInterface, liveTokenCount: int}>
|
||||||
|
*/
|
||||||
|
public function listClientsWithLiveTokenCounts(): array
|
||||||
|
{
|
||||||
|
$clients = $this->clientManager->list(null);
|
||||||
|
|
||||||
|
$rows = $this->entityManager->createQueryBuilder()
|
||||||
|
->select('IDENTITY(at.client) as clientId', 'COUNT(at.identifier) as tokenCount')
|
||||||
|
->from(AccessToken::class, 'at')
|
||||||
|
->where('at.revoked = false')
|
||||||
|
->andWhere('at.expiry > :now')
|
||||||
|
->groupBy('at.client')
|
||||||
|
->setParameter('now', new \DateTimeImmutable())
|
||||||
|
->getQuery()
|
||||||
|
->getResult();
|
||||||
|
|
||||||
|
/** @var array<string, int> $countByClientId */
|
||||||
|
$countByClientId = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$countByClientId[$row['clientId']] = (int) $row['tokenCount'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_map(static fn (ClientInterface $client): array => [
|
||||||
|
'client' => $client,
|
||||||
|
'liveTokenCount' => $countByClientId[$client->getIdentifier()] ?? 0,
|
||||||
|
], $clients);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return bool false if no client with that identifier exists
|
||||||
|
*/
|
||||||
|
public function deleteClient(string $identifier): bool
|
||||||
|
{
|
||||||
|
$client = $this->clientManager->find($identifier);
|
||||||
|
if (!$client instanceof AbstractClient) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->deleteAllCredentialsForClient($identifier);
|
||||||
|
$this->clientManager->remove($client);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deleteAllCredentialsForClient(string $clientIdentifier): void
|
||||||
|
{
|
||||||
|
// Refresh tokens first - they reference access tokens (not the client directly), so this has to
|
||||||
|
// run before the access tokens themselves are deleted.
|
||||||
|
$accessTokenIdsSubQuery = $this->entityManager->createQueryBuilder()
|
||||||
|
->select('at2.identifier')
|
||||||
|
->from(AccessToken::class, 'at2')
|
||||||
|
->where('at2.client = :client')
|
||||||
|
->getDQL();
|
||||||
|
|
||||||
|
$refreshTokenDelete = $this->entityManager->createQueryBuilder();
|
||||||
|
$refreshTokenDelete->delete(RefreshToken::class, 'rt')
|
||||||
|
->where($refreshTokenDelete->expr()->in('rt.accessToken', $accessTokenIdsSubQuery))
|
||||||
|
->setParameter('client', $clientIdentifier)
|
||||||
|
->getQuery()
|
||||||
|
->execute();
|
||||||
|
|
||||||
|
$this->entityManager->createQueryBuilder()
|
||||||
|
->delete(AccessToken::class, 'at')
|
||||||
|
->where('at.client = :client')
|
||||||
|
->setParameter('client', $clientIdentifier)
|
||||||
|
->getQuery()
|
||||||
|
->execute();
|
||||||
|
|
||||||
|
$this->entityManager->createQueryBuilder()
|
||||||
|
->delete(AuthorizationCode::class, 'ac')
|
||||||
|
->where('ac.client = :client')
|
||||||
|
->setParameter('client', $clientIdentifier)
|
||||||
|
->getQuery()
|
||||||
|
->execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
60
templates/tools/oauth_clients/oauth_clients.html.twig
Normal file
60
templates/tools/oauth_clients/oauth_clients.html.twig
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
{% extends "main_card.html.twig" %}
|
||||||
|
|
||||||
|
{% block title %}{% trans %}oauth_clients.title{% endtrans %}{% endblock %}
|
||||||
|
|
||||||
|
{% block card_title %}
|
||||||
|
<i class="fa-solid fa-plug-circle-bolt fa-fw" aria-hidden="true"></i> {% trans %}oauth_clients.title{% endtrans %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block card_content %}
|
||||||
|
<p class="text-muted">{% trans %}oauth_clients.description{% endtrans %}</p>
|
||||||
|
|
||||||
|
{% if clients is empty %}
|
||||||
|
<b>{% trans %}oauth_clients.none_yet{% endtrans %}</b>
|
||||||
|
{% else %}
|
||||||
|
<table class="table table-striped table-bordered table-hover table-sm mt-2">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans %}oauth_clients.name{% endtrans %}</th>
|
||||||
|
<th>{% trans %}oauth_clients.client_id{% endtrans %}</th>
|
||||||
|
<th>{% trans %}oauth_clients.scopes{% endtrans %}</th>
|
||||||
|
<th>{% trans %}oauth_clients.redirect_uris{% endtrans %}</th>
|
||||||
|
<th>{% trans %}oauth_clients.status{% endtrans %}</th>
|
||||||
|
<th>{% trans %}oauth_clients.live_tokens{% endtrans %}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in clients %}
|
||||||
|
{% set client = row.client %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ client.name }}</td>
|
||||||
|
<td><code>{{ client.identifier }}</code></td>
|
||||||
|
<td>{{ client.scopes|join(', ') }}</td>
|
||||||
|
<td>{{ client.redirectUris|join(', ') }}</td>
|
||||||
|
<td>
|
||||||
|
{% if client.active %}
|
||||||
|
<span class="badge bg-success badge-success">{% trans %}oauth_clients.active{% endtrans %}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary badge-secondary">{% trans %}oauth_clients.inactive{% endtrans %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ row.liveTokenCount }}</td>
|
||||||
|
<td>
|
||||||
|
<form action="{{ path('oauth_clients_delete', {'identifier': client.identifier}) }}" method="post"
|
||||||
|
{{ stimulus_controller('elements/delete_btn') }} {{ stimulus_action('elements/delete_btn', "submit", "submit") }}
|
||||||
|
data-delete-title="{% trans %}oauth_clients.delete.title{% endtrans %}"
|
||||||
|
data-delete-message="{% trans %}oauth_clients.delete.message{% endtrans %}">
|
||||||
|
<input type="hidden" name="_method" value="DELETE">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('oauth_client_delete' ~ client.identifier) }}">
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm">
|
||||||
|
<i class="fas fa-trash-alt fa-fw"></i> {% trans %}oauth_clients.delete{% endtrans %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
52
templates/users/_oauth_connected_apps.html.twig
Normal file
52
templates/users/_oauth_connected_apps.html.twig
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
{# @var user \App\Entity\UserSystem\User #}
|
||||||
|
{# @var connected_apps array<array{client: \League\Bundle\OAuth2ServerBundle\Model\ClientInterface, expiry: \DateTimeInterface}> #}
|
||||||
|
|
||||||
|
{% import "helper.twig" as helper %}
|
||||||
|
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="fa-solid fa-plug-circle-bolt fa-fw" aria-hidden="true"></i>
|
||||||
|
{% trans %}user.settings.oauth_connected_apps{% endtrans %}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<span class="text-muted">{% trans %}user.settings.oauth_connected_apps.description{% endtrans %}</span>
|
||||||
|
|
||||||
|
{% if connected_apps is empty %}
|
||||||
|
<br><br>
|
||||||
|
<b>{% trans %}user.settings.oauth_connected_apps.none_yet{% endtrans %}</b>
|
||||||
|
<br><br>
|
||||||
|
{% else %}
|
||||||
|
<form action="{{ path('user_oauth_connected_apps_revoke') }}" method="post"
|
||||||
|
{{ stimulus_controller('elements/delete_btn') }} {{ stimulus_action('elements/delete_btn', "submit", "submit") }}
|
||||||
|
data-delete-title="{% trans %}user.settings.oauth_connected_apps.revoke.title{% endtrans %}"
|
||||||
|
data-delete-message="{% trans %}user.settings.oauth_connected_apps.revoke.message{% endtrans %}">
|
||||||
|
<input type="hidden" name="_method" value="DELETE">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('delete' ~ user.id) }}">
|
||||||
|
<table class="table table-striped table-bordered table-hover table-sm mt-2">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans %}user.settings.oauth_connected_apps.app_name{% endtrans %}</th>
|
||||||
|
<th>{% trans %}user.settings.oauth_connected_apps.access_until{% endtrans %}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{% for connected_app in connected_apps %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ connected_app.client.name }}</td>
|
||||||
|
<td>{{ helper.format_date_nullable(connected_app.expiry) }}</td>
|
||||||
|
<td>
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm" name="client_id"
|
||||||
|
value="{{ connected_app.client.identifier }}" {% if not is_granted('@api.manage_tokens') %}disabled="disabled"{% endif %}>
|
||||||
|
<i class="fas fa-trash-alt fa-fw"></i> {% trans %}user.settings.oauth_connected_apps.revoke{% endtrans %}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -79,5 +79,6 @@
|
||||||
|
|
||||||
{% if is_granted("@api.access_api") %}
|
{% if is_granted("@api.access_api") %}
|
||||||
{% include "users/_api_tokens.html.twig" %}
|
{% include "users/_api_tokens.html.twig" %}
|
||||||
|
{% include "users/_oauth_connected_apps.html.twig" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
207
tests/Controller/OAuth/ClientRegistrationControllerTest.php
Normal file
207
tests/Controller/OAuth/ClientRegistrationControllerTest.php
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Controller\OAuth;
|
||||||
|
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
use Symfony\Component\RateLimiter\RateLimiterFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the real unauthenticated POST /oauth/register HTTP flow
|
||||||
|
* (App\Controller\OAuth\ClientRegistrationController).
|
||||||
|
*/
|
||||||
|
final class ClientRegistrationControllerTest extends WebTestCase
|
||||||
|
{
|
||||||
|
public function testMinimalRegistrationUsesDefaults(): void
|
||||||
|
{
|
||||||
|
$httpClient = static::createClient();
|
||||||
|
$httpClient->jsonRequest('POST', '/oauth/register', [
|
||||||
|
'redirect_uris' => ['https://client.example.invalid/callback'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
$data = json_decode((string) $httpClient->getResponse()->getContent(), true);
|
||||||
|
|
||||||
|
self::assertMatchesRegularExpression('/^[0-9a-f]{32}$/', $data['client_id']);
|
||||||
|
self::assertIsInt($data['client_id_issued_at']);
|
||||||
|
self::assertSame(['https://client.example.invalid/callback'], $data['redirect_uris']);
|
||||||
|
self::assertSame(['authorization_code', 'refresh_token'], $data['grant_types']);
|
||||||
|
self::assertSame(['code'], $data['response_types']);
|
||||||
|
self::assertSame('none', $data['token_endpoint_auth_method']);
|
||||||
|
self::assertSame('read_only', $data['scope']);
|
||||||
|
|
||||||
|
$client = $httpClient->getContainer()->get(ClientManagerInterface::class)->find($data['client_id']);
|
||||||
|
self::assertNotNull($client);
|
||||||
|
self::assertNull($client->getSecret());
|
||||||
|
self::assertTrue($client->isActive());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFullRegistrationHonoursRequestedFields(): void
|
||||||
|
{
|
||||||
|
$httpClient = static::createClient();
|
||||||
|
$httpClient->jsonRequest('POST', '/oauth/register', [
|
||||||
|
'redirect_uris' => ['https://client.example.invalid/callback', 'https://client.example.invalid/callback2'],
|
||||||
|
'client_name' => 'My MCP Client',
|
||||||
|
'grant_types' => ['authorization_code'],
|
||||||
|
'response_types' => ['code'],
|
||||||
|
'token_endpoint_auth_method' => 'none',
|
||||||
|
'scope' => 'edit full',
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
$data = json_decode((string) $httpClient->getResponse()->getContent(), true);
|
||||||
|
|
||||||
|
self::assertSame('My MCP Client', $data['client_name']);
|
||||||
|
self::assertSame(['authorization_code'], $data['grant_types']);
|
||||||
|
self::assertSame('edit full', $data['scope']);
|
||||||
|
|
||||||
|
$client = $httpClient->getContainer()->get(ClientManagerInterface::class)->find($data['client_id']);
|
||||||
|
self::assertNotNull($client);
|
||||||
|
self::assertSame('My MCP Client', $client->getName());
|
||||||
|
self::assertCount(2, $client->getRedirectUris());
|
||||||
|
self::assertCount(2, $client->getScopes());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLoopbackHttpRedirectUriIsAllowed(): void
|
||||||
|
{
|
||||||
|
$httpClient = static::createClient();
|
||||||
|
$httpClient->jsonRequest('POST', '/oauth/register', [
|
||||||
|
'redirect_uris' => ['http://127.0.0.1:51234/callback'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPrivateUseSchemeRedirectUriIsAllowed(): void
|
||||||
|
{
|
||||||
|
$httpClient = static::createClient();
|
||||||
|
$httpClient->jsonRequest('POST', '/oauth/register', [
|
||||||
|
'redirect_uris' => ['com.example.myapp:/callback'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNonLoopbackHttpRedirectUriIsRejected(): void
|
||||||
|
{
|
||||||
|
$httpClient = static::createClient();
|
||||||
|
$httpClient->jsonRequest('POST', '/oauth/register', [
|
||||||
|
'redirect_uris' => ['http://evil.example.invalid/callback'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(400);
|
||||||
|
$data = json_decode((string) $httpClient->getResponse()->getContent(), true);
|
||||||
|
self::assertSame('invalid_redirect_uri', $data['error']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRedirectUriWithFragmentIsRejected(): void
|
||||||
|
{
|
||||||
|
$httpClient = static::createClient();
|
||||||
|
$httpClient->jsonRequest('POST', '/oauth/register', [
|
||||||
|
'redirect_uris' => ['https://client.example.invalid/callback#fragment'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMissingRedirectUrisIsRejected(): void
|
||||||
|
{
|
||||||
|
$httpClient = static::createClient();
|
||||||
|
$httpClient->jsonRequest('POST', '/oauth/register', []);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(400);
|
||||||
|
$data = json_decode((string) $httpClient->getResponse()->getContent(), true);
|
||||||
|
self::assertSame('invalid_redirect_uri', $data['error']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConfidentialAuthMethodIsRejected(): void
|
||||||
|
{
|
||||||
|
$httpClient = static::createClient();
|
||||||
|
$httpClient->jsonRequest('POST', '/oauth/register', [
|
||||||
|
'redirect_uris' => ['https://client.example.invalid/callback'],
|
||||||
|
'token_endpoint_auth_method' => 'client_secret_basic',
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(400);
|
||||||
|
$data = json_decode((string) $httpClient->getResponse()->getContent(), true);
|
||||||
|
self::assertSame('invalid_client_metadata', $data['error']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnsupportedGrantTypeIsRejected(): void
|
||||||
|
{
|
||||||
|
$httpClient = static::createClient();
|
||||||
|
$httpClient->jsonRequest('POST', '/oauth/register', [
|
||||||
|
'redirect_uris' => ['https://client.example.invalid/callback'],
|
||||||
|
'grant_types' => ['client_credentials'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnsupportedResponseTypeIsRejected(): void
|
||||||
|
{
|
||||||
|
$httpClient = static::createClient();
|
||||||
|
$httpClient->jsonRequest('POST', '/oauth/register', [
|
||||||
|
'redirect_uris' => ['https://client.example.invalid/callback'],
|
||||||
|
'response_types' => ['token'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnknownScopeIsRejected(): void
|
||||||
|
{
|
||||||
|
$httpClient = static::createClient();
|
||||||
|
$httpClient->jsonRequest('POST', '/oauth/register', [
|
||||||
|
'redirect_uris' => ['https://client.example.invalid/callback'],
|
||||||
|
'scope' => 'superadmin',
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(400);
|
||||||
|
$data = json_decode((string) $httpClient->getResponse()->getContent(), true);
|
||||||
|
self::assertSame('invalid_client_metadata', $data['error']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMalformedJsonBodyIsRejected(): void
|
||||||
|
{
|
||||||
|
$httpClient = static::createClient();
|
||||||
|
$httpClient->request('POST', '/oauth/register', [], [], ['CONTENT_TYPE' => 'application/json'], '{not json');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRateLimiterRejectsAfterConfiguredLimit(): void
|
||||||
|
{
|
||||||
|
// Exercises the actual "oauth_client_registration" limiter config (config/packages/rate_limiter.yaml)
|
||||||
|
// directly, with a random per-test key, rather than firing 21 real HTTP requests against a shared
|
||||||
|
// IP-keyed bucket - that would make this test (and any other test hitting the endpoint in the same
|
||||||
|
// run/hour) flaky, since the limiter's storage persists across requests and test runs.
|
||||||
|
$httpClient = static::createClient();
|
||||||
|
$factory = $httpClient->getContainer()->get('limiter.oauth_client_registration');
|
||||||
|
self::assertInstanceOf(RateLimiterFactory::class, $factory);
|
||||||
|
|
||||||
|
$limiter = $factory->create('test-'.bin2hex(random_bytes(8)));
|
||||||
|
self::assertTrue($limiter->consume(20)->isAccepted());
|
||||||
|
self::assertFalse($limiter->consume(1)->isAccepted());
|
||||||
|
}
|
||||||
|
}
|
||||||
75
tests/Controller/OAuth/DiscoveryControllerTest.php
Normal file
75
tests/Controller/OAuth/DiscoveryControllerTest.php
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Controller\OAuth;
|
||||||
|
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the real unauthenticated GET /.well-known/oauth-* HTTP flow
|
||||||
|
* (App\Controller\OAuth\DiscoveryController).
|
||||||
|
*/
|
||||||
|
final class DiscoveryControllerTest extends WebTestCase
|
||||||
|
{
|
||||||
|
public function testAuthorizationServerMetadata(): void
|
||||||
|
{
|
||||||
|
$httpClient = static::createClient();
|
||||||
|
$httpClient->request('GET', '/.well-known/oauth-authorization-server');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertResponseHeaderSame('Content-Type', 'application/json');
|
||||||
|
$data = json_decode((string) $httpClient->getResponse()->getContent(), true);
|
||||||
|
|
||||||
|
self::assertStringEndsWith('/authorize', $data['authorization_endpoint']);
|
||||||
|
self::assertStringEndsWith('/token', $data['token_endpoint']);
|
||||||
|
self::assertStringEndsWith('/oauth/register', $data['registration_endpoint']);
|
||||||
|
self::assertSame($data['issuer'], parse_url($data['authorization_endpoint'], PHP_URL_SCHEME).'://'.parse_url($data['authorization_endpoint'], PHP_URL_HOST));
|
||||||
|
self::assertSame(['read_only', 'edit', 'admin', 'full'], $data['scopes_supported']);
|
||||||
|
self::assertSame(['code'], $data['response_types_supported']);
|
||||||
|
self::assertSame(['authorization_code', 'refresh_token'], $data['grant_types_supported']);
|
||||||
|
self::assertSame(['none'], $data['token_endpoint_auth_methods_supported']);
|
||||||
|
self::assertSame(['S256'], $data['code_challenge_methods_supported']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testProtectedResourceMetadata(): void
|
||||||
|
{
|
||||||
|
$httpClient = static::createClient();
|
||||||
|
$httpClient->request('GET', '/.well-known/oauth-protected-resource');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
$data = json_decode((string) $httpClient->getResponse()->getContent(), true);
|
||||||
|
|
||||||
|
self::assertSame([$data['resource']], $data['authorization_servers']);
|
||||||
|
self::assertSame(['header'], $data['bearer_methods_supported']);
|
||||||
|
self::assertSame(['read_only', 'edit', 'admin', 'full'], $data['scopes_supported']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDiscoveryEndpointsRequireNoAuthentication(): void
|
||||||
|
{
|
||||||
|
$httpClient = static::createClient();
|
||||||
|
$httpClient->request('GET', '/.well-known/oauth-authorization-server');
|
||||||
|
self::assertResponseStatusCodeSame(200);
|
||||||
|
|
||||||
|
$httpClient->request('GET', '/.well-known/oauth-protected-resource');
|
||||||
|
self::assertResponseStatusCodeSame(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
104
tests/Controller/OAuthClientAdminControllerTest.php
Normal file
104
tests/Controller/OAuthClientAdminControllerTest.php
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Controller;
|
||||||
|
|
||||||
|
use App\Entity\UserSystem\User;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Model\Client;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\ValueObject\Grant;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\ValueObject\RedirectUri;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\ValueObject\Scope;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the real GET /tools/oauth_clients list + DELETE .../delete HTTP flow
|
||||||
|
* (App\Controller\OAuthClientAdminController, App\Services\OAuth\OAuthClientAdminManager).
|
||||||
|
*/
|
||||||
|
final class OAuthClientAdminControllerTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private function loginAsAdmin(): array
|
||||||
|
{
|
||||||
|
$httpClient = static::createClient();
|
||||||
|
$entityManager = static::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
$admin = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']);
|
||||||
|
self::assertInstanceOf(User::class, $admin);
|
||||||
|
$httpClient->loginUser($admin);
|
||||||
|
|
||||||
|
return [$httpClient, $admin];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function registerTestClient(KernelBrowser $httpClient): Client
|
||||||
|
{
|
||||||
|
$clientManager = $httpClient->getContainer()->get(ClientManagerInterface::class);
|
||||||
|
|
||||||
|
$client = new Client('Admin-visible Test App', 'test-admin-'.bin2hex(random_bytes(8)), null);
|
||||||
|
$client->setRedirectUris(new RedirectUri('https://client.example.invalid/callback'));
|
||||||
|
$client->setGrants(new Grant('authorization_code'), new Grant('refresh_token'));
|
||||||
|
$client->setScopes(new Scope('read_only'));
|
||||||
|
$client->setActive(true);
|
||||||
|
$clientManager->save($client);
|
||||||
|
|
||||||
|
return $client;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRegisteredClientIsListed(): void
|
||||||
|
{
|
||||||
|
[$httpClient, ] = $this->loginAsAdmin();
|
||||||
|
$client = $this->registerTestClient($httpClient);
|
||||||
|
|
||||||
|
$crawler = $httpClient->request('GET', '/en/tools/oauth_clients');
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertStringContainsString('Admin-visible Test App', $crawler->filter('body')->text());
|
||||||
|
self::assertStringContainsString($client->getIdentifier(), $crawler->filter('body')->text());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDeleteRemovesClient(): void
|
||||||
|
{
|
||||||
|
[$httpClient, ] = $this->loginAsAdmin();
|
||||||
|
$client = $this->registerTestClient($httpClient);
|
||||||
|
$identifier = $client->getIdentifier();
|
||||||
|
|
||||||
|
$crawler = $httpClient->request('GET', '/en/tools/oauth_clients');
|
||||||
|
$form = $crawler->selectButton('Delete')->form();
|
||||||
|
$httpClient->submit($form);
|
||||||
|
|
||||||
|
self::assertResponseRedirects('/en/tools/oauth_clients');
|
||||||
|
|
||||||
|
$clientManager = $httpClient->getContainer()->get(ClientManagerInterface::class);
|
||||||
|
self::assertNull($clientManager->find($identifier));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNonAdminUserIsDenied(): void
|
||||||
|
{
|
||||||
|
$httpClient = static::createClient();
|
||||||
|
$entityManager = static::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
$user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'user']);
|
||||||
|
self::assertInstanceOf(User::class, $user);
|
||||||
|
$httpClient->loginUser($user);
|
||||||
|
|
||||||
|
$httpClient->request('GET', '/en/tools/oauth_clients');
|
||||||
|
self::assertResponseStatusCodeSame(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
114
tests/Controller/UserSettingsControllerConnectedAppsTest.php
Normal file
114
tests/Controller/UserSettingsControllerConnectedAppsTest.php
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Controller;
|
||||||
|
|
||||||
|
use App\Entity\UserSystem\User;
|
||||||
|
use App\Services\OAuth\ConnectedAppManager;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Manager\AccessTokenManagerInterface;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Model\AccessToken;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Model\Client;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\ValueObject\Grant;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\ValueObject\RedirectUri;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\ValueObject\Scope;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the real user-settings "connected apps" list + revoke HTTP flow
|
||||||
|
* (App\Controller\UserSettingsController::revokeConnectedApp, App\Services\OAuth\ConnectedAppManager).
|
||||||
|
*/
|
||||||
|
final class UserSettingsControllerConnectedAppsTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private function loginAsAdmin(): array
|
||||||
|
{
|
||||||
|
$httpClient = static::createClient();
|
||||||
|
$entityManager = static::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
$admin = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']);
|
||||||
|
self::assertInstanceOf(User::class, $admin);
|
||||||
|
$httpClient->loginUser($admin);
|
||||||
|
|
||||||
|
return [$httpClient, $admin];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createConnectedApp(KernelBrowser $httpClient, User $admin): Client
|
||||||
|
{
|
||||||
|
$clientManager = $httpClient->getContainer()->get(ClientManagerInterface::class);
|
||||||
|
$accessTokenManager = $httpClient->getContainer()->get(AccessTokenManagerInterface::class);
|
||||||
|
|
||||||
|
$client = new Client('My Connected Test App', 'test-conn-http-'.bin2hex(random_bytes(8)), null);
|
||||||
|
$client->setRedirectUris(new RedirectUri('https://client.example.invalid/callback'));
|
||||||
|
$client->setGrants(new Grant('authorization_code'), new Grant('refresh_token'));
|
||||||
|
$client->setScopes(new Scope('read_only'));
|
||||||
|
$client->setActive(true);
|
||||||
|
$clientManager->save($client);
|
||||||
|
|
||||||
|
$accessTokenManager->save(new AccessToken(
|
||||||
|
'at-'.bin2hex(random_bytes(16)),
|
||||||
|
new \DateTimeImmutable('+1 hour'),
|
||||||
|
$client,
|
||||||
|
$admin->getUserIdentifier(),
|
||||||
|
[new Scope('read_only')],
|
||||||
|
));
|
||||||
|
|
||||||
|
return $client;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConnectedAppIsListedAndCanBeRevoked(): void
|
||||||
|
{
|
||||||
|
[$httpClient, $admin] = $this->loginAsAdmin();
|
||||||
|
$client = $this->createConnectedApp($httpClient, $admin);
|
||||||
|
|
||||||
|
$crawler = $httpClient->request('GET', '/en/user/settings');
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertStringContainsString('My Connected Test App', $crawler->filter('body')->text());
|
||||||
|
|
||||||
|
$form = $crawler->filter('button[name="client_id"][value="'.$client->getIdentifier().'"]')->form([
|
||||||
|
'client_id' => $client->getIdentifier(),
|
||||||
|
]);
|
||||||
|
$httpClient->submit($form);
|
||||||
|
|
||||||
|
self::assertResponseRedirects('/en/user/settings');
|
||||||
|
|
||||||
|
$connectedAppManager = $httpClient->getContainer()->get(ConnectedAppManager::class);
|
||||||
|
self::assertSame([], $connectedAppManager->listConnectedClients($admin->getUserIdentifier()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRevokeWithInvalidCsrfTokenDoesNothing(): void
|
||||||
|
{
|
||||||
|
[$httpClient, $admin] = $this->loginAsAdmin();
|
||||||
|
$client = $this->createConnectedApp($httpClient, $admin);
|
||||||
|
|
||||||
|
$httpClient->request('POST', '/en/user/oauth_connected_apps/revoke', [
|
||||||
|
'_method' => 'DELETE',
|
||||||
|
'_token' => 'invalid',
|
||||||
|
'client_id' => $client->getIdentifier(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseRedirects('/en/user/settings');
|
||||||
|
|
||||||
|
$connectedAppManager = $httpClient->getContainer()->get(ConnectedAppManager::class);
|
||||||
|
self::assertCount(1, $connectedAppManager->listConnectedClients($admin->getUserIdentifier()));
|
||||||
|
}
|
||||||
|
}
|
||||||
134
tests/Services/OAuth/ConnectedAppManagerTest.php
Normal file
134
tests/Services/OAuth/ConnectedAppManagerTest.php
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Services\OAuth;
|
||||||
|
|
||||||
|
use App\Services\OAuth\ConnectedAppManager;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Manager\AccessTokenManagerInterface;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Manager\AuthorizationCodeManagerInterface;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Manager\RefreshTokenManagerInterface;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Model\AccessToken;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Model\AuthorizationCode;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Model\Client;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Model\RefreshToken;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\ValueObject\Grant;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\ValueObject\RedirectUri;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\ValueObject\Scope;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
|
||||||
|
final class ConnectedAppManagerTest extends KernelTestCase
|
||||||
|
{
|
||||||
|
private function createClient(ClientManagerInterface $clientManager): Client
|
||||||
|
{
|
||||||
|
$client = new Client('Test Connected App', 'test-conn-'.bin2hex(random_bytes(8)), null);
|
||||||
|
$client->setRedirectUris(new RedirectUri('https://client.example.invalid/callback'));
|
||||||
|
$client->setGrants(new Grant('authorization_code'), new Grant('refresh_token'));
|
||||||
|
$client->setScopes(new Scope('read_only'));
|
||||||
|
$client->setActive(true);
|
||||||
|
$clientManager->save($client);
|
||||||
|
|
||||||
|
return $client;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testListAndRevokeRoundTrip(): void
|
||||||
|
{
|
||||||
|
self::bootKernel();
|
||||||
|
$container = static::getContainer();
|
||||||
|
|
||||||
|
$clientManager = $container->get(ClientManagerInterface::class);
|
||||||
|
$accessTokenManager = $container->get(AccessTokenManagerInterface::class);
|
||||||
|
$refreshTokenManager = $container->get(RefreshTokenManagerInterface::class);
|
||||||
|
$authCodeManager = $container->get(AuthorizationCodeManagerInterface::class);
|
||||||
|
$entityManager = $container->get(EntityManagerInterface::class);
|
||||||
|
$manager = $container->get(ConnectedAppManager::class);
|
||||||
|
|
||||||
|
$userIdentifier = 'test-user-'.bin2hex(random_bytes(8));
|
||||||
|
$client = $this->createClient($clientManager);
|
||||||
|
|
||||||
|
$accessToken = new AccessToken(
|
||||||
|
'at-'.bin2hex(random_bytes(16)),
|
||||||
|
new \DateTimeImmutable('+1 hour'),
|
||||||
|
$client,
|
||||||
|
$userIdentifier,
|
||||||
|
[new Scope('read_only')],
|
||||||
|
);
|
||||||
|
$accessTokenManager->save($accessToken);
|
||||||
|
|
||||||
|
$refreshToken = new RefreshToken(
|
||||||
|
'rt-'.bin2hex(random_bytes(16)),
|
||||||
|
new \DateTimeImmutable('+30 days'),
|
||||||
|
$accessToken,
|
||||||
|
);
|
||||||
|
$refreshTokenManager->save($refreshToken);
|
||||||
|
|
||||||
|
$authCode = new AuthorizationCode(
|
||||||
|
'ac-'.bin2hex(random_bytes(16)),
|
||||||
|
new \DateTimeImmutable('+10 minutes'),
|
||||||
|
$client,
|
||||||
|
$userIdentifier,
|
||||||
|
[new Scope('read_only')],
|
||||||
|
);
|
||||||
|
$authCodeManager->save($authCode);
|
||||||
|
|
||||||
|
// A second, unrelated user must not see this client as connected.
|
||||||
|
self::assertSame([], $manager->listConnectedClients('someone-else'));
|
||||||
|
|
||||||
|
$connected = $manager->listConnectedClients($userIdentifier);
|
||||||
|
self::assertCount(1, $connected);
|
||||||
|
self::assertSame($client->getIdentifier(), $connected[0]['client']->getIdentifier());
|
||||||
|
|
||||||
|
$manager->revokeForUserAndClient($userIdentifier, $client->getIdentifier());
|
||||||
|
|
||||||
|
self::assertSame([], $manager->listConnectedClients($userIdentifier));
|
||||||
|
|
||||||
|
$entityManager->clear();
|
||||||
|
self::assertTrue($accessTokenManager->find($accessToken->getIdentifier())->isRevoked());
|
||||||
|
self::assertTrue($refreshTokenManager->find($refreshToken->getIdentifier())->isRevoked());
|
||||||
|
self::assertTrue($authCodeManager->find($authCode->getIdentifier())->isRevoked());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExpiredAccessTokenIsNotListedAsConnected(): void
|
||||||
|
{
|
||||||
|
self::bootKernel();
|
||||||
|
$container = static::getContainer();
|
||||||
|
|
||||||
|
$clientManager = $container->get(ClientManagerInterface::class);
|
||||||
|
$accessTokenManager = $container->get(AccessTokenManagerInterface::class);
|
||||||
|
$manager = $container->get(ConnectedAppManager::class);
|
||||||
|
|
||||||
|
$userIdentifier = 'test-user-'.bin2hex(random_bytes(8));
|
||||||
|
$client = $this->createClient($clientManager);
|
||||||
|
|
||||||
|
$accessToken = new AccessToken(
|
||||||
|
'at-'.bin2hex(random_bytes(16)),
|
||||||
|
new \DateTimeImmutable('-1 hour'),
|
||||||
|
$client,
|
||||||
|
$userIdentifier,
|
||||||
|
[new Scope('read_only')],
|
||||||
|
);
|
||||||
|
$accessTokenManager->save($accessToken);
|
||||||
|
|
||||||
|
self::assertSame([], $manager->listConnectedClients($userIdentifier));
|
||||||
|
}
|
||||||
|
}
|
||||||
94
tests/Services/OAuth/OAuthClientAdminManagerTest.php
Normal file
94
tests/Services/OAuth/OAuthClientAdminManagerTest.php
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Services\OAuth;
|
||||||
|
|
||||||
|
use App\Services\OAuth\OAuthClientAdminManager;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Manager\AccessTokenManagerInterface;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Model\AccessToken;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Model\Client;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\ValueObject\Grant;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\ValueObject\RedirectUri;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\ValueObject\Scope;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
|
||||||
|
final class OAuthClientAdminManagerTest extends KernelTestCase
|
||||||
|
{
|
||||||
|
public function testListIncludesLiveTokenCountAndDeleteRemovesClientAndTokens(): void
|
||||||
|
{
|
||||||
|
self::bootKernel();
|
||||||
|
$container = static::getContainer();
|
||||||
|
|
||||||
|
$clientManager = $container->get(ClientManagerInterface::class);
|
||||||
|
$accessTokenManager = $container->get(AccessTokenManagerInterface::class);
|
||||||
|
$manager = $container->get(OAuthClientAdminManager::class);
|
||||||
|
|
||||||
|
$client = new Client('Admin Manager Test App', 'test-admin-mgr-'.bin2hex(random_bytes(8)), null);
|
||||||
|
$client->setRedirectUris(new RedirectUri('https://client.example.invalid/callback'));
|
||||||
|
$client->setGrants(new Grant('authorization_code'), new Grant('refresh_token'));
|
||||||
|
$client->setScopes(new Scope('read_only'));
|
||||||
|
$client->setActive(true);
|
||||||
|
$clientManager->save($client);
|
||||||
|
|
||||||
|
$accessToken = new AccessToken(
|
||||||
|
'at-'.bin2hex(random_bytes(16)),
|
||||||
|
new \DateTimeImmutable('+1 hour'),
|
||||||
|
$client,
|
||||||
|
'some-user',
|
||||||
|
[new Scope('read_only')],
|
||||||
|
);
|
||||||
|
$accessTokenManager->save($accessToken);
|
||||||
|
|
||||||
|
$rows = $manager->listClientsWithLiveTokenCounts();
|
||||||
|
$row = self::findRowForClient($rows, $client->getIdentifier());
|
||||||
|
self::assertNotNull($row);
|
||||||
|
self::assertSame(1, $row['liveTokenCount']);
|
||||||
|
|
||||||
|
self::assertTrue($manager->deleteClient($client->getIdentifier()));
|
||||||
|
self::assertNull($clientManager->find($client->getIdentifier()));
|
||||||
|
|
||||||
|
// AccessTokenManager::find() uses EntityManager::find(), which checks the identity map before
|
||||||
|
// hitting the DB - without clearing it here, it would just hand back the same in-memory object
|
||||||
|
// we already hold, masking whether the row was actually deleted.
|
||||||
|
$container->get(EntityManagerInterface::class)->clear();
|
||||||
|
self::assertNull($accessTokenManager->find($accessToken->getIdentifier()));
|
||||||
|
|
||||||
|
self::assertFalse($manager->deleteClient($client->getIdentifier()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array{client: \League\Bundle\OAuth2ServerBundle\Model\ClientInterface, liveTokenCount: int}> $rows
|
||||||
|
* @return array{client: \League\Bundle\OAuth2ServerBundle\Model\ClientInterface, liveTokenCount: int}|null
|
||||||
|
*/
|
||||||
|
private static function findRowForClient(array $rows, string $identifier): ?array
|
||||||
|
{
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if ($row['client']->getIdentifier() === $identifier) {
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13859,5 +13859,155 @@ Buerklin-API Authentication server:
|
||||||
<target>Deny</target>
|
<target>Deny</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
|
<unit id="oaConn01" name="user.settings.oauth_connected_apps">
|
||||||
|
<segment>
|
||||||
|
<source>user.settings.oauth_connected_apps</source>
|
||||||
|
<target>Connected apps</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="oaConn02" name="user.settings.oauth_connected_apps.description">
|
||||||
|
<segment>
|
||||||
|
<source>user.settings.oauth_connected_apps.description</source>
|
||||||
|
<target>These applications signed in through the Part-DB OAuth login and were granted access to your account. Revoking an application here immediately ends its access; it will need to go through the login/consent screen again to reconnect.</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="oaConn03" name="user.settings.oauth_connected_apps.none_yet">
|
||||||
|
<segment>
|
||||||
|
<source>user.settings.oauth_connected_apps.none_yet</source>
|
||||||
|
<target>No connected apps yet.</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="oaConn04" name="user.settings.oauth_connected_apps.app_name">
|
||||||
|
<segment>
|
||||||
|
<source>user.settings.oauth_connected_apps.app_name</source>
|
||||||
|
<target>Application</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="oaConn05" name="user.settings.oauth_connected_apps.access_until">
|
||||||
|
<segment>
|
||||||
|
<source>user.settings.oauth_connected_apps.access_until</source>
|
||||||
|
<target>Access valid until</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="oaConn06" name="user.settings.oauth_connected_apps.revoke">
|
||||||
|
<segment>
|
||||||
|
<source>user.settings.oauth_connected_apps.revoke</source>
|
||||||
|
<target>Revoke</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="oaConn07" name="user.settings.oauth_connected_apps.revoke.title">
|
||||||
|
<segment>
|
||||||
|
<source>user.settings.oauth_connected_apps.revoke.title</source>
|
||||||
|
<target>Do you really want to revoke access for this application?</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="oaConn08" name="user.settings.oauth_connected_apps.revoke.message">
|
||||||
|
<segment>
|
||||||
|
<source>user.settings.oauth_connected_apps.revoke.message</source>
|
||||||
|
<target>The application will immediately lose access to Part-DB and will have to go through the login/consent screen again to reconnect. This action can not be undone!</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="oaConn09" name="user.settings.oauth_connected_apps.revoked">
|
||||||
|
<segment>
|
||||||
|
<source>user.settings.oauth_connected_apps.revoked</source>
|
||||||
|
<target>Access revoked successfully!</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="oaAdm001" name="perm.system.manage_oauth_clients">
|
||||||
|
<segment>
|
||||||
|
<source>perm.system.manage_oauth_clients</source>
|
||||||
|
<target>Manage OAuth2 clients</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="oaAdm002" name="oauth_clients.title">
|
||||||
|
<segment>
|
||||||
|
<source>oauth_clients.title</source>
|
||||||
|
<target>OAuth2 clients</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="oaAdm003" name="oauth_clients.description">
|
||||||
|
<segment>
|
||||||
|
<source>oauth_clients.description</source>
|
||||||
|
<target>Applications registered with Part-DB's OAuth2 server (usually through open Dynamic Client Registration, RFC 7591 - any application can self-register, so the real access control gate is the per-user consent screen a user sees before granting one of these access to their account). Deleting a client here immediately revokes all access it was ever granted and prevents it from being used again.</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="oaAdm004" name="oauth_clients.none_yet">
|
||||||
|
<segment>
|
||||||
|
<source>oauth_clients.none_yet</source>
|
||||||
|
<target>No OAuth2 clients registered yet.</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="oaAdm005" name="oauth_clients.name">
|
||||||
|
<segment>
|
||||||
|
<source>oauth_clients.name</source>
|
||||||
|
<target>Name</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="oaAdm006" name="oauth_clients.client_id">
|
||||||
|
<segment>
|
||||||
|
<source>oauth_clients.client_id</source>
|
||||||
|
<target>Client ID</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="oaAdm007" name="oauth_clients.scopes">
|
||||||
|
<segment>
|
||||||
|
<source>oauth_clients.scopes</source>
|
||||||
|
<target>Scopes</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="oaAdm008" name="oauth_clients.redirect_uris">
|
||||||
|
<segment>
|
||||||
|
<source>oauth_clients.redirect_uris</source>
|
||||||
|
<target>Redirect URIs</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="oaAdm009" name="oauth_clients.status">
|
||||||
|
<segment>
|
||||||
|
<source>oauth_clients.status</source>
|
||||||
|
<target>Status</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="oaAdm010" name="oauth_clients.active">
|
||||||
|
<segment>
|
||||||
|
<source>oauth_clients.active</source>
|
||||||
|
<target>Active</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="oaAdm011" name="oauth_clients.inactive">
|
||||||
|
<segment>
|
||||||
|
<source>oauth_clients.inactive</source>
|
||||||
|
<target>Inactive</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="oaAdm012" name="oauth_clients.live_tokens">
|
||||||
|
<segment>
|
||||||
|
<source>oauth_clients.live_tokens</source>
|
||||||
|
<target>Live access tokens</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="oaAdm013" name="oauth_clients.delete">
|
||||||
|
<segment>
|
||||||
|
<source>oauth_clients.delete</source>
|
||||||
|
<target>Delete</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="oaAdm014" name="oauth_clients.delete.title">
|
||||||
|
<segment>
|
||||||
|
<source>oauth_clients.delete.title</source>
|
||||||
|
<target>Do you really want to delete this OAuth2 client?</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="oaAdm015" name="oauth_clients.delete.message">
|
||||||
|
<segment>
|
||||||
|
<source>oauth_clients.delete.message</source>
|
||||||
|
<target>This immediately revokes all access this application was ever granted, for every user. This action can not be undone; the application would have to register again to reconnect.</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="oaAdm016" name="oauth_clients.deleted">
|
||||||
|
<segment>
|
||||||
|
<source>oauth_clients.deleted</source>
|
||||||
|
<target>OAuth2 client deleted successfully!</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
</file>
|
</file>
|
||||||
</xliff>
|
</xliff>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue