Skip to content
Merged
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
37 changes: 1 addition & 36 deletions bundle/src/Controller/OidcController.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,6 @@ public function __construct(
private OidcApiService $oidcApiService,
private OidcUserService $oidcUserService,
private LoggerInterface $logger,
private DenormalizerInterface $denormalizer,
private ValidatorInterface $validator,
) {
}

Expand Down Expand Up @@ -90,36 +88,12 @@ public function oidcCallback(Request $request): RedirectResponse
}

try {
$idToken = $this->oidcApiService->getIdToken($code);
$jwks = $this->oidcApiService->getJwks();
$wellKnown = $this->oidcApiService->getWellKnownConfig();
$decodedIdToken = $this->oidcApiService->getDecodedIdToken($code, $this->oidcConfig->getCallbackUrl($request));
} catch (OidcApiException $e) {
$this->logger->error('OIDC authentication failed: ' . $e->getMessage());
throw new BadRequestHttpException('Unable to authenticate ' . $e->getMessage());
}

try {
$keys = JWK::parseKeySet($jwks, JwkHelper::getDefaultAlg($wellKnown));
$decoded = JWT::decode($idToken, $keys);
} catch (\Exception $e) {
$this->logger->error('Failed to parse JWKS: ' . $e->getMessage());
throw new BadRequestHttpException('Invalid JWKS: ' . $e->getMessage());
}

try {
$decoded->raw_token = $idToken;
/** @var OidcDecodedIdTokenDto $decodedIdToken */
$decodedIdToken = $this->denormalizer->denormalize($decoded, OidcDecodedIdTokenDto::class);
} catch (\Throwable $e) {
$this->logger->error('Failed to decode ID Token: ' . $e->getMessage());
throw new BadRequestHttpException('Invalid ID Token: ' . $e->getMessage());
}

$errors = $this->validator->validate($decodedIdToken);
if (count($errors) > 0) {
throw new BadRequestHttpException($this->validationErrorsToString($errors));
}

if ($decodedIdToken->nonce !== $sessionNonce) {
throw new BadRequestHttpException('Invalid nonce in ID Token.');
}
Expand All @@ -134,15 +108,6 @@ public function oidcCallback(Request $request): RedirectResponse
return new RedirectResponse($sessionRedirect);
}

private function validationErrorsToString(ConstraintViolationListInterface $errors): string
{
$return = 'ID token validation failed: ';
foreach ($errors as $error) {
$return .= '[' . $error->getPropertyPath() . '] ' . $error->getMessage();
}
return $return;
}

#[Route('/logout', methods: 'GET')]
public function oidcLogout(Request $request): RedirectResponse
{
Expand Down
48 changes: 44 additions & 4 deletions src/Auth/Oidc/OidcApiService.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@

namespace Hyvor\Internal\Auth\Oidc;

use Firebase\JWT\JWK;
use Firebase\JWT\JWT;
use Hyvor\Internal\Auth\Oidc\Dto\OidcDecodedIdTokenDto;
use Hyvor\Internal\Auth\Oidc\Dto\OidcWellKnownConfigDto;
use Hyvor\Internal\Auth\Oidc\Exception\OidcApiException;
use Psr\Log\LoggerInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
Expand All @@ -19,6 +25,8 @@ public function __construct(
private OidcConfig $oidcConfig,
private HttpClientInterface $httpClient,
private CacheInterface $cache,
private DenormalizerInterface $denormalizer,
private ValidatorInterface $validator,
) {
}

Expand All @@ -34,6 +42,38 @@ public function getWellKnownConfig(): OidcWellKnownConfigDto
});
}

/**
* @throws OidcApiException
*/
public function getDecodedIdToken(string $code, string $redirectUri): OidcDecodedIdTokenDto
{
$idToken = $this->getIdToken($code, $redirectUri);
$jwks = $this->getJwks();
$wellKnown = $this->getWellKnownConfig();

try {
$keys = JWK::parseKeySet($jwks, JwkHelper::getDefaultAlg($wellKnown));
$decoded = JWT::decode($idToken, $keys);
} catch (\Exception $e) {
throw new OidcApiException('Invalid JWKS: ' . $e->getMessage());
}

try {
$decoded->raw_token = $idToken;
/** @var OidcDecodedIdTokenDto $decodedIdToken */
$decodedIdToken = $this->denormalizer->denormalize($decoded, OidcDecodedIdTokenDto::class);
} catch (\Throwable $e) {
throw new OidcApiException('unable to decode ID token: ' . $e->getMessage());
}

$errors = $this->validator->validate($decodedIdToken);
if (count($errors) > 0) {
throw new OidcApiException((string) $errors);
}

return $decodedIdToken;
}

/**
* @throws OidcApiException
*/
Expand Down Expand Up @@ -76,7 +116,7 @@ private function fetchWellKnownConfig(): OidcWellKnownConfigDto
* ID Token is a JWT that contains information about the user.
* @throws OidcApiException
*/
public function getIdToken(string $code): string
private function getIdToken(string $code, string $redirectUri): string
{
$wellKnownConfig = $this->getWellKnownConfig();
$tokenEndpoint = $wellKnownConfig->tokenEndpoint;
Expand All @@ -89,7 +129,7 @@ public function getIdToken(string $code): string
'body' => [
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => $this->oidcConfig->getCallbackUrl(),
'redirect_uri' => $redirectUri,
'client_id' => $this->oidcConfig->getClientId(),
'client_secret' => $this->oidcConfig->getClientSecret(),
],
Expand All @@ -110,7 +150,7 @@ public function getIdToken(string $code): string
* @return array<mixed>
* @throws OidcApiException
*/
public function getJwks(): array
private function getJwks(): array
{
$jwsUri = $this->getWellKnownConfig()->jwksUri;

Expand All @@ -122,4 +162,4 @@ public function getJwks(): array
}
}

}
}
31 changes: 31 additions & 0 deletions src/Auth/Oidc/OidcApiServiceFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

namespace Hyvor\Internal\Auth\Oidc;

use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

class OidcApiServiceFactory
{

public function __construct(
private HttpClientInterface $httpClient,
private CacheInterface $cache,
private DenormalizerInterface $denormalizer,
private ValidatorInterface $validator,
) {}

public function create(OidcConfig $config): OidcApiService
{
return new OidcApiService(
$config,
$this->httpClient,
$this->cache,
$this->denormalizer,
$this->validator
);
}

}
84 changes: 84 additions & 0 deletions src/Auth/Oidc/Testing/OidcTestingUtils.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

namespace Hyvor\Internal\Auth\Oidc\Testing;

use Firebase\JWT\JWT;

class OidcTestingUtils
{

private static function base64urlEncode(mixed $data): string
{
assert(is_string($data));
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}

/**
* @return array{privateKeyPem: string, publicKeyPem: string, rsa: array<string, mixed>, jwks: array<string, mixed>}
*/
public static function generateKey(string $kid = 'kid'): array
{
$config = [
"private_key_bits" => 2048,
"private_key_type" => \OPENSSL_KEYTYPE_RSA,
];
$res = openssl_pkey_new($config);
assert($res !== false);
openssl_pkey_export($res, $privateKeyPem);
$keyDetails = openssl_pkey_get_details($res);
assert($keyDetails !== false);
$publicKeyPem = $keyDetails['key'];
$rsa = $keyDetails['rsa'];

$jwks = [
"keys" => [
[
"kty" => "RSA",
"use" => "sig",
"kid" => $kid,
"alg" => "RS256",
"n" => self::base64urlEncode($rsa['n']),
"e" => self::base64urlEncode($rsa['e']),
]
]
];

return [
'privateKeyPem' => $privateKeyPem,
'publicKeyPem' => $publicKeyPem,
'rsa' => $rsa,
'jwks' => $jwks,
];
}

/**
* @param array<mixed> $payload
*/
public static function createIdToken(
string $privateKeyPem,
array $payload,
string $kid = 'kid',
): string {

// $payloadExample = [
// "iss" => "https://issuer.com",
// "sub" => "user123",
// "exp" => $now + 3600,
// "iat" => $now,
// "auth_time" => $now,
// "nonce" => "my-nonce",
// "name" => "Jane",
// "email" => "jane@example.com",
// ];

$headers = [
'kid' => $kid,
'alg' => 'RS256',
'typ' => 'JWT',
];

return JWT::encode($payload, $privateKeyPem, 'RS256', null, $headers);
}

}

8 changes: 7 additions & 1 deletion src/Util/Crypt/Encryption.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@

/**
* Laravel-compatible encryption
* @deprecated use Comms API
*/
class Encryption implements \Illuminate\Contracts\Encryption\Encrypter, StringEncrypter
{
Expand Down Expand Up @@ -38,11 +37,17 @@ public function decryptString($payload)
return $this->getEncrypter()->decryptString($payload);
}

/**
* @deprecated this method serializes an input, which causes many troubles. Use encryptString() instead
*/
public function encrypt(#[\SensitiveParameter] $value, $serialize = true)
{
return $this->getEncrypter()->encrypt($value, $serialize);
}

/**
* @deprecated prefer decryptString() instead
*/
public function decrypt($payload, $unserialize = true)
{
return $this->getEncrypter()->decrypt($payload, $unserialize);
Expand Down Expand Up @@ -77,6 +82,7 @@ public function getPreviousKeys()
* @param class-string<T> $to
* @return T
* @throws DecryptException
* @deprecated
*/
public function decryptTo(string $value, string $to)
{
Expand Down
Loading