Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision

Target

Select target project
  • dbp/relay/dbp-relay-auth-bundle
1 result
Select Git revision
Show changes
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));
}
......