Skip to content

Commit

Permalink
Use OIDC JWTs to authenticate /api/import/package requests
Browse files Browse the repository at this point in the history
  • Loading branch information
KorvinSzanto committed Jun 4, 2024
1 parent bb85c6d commit 63ba789
Show file tree
Hide file tree
Showing 3 changed files with 226 additions and 1 deletion.
2 changes: 1 addition & 1 deletion config/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
Api\EntryPoint\GetPackageVersionLocales::ACCESS_KEY => Api\UserControl::ACCESSOPTION_EVERYBODY,
Api\EntryPoint\GetPackageVersionTranslations::ACCESS_KEY => Api\UserControl::ACCESSOPTION_EVERYBODY,
Api\EntryPoint\FillTranslations::ACCESS_KEY => Api\UserControl::ACCESSOPTION_EVERYBODY,
Api\EntryPoint\ImportPackage::ACCESS_KEY => Api\UserControl::ACCESSOPTION_GLOBALADMINS,
Api\EntryPoint\ImportPackage::ACCESS_KEY => Api\UserControl::ACCESSOPTION_MARKET,
Api\EntryPoint\ImportPackageVersionTranslatables::ACCESS_KEY => Api\UserControl::ACCESSOPTION_GLOBALADMINS,
Api\EntryPoint\ImportTranslations::ACCESS_KEY_WITHOUTAPPROVE => Api\UserControl::ACCESSOPTION_TRANSLATORS_OWNLOCALES,
Api\EntryPoint\ImportTranslations::ACCESS_KEY_WITHAPPROVE => Api\UserControl::ACCESSOPTION_LOCALEADMINS_OWNLOCALES,
Expand Down
174 changes: 174 additions & 0 deletions src/Api/Jwt/SignedWithOidc.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
<?php

namespace CommunityTranslation\Api\Jwt;

use Concrete\Core\Cache\Level\ExpensiveCache;
use Concrete\Core\Logging\Channels;
use Concrete\Core\Logging\LoggerAwareInterface;
use Concrete\Core\Logging\LoggerAwareTrait;
use GuzzleHttp\Client;
use GuzzleHttp\RequestOptions;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Signer\Rsa\Sha256;
use Lcobucci\JWT\Signer\Rsa\Sha384;
use Lcobucci\JWT\Signer\Rsa\Sha512;
use Lcobucci\JWT\Token;
use Lcobucci\JWT\Validation\Constraint\SignedWith as JwtSignedWith;
use Lcobucci\JWT\Validation\ConstraintViolation;
use Lcobucci\JWT\Validation\SignedWith;
use phpseclib3\Crypt\PublicKeyLoader;
use phpseclib3\Math\BigInteger;
use Psr\Http\Message\ResponseInterface;
use Stash\Interfaces\ItemInterface;

class SignedWithOidc implements SignedWith, LoggerAwareInterface
{

use LoggerAwareTrait;
public function __construct(
protected ExpensiveCache $cache,
protected Client $client,
) {}

public function assert(Token $token): void
{
$issuer = (string) $token->claims()->get('iss');
$kid = (string) $token->headers()->get('kid');
$algo = (string) $token->headers()->get('alg');

// Make sure we have an authorized issuer
match (true) {
$issuer === '' => throw new ConstraintViolation('Token missing iss claim'),
$kid === '' => throw new ConstraintViolation('Token missing kid header'),
$algo === '' => throw new ConstraintViolation('Token missing alg header'),
default => null,
};

if (!preg_match($_ENV['MARKETPLACE_ISSUER_REGEX'] ?? '~^https://market.concretecms.org/?$~', $issuer)) {
throw new ConstraintViolation('Access denied, invalid issuer');
}

// Make sure the key is signed with a valid market key, is valid now, and is permitted for us
$key = $this->getKey($issuer, $kid);

$signer = match ($algo) {
'RS256' => new Sha256(),
'RS384' => new Sha384(),
'RS512' => new Sha512(),
default => throw new ConstraintViolation(sprintf(
'Access denied, unsupported token algorithm "%s"',
preg_replace('/[^[:alnum:]]/', '', $algo)
)),
};

// Assert the given token is signed with the expected key
(new JwtSignedWith($signer, InMemory::plainText($key)))->assert($token);
}

protected function getKey(string $issuer, string $kid): ?string
{
$jwks = $this->getJwkUri($issuer);
if (!$jwks) {
return null;
}

$issuerKey = hash('sha256', $issuer);
$cacheKey = "ct.oidc.{$issuerKey}.jwk";
$cacheItem = $this->cache->getItem($cacheKey);

try {
if ($cacheItem->isHit()) {
$json = $cacheItem->get();
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
} else {
$response = $this->client->get($jwks, [RequestOptions::HEADERS => ['Accept' => 'application/json']]);
if ($response->getStatusCode() !== 200) {
return null;
}

$json = $response->getBody()->getContents();
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);

$this->cacheResponse($cacheItem, $json, $response);
}
} catch (\Throwable $e) {
$this->logger?->warning('Unable to load OIDC JWKs: ' . $e->getMessage());
return null;
}

foreach ($data['keys'] ?? [] as $key) {
if ($key['kid'] === $kid) {
$e = base64_decode($key['e'] ?? '');
$n = base64_decode(strtr($key['n'] ?? '', '-_', '+/'), true);

if ($e === '' || $n === '') {
$this->logger?->warning('Unable to load OIDC JWK, invalid base64');
return null;
}

try {
return PublicKeyLoader::load([
'e' => new BigInteger($e, 256),
'n' => new BigInteger($n, 256),
]);
} catch (\Throwable $e) {
$this->logger?->warning('Unable to load OIDC key: ' . $e->getMessage());
return null;
}
}
}

return null;
}

private function getJwkUri(string $issuer): ?string
{
$issuerKey = hash('sha256', $issuer);
$cacheKey = "ct.oidc.{$issuerKey}";

$cacheItem = $this->cache->getItem($cacheKey);

try {
if ($cacheItem->isHit()) {
$json = $cacheItem->get();
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
} else {
$metadataUri = rtrim($issuer, '/') . '/.well-known/openid-configuration';
$response = $this->client->get(
$metadataUri,
[RequestOptions::HEADERS => ['Accept' => 'application/json']]
);
if ($response->getStatusCode() !== 200) {
return null;
}

$json = $response->getBody()->getContents();
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);

$this->cacheResponse($cacheItem, $json, $response);
}
} catch (\Throwable $e) {
$this->logger?->warning('Unable to load OIDC configuration: ' . $e->getMessage());
return null;
}

return $data['jwks_uri'] ?? null;
}

public function getLoggerChannel(): string
{
return Channels::CHANNEL_API;
}

private function cacheResponse(ItemInterface $cacheItem, string $json, ResponseInterface $response): void
{
preg_match('/max-age=(\d+)/', $response->getHeader('Cache-Control')[0] ?? '', $matches);
$duration = (int) ($matches[1] ?? 0);

if ($duration > 0) {
$cacheItem->set($json);
$cacheItem->setTTL($duration);
$cacheItem->save();
}
}
}
51 changes: 51 additions & 0 deletions src/Api/UserControl.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace CommunityTranslation\Api;

use CommunityTranslation\Api\Jwt\SignedWithOidc;
use CommunityTranslation\Repository\Locale as LocaleRepository;
use CommunityTranslation\Service\Access;
use Concrete\Core\Application\Application;
Expand All @@ -18,6 +19,14 @@
use Concrete\Core\User\UserList;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\EntityManagerInterface;
use Lcobucci\Clock\FrozenClock;
use Lcobucci\JWT\Encoding\JoseEncoder;
use Lcobucci\JWT\Exception as JwtException;
use Lcobucci\JWT\Token\Parser;
use Lcobucci\JWT\Validation\Constraint\PermittedFor;
use Lcobucci\JWT\Validation\Constraint\StrictValidAt;
use Lcobucci\JWT\Validation\RequiredConstraintsViolated;
use Lcobucci\JWT\Validation\Validator;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response;

Expand Down Expand Up @@ -45,6 +54,8 @@ class UserControl

public const ACCESSOPTION_SITEADMINS = 'siteadmins';

public const ACCESSOPTION_MARKET = 'market';

public const ACCESSOPTION_ROOT = 'root';

public const ACCESSOPTION_NOBODY = 'nobody';
Expand Down Expand Up @@ -114,6 +125,9 @@ public function checkGenericAccess(string $configKey): void
}
}

return;
case self::ACCESSOPTION_MARKET:
$this->validateMarketToken();
return;
case self::ACCESSOPTION_ROOT:
$user = $this->getRequestUser();
Expand Down Expand Up @@ -188,6 +202,9 @@ public function checkLocaleAccess(string $configKey): array
}

return $this->getApprovedLocales();

case self::ACCESSOPTION_MARKET:
throw new AccessDeniedException();
case self::ACCESSOPTION_ROOT:
$user = $this->getRequestUser();
if (!$user->isSuperUser()) {
Expand Down Expand Up @@ -391,4 +408,38 @@ private function getConfiguredAccessLevel(string $configKey): string

return is_string($level) ? $level : '';
}

/**
* @throws AccessDeniedException
*/
public function validateMarketToken(): void
{
// Extract the JWT from the request if one exists
try {
$bearer = (string) $this->request->headers->get('authorization');
$jwt = substr($bearer, 7);
$token = (new Parser(new JoseEncoder()))->parse($jwt);
} catch (JwtException $e) {
throw new AccessDeniedException(t('Access denied, invalid token.'));
} catch (\Throwable) {
throw new AccessDeniedException(t('Access denied, unable to process token.'));
}

// Validate the token was signed with valid OIDC, is valid now, and is permitted for translate
try {

// Use service locator to load SignedWithOidc so that we don't load expensive cache / config when not needed
$signedWithOidc = $this->app->make(SignedWithOidc::class);
(new Validator())->assert(
$token,
$signedWithOidc,
new StrictValidAt(new FrozenClock(new \DateTimeImmutable())),
new PermittedFor('https://translate.concretecms.org'),
);
} catch (RequiredConstraintsViolated $e) {
throw new AccessDeniedException(t('Access denied, %s', $e->getMessage()));
} catch (\Throwable) {
throw new AccessDeniedException(t('Access denied, unable to validate token.'));
}
}
}

0 comments on commit 63ba789

Please sign in to comment.