Skip to content
Commits on Source (57)
......@@ -17,6 +17,7 @@ stages:
.test_defaults: &test_defaults
script:
- sudo update-alternatives --set php "/usr/bin/${PHP}"
- composer validate
- composer install
- composer test
......
# ????-??-??
# v0.1.8 - 2022-05-09
* Rework bundle config
* Make the config option naming less ambiguous
* Add an option to specify the local validation time leeway
\ No newline at end of file
* Add a health check for fetching the OIDC config provided by the OIDC server
(Keycloak for example)
* Add a health check which checks if the server time is in sync with the OIDC
server time
* Stop using the abandoned web-token/jwt-easy and use to the underlying
libraries directly instead, as recommended
{
"name": "dbp/relay-auth-bundle",
"description": "Authentification bundle for the Relay API gateway",
"type": "symfony-bundle",
"license": "AGPL-3.0-or-later",
"require": {
"php": ">=7.3",
"ext-json": "*",
"ext-gmp": "*",
"ext-curl": "*",
"ext-gmp": "*",
"ext-json": "*",
"dbp/relay-core-bundle": "^0.1.10",
"guzzlehttp/guzzle": "^7.0",
"kevinrob/guzzle-cache-middleware": "^3.3 | ^4.0",
"symfony/framework-bundle": "^5.2",
"symfony/security-core": "^5.2",
"symfony/yaml": "^5.2",
"web-token/jwt-easy": "^2.1",
"web-token/jwt-checker": "^2.0",
"web-token/jwt-signature-algorithm-rsa": "^2.1"
},
"require-dev": {
......@@ -41,6 +42,9 @@
"sort-packages": true,
"platform": {
"php": "7.3"
},
"allow-plugins": {
"composer/package-versions-deprecated": true
}
},
"extra": {
......
This diff is collapsed.
<?xml version="1.0"?>
<psalm
totallyTyped="false"
errorLevel="4"
resolveFromConfigFile="true"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
......@@ -13,4 +12,11 @@
<directory name="vendor" />
</ignoreFiles>
</projectFiles>
<issueHandlers>
<UndefinedDocblockClass>
<errorLevel type="suppress">
<referencedClass name="UnitEnum" />
</errorLevel>
</UndefinedDocblockClass>
</issueHandlers>
</psalm>
......@@ -31,6 +31,13 @@ class BearerUserProvider implements BearerUserProviderInterface, LoggerAwareInte
$this->config = $config;
}
public function getValidationLeewaySeconds(): int
{
$config = $this->config;
return $config['local_validation_leeway'];
}
public function loadUserByToken(string $accessToken): UserInterface
{
$config = $this->config;
......
......@@ -6,9 +6,18 @@ namespace Dbp\Relay\AuthBundle\Authenticator;
use Dbp\Relay\AuthBundle\OIDC\OIDError;
use Dbp\Relay\AuthBundle\OIDC\OIDProvider;
use Jose\Component\Checker;
use Jose\Component\Checker\AlgorithmChecker;
use Jose\Component\Checker\ClaimCheckerManager;
use Jose\Component\Checker\HeaderCheckerManager;
use Jose\Component\Core\AlgorithmManager;
use Jose\Component\Core\JWKSet;
use Jose\Easy\Load;
use Jose\Easy\Validate;
use Jose\Component\Signature\Algorithm;
use Jose\Component\Signature\JWSLoader;
use Jose\Component\Signature\JWSTokenSupport;
use Jose\Component\Signature\JWSVerifier;
use Jose\Component\Signature\Serializer\CompactSerializer;
use Jose\Component\Signature\Serializer\JWSSerializerManager;
class LocalTokenValidator extends TokenValidatorBase
{
......@@ -46,28 +55,55 @@ class LocalTokenValidator extends TokenValidatorBase
// The spec doesn't allow this, but just to be sure
assert(!in_array('none', $algs, true));
// Checks not needed/used here:
// * sub(): This is the keycloak user ID by default, nothing we know beforehand
// * jti(): Nothing we know beforehand
// * aud(): The audience needs to be checked afterwards with checkAudience()
$keySet = JWKSet::createFromKeyData($jwks);
$serializerManager = new JWSSerializerManager([
new CompactSerializer(),
]);
$algorithmManager = new AlgorithmManager([
new Algorithm\RS256(),
new Algorithm\RS384(),
new Algorithm\RS512(),
new Algorithm\PS256(),
new Algorithm\PS384(),
new Algorithm\PS512(),
]);
$jwsVerifier = new JWSVerifier(
$algorithmManager
);
$headerCheckerManager = new HeaderCheckerManager(
[new AlgorithmChecker($algs, true)],
[new JWSTokenSupport()],
);
$jwsLoader = new JWSLoader(
$serializerManager,
$jwsVerifier,
$headerCheckerManager
);
try {
$keySet = JWKSet::createFromKeyData($jwks);
$validate = Load::jws($accessToken);
$validate = $validate
->algs($algs)
->keyset($keySet)
->exp($this->leewaySeconds)
->iat($this->leewaySeconds)
->nbf($this->leewaySeconds)
->iss($issuer);
assert($validate instanceof Validate);
$jwtResult = $validate->run();
$jws = $jwsLoader->loadAndVerifyWithKeySet($accessToken, $keySet, $signature);
$jwt = json_decode($jws->getPayload(), true, 512, JSON_THROW_ON_ERROR);
// Checks not needed/used here:
// * sub: This is the keycloak user ID by default, nothing we know beforehand
// * jti: Nothing we know beforehand
// * aud: The audience needs to be checked afterwards with checkAudience()
$claimCheckerManager = new ClaimCheckerManager([
new Checker\IssuedAtChecker($this->leewaySeconds),
new Checker\NotBeforeChecker($this->leewaySeconds),
new Checker\ExpirationTimeChecker($this->leewaySeconds),
new Checker\IssuerChecker([$issuer]),
]);
$claimCheckerManager->check($jwt);
} catch (\Exception $e) {
throw new TokenValidationException('Token validation failed: '.$e->getMessage());
}
$jwt = $jwtResult->claims->all();
// XXX: Keycloak will add extra data to the token returned by introspection, mirror this behaviour here
// to avoid breakage when switching between local/remote validation.
// https://github.com/keycloak/keycloak/blob/8225157a1cecef30034530aa/services/src/main/java/org/keycloak/protocol/oidc/AccessTokenIntrospectionProvider.java#L59
......
......@@ -52,7 +52,7 @@ class OIDProvider implements LoggerAwareInterface
$this->clientHandler = $handler;
}
private function getClient(): Client
private function getClient(bool $noCache = false): Client
{
$stack = HandlerStack::create($this->clientHandler);
if ($this->logger !== null) {
......@@ -67,7 +67,7 @@ class OIDProvider implements LoggerAwareInterface
$client = new Client($options);
if ($this->cachePool !== null) {
if ($this->cachePool !== null && !$noCache) {
$cacheMiddleWare = new CacheMiddleware(
new GreedyCacheStrategy(
new Psr6CacheStorage($this->cachePool),
......@@ -80,6 +80,17 @@ class OIDProvider implements LoggerAwareInterface
return $client;
}
public function getProviderDateTime(): \DateTimeInterface
{
$serverUrl = $this->config['server_url'] ?? '';
$client = $this->getClient(true);
$response = $client->request('GET', $serverUrl);
$date = new \DateTimeImmutable($response->getHeader('Date')[0]);
$date = $date->setTimezone(new \DateTimeZone('UTC'));
return $date;
}
/**
* @throws OIDError
*/
......
......@@ -28,5 +28,9 @@ services:
autowire: true
autoconfigure: true
Dbp\Relay\AuthBundle\Service\HealthCheck:
autowire: true
autoconfigure: true
Dbp\Relay\AuthBundle\API\UserRolesInterface:
'@Dbp\Relay\AuthBundle\Service\DefaultUserRoles'
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Dbp\Relay\AuthBundle\Service;
use Dbp\Relay\AuthBundle\Authenticator\BearerUserProvider;
use Dbp\Relay\AuthBundle\OIDC\OIDProvider;
use Dbp\Relay\CoreBundle\HealthCheck\CheckInterface;
use Dbp\Relay\CoreBundle\HealthCheck\CheckOptions;
use Dbp\Relay\CoreBundle\HealthCheck\CheckResult;
class HealthCheck implements CheckInterface
{
private $oidcProvider;
private $userProvider;
public function __construct(OIDProvider $oidcProvider, BearerUserProvider $userProvider)
{
$this->oidcProvider = $oidcProvider;
$this->userProvider = $userProvider;
}
public function getName(): string
{
return 'auth';
}
private function checkMethod(string $description, callable $func): CheckResult
{
$result = new CheckResult($description);
try {
$func();
} catch (\Throwable $e) {
$result->set(CheckResult::STATUS_FAILURE, $e->getMessage(), ['exception' => $e]);
return $result;
}
$result->set(CheckResult::STATUS_SUCCESS);
return $result;
}
public function checkConfig()
{
$this->oidcProvider->getProviderConfig();
}
public function checkPublicKey()
{
$this->oidcProvider->getJWKs();
}
public function checkTimeSync()
{
$providerTime = $this->oidcProvider->getProviderDateTime();
$systemTime = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
$leeway = $this->userProvider->getValidationLeewaySeconds();
$difference = abs($providerTime->getTimestamp() - $systemTime->getTimestamp());
if ($difference > $leeway) {
throw new \RuntimeException("The system time and the OIDC server time is out of sync ($difference > $leeway seconds)");
}
}
public function check(CheckOptions $options): array
{
$results = [];
$results[] = $this->checkMethod('Check if the OIDC config can be fetched', [$this, 'checkConfig']);
$results[] = $this->checkMethod('Check if the OIDC public key can be fetched', [$this, 'checkPublicKey']);
$results[] = $this->checkMethod('Check if the OIDC server time is in sync', [$this, 'checkTimeSync']);
return $results;
}
}
......@@ -10,9 +10,11 @@ use Dbp\Relay\AuthBundle\OIDC\OIDProvider;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Jose\Component\Core\AlgorithmManager;
use Jose\Component\Core\JWK;
use Jose\Easy\Build;
use Jose\Easy\JWSBuilder;
use Jose\Component\Signature\Algorithm\RS256;
use Jose\Component\Signature\JWSBuilder;
use Jose\Component\Signature\Serializer\CompactSerializer;
use PHPUnit\Framework\TestCase;
class LocalTokenValidatorTest extends TestCase
......@@ -63,24 +65,35 @@ class LocalTokenValidatorTest extends TestCase
];
}
private function getJWT(array $options = [])
private function getJWT(array $options = []): string
{
$jwk = $this->getJWK();
$time = $options['time'] ?? time();
$builder = Build::jws()
->exp($time + 3600)
->iat($time)
->nbf($time)
->jti('0123456789')
->alg('RS256')
->iss($options['issuer'] ?? $this->oid->getProviderConfig()->getIssuer())
->aud('audience1')
->aud('audience2')
->sub('subject');
assert($builder instanceof JWSBuilder);
return $builder->sign($jwk);
$payload = json_encode([
'exp' => $time + 3600,
'iat' => $time,
'nbf' => $time,
'jti' => '0123456789',
'iss' => $options['issuer'] ?? $this->oid->getProviderConfig()->getIssuer(),
'aud' => ['audience1', 'audience2'],
'sub' => 'subject',
]);
$algorithmManager = new AlgorithmManager([
new RS256(),
]);
$serializer = new CompactSerializer();
$jwsBuilder = new JWSBuilder($algorithmManager);
$jws = $jwsBuilder
->create()
->withPayload($payload)
->addSignature($jwk, ['alg' => 'RS256'])
->build();
return $serializer->serialize($jws, 0);
}
private function mockResponses(array $responses)
......@@ -139,7 +152,7 @@ class LocalTokenValidatorTest extends TestCase
$jwt = $this->getJWT();
$payload = explode('.', $jwt)[1];
$noneToken = base64_encode('{"alg":"none","typ":"JWT"}').'.'.$payload.'.';
$this->expectExceptionMessageMatches('/Unsupported algorithm/');
$this->expectExceptionMessageMatches('/Unable to load and verify the token/');
$this->tokenValidator->validate($noneToken);
}
......@@ -177,7 +190,7 @@ class LocalTokenValidatorTest extends TestCase
$parts = explode('.', $jwt);
$parts[1] = 'REVBREJFRUY=';
$this->expectExceptionMessageMatches('/Invalid signature/');
$this->expectExceptionMessageMatches('/Unable to load and verify the token/');
$this->tokenValidator->validate(implode('.', $parts));
}
......