From 1312baa6e4b3ad4542f7c7c5ffa513aab4789170 Mon Sep 17 00:00:00 2001 From: Supun Wimalasena Date: Fri, 6 Feb 2026 06:02:33 +0100 Subject: [PATCH 1/2] oidc factory 1 --- bundle/src/Controller/OidcController.php | 26 +----- src/Auth/Oidc/OidcApiService.php | 49 +++++++++++- src/Auth/Oidc/OidcApiServiceFactory.php | 34 ++++++++ src/Auth/Oidc/Testing/OidcTestingUtils.php | 84 ++++++++++++++++++++ src/Util/Crypt/Encryption.php | 8 +- tests/Bundle/Controller/OidcCallbackTest.php | 65 ++------------- 6 files changed, 179 insertions(+), 87 deletions(-) create mode 100644 src/Auth/Oidc/OidcApiServiceFactory.php create mode 100644 src/Auth/Oidc/Testing/OidcTestingUtils.php diff --git a/bundle/src/Controller/OidcController.php b/bundle/src/Controller/OidcController.php index 80e371b..19a2200 100644 --- a/bundle/src/Controller/OidcController.php +++ b/bundle/src/Controller/OidcController.php @@ -90,36 +90,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); } 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.'); } diff --git a/src/Auth/Oidc/OidcApiService.php b/src/Auth/Oidc/OidcApiService.php index bfa5af5..d0dc50e 100644 --- a/src/Auth/Oidc/OidcApiService.php +++ b/src/Auth/Oidc/OidcApiService.php @@ -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; @@ -19,6 +25,9 @@ public function __construct( private OidcConfig $oidcConfig, private HttpClientInterface $httpClient, private CacheInterface $cache, + private LoggerInterface $logger, + private DenormalizerInterface $denormalizer, + private ValidatorInterface $validator, ) { } @@ -34,6 +43,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 */ @@ -76,7 +117,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; @@ -89,7 +130,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(), ], @@ -110,7 +151,7 @@ public function getIdToken(string $code): string * @return array * @throws OidcApiException */ - public function getJwks(): array + private function getJwks(): array { $jwsUri = $this->getWellKnownConfig()->jwksUri; @@ -122,4 +163,4 @@ public function getJwks(): array } } -} \ No newline at end of file +} diff --git a/src/Auth/Oidc/OidcApiServiceFactory.php b/src/Auth/Oidc/OidcApiServiceFactory.php new file mode 100644 index 0000000..3d7eba7 --- /dev/null +++ b/src/Auth/Oidc/OidcApiServiceFactory.php @@ -0,0 +1,34 @@ +httpClient, + $this->cache, + $this->logger, + $this->denormalizer, + $this->validator + ); + } + +} diff --git a/src/Auth/Oidc/Testing/OidcTestingUtils.php b/src/Auth/Oidc/Testing/OidcTestingUtils.php new file mode 100644 index 0000000..77db29c --- /dev/null +++ b/src/Auth/Oidc/Testing/OidcTestingUtils.php @@ -0,0 +1,84 @@ +, jwks: array} + */ + 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 $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); + } + +} + diff --git a/src/Util/Crypt/Encryption.php b/src/Util/Crypt/Encryption.php index 05896ee..4a33c80 100644 --- a/src/Util/Crypt/Encryption.php +++ b/src/Util/Crypt/Encryption.php @@ -8,7 +8,6 @@ /** * Laravel-compatible encryption - * @deprecated use Comms API */ class Encryption implements \Illuminate\Contracts\Encryption\Encrypter, StringEncrypter { @@ -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); @@ -77,6 +82,7 @@ public function getPreviousKeys() * @param class-string $to * @return T * @throws DecryptException + * @deprecated */ public function decryptTo(string $value, string $to) { diff --git a/tests/Bundle/Controller/OidcCallbackTest.php b/tests/Bundle/Controller/OidcCallbackTest.php index 35c6af4..eb9a3ae 100644 --- a/tests/Bundle/Controller/OidcCallbackTest.php +++ b/tests/Bundle/Controller/OidcCallbackTest.php @@ -8,6 +8,7 @@ use Hyvor\Internal\Auth\Oidc\OidcApiService; use Hyvor\Internal\Auth\Oidc\OidcConfig; use Hyvor\Internal\Auth\Oidc\OidcUserService; +use Hyvor\Internal\Auth\Oidc\Testing\OidcTestingUtils; use Hyvor\Internal\Bundle\Controller\OidcController; use Hyvor\Internal\Bundle\Entity\OidcUser; use Hyvor\Internal\Bundle\EventDispatcher\TestEventDispatcher; @@ -70,50 +71,6 @@ public function test_fails_when_code_empty(): void $this->kernel->handle($request, catch: false); } - /** - * @return array{privateKeyPem: string, publicKeyPem: string, rsa: array, jwks: array} - */ - private function generateKey(): 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" => "example-key-id-1", // match with the JWT header - "alg" => "RS256", - "n" => $this->base64urlEncode($rsa['n']), - "e" => $this->base64urlEncode($rsa['e']), - ] - ] - ]; - - return [ - 'privateKeyPem' => $privateKeyPem, - 'publicKeyPem' => $publicKeyPem, - 'rsa' => $rsa, - 'jwks' => $jwks, - ]; - } - - private function base64urlEncode(mixed $data): string - { - assert(is_string($data)); - return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); - } - private function wellKnownResponse(): JsonMockResponse { return new JsonMockResponse([ @@ -171,13 +128,7 @@ private function createIdToken( $payload = $extendPayload ? array_merge($defaultPayload, $payload) : $payload; } - $headers = [ - 'kid' => 'example-key-id-1', - 'alg' => 'RS256', - 'typ' => 'JWT', - ]; - - return JWT::encode($payload, $privateKeyPem, 'RS256', null, $headers); + return OidcTestingUtils::createIdToken($privateKeyPem, $payload); } public function test_gets_id_token_and_signs_up(): void @@ -185,7 +136,7 @@ public function test_gets_id_token_and_signs_up(): void [ 'privateKeyPem' => $privateKeyPem, 'jwks' => $jwks - ] = $this->generateKey(); + ] = OidcTestingUtils::generateKey(); $jwt = $this->createIdToken($privateKeyPem); @@ -291,7 +242,7 @@ public function test_id_token_decoding_fail(): void [ 'privateKeyPem' => $privateKeyPem, 'jwks' => $jwks - ] = $this->generateKey(); + ] = OidcTestingUtils::generateKey(); $idToken = $this->createIdToken($privateKeyPem, payload: ['iss' => 1]); @@ -313,7 +264,7 @@ public function test_error_on_validation_failure(): void [ 'privateKeyPem' => $privateKeyPem, 'jwks' => $jwks - ] = $this->generateKey(); + ] = OidcTestingUtils::generateKey(); $idToken = $this->createIdToken($privateKeyPem, payload: ['email' => 'wrong'], extendPayload: true); @@ -335,7 +286,7 @@ public function test_fails_when_nonce_does_not_match(): void [ 'privateKeyPem' => $privateKeyPem, 'jwks' => $jwks - ] = $this->generateKey(); + ] = OidcTestingUtils::generateKey(); $idToken = $this->createIdToken($privateKeyPem, payload: ['nonce' => 'wrong'], extendPayload: true); @@ -357,7 +308,7 @@ public function test_logs_in_and_updates_data(): void [ 'privateKeyPem' => $privateKeyPem, 'jwks' => $jwks - ] = $this->generateKey(); + ] = OidcTestingUtils::generateKey(); $idToken = $this->createIdToken( $privateKeyPem, @@ -402,7 +353,7 @@ public function test_microsoft_issuer_uses_rs256_fallback_when_alg_missing_in_jw [ 'privateKeyPem' => $privateKeyPem, 'jwks' => $jwks - ] = $this->generateKey(); + ] = OidcTestingUtils::generateKey(); unset($jwks['keys'][0]['alg']); From 4732980b2593a6c7d8a2a2b2cd2eb54789c982b7 Mon Sep 17 00:00:00 2001 From: Supun Wimalasena Date: Fri, 6 Feb 2026 20:05:53 +0100 Subject: [PATCH 2/2] test fixes --- bundle/src/Controller/OidcController.php | 13 +------------ src/Auth/Oidc/OidcApiService.php | 1 - src/Auth/Oidc/OidcApiServiceFactory.php | 3 --- tests/Bundle/Controller/OidcCallbackTest.php | 4 ++-- 4 files changed, 3 insertions(+), 18 deletions(-) diff --git a/bundle/src/Controller/OidcController.php b/bundle/src/Controller/OidcController.php index 19a2200..19256b7 100644 --- a/bundle/src/Controller/OidcController.php +++ b/bundle/src/Controller/OidcController.php @@ -34,8 +34,6 @@ public function __construct( private OidcApiService $oidcApiService, private OidcUserService $oidcUserService, private LoggerInterface $logger, - private DenormalizerInterface $denormalizer, - private ValidatorInterface $validator, ) { } @@ -90,7 +88,7 @@ public function oidcCallback(Request $request): RedirectResponse } try { - $decodedIdToken = $this->oidcApiService->getDecodedIdToken($code); + $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()); @@ -110,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 { diff --git a/src/Auth/Oidc/OidcApiService.php b/src/Auth/Oidc/OidcApiService.php index d0dc50e..df3caeb 100644 --- a/src/Auth/Oidc/OidcApiService.php +++ b/src/Auth/Oidc/OidcApiService.php @@ -25,7 +25,6 @@ public function __construct( private OidcConfig $oidcConfig, private HttpClientInterface $httpClient, private CacheInterface $cache, - private LoggerInterface $logger, private DenormalizerInterface $denormalizer, private ValidatorInterface $validator, ) { diff --git a/src/Auth/Oidc/OidcApiServiceFactory.php b/src/Auth/Oidc/OidcApiServiceFactory.php index 3d7eba7..c90e59a 100644 --- a/src/Auth/Oidc/OidcApiServiceFactory.php +++ b/src/Auth/Oidc/OidcApiServiceFactory.php @@ -2,7 +2,6 @@ namespace Hyvor\Internal\Auth\Oidc; -use Psr\Log\LoggerInterface; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Validator\Validator\ValidatorInterface; use Symfony\Contracts\Cache\CacheInterface; @@ -14,7 +13,6 @@ class OidcApiServiceFactory public function __construct( private HttpClientInterface $httpClient, private CacheInterface $cache, - private LoggerInterface $logger, private DenormalizerInterface $denormalizer, private ValidatorInterface $validator, ) {} @@ -25,7 +23,6 @@ public function create(OidcConfig $config): OidcApiService $config, $this->httpClient, $this->cache, - $this->logger, $this->denormalizer, $this->validator ); diff --git a/tests/Bundle/Controller/OidcCallbackTest.php b/tests/Bundle/Controller/OidcCallbackTest.php index eb9a3ae..85b17d2 100644 --- a/tests/Bundle/Controller/OidcCallbackTest.php +++ b/tests/Bundle/Controller/OidcCallbackTest.php @@ -254,7 +254,7 @@ public function test_id_token_decoding_fail(): void $request = $this->createRequest(); $this->expectException(BadRequestHttpException::class); - $this->expectExceptionMessage('Invalid ID Token: The type of the "iss" attribute for class'); + $this->expectExceptionMessage('Unable to authenticate unable to decode ID token: The type of the "iss" attribute'); $this->kernel->handle($request, catch: false); } @@ -276,7 +276,7 @@ public function test_error_on_validation_failure(): void $request = $this->createRequest(); $this->expectException(BadRequestHttpException::class); - $this->expectExceptionMessage('ID token validation failed: [email] This value is not a valid email address.'); + $this->expectExceptionMessage('This value is not a valid email address.'); $this->kernel->handle($request, catch: false); }