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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/cloud_federation_api/appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
*/
return [
'routes' => [
[
'name' => 'Token#jwks',
'url' => '/api/v1/jwks',
'verb' => 'GET',
],
[
'name' => 'RequestHandler#addShare',
'url' => '/shares',
Expand Down
30 changes: 17 additions & 13 deletions apps/cloud_federation_api/lib/Controller/OCMRequestController.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@

namespace OCA\CloudFederationAPI\Controller;

use JsonException;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\BruteForceProtection;
Expand All @@ -18,6 +17,7 @@
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Http\Response;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Federation\ICloudFederationProviderManager;
use OCP\IRequest;
use OCP\OCM\Events\OCMEndpointRequestEvent;
use OCP\OCM\Exceptions\OCMArgumentException;
Expand All @@ -31,6 +31,7 @@ public function __construct(
IRequest $request,
private readonly IEventDispatcher $eventDispatcher,
private readonly IOCMDiscoveryService $ocmDiscoveryService,
private readonly ICloudFederationProviderManager $cloudFederationProviderManager,
private readonly LoggerInterface $logger,
) {
parent::__construct($appName, $request);
Expand All @@ -56,26 +57,29 @@ public function manageOCMRequests(string $ocmPath): Response {
throw new OCMArgumentException('path is not UTF-8');
}

// Resolve the signer origin from the payload before verification.
$payload = $this->request->getParams();
$origin = null;
if ($payload !== []) {
$identity = $this->cloudFederationProviderManager->resolveSenderIdentity($payload);
if ($identity !== null) {
try {
$origin = $this->ocmDiscoveryService->getHostFromOcmAddress($identity);
} catch (IncomingRequestException) {
// unresolvable origin; verification will fail without one
}
}
}

try {
// if request is signed and well signed, no exceptions are thrown
// if request is not signed and host is known for not supporting signed request, no exceptions are thrown
$signedRequest = $this->ocmDiscoveryService->getIncomingSignedRequest();
$signedRequest = $this->ocmDiscoveryService->getIncomingSignedRequest($origin);
} catch (IncomingRequestException $e) {
$this->logger->warning('incoming ocm request exception', ['exception' => $e]);
$response = new JSONResponse(['message' => $e->getMessage(), 'validationErrors' => []], Http::STATUS_BAD_REQUEST);
$response->throttle();
return $response;
}

// assuming that ocm request contains a json array
$payload = $signedRequest?->getBody() ?? file_get_contents('php://input');
try {
$payload = ($payload) ? json_decode($payload, true, 512, JSON_THROW_ON_ERROR) : null;
} catch (JsonException $e) {
$this->logger->debug('json decode error', ['exception' => $e]);
$payload = null;
}

$event = new OCMEndpointRequestEvent(
$this->request->getMethod(),
preg_replace('@/+@', '/', $ocmPath),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,8 @@ public function addShare($shareWith, $name, $description, $providerId, $owner, $
try {
// if request is signed and well signed, no exceptions are thrown
// if request is not signed and host is known for not supporting signed request, no exception are thrown
$signedRequest = $this->ocmDiscoveryService->getIncomingSignedRequest();
$origin = $this->ocmDiscoveryService->getHostFromOcmAddress($owner);
$signedRequest = $this->ocmDiscoveryService->getIncomingSignedRequest($origin);
$this->confirmSignedOrigin($signedRequest, 'owner', $owner);
} catch (IncomingRequestException $e) {
$this->logger->warning('incoming request exception', ['exception' => $e]);
Expand Down Expand Up @@ -307,10 +308,15 @@ public function receiveNotification($notificationType, $resourceType, $providerI

if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) {
try {
// if request is signed and well signed, no exception are thrown
// if request is not signed and host is known for not supporting signed request, no exception are thrown
$signedRequest = $this->ocmDiscoveryService->getIncomingSignedRequest();
$this->confirmNotificationIdentity($signedRequest, $resourceType, $notification);
$identity = $this->resolveNotificationIdentity($resourceType, $notification);
$origin = null;
if ($identity !== '') {
$origin = $this->ocmDiscoveryService->getHostFromOcmAddress($identity);
}
$signedRequest = $this->ocmDiscoveryService->getIncomingSignedRequest($origin);
if ($identity !== '') {
$this->ocmDiscoveryService->confirmRequestOrigin($signedRequest?->getOrigin(), $identity);
}
} catch (IncomingRequestException $e) {
$this->logger->warning('incoming request exception', ['exception' => $e]);
return new JSONResponse(['message' => $e->getMessage(), 'validationErrors' => []], Http::STATUS_BAD_REQUEST);
Expand Down Expand Up @@ -450,22 +456,16 @@ private function confirmSignedOrigin(?IIncomingSignedRequest $signedRequest, str
}

/**
* confirm identity of the remote instance on notification, based on the share token.
* Resolve the sender identity from a notification's sharedSecret.
* Returns '' when the provider does not implement signed federation.
*
* If request is not signed, we still verify that the hostname from the extracted value does,
* actually, not support signed request
*
* @param IIncomingSignedRequest|null $signedRequest
* @param string $resourceType
* @param array<string, mixed> $notification
*
* @throws IncomingRequestException
* @throws BadRequestException
*/
private function confirmNotificationIdentity(
?IIncomingSignedRequest $signedRequest,
string $resourceType,
array $notification,
): void {
private function resolveNotificationIdentity(string $resourceType, array $notification): string {
$sharedSecret = $notification['sharedSecret'] ?? '';
if ($sharedSecret === '') {
throw new BadRequestException(['sharedSecret']);
Expand All @@ -481,14 +481,12 @@ private function confirmNotificationIdentity(
$mapping = Server::get(OcmTokenMapMapper::class)->getByAccessTokenId($accessTokenDb->getId());
$identity = $provider->getFederationIdFromSharedSecret($mapping->getRefreshToken(), $notification);
}
} else {
$this->logger->debug('cloud federation provider {provider} does not implements ISignedCloudFederationProvider', ['provider' => $provider::class]);
return;
return $identity;
}
$this->logger->debug('cloud federation provider {provider} does not implement ISignedCloudFederationProvider', ['provider' => $provider::class]);
} catch (\Exception $e) {
throw new IncomingRequestException($e->getMessage(), previous: $e);
}

$this->ocmDiscoveryService->confirmRequestOrigin($signedRequest?->getOrigin(), $identity);
return '';
}
}
54 changes: 51 additions & 3 deletions apps/cloud_federation_api/lib/Controller/TokenController.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Authentication\Exceptions\ExpiredTokenException;
use OCP\Authentication\Exceptions\InvalidTokenException;
use OCP\Authentication\Token\IToken;
use OCP\IAppConfig;
use OCP\IRequest;
use OCP\OCM\IOCMDiscoveryService;
use OCP\Security\ISecureRandom;
use OCP\Security\Signature\Exceptions\IncomingRequestException;
use OCP\Security\Signature\Exceptions\SignatoryNotFoundException;
Expand Down Expand Up @@ -51,19 +53,44 @@ public function __construct(
private readonly IAppConfig $appConfig,
private readonly OcmTokenMapMapper $ocmTokenMapMapper,
private readonly IShareManager $shareManager,
private readonly IOCMDiscoveryService $ocmDiscoveryService,
) {
parent::__construct('cloud_federation_api', $request);
}

/**
* Resolve the signer origin from the refresh token's share, or null.
*
* @param string $code refresh token
* @return string|null signer origin, or null if it cannot be determined
*/
private function resolveOriginFromRefreshToken(string $code): ?string {
if ($code === '') {
return null;
}
try {
$share = $this->shareManager->getShareByToken($code);
$sharedWith = $share->getSharedWith();
if ($sharedWith === null || $sharedWith === '') {
return null;
}
return $this->ocmDiscoveryService->getHostFromOcmAddress($sharedWith);
} catch (\Throwable) {
return null;
}
}

/**
* Verify the signature of incoming request if available
*
* @param string|null $origin the origin of the request, or null if unknown
*
* @return IIncomingSignedRequest|null null if remote does not support signed requests
* @throws IncomingRequestException if signature is required but invalid
*/
private function verifySignedRequest(): ?IIncomingSignedRequest {
private function verifySignedRequest(?string $origin): ?IIncomingSignedRequest {
try {
$signedRequest = $this->signatureManager->getIncomingSignedRequest($this->signatoryManager);
$signedRequest = $this->signatureManager->getIncomingSignedRequest($this->signatoryManager, null, $origin);
$this->logger->debug('Token request signature verified', [
'origin' => $signedRequest->getOrigin()
]);
Expand Down Expand Up @@ -109,6 +136,27 @@ private function resolveJwtSigningKey(string $privateKeyPem): array {
throw new \RuntimeException('Unsupported signatory key type for JWT access token');
}

/**
* Serve the local JWK Set
*
* @return JSONResponse<Http::STATUS_OK, array{keys: list<array<string, string>>}, array{}>
*
* 200: JWK Set returned
*/
#[PublicPage]
#[NoCSRFRequired]
public function jwks(): JSONResponse {
$keys = [];
try {
foreach ($this->signatoryManager->getLocalJwks() as $jwk) {
$keys[] = $jwk;
}
} catch (\Throwable $e) {
$this->logger->warning('failed to build local JWKs', ['exception' => $e]);
}
return new JSONResponse(['keys' => $keys]);
}

/**
* Exchange a refresh token for a short-lived access token
*
Expand All @@ -126,7 +174,7 @@ private function resolveJwtSigningKey(string $privateKeyPem): array {
#[FrontpageRoute(verb: 'POST', url: '/api/v1/access-token')]
public function accessToken(string $grant_type = '', string $code = ''): DataResponse {
try {
$signedRequest = $this->verifySignedRequest();
$signedRequest = $this->verifySignedRequest($this->resolveOriginFromRefreshToken($code));
} catch (IncomingRequestException $e) {
$this->logger->warning('Token request signature verification failed', [
'exception' => $e
Expand Down
8 changes: 4 additions & 4 deletions apps/cloud_federation_api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -323,13 +323,13 @@
}
},
"tags": [
{
"name": "request_handler",
"description": "Open-Cloud-Mesh-API"
},
{
"name": "token",
"description": "Controller for the /token endpoint Exchanges long-lived refresh tokens for short-lived access tokens"
},
{
"name": "request_handler",
"description": "Open-Cloud-Mesh-API"
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
use OCP\Authentication\Token\IToken;
use OCP\IAppConfig;
use OCP\IRequest;
use OCP\OCM\IOCMDiscoveryService;
use OCP\Security\ISecureRandom;
use OCP\Security\Signature\Exceptions\SignatoryNotFoundException;
use OCP\Security\Signature\Exceptions\SignatureException;
Expand All @@ -47,6 +48,7 @@ class TokenControllerTest extends TestCase {
private IAppConfig&MockObject $appConfig;
private OcmTokenMapMapper&MockObject $ocmTokenMapMapper;
private IShareManager&MockObject $shareManager;
private IOCMDiscoveryService&MockObject $ocmDiscoveryService;

private TokenController $controller;

Expand All @@ -67,6 +69,7 @@ protected function setUp(): void {
$this->appConfig = $this->createMock(IAppConfig::class);
$this->ocmTokenMapMapper = $this->createMock(OcmTokenMapMapper::class);
$this->shareManager = $this->createMock(IShareManager::class);
$this->ocmDiscoveryService = $this->createMock(IOCMDiscoveryService::class);

$this->controller = new TokenController(
$this->request,
Expand All @@ -79,6 +82,7 @@ protected function setUp(): void {
$this->appConfig,
$this->ocmTokenMapMapper,
$this->shareManager,
$this->ocmDiscoveryService,
);
}

Expand Down
2 changes: 0 additions & 2 deletions core/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@
use OC\Core\Sharing\Recipient\TokenShareRecipientType;
use OC\Core\Sharing\Recipient\UserShareRecipientType;
use OC\OCM\OCMDiscoveryHandler;
use OC\OCM\OCMJwksHandler;
use OC\TagManager;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
Expand Down Expand Up @@ -110,7 +109,6 @@ public function register(IRegistrationContext $context): void {
$context->registerConfigLexicon(ConfigLexicon::class);

$context->registerWellKnownHandler(OCMDiscoveryHandler::class);
$context->registerWellKnownHandler(OCMJwksHandler::class);
$context->registerCapability(Capabilities::class);

$context->registerEventListener(RestrictInteractionEvent::class, RestrictInteractionListener::class);
Expand Down
1 change: 0 additions & 1 deletion lib/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -2047,7 +2047,6 @@
'OC\\OCM\\Model\\OCMResource' => $baseDir . '/lib/private/OCM/Model/OCMResource.php',
'OC\\OCM\\OCMDiscoveryHandler' => $baseDir . '/lib/private/OCM/OCMDiscoveryHandler.php',
'OC\\OCM\\OCMDiscoveryService' => $baseDir . '/lib/private/OCM/OCMDiscoveryService.php',
'OC\\OCM\\OCMJwksHandler' => $baseDir . '/lib/private/OCM/OCMJwksHandler.php',
'OC\\OCM\\OCMSignatoryManager' => $baseDir . '/lib/private/OCM/OCMSignatoryManager.php',
'OC\\OCM\\Rfc9421SignatoryManager' => $baseDir . '/lib/private/OCM/Rfc9421SignatoryManager.php',
'OC\\OCS\\ApiHelper' => $baseDir . '/lib/private/OCS/ApiHelper.php',
Expand Down
1 change: 0 additions & 1 deletion lib/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -2088,7 +2088,6 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\OCM\\Model\\OCMResource' => __DIR__ . '/../../..' . '/lib/private/OCM/Model/OCMResource.php',
'OC\\OCM\\OCMDiscoveryHandler' => __DIR__ . '/../../..' . '/lib/private/OCM/OCMDiscoveryHandler.php',
'OC\\OCM\\OCMDiscoveryService' => __DIR__ . '/../../..' . '/lib/private/OCM/OCMDiscoveryService.php',
'OC\\OCM\\OCMJwksHandler' => __DIR__ . '/../../..' . '/lib/private/OCM/OCMJwksHandler.php',
'OC\\OCM\\OCMSignatoryManager' => __DIR__ . '/../../..' . '/lib/private/OCM/OCMSignatoryManager.php',
'OC\\OCM\\Rfc9421SignatoryManager' => __DIR__ . '/../../..' . '/lib/private/OCM/Rfc9421SignatoryManager.php',
'OC\\OCS\\ApiHelper' => __DIR__ . '/../../..' . '/lib/private/OCS/ApiHelper.php',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use OC\OCM\OCMDiscoveryService;
use OCA\Federation\TrustedServers;
use OCP\AppFramework\Http\Attribute\AnonRateLimit;
use OCP\Federation\ICloudFederationProviderManager;
use OCP\IRequest;
use OCP\Server;

Expand All @@ -24,12 +25,14 @@
*/
#[Attribute(Attribute::TARGET_METHOD)]
class FederationRateLimit extends AnonRateLimit {
private readonly ICloudFederationProviderManager $federationProviderManager;
private readonly OCMDiscoveryService $discoveryService;
private readonly ?TrustedServers $trustedServers;

public function __construct(int $limit, int $period) {
parent::__construct($limit, $period);

$this->federationProviderManager = Server::get(ICloudFederationProviderManager::class);
$this->discoveryService = Server::get(OCMDiscoveryService::class);
$this->trustedServers = Server::get(TrustedServers::class);
}
Expand All @@ -41,14 +44,22 @@ public function shouldApply(IRequest $request): bool {
}

try {
$signedRequest = $this->discoveryService->getIncomingSignedRequest();
// Resolve the signer origin from the payload so trusted servers
// can be exempted.
$parsed = $request->getParams();
$identity = $this->federationProviderManager->resolveSenderIdentity($parsed);
$origin = null;
if ($identity !== null) {
$origin = $this->discoveryService->getHostFromOcmAddress($identity);
}

$signedRequest = $this->discoveryService->getIncomingSignedRequest($origin);
if (!$signedRequest) {
return true;
}
$signedRequest->verify();
return !$this->trustedServers->isTrustedServer($signedRequest->getOrigin());
} catch (\Exception) {
// no or invalid signature
// no or invalid signature, or unresolvable origin
return true;
}
}
Expand Down
Loading
Loading