diff --git a/app/libs/Auth/AuthService.php b/app/libs/Auth/AuthService.php index 928c5af8..02a653a5 100644 --- a/app/libs/Auth/AuthService.php +++ b/app/libs/Auth/AuthService.php @@ -32,6 +32,7 @@ use Models\OAuth2\OAuth2OTP; use OAuth2\Exceptions\InvalidOTPException; use OAuth2\Models\IClient; +use OAuth2\Models\SessionReloadHint; use OAuth2\OAuth2Protocol; use OAuth2\Services\IPrincipalService; use OAuth2\Services\ISecurityContextService; @@ -664,34 +665,88 @@ public function getLoggedRPs(): array } /** - * @param string $jti - * @throws Exception + * @param SessionReloadHint $hint + * @return void + * @throws ReloadSessionException */ - public function reloadSession(string $jti): void + public function reloadSession(SessionReloadHint $hint): void { + $former_session_id = Session::getId(); + $jti = $hint->getJti(); Log::debug(sprintf("AuthService::reloadSession jti %s", $jti)); $session_id = $this->cache_service->getSingleValue($jti); Log::debug(sprintf("AuthService::reloadSession session_id %s", $session_id)); - if (empty($session_id)) + if (empty($session_id)) { + Log::warning("AuthService::reloadSession session_id is not present at cache"); + if($hint->allowsSubFallback()) { + $this->loginFromReloadHint($hint); + return; + } throw new ReloadSessionException('session not found!'); + } if ($this->cache_service->exists($session_id . "invalid")) { // session was marked as void, check if we are authenticated - if (!Auth::check()) + if (!Auth::check()) { + Session::setId($former_session_id); + Session::start(); throw new ReloadSessionException('user not found!'); + } } - Session::setId(Crypt::decrypt($session_id)); - Session::start(); - if (!Auth::check()) { - $user_id = $this->principal_service->get()->getUserId(); - Log::debug(sprintf("AuthService::reloadSession user_id %s", $user_id)); - $user = $this->getUserById($user_id); - if (is_null($user)) - throw new ReloadSessionException('user not found!'); - Auth::login($user); + try { + Session::setId(Crypt::decrypt($session_id)); + Session::start(); + if (!Auth::check()) { + $session_user_id = $this->principal_service->get()->getUserId(); + Log::debug(sprintf("AuthService::reloadSession user_id %s", $session_user_id)); + $user = $this->getUserById($session_user_id); + if (is_null($user)) + throw new ReloadSessionException('user not found!'); + Auth::login($user); + } } + catch (ReloadSessionException $ex) { + Log::warning(sprintf("AuthService::reloadSession ex %s", $ex->getMessage())); + Session::setId($former_session_id); + Session::start(); + if($hint->allowsSubFallback()) { + $this->loginFromReloadHint($hint); + return; + } + throw $ex; + } + catch (\Throwable $ex) { + // Any non-ReloadSessionException failure (e.g. a DB error inside + // getUserById()) must still leave the caller's real session intact. + Session::setId($former_session_id); + Session::start(); + throw $ex; + } + } + + /** + * Sub-based fallback of reloadSession(): logs in the user an IDP-signed + * id_token_hint names, on the caller's current session. + * @param SessionReloadHint $hint must allow the sub fallback + * @throws ReloadSessionException + */ + private function loginFromReloadHint(SessionReloadHint $hint): void + { + $user_id = $hint->getUserId(); + Log::warning(sprintf("AuthService::loginFromReloadHint user id provided %s", $user_id)); + $user = $this->getUserById($user_id); + if (is_null($user) || !$user->canLogin()) + throw new ReloadSessionException('user not found!'); + Auth::login($user); + // Auth::login() alone leaves this session's IDP-specific principal + // state (user_id/auth_time/op_browser_state) unset - every other + // login path in this class pairs it with register(). + // The user did not authenticate now: they authenticated when the IDP + // issued the hint, so register the time the hint attests. + $this->principal_service->clear(); + $this->principal_service->register($user->getId(), $hint->getAuthTime()); } /** diff --git a/app/libs/OAuth2/GrantTypes/InteractiveGrantType.php b/app/libs/OAuth2/GrantTypes/InteractiveGrantType.php index b1dbfa52..155e3ef3 100644 --- a/app/libs/OAuth2/GrantTypes/InteractiveGrantType.php +++ b/app/libs/OAuth2/GrantTypes/InteractiveGrantType.php @@ -41,6 +41,7 @@ use OAuth2\Heuristics\ServerEncryptionKeyFinder; use OAuth2\Heuristics\ServerSigningKeyFinder; use OAuth2\Models\IClient; +use OAuth2\Models\SessionReloadHint; use OAuth2\Repositories\IClientRepository; use OAuth2\Services\ITokenService; use OAuth2\OAuth2Protocol; @@ -59,6 +60,7 @@ use OAuth2\Strategies\IOAuth2AuthenticationStrategy; use utils\exceptions\InvalidCompactSerializationException; use utils\factories\BasicJWTFactory; +use utils\json_types\NumericDate; use Utils\Services\IAuthService; use Utils\Services\ILogService; use phpseclib\Crypt\Random; @@ -496,6 +498,14 @@ protected function processUserHint(OAuth2AuthenticationRequest $request, Client } else if(!empty($token_hint) && !$request->isProcessedParam(OAuth2Protocol::OAuth2Protocol_IDTokenHint)) { Log::debug("InteractiveGrant::processUserHint processing Token hint..."); + // Mark as processed up front: if verification/reload fails below, the exception + // is caught upstream and the pending memento gets re-serialized with this same + // request. Without this, every resume of that memento (e.g. right after a + // successful password login redirects back to /oauth2/auth) would retry this + // same stale/invalid hint, fail again, and log the user right back out — + // an infinite login loop driven by a hint that can never succeed. + $request->markParamAsProcessed(OAuth2Protocol::OAuth2Protocol_IDTokenHint); + $jwt = BasicJWTFactory::build($token_hint); if($jwt instanceof IJWE) { @@ -513,6 +523,13 @@ protected function processUserHint(OAuth2AuthenticationRequest $request, Client $payload = $jwt->getPlainText(); $jwt = BasicJWTFactory::build($payload); } + // Only a signature made with this IDP's own private key proves the IDP + // issued the hint. A key the client controls (an HS* client secret, a + // public key the client registered, its jwks_uri) proves the *client* + // made it, so it must never unlock the sub-based fallback in + // reloadSession() - it keeps the jti-only semantics. + $issued_by_this_idp = false; + if($jwt instanceof IJWS) { $this->log_service->debug_msg("InteractiveGrantType::processUserHint token hint is IJWS"); // signed by client ? @@ -536,6 +553,7 @@ protected function processUserHint(OAuth2AuthenticationRequest $request, Client $jwt->getJOSEHeader()->getKeyID()->getValue() ); $jwt->setKey($server_private_sig_key); + $issued_by_this_idp = true; } $verified = $jwt->verify($jwt->getJOSEHeader()->getAlgorithm()->getString()); @@ -544,14 +562,79 @@ protected function processUserHint(OAuth2AuthenticationRequest $request, Client throw new InvalidLoginHint('invalid id_token_hint'); } - $sub = $jwt->getClaimSet()->getSubject(); + if(!$jwt instanceof IJWS) { + // neither IJWE->IJWS nor a plain IJWS: e.g. an unsecured JWT (alg=none). + // Never trust a sub/jti pair that was not cryptographically verified above. + $this->log_service->debug_msg("InteractiveGrantType::processUserHint token hint is not signed/verifiable"); + throw new InvalidLoginHint('id_token_hint must be signed'); + } + + $claim_set = $jwt->getClaimSet(); + + // A verified signature only proves who issued the token, not that it's + // still inside its validity window. The jti cache entry mirrors the + // token's own lifetime but isn't a substitute for checking exp directly + // (eviction timing, clock skew, etc. aren't guaranteed to line up). + // Intentionally NOT checking aud here: this hint is meant to carry SSO + // across different clients of this IDP (e.g. client A -> client B), so + // the token's original audience is expected to differ from the client + // making this request. + // RFC 7519 §4.1.4: the current time MUST be strictly before exp, so + // exp == now is already expired. + $expiration_time = $claim_set->getExpirationTime(); + if(is_null($expiration_time) || !$expiration_time->isAfter(NumericDate::now())) { + $this->log_service->debug_msg("InteractiveGrantType::processUserHint token hint is expired"); + throw new InvalidLoginHint('id_token_hint is expired'); + } + + $sub = $claim_set->getSubject(); + if(is_null($sub)) { + $this->log_service->debug_msg("InteractiveGrantType::processUserHint: sub is null"); + throw new InvalidLoginHint('invalid sub!'); + } + $user_id = $this->auth_service->unwrapUserId($sub->getString()); $user = $this->auth_service->getUserById($user_id); + $jti = $claim_set->getJWTID(); + + if(is_null($jti)) { + $this->log_service->debug_msg("InteractiveGrantType::processUserHint: jti is null"); + throw new InvalidLoginHint('invalid jti!'); + } - $jti = $jwt->getClaimSet()->getJWTID(); - if(is_null($jti)) throw new InvalidLoginHint('invalid jti!'); + $this->log_service->debug_msg( + sprintf + ( + "InteractiveGrantType::processUserHint: jwt sub %s user_id %s jti %s", + $sub->getString(), + $user_id, + $jti->getValue() + ) + ); - $this->auth_service->reloadSession($jti->getValue()); + // The fallback login must carry the authentication time the IDP originally + // attested for this user (auth_time when the RP asked for max_age, else iat), + // not "now": shouldForceReLogin() and the next id_token's auth_time claim + // both read it from the registered principal. + // Note: getClaimByName() returns the stored JsonValue at runtime (see + // JWTClaimSet::addClaim / JWTClaimSetFactory), not a JWTClaim. + $hint_auth_time = null; + $auth_time_claim = $claim_set->getClaimByName(OAuth2Protocol::OAuth2Protocol_AuthTime); + $issued_at = $claim_set->getIssuedAt(); + if(!is_null($auth_time_claim)) + $hint_auth_time = intval($auth_time_claim->getValue()); + else if(!is_null($issued_at)) + $hint_auth_time = intval($issued_at->getValue()); + + // The sub-based fallback (login by $user_id when the jti is no longer + // cached) is only safe for a hint this IDP is proven to have issued AND + // that carries an attested authentication time; otherwise degrade to the + // jti-only semantics rather than inventing an auth_time. + $reload_hint = ($issued_by_this_idp && !is_null($hint_auth_time)) + ? SessionReloadHint::withSubFallback($jti->getValue(), intval($user_id), $hint_auth_time) + : SessionReloadHint::jtiOnly($jti->getValue()); + + $this->auth_service->reloadSession($reload_hint); $request->markParamAsProcessed(OAuth2Protocol::OAuth2Protocol_IDTokenHint); } diff --git a/app/libs/OAuth2/Models/SessionReloadHint.php b/app/libs/OAuth2/Models/SessionReloadHint.php new file mode 100644 index 00000000..f99f9891 --- /dev/null +++ b/app/libs/OAuth2/Models/SessionReloadHint.php @@ -0,0 +1,117 @@ +jti = $jti; + $this->user_id = $user_id; + $this->auth_time = $auth_time; + } + + /** + * Hint verified with a client-controlled key: jti-only semantics. + * @param string $jti + * @return SessionReloadHint + */ + public static function jtiOnly(string $jti): self + { + return new self($jti, null, null); + } + + /** + * Hint verified with the IDP's own signing key and carrying an attested + * authentication time (its auth_time claim, else iat). + * @param string $jti + * @param int $user_id + * @param int $auth_time epoch the IDP originally attested for that user + * @return SessionReloadHint + */ + public static function withSubFallback(string $jti, int $user_id, int $auth_time): self + { + return new self($jti, $user_id, $auth_time); + } + + /** + * @return string + */ + public function getJti(): string + { + return $this->jti; + } + + /** + * @return bool + */ + public function allowsSubFallback(): bool + { + return !is_null($this->user_id); + } + + /** + * @return int|null + */ + public function getUserId(): ?int + { + return $this->user_id; + } + + /** + * @return int|null + */ + public function getAuthTime(): ?int + { + return $this->auth_time; + } +} diff --git a/app/libs/Utils/Services/IAuthService.php b/app/libs/Utils/Services/IAuthService.php index a8fc3dda..0eaed916 100644 --- a/app/libs/Utils/Services/IAuthService.php +++ b/app/libs/Utils/Services/IAuthService.php @@ -17,6 +17,7 @@ use Models\OAuth2\Client; use Models\OAuth2\OAuth2OTP; use OAuth2\Models\IClient; +use OAuth2\Models\SessionReloadHint; use OpenId\Models\IOpenIdUser; /** * Interface IAuthService @@ -137,10 +138,13 @@ public function registerRPLogin(string $client_id); public function getLoggedRPs():array; /** - * @param string $jti + * Resumes the OP session an id_token_hint refers to (through its cached jti). + * When the hint allows the sub-based fallback and the cached session can't be + * resumed, logs in the user the hint names with the auth_time it attests. + * @param SessionReloadHint $hint * @return void */ - public function reloadSession(string $jti):void; + public function reloadSession(SessionReloadHint $hint):void; const LOGGED_RELAYING_PARTIES_COOKIE_NAME = 'rps'; diff --git a/tests/AuthServiceReloadSessionTest.php b/tests/AuthServiceReloadSessionTest.php new file mode 100644 index 00000000..bd863ff2 --- /dev/null +++ b/tests/AuthServiceReloadSessionTest.php @@ -0,0 +1,270 @@ +register(). + * - A non-ReloadSessionException failure inside the try block (e.g. a DB + * error) must still restore the caller's former session before + * propagating, and the ReloadSessionException catch block must rethrow + * rather than return silently when there is no $user_id to fall back to. + */ +#[\PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses] +#[\PHPUnit\Framework\Attributes\PreserveGlobalState(false)] +final class AuthServiceReloadSessionTest extends PHPUnitTestCase +{ + use MockeryPHPUnitIntegration; + + private AuthService $service; + + private $mock_user_repository; + private $mock_principal_service; + private $mock_cache_service; + + private $auth_mock; + private $session_mock; + private $crypt_mock; + private $log_mock; + + protected function setUp(): void + { + parent::setUp(); + + $this->mock_user_repository = $this->createMock(IUserRepository::class); + $mock_otp_repository = $this->createMock(IOAuth2OTPRepository::class); + $this->mock_principal_service = $this->createMock(IPrincipalService::class); + $mock_user_service = $this->createMock(IUserService::class); + $mock_user_action_service = $this->createMock(IUserActionService::class); + $this->mock_cache_service = $this->createMock(ICacheService::class); + $mock_auth_user_service = $this->createMock(IAuthUserService::class); + $mock_security_context_service = $this->createMock(ISecurityContextService::class); + $mock_tx_service = $this->createMock(ITransactionService::class); + + $this->auth_mock = Mockery::mock('alias:Illuminate\Support\Facades\Auth'); + $this->session_mock = Mockery::mock('alias:Illuminate\Support\Facades\Session'); + $this->crypt_mock = Mockery::mock('alias:Illuminate\Support\Facades\Crypt'); + $this->log_mock = Mockery::mock('alias:Illuminate\Support\Facades\Log'); + + $this->log_mock->shouldReceive('debug')->zeroOrMoreTimes(); + $this->log_mock->shouldReceive('debug_msg')->zeroOrMoreTimes(); + $this->log_mock->shouldReceive('warning')->zeroOrMoreTimes(); + + $this->session_mock->shouldReceive('start')->zeroOrMoreTimes(); + + $this->service = new AuthService( + $this->mock_user_repository, + $mock_otp_repository, + $this->mock_principal_service, + $mock_user_service, + $mock_user_action_service, + $this->mock_cache_service, + $mock_auth_user_service, + $mock_security_context_service, + $mock_tx_service + ); + } + + private function mockUser(int $id, bool $can_login): Mockery\MockInterface + { + $user = Mockery::mock(User::class); + $user->shouldReceive('getId')->andReturn($id); + $user->shouldReceive('canLogin')->andReturn($can_login); + return $user; + } + + // ----------------------------------------------------------------------- + // Cache-miss fallback: reloadSession() falls straight to Auth::login($user) + // when the jti isn't cached at all. + // ----------------------------------------------------------------------- + + public function testCacheMissFallbackRejectsUserThatCannotLogin(): void + { + $this->mock_cache_service->method('getSingleValue')->with('jti-1')->willReturn(null); + + $user = $this->mockUser(42, can_login: false); + $this->mock_user_repository->method('getByIdWithGroups')->with(42)->willReturn($user); + + $this->session_mock->shouldReceive('getId')->once()->andReturn('former-session-id'); + $this->auth_mock->shouldNotReceive('login'); + $this->mock_principal_service->expects($this->never())->method('register'); + + $this->expectException(ReloadSessionException::class); + + $this->service->reloadSession(SessionReloadHint::withSubFallback('jti-1', 42, 1700000000)); + } + + public function testCacheMissFallbackRegistersPrincipalOnSuccess(): void + { + $this->mock_cache_service->method('getSingleValue')->with('jti-1')->willReturn(null); + + $user = $this->mockUser(42, can_login: true); + $this->mock_user_repository->method('getByIdWithGroups')->with(42)->willReturn($user); + + $this->session_mock->shouldReceive('getId')->once()->andReturn('former-session-id'); + $this->auth_mock->shouldReceive('login')->once()->with($user); + + // The principal must be registered with the auth_time the hint attested, + // not with "now" - the user did not authenticate on this request. + $this->mock_principal_service->expects($this->once())->method('clear'); + $this->mock_principal_service->expects($this->once())->method('register')->with(42, 1700000000); + + $this->service->reloadSession(SessionReloadHint::withSubFallback('jti-1', 42, 1700000000)); + } + + // ----------------------------------------------------------------------- + // catch(ReloadSessionException): the cached session resumes but has no + // live principal, so the fallback by $user_id kicks in. + // ----------------------------------------------------------------------- + + private function mockFailedSessionResume(): void + { + $this->mock_cache_service->method('getSingleValue')->with('jti-1')->willReturn('encrypted-session-id'); + $this->mock_cache_service->method('exists')->with('encrypted-session-idinvalid')->willReturn(false); + + $this->crypt_mock->shouldReceive('decrypt')->with('encrypted-session-id')->andReturn('decrypted-session-id'); + $this->session_mock->shouldReceive('setId')->with('decrypted-session-id')->zeroOrMoreTimes(); + $this->auth_mock->shouldReceive('check')->andReturn(false); + + $principal = new Principal(); + $principal->setState([0, time(), '']); + $this->mock_principal_service->method('get')->willReturn($principal); + } + + public function testCatchFallbackRejectsUserThatCannotLogin(): void + { + $this->mockFailedSessionResume(); + + $this->session_mock->shouldReceive('getId')->once()->andReturn('former-session-id'); + $this->session_mock->shouldReceive('setId')->with('former-session-id')->once(); + + $user = $this->mockUser(99, can_login: false); + // getByIdWithGroups is called twice: once with 0 (inside the try + // block, from the resumed-but-empty session's principal - throws + // and reaches the catch), then with 99 (the $user_id fallback). + $this->mock_user_repository->method('getByIdWithGroups')->willReturnMap([ + [0, null], + [99, $user], + ]); + + $this->auth_mock->shouldNotReceive('login'); + $this->mock_principal_service->expects($this->never())->method('register'); + + $this->expectException(ReloadSessionException::class); + + $this->service->reloadSession(SessionReloadHint::withSubFallback('jti-1', 99, 1700000000)); + } + + public function testCatchFallbackRegistersPrincipalOnSuccess(): void + { + $this->mockFailedSessionResume(); + + $this->session_mock->shouldReceive('getId')->once()->andReturn('former-session-id'); + $this->session_mock->shouldReceive('setId')->with('former-session-id')->once(); + + $user = $this->mockUser(99, can_login: true); + $this->mock_user_repository->method('getByIdWithGroups')->willReturnMap([ + [0, null], + [99, $user], + ]); + + $this->auth_mock->shouldReceive('login')->once()->with($user); + // Same contract as the cache-miss branch: register the attested auth_time. + $this->mock_principal_service->expects($this->once())->method('clear'); + $this->mock_principal_service->expects($this->once())->method('register')->with(99, 1700000000); + + $this->service->reloadSession(SessionReloadHint::withSubFallback('jti-1', 99, 1700000000)); + } + + /** + * A jti-only hint (no sub fallback): the failed resume must propagate, + * not return as if reloadSession() had succeeded. + */ + public function testCatchRethrowsWhenNoFallbackUserIdProvided(): void + { + $this->mockFailedSessionResume(); + $this->mock_user_repository->method('getByIdWithGroups')->with(0)->willReturn(null); + + $this->session_mock->shouldReceive('getId')->once()->andReturn('former-session-id'); + $this->session_mock->shouldReceive('setId')->with('former-session-id')->once(); + + $this->auth_mock->shouldNotReceive('login'); + + $this->expectException(ReloadSessionException::class); + + $this->service->reloadSession(SessionReloadHint::jtiOnly('jti-1')); + } + + // ----------------------------------------------------------------------- + // A non-ReloadSessionException failure (e.g. a DB error) must still + // restore the caller's former session before propagating. + // ----------------------------------------------------------------------- + + public function testNonReloadSessionExceptionRestoresFormerSessionAndRethrows(): void + { + $this->mock_cache_service->method('getSingleValue')->with('jti-1')->willReturn('encrypted-session-id'); + $this->mock_cache_service->method('exists')->with('encrypted-session-idinvalid')->willReturn(false); + + $this->crypt_mock->shouldReceive('decrypt')->with('encrypted-session-id')->andReturn('decrypted-session-id'); + $this->session_mock->shouldReceive('setId')->with('decrypted-session-id')->once(); + $this->auth_mock->shouldReceive('check')->andReturn(false); + + $principal = new Principal(); + $principal->setState([7, time(), 'opbs']); + $this->mock_principal_service->method('get')->willReturn($principal); + + // A DB-layer failure, NOT a ReloadSessionException. + $this->mock_user_repository + ->method('getByIdWithGroups') + ->with(7) + ->willThrowException(new RuntimeException('DB is down')); + + $this->session_mock->shouldReceive('getId')->once()->andReturn('former-session-id'); + $this->session_mock->shouldReceive('setId')->with('former-session-id')->once(); + + $this->auth_mock->shouldNotReceive('login'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('DB is down'); + + $this->service->reloadSession(SessionReloadHint::withSubFallback('jti-1', 5, 1700000000)); + } +} diff --git a/tests/OIDCColdSessionReloadTest.php b/tests/OIDCColdSessionReloadTest.php index c39cebd7..cb43cc45 100644 --- a/tests/OIDCColdSessionReloadTest.php +++ b/tests/OIDCColdSessionReloadTest.php @@ -17,14 +17,23 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Session; use Illuminate\Support\Str; +use jwa\JSONWebSignatureAndEncryptionAlgorithms; use jwe\IJWE; +use jwk\impl\OctetSequenceJWKFactory; +use jwk\impl\OctetSequenceJWKSpecification; use jwk\impl\RSAJWKFactory; use jwk\impl\RSAJWKPEMPrivateKeySpecification; use jwk\JSONWebKeyPublicKeyUseValues; use jws\IJWS; +use jws\impl\specs\JWS_ParamsSpecification; +use jws\JWSFactory; +use jwt\impl\JWTClaimSet; use LaravelDoctrine\ORM\Facades\EntityManager; use OAuth2\OAuth2Protocol; use utils\factories\BasicJWTFactory; +use utils\json_types\JsonValue; +use utils\json_types\NumericDate; +use utils\json_types\StringOrURI; use Utils\Services\IAuthService; use Utils\Services\UtilsServiceCatalog; @@ -65,6 +74,12 @@ protected function prepareForTests(): void Session::start(); } + protected function tearDown(): void + { + unset($_ENV['id.token.lifetime']); + parent::tearDown(); + } + /** * Point the session facade at a brand-new anonymous session WITHOUT * destroying the previous session's server-side data - a real cold @@ -176,4 +191,110 @@ public function testColdSessionReloadFromBackChannelMintedIdTokenHint() $this->assertTrue(Auth::check(), 'reloadSession must leave the user authenticated'); $this->assertEquals($this->user->getId(), Auth::user()->getId()); } + + /** + * A real, correctly-signed id_token_hint whose exp has already passed + * must not authenticate anyone. A verified signature only proves who + * issued the token, not that it's still within its validity window - + * this covers the claim-validation gap raised on PR #158. + */ + public function testColdSessionRejectsExpiredIdTokenHint() + { + // A near-zero id_token lifetime lets a real, back-channel-minted + // token expire almost immediately, without hand-forging a signature. + $_ENV['id.token.lifetime'] = 1; + + $this->be($this->user); + Session::put("openid.authorization.response", IAuthService::AuthorizationResponse_AllowOnce); + + $response = $this->action("POST", "OAuth2\OAuth2ProviderController@auth", $this->authorizeParams()); + parse_str(parse_url($response->getTargetUrl(), PHP_URL_QUERY), $query); + $code = $query['code']; + + $this->startColdSession(); + + $response = $this->action("POST", "OAuth2\OAuth2ProviderController@token", + [ + 'code' => $code, + 'redirect_uri' => self::RedirectUri, + 'grant_type' => OAuth2Protocol::OAuth2Protocol_GrantType_AuthCode, + ], + [], [], [], + ["HTTP_Authorization" => " Basic " . base64_encode(self::ClientId . ':' . self::ClientSecret)]); + + $json = json_decode($response->getContent()); + $id_token_hint = $json->id_token; + $jwt = BasicJWTFactory::build($json->id_token); + if ($jwt instanceof IJWE) { + $recipient_key = RSAJWKFactory::build + ( + new RSAJWKPEMPrivateKeySpecification + ( + TestSeeder::$client_private_key_1, + RSAJWKPEMPrivateKeySpecification::WithoutPassword, + $jwt->getJOSEHeader()->getAlgorithm()->getString() + ) + ); + $recipient_key->setKeyUse(JSONWebKeyPublicKeyUseValues::Encryption)->setId('recipient_public_key'); + $jwt->setRecipientKey($recipient_key); + $id_token_hint = $jwt->getPlainText(); + } + + // Let the 1-second id_token_lifetime actually elapse. + sleep(2); + + $this->startColdSession(); + + $params = $this->authorizeParams(); + $params[OAuth2Protocol::OAuth2Protocol_IDTokenHint] = $id_token_hint; + + $response = $this->action("POST", "OAuth2\OAuth2ProviderController@auth", $params); + + $this->assertResponseStatus(302); + $this->assertTrue(str_contains($response->getTargetUrl(), '/auth/login'), + sprintf('an expired id_token_hint must require login, got %s', $response->getTargetUrl())); + $this->assertFalse(Auth::check(), 'an expired id_token_hint must not authenticate anyone'); + } + + /** + * The seeded test client signs its id_tokens with HS512 - i.e. with the + * client secret, a key the client itself holds. A token minted with that + * secret verifies exactly like an IDP-issued one, so it proves nothing + * about who issued it. When its jti is not in the cache (never minted by + * the IDP, or long evicted), reloadSession()'s sub-based fallback must NOT + * turn it into a login for whatever user id the token names. + */ + public function testColdSessionRejectsClientSignedIdTokenHintWhenJtiIsNotCached() + { + $alg = JSONWebSignatureAndEncryptionAlgorithms::HS512; + + $client_jwk = OctetSequenceJWKFactory::build(new OctetSequenceJWKSpecification(self::ClientSecret, $alg)); + $client_jwk->setKeyUse(JSONWebKeyPublicKeyUseValues::Signature); + + $now = time(); + $claim_set = new JWTClaimSet( + new StringOrURI('https://idp.test'), + new StringOrURI((string)$this->user->getId()), + new StringOrURI(self::ClientId), + new NumericDate($now), + new NumericDate($now + 600), + new JsonValue('never-cached-' . Str::random(16)) + ); + + $forged_hint = JWSFactory::build( + new JWS_ParamsSpecification($client_jwk, new StringOrURI($alg), $claim_set) + )->toCompactSerialization(); + + $this->startColdSession(); + + $params = $this->authorizeParams(); + $params[OAuth2Protocol::OAuth2Protocol_IDTokenHint] = $forged_hint; + + $response = $this->action("POST", "OAuth2\OAuth2ProviderController@auth", $params); + + $this->assertResponseStatus(302); + $this->assertTrue(str_contains($response->getTargetUrl(), '/auth/login'), + sprintf('a client-signed id_token_hint with an unknown jti must require login, got %s', $response->getTargetUrl())); + $this->assertFalse(Auth::check(), 'a client-signed id_token_hint must never authenticate anyone by sub'); + } } diff --git a/tests/StubServerConfigurationService.php b/tests/StubServerConfigurationService.php index 4c8368fd..d5a808c7 100644 --- a/tests/StubServerConfigurationService.php +++ b/tests/StubServerConfigurationService.php @@ -24,6 +24,10 @@ public function getConfigValue($value) return intval($_ENV['access.token.lifetime']); } + if ($value === 'OAuth2.IdToken.Lifetime' && isset($_ENV['id.token.lifetime'])) { + return intval($_ENV['id.token.lifetime']); + } + return parent::getConfigValue($value); } } \ No newline at end of file diff --git a/tests/unit/InteractiveGrantTypeTest.php b/tests/unit/InteractiveGrantTypeTest.php index 5c10fe39..b3cb544e 100644 --- a/tests/unit/InteractiveGrantTypeTest.php +++ b/tests/unit/InteractiveGrantTypeTest.php @@ -13,15 +13,31 @@ * limitations under the License. **/ +use App\libs\OAuth2\Exceptions\ReloadSessionException; use Auth\User; use Exception; use Illuminate\Container\Container; use Illuminate\Support\Facades\Facade; +use jwa\cryptographic_algorithms\DigitalSignatures_MACs_Registry; +use jwa\JSONWebSignatureAndEncryptionAlgorithms; +use jwk\IJWK; +use jwk\impl\OctetSequenceJWKFactory; +use jwk\impl\OctetSequenceJWKSpecification; +use jwk\impl\RSAJWKFactory; +use jwk\impl\RSAJWKPEMPrivateKeySpecification; +use jwk\JSONWebKeyPublicKeyUseValues; +use jws\impl\specs\JWS_ParamsSpecification; +use jws\JWSFactory; +use jwt\impl\JWTClaimSet; +use jwt\JWTClaim; use Mockery; use Models\OAuth2\Client; +use Models\OAuth2\ServerPrivateKey; use OAuth2\Models\IClient; +use OAuth2\Models\JWTResponseInfo; use OAuth2\Models\Principal; use OAuth2\Models\SecurityContext; +use OAuth2\Models\SessionReloadHint; use OAuth2\OAuth2Message; use OAuth2\OAuth2Protocol; use OAuth2\Repositories\IClientRepository; @@ -41,6 +57,10 @@ use OAuth2\Strategies\IOAuth2AuthenticationStrategy; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; +use Utils\Db\ITransactionService; +use utils\json_types\JsonValue; +use utils\json_types\NumericDate; +use utils\json_types\StringOrURI; use Utils\Services\IAuthService; use Utils\Services\ILogService; @@ -502,6 +522,478 @@ public function testIdTokenHintProcessedParamPersistsThroughMemento(): void ); } + // ----------------------------------------------------------------------- + // Fix: reject an unsigned (alg=none) id_token_hint before trusting it, + // and mark a failed hint as processed so it can't loop the user out of + // a login they just completed. + // ----------------------------------------------------------------------- + + /** + * Builds a compact-serialization "unsecured JWT" (RFC 7519 §6): a real, + * parseable JWS-shaped token with alg=none and no signature segment. + * BasicJWTFactory::build() turns this into an UnsecuredJWT, which is + * neither IJWE nor IJWS - exactly the forgeable shape the fix rejects. + */ + private function buildUnsignedIdTokenHint(array $payload): string + { + $encode = function (array $data): string { + return rtrim(strtr(base64_encode(json_encode($data)), '+/', '-_'), '='); + }; + $header = ['alg' => 'none', 'typ' => 'JWT']; + return $encode($header) . '.' . $encode($payload) . '.'; + } + + /** + * An id_token_hint with alg=none carries no signature at all, so it must + * never be trusted: not for reloadSession's fallback authentication, not + * even to read who it claims to be. Anyone could forge one naming any + * user_id. The fix rejects it (not IJWS) before sub/jti are ever read. + */ + public function testProcessUserHintRejectsUnsignedAlgNoneIdTokenHint(): void + { + $forged_hint = $this->buildUnsignedIdTokenHint([ + 'sub' => '999', + 'jti' => 'forged-jti-attacker-controlled', + 'iss' => 'https://idp.test', + 'aud' => 'test-client-id', + ]); + + $request = $this->buildOIDCRequest([ + OAuth2Protocol::OAuth2Protocol_IDTokenHint => $forged_hint, + ]); + + $this->setupValidClient(); + $this->setupSecurityContext(); + $this->allowCleanupCalls(); + + $this->auth_service->shouldReceive('isUserLogged')->andReturn(false); + $this->auth_service->shouldReceive('getUserAuthenticationResponse') + ->andReturn(IAuthService::AuthenticationResponse_None); + + // The forged sub/jti must never reach account resolution or session + // reload - the hint has to be rejected before either is read. + $this->auth_service->shouldNotReceive('unwrapUserId'); + $this->auth_service->shouldNotReceive('getUserById'); + $this->auth_service->shouldNotReceive('reloadSession'); + + $this->auth_service->shouldReceive('logout')->with(false)->once(); + $this->memento_service->shouldReceive('serialize')->once(); + + $login_redirect = 'login-redirect-response'; + $this->auth_strategy->shouldReceive('doLogin') + ->once() + ->andReturn($login_redirect); + + $result = $this->grant_type->publicHandle($request); + + $this->assertEquals($login_redirect, $result); + } + + /** + * A hint that fails must be marked processed on this same pass. Before + * this fix, the "processed" flag was only set after a *successful* + * reloadSession(), so a stale/invalid hint kept getting re-attempted + * every time the pending OAuth2 memento was resumed - including right + * after a fresh, valid password login redirects back to /oauth2/auth - + * logging the user back out in an infinite loop. + */ + public function testFailedIdTokenHintIsMarkedProcessedToPreventLoginLoop(): void + { + $forged_hint = $this->buildUnsignedIdTokenHint([ + 'sub' => '999', + 'jti' => 'forged-jti-attacker-controlled', + ]); + + $request = $this->buildOIDCRequest([ + OAuth2Protocol::OAuth2Protocol_IDTokenHint => $forged_hint, + ]); + + $this->assertFalse( + $request->isProcessedParam(OAuth2Protocol::OAuth2Protocol_IDTokenHint) + ); + + $this->setupValidClient(); + $this->setupSecurityContext(); + $this->allowCleanupCalls(); + + $this->auth_service->shouldReceive('isUserLogged')->andReturn(false); + $this->auth_service->shouldReceive('getUserAuthenticationResponse') + ->andReturn(IAuthService::AuthenticationResponse_None); + + $this->auth_service->shouldReceive('logout')->with(false)->once(); + $this->memento_service->shouldReceive('serialize')->once(); + $this->auth_strategy->shouldReceive('doLogin')->once()->andReturn('login-response'); + + $this->grant_type->publicHandle($request); + + // The same $request instance handle() mutated: even though the hint + // failed, it must be marked processed so a memento resume of this + // exact request won't retry (and fail, and log out) again. + $this->assertTrue( + $request->isProcessedParam(OAuth2Protocol::OAuth2Protocol_IDTokenHint), + 'a failed id_token_hint must still be marked processed to avoid a login loop' + ); + } + + // ----------------------------------------------------------------------- + // Fix: the sub-based fallback in reloadSession() is only unlocked by a hint + // whose signature was verified with the IDP's own server key. A signature + // that verifies with a client-controlled key (HS* client secret, a public + // key the client registered, its jwks_uri) proves the *client* made it, + // not the IDP, so it must keep the old jti-only semantics. + // ----------------------------------------------------------------------- + + /** + * @param int|null $iat issued-at epoch (defaults to now) + * @param int|null $auth_time optional auth_time claim, as the IDP adds it when max_age was requested + */ + private function buildHintClaimSet(string $sub, string $jti, ?int $iat = null, ?int $auth_time = null): JWTClaimSet + { + $iat = $iat ?? time(); + $claim_set = new JWTClaimSet( + new StringOrURI('https://idp.test'), + new StringOrURI($sub), + new StringOrURI('test-client-id'), + new NumericDate($iat), + new NumericDate($iat + 600), + new JsonValue($jti) + ); + if (!is_null($auth_time)) { + $claim_set->addClaim(new JWTClaim(OAuth2Protocol::OAuth2Protocol_AuthTime, new JsonValue($auth_time))); + } + return $claim_set; + } + + /** + * Signs a real JWS with the library so the header round-trips exactly the + * way JWS::verify() re-serializes it. + */ + private function signHint(IJWK $jwk, string $alg, JWTClaimSet $claim_set): string + { + return JWSFactory::build( + new JWS_ParamsSpecification($jwk, new StringOrURI($alg), $claim_set) + )->toCompactSerialization(); + } + + /** + * Common expectations for a hint that verifies but whose reloadSession() + * fails: the request must end at the login page, never authenticated. + */ + private function expectHintReloadFailureEndsAtLogin(): string + { + $this->auth_service->shouldReceive('isUserLogged')->andReturn(false); + $this->auth_service->shouldReceive('getUserAuthenticationResponse') + ->andReturn(IAuthService::AuthenticationResponse_None); + $this->auth_service->shouldReceive('unwrapUserId')->with('999')->andReturn('999'); + $this->auth_service->shouldReceive('getUserById')->with('999')->andReturn(null); + + $this->auth_service->shouldReceive('logout')->with(false)->once(); + $this->memento_service->shouldReceive('serialize')->once(); + + $login_redirect = 'login-redirect-response'; + $this->auth_strategy->shouldReceive('doLogin')->once()->andReturn($login_redirect); + return $login_redirect; + } + + /** + * The client signs its id_tokens with HS512, i.e. with its own client + * secret - the IDP and the client share that key, so a token minted by + * the client verifies exactly like an IDP-issued one. Such a hint must + * reach reloadSession() WITHOUT the sub-based fallback (a jti-only hint). + */ + public function testClientKeyVerifiedIdTokenHintDoesNotUnlockSubFallback(): void + { + $secret = 'ITc/6Y5N7kOtGKhgITc/6Y5N7kOtGKhgITc/6Y5N7kOtGKhgITc/6Y5N7kOtGKhg'; + $alg = JSONWebSignatureAndEncryptionAlgorithms::HS512; + + $client_jwk = OctetSequenceJWKFactory::build(new OctetSequenceJWKSpecification($secret, $alg)); + $client_jwk->setKeyUse(JSONWebKeyPublicKeyUseValues::Signature); + + $hint = $this->signHint($client_jwk, $alg, $this->buildHintClaimSet('999', 'jti-client-signed')); + + $request = $this->buildOIDCRequest([ + OAuth2Protocol::OAuth2Protocol_IDTokenHint => $hint, + ]); + + $client = $this->setupValidClient(); + $client->shouldReceive('getIdTokenResponseInfo') + ->andReturn(new JWTResponseInfo(DigitalSignatures_MACs_Registry::getInstance()->get($alg))); + $client->shouldReceive('getClientType')->andReturn(IClient::ClientType_Confidential); + $client->shouldReceive('getClientSecret')->andReturn($secret); + + $this->setupSecurityContext(); + $this->allowCleanupCalls(); + $login_redirect = $this->expectHintReloadFailureEndsAtLogin(); + + // Key assertion: the signature was verified with the CLIENT's key, so + // the sub-based fallback must not be offered to reloadSession(). + $this->auth_service->shouldReceive('reloadSession') + ->once() + ->withArgs(function (SessionReloadHint $hint) { + return $hint->getJti() === 'jti-client-signed' && !$hint->allowsSubFallback(); + }) + ->andThrow(new ReloadSessionException('session not found!')); + + $result = $this->grant_type->publicHandle($request); + + $this->assertEquals($login_redirect, $result); + } + + /** + * Positive control: a hint verified with the IDP's own RS256 server key + * (the client has no registered signing key and no jwks_uri, so the + * client-key lookup throws RecipientKeyNotFoundException) keeps the + * sub-based fallback - reloadSession() receives a hint carrying the resolved user_id. + */ + public function testServerKeyVerifiedIdTokenHintKeepsSubFallback(): void + { + [$server_jwk, $alg] = $this->setupServerKeySignedClient(); + + $iat = time() - 30; + $hint = $this->signHint($server_jwk, $alg, $this->buildHintClaimSet('999', 'jti-server-signed', $iat)); + + $request = $this->buildOIDCRequest([ + OAuth2Protocol::OAuth2Protocol_IDTokenHint => $hint, + ]); + + $this->setupSecurityContext(); + $this->allowCleanupCalls(); + $login_redirect = $this->expectHintReloadFailureEndsAtLogin(); + + // Key assertion: the signature was verified with the SERVER key, so + // the sub-based fallback is offered to reloadSession(), and with no + // auth_time claim the hint's iat is the attested authentication time. + $this->auth_service->shouldReceive('reloadSession') + ->once() + ->withArgs(function (SessionReloadHint $hint) use ($iat) { + return $hint->getJti() === 'jti-server-signed' + && $hint->allowsSubFallback() + && $hint->getUserId() === 999 + && $hint->getAuthTime() === $iat; + }) + ->andThrow(new ReloadSessionException('user not found!')); + + $result = $this->grant_type->publicHandle($request); + + $this->assertEquals($login_redirect, $result); + } + + /** + * When the hint carries an explicit auth_time claim (the IDP adds it + * whenever the original request asked for max_age), that value - not + * iat, and never "now" - is what the fallback must register, so that + * shouldForceReLogin() and the next id_token's auth_time stay truthful. + */ + public function testServerKeyVerifiedIdTokenHintForwardsAuthTimeClaimOverIat(): void + { + [$server_jwk, $alg] = $this->setupServerKeySignedClient(); + + $now = time(); + $iat = $now - 10; + $auth_time = $now - 500; + $hint = $this->signHint( + $server_jwk, + $alg, + $this->buildHintClaimSet('999', 'jti-server-signed-auth-time', $iat, $auth_time) + ); + + $request = $this->buildOIDCRequest([ + OAuth2Protocol::OAuth2Protocol_IDTokenHint => $hint, + ]); + + $this->setupSecurityContext(); + $this->allowCleanupCalls(); + $login_redirect = $this->expectHintReloadFailureEndsAtLogin(); + + $this->auth_service->shouldReceive('reloadSession') + ->once() + ->withArgs(function (SessionReloadHint $hint) use ($auth_time) { + return $hint->getJti() === 'jti-server-signed-auth-time' + && $hint->allowsSubFallback() + && $hint->getUserId() === 999 + && $hint->getAuthTime() === $auth_time; + }) + ->andThrow(new ReloadSessionException('user not found!')); + + $result = $this->grant_type->publicHandle($request); + + $this->assertEquals($login_redirect, $result); + } + + /** + * An RS256 client with no registered signing key and no jwks_uri, so the + * client-key lookup throws RecipientKeyNotFoundException and + * InteractiveGrantType falls back to the IDP's server signing key. + * + * @return array{0: IJWK, 1: string} the server JWK to sign hints with, and the alg + */ + private function setupServerKeySignedClient(): array + { + $alg = JSONWebSignatureAndEncryptionAlgorithms::RS256; + + $key_pair = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); + openssl_pkey_export($key_pair, $pem); + + $server_jwk = RSAJWKFactory::build( + new RSAJWKPEMPrivateKeySpecification($pem, RSAJWKPEMPrivateKeySpecification::WithoutPassword, $alg) + ); + $server_jwk->setKeyUse(JSONWebKeyPublicKeyUseValues::Signature)->setId('server-sig-key'); + + $client = $this->setupValidClient(); + $client->shouldReceive('getIdTokenResponseInfo') + ->andReturn(new JWTResponseInfo(DigitalSignatures_MACs_Registry::getInstance()->get($alg))); + // No client-controlled signing key anywhere -> RecipientKeyNotFoundException + // -> InteractiveGrantType falls back to the server signing key. + $client->shouldReceive('getCurrentPublicKeyByUse') + ->with(JSONWebKeyPublicKeyUseValues::Signature, $alg) + ->andReturn(null); + $this->jwk_set_reader_service->shouldReceive('read')->with($client)->andReturn(null); + + // ServerSigningKeyFinder resolves ITransactionService through the App facade. + $app = Facade::getFacadeApplication(); + $app->instance('app', $app); + $tx_service = Mockery::mock(ITransactionService::class); + $tx_service->shouldReceive('transaction')->andReturnUsing(function (callable $callback) { + return $callback(); + }); + $app->instance(ITransactionService::class, $tx_service); + + $server_key_alg = Mockery::mock(); + $server_key_alg->shouldReceive('getName')->andReturn($alg); + $server_key = Mockery::mock(ServerPrivateKey::class); + $server_key->shouldReceive('isActive')->andReturn(true); + $server_key->shouldReceive('getAlg')->andReturn($server_key_alg); + $server_key->shouldReceive('toJWK')->andReturn($server_jwk); + $server_key->shouldReceive('markAsUsed')->andReturnNull(); + $this->server_private_key_repository->shouldReceive('getByKeyIdentifier') + ->with('server-sig-key') + ->andReturn($server_key); + + return [$server_jwk, $alg]; + } + + // ----------------------------------------------------------------------- + // Claim validation on a verified hint (CodeRabbit threads on PR #158): + // exp must be strictly in the future, and a missing sub must be rejected + // as an InvalidLoginHint instead of dereferencing null. + // ----------------------------------------------------------------------- + + /** + * A confidential client whose id_tokens are HS512-signed with its own + * secret, plus the matching JWK to sign test hints with. + * + * @return array{0: IJWK, 1: string} + */ + private function setupClientSecretSignedClient(): array + { + $secret = 'ITc/6Y5N7kOtGKhgITc/6Y5N7kOtGKhgITc/6Y5N7kOtGKhgITc/6Y5N7kOtGKhg'; + $alg = JSONWebSignatureAndEncryptionAlgorithms::HS512; + + $client = $this->setupValidClient(); + $client->shouldReceive('getIdTokenResponseInfo') + ->andReturn(new JWTResponseInfo(DigitalSignatures_MACs_Registry::getInstance()->get($alg))); + $client->shouldReceive('getClientType')->andReturn(IClient::ClientType_Confidential); + $client->shouldReceive('getClientSecret')->andReturn($secret); + + $jwk = OctetSequenceJWKFactory::build(new OctetSequenceJWKSpecification($secret, $alg)); + $jwk->setKeyUse(JSONWebKeyPublicKeyUseValues::Signature); + + return [$jwk, $alg]; + } + + /** + * Common expectations for a verified hint that must be rejected during + * claim validation: reloadSession() is never reached and the request + * ends at the login page. + */ + private function expectHintRejectedBeforeReload(): string + { + $this->auth_service->shouldReceive('isUserLogged')->andReturn(false); + $this->auth_service->shouldReceive('getUserAuthenticationResponse') + ->andReturn(IAuthService::AuthenticationResponse_None); + + $this->auth_service->shouldNotReceive('unwrapUserId'); + $this->auth_service->shouldNotReceive('getUserById'); + $this->auth_service->shouldNotReceive('reloadSession'); + + $this->auth_service->shouldReceive('logout')->with(false)->once(); + $this->memento_service->shouldReceive('serialize')->once(); + + $login_redirect = 'login-redirect-response'; + $this->auth_strategy->shouldReceive('doLogin')->once()->andReturn($login_redirect); + return $login_redirect; + } + + /** + * RFC 7519 §4.1.4: the current time MUST be strictly before exp. A hint + * whose exp equals the current second is already expired. + * + * NumericDate::now() is wall-clock time, so this test builds the hint + * with exp = time() right before handling it; the whole handle() call + * runs well within one second. + */ + public function testProcessUserHintRejectsIdTokenHintWhoseExpEqualsNow(): void + { + [$jwk, $alg] = $this->setupClientSecretSignedClient(); + + $now = time(); + $claim_set = new JWTClaimSet( + new StringOrURI('https://idp.test'), + new StringOrURI('999'), + new StringOrURI('test-client-id'), + new NumericDate($now - 60), + new NumericDate($now), + new JsonValue('jti-exp-boundary') + ); + $hint = $this->signHint($jwk, $alg, $claim_set); + + $request = $this->buildOIDCRequest([ + OAuth2Protocol::OAuth2Protocol_IDTokenHint => $hint, + ]); + + $this->setupSecurityContext(); + $this->allowCleanupCalls(); + $login_redirect = $this->expectHintRejectedBeforeReload(); + + $result = $this->grant_type->publicHandle($request); + + $this->assertEquals($login_redirect, $result); + } + + /** + * A verified hint without a sub claim must be rejected as an + * InvalidLoginHint (ending at the login page like any other bad hint), + * not blow up dereferencing null - that raises an Error, which the + * Exception-only catch in mustAuthenticateUser() does not handle. + */ + public function testProcessUserHintRejectsIdTokenHintWithoutSub(): void + { + [$jwk, $alg] = $this->setupClientSecretSignedClient(); + + $now = time(); + $claim_set = new JWTClaimSet( + new StringOrURI('https://idp.test'), + null, + new StringOrURI('test-client-id'), + new NumericDate($now), + new NumericDate($now + 600), + new JsonValue('jti-no-sub') + ); + $hint = $this->signHint($jwk, $alg, $claim_set); + + $request = $this->buildOIDCRequest([ + OAuth2Protocol::OAuth2Protocol_IDTokenHint => $hint, + ]); + + $this->setupSecurityContext(); + $this->allowCleanupCalls(); + $login_redirect = $this->expectHintRejectedBeforeReload(); + + $result = $this->grant_type->publicHandle($request); + + $this->assertEquals($login_redirect, $result); + } + // ----------------------------------------------------------------------- // Normal flow: consent accepted -> successful authorization // -----------------------------------------------------------------------