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
83 changes: 69 additions & 14 deletions app/libs/Auth/AuthService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Comment on lines +680 to +684
}
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) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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());
}

/**
Expand Down
91 changes: 87 additions & 4 deletions app/libs/OAuth2/GrantTypes/InteractiveGrantType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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 ?
Expand All @@ -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());
Expand All @@ -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);
}
Expand Down
117 changes: 117 additions & 0 deletions app/libs/OAuth2/Models/SessionReloadHint.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php namespace OAuth2\Models;
/**
* Copyright 2026 OpenStack Foundation
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/

/**
* Class SessionReloadHint
*
* What an id_token_hint authorizes AuthService::reloadSession() to do.
*
* - jtiOnly(): the hint's signature was verified with a client-controlled key
* (HS* client secret, a public key the client registered, its jwks_uri), so it
* only proves the client made it. The session may be resumed through the
* cached jti, nothing else.
* - withSubFallback(): the signature was verified with this IDP's own signing
* key AND the hint carries an attested authentication time, so when the
* cached session can't be resumed the user it names may be logged in with
* that auth_time.
*
* The named constructors make "user_id without auth_time" (or the reverse)
* unrepresentable.
*
* @package OAuth2\Models
*/
final class SessionReloadHint
{
/**
* @var string
*/
private $jti;

/**
* @var int|null
*/
private $user_id;

/**
* @var int|null
*/
private $auth_time;

/**
* @param string $jti
* @param int|null $user_id
* @param int|null $auth_time
*/
private function __construct(string $jti, ?int $user_id, ?int $auth_time)
{
$this->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;
}
}
8 changes: 6 additions & 2 deletions app/libs/Utils/Services/IAuthService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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';

Expand Down
Loading
Loading