5

The nature of cookie based sessions and state parameter handling in OAuth2 Auth Code Flow expose a problem, when new browser session is started with multiple tabs trying to concurrently open several links on a "Secure Server" ( our Oauth2 confidential client).

When the browser is started it discards all previous session cookies. Multiple tabs may be opened at once by the browser in case of crash recovery, or by the user, from bookmarks folder or history.

In such cases all tabs will simultaneously send unauthenticated requests to Secure Server. Each request will start a new session and a new Auth Code Flow, with new state param, that will be saved in this session.

All Secure Server's redirect-to-Identity-Provider responses will bear a session cookie with the same name, but a different value. They will overwrite each other in browser, and only the last one will be kept by browser as the Session ID.

Each tab will continue down the Authorization Code flow to Identity-Provider login page and back to Secure Server, bearing different state param, but same session cookie (set by the last tab).

Those state params were saved in now lost sessions and cannot be verified. State param validation failure is forbidden, and error 403 is issued.

The result is that all tabs except the last one end on 403 page.

Are there any known practices to handle this problem ?

Thanks

Liphtier
  • 552
  • 6
  • 13
  • I use cookies with different names, and put the cookie name in the state, Secure Server uses the cookie name in the state to obtain the specified cookie. – Libron Apr 19 '21 at 05:07
  • So in other words, you put the state in a cookie name. (What do you put in this cookie value? Maybe return URL ?) Then how do you validate the state? Check that there's a cookie with state in name ? If so, then you compare two tokens, that are both provided by client. Isn't it insecure? – Liphtier Apr 19 '21 at 16:04
  • My state is generated by the backend server. The backend server sets a cookie with a random name for the browser (http only), which contains a random value for the state. The state contains the cookie name and the random value and redirectUri. When the backend server receives code and state, it will first check whether the cookie exists, and then check the random value. – Libron Apr 20 '21 at 01:07
  • The state contains "the cookie name and the random value and redirectUri" - as a single string encrypted with some server side key ? – Liphtier Apr 21 '21 at 16:27
  • It's just a json string, any modification of cookie name or random value will cause verification to fail. The check of redirectUri is the responsibility of the client. – Libron Apr 22 '21 at 01:07
  • @LiTang, your solution is simple and almost works, but it misses the target of validating the state param returned with something that you trust, server-side copy of state. An attacker can steal a URL containing the code and state, guess cookie name and value from that JSON, insert them into his request, and get authorization instead of initial user. I would propose to at least encrypt the state JSON, so, that only server could know what are parts of that state are cookie key and value. Probably that will be complete solution, but I'd like to hear more opinions. – Liphtier Apr 22 '21 at 05:48
  • You are right, in fact, I hash the random value on the URL, and the attacker will not be able to guess the random value in cookie. – Libron Apr 22 '21 at 09:09
  • 1
    If you one-way hash the random value in the state, what can stop an attacker from replacing both cookie value and it's hash in state param ? – Liphtier Apr 22 '21 at 11:26
  • 2
    Thank you for your hint, hash is invalid, my original idea was wrong. Considering that code was stolen, it seems that it cannot prevent attackers from using their own state and cookies to replace them. I am trying to find more information. Now, I I think [state use for making sure the response belongs to a request initiated by the same user](https://pipedrive.readme.io/docs/marketplace-oauth-authorization-state-parameter#section-csfr-attacks). The responsibility for code being stolen will belong to the Auth Server(code can only be used once, if it is reused, all tokens will be revoked). – Libron Apr 22 '21 at 16:05
  • 1
    In addition, regarding the original concurrency problem, I found a similar answer [here](https://auth0.com/docs/protocols/state-parameters#use-the-stored-url-to-redirect-users), using a random value as the key. – Libron Apr 22 '21 at 16:08

2 Answers2

2

Interesting question and in most cases this will be a challenge to get working and will be a combination of support from:

  • Client Side OAuth libraries
  • Authorization Servers

COMPLIANT LIBRARY

The oidc-client-js library demonstrates the required technique, via a state store per redirect. Last man will then win, as you say, without any errors for end users.

It is one of those usability areas where a client side Web UI has greater control than redirects triggered by server side web stacks, such as ASP.Net / Spring Boot.

VISUALISING THE BEHAVIOUR

Run my Online OAuth SPA and trigger 2 redirects, but do not log onto either. Then browse to this URL and look in the browser's local storage tools at the redirect state:

enter image description here

The last man to win will then update the user store, whose data is used for subsequent renewal redirects and token validation (note that my SPA stores the actual tokens in memory rather than in this user store):

enter image description here

NON COMPLIANT AUTHORIZATION SERVER

Unfortunately my Online Authorization Server (AWS Cognito) does not like receiving 2 logins like this and the second login fails.

enter image description here

Gary Archer
  • 22,534
  • 2
  • 12
  • 24
  • It looks like a similar problem. But in your case the error comes from Auth Server (or Identity Provider) - and it's not clear why it fails, as the AS has nothing to do with redirect state param, it should just to transmit it back to code flow initiator. – Liphtier Jan 13 '21 at 09:21
  • I'm looking for a solution, too. Just one question: "last will win" is true only if we can guarantee that order of execution on the serve is same as order of received, right? Otherwise if we have request A and B wich are executed server-side in the order (A,B) but received like (B,A), A would "win" making this flow fail... So maybe a server-side timestamp would suffice? – marco6 Oct 15 '21 at 08:08
0

I have developed a solution,

It is published on TheNetworg OAuth2-Azure discussions

I still need more opinions to consider it safe and sufficient.

Every time the Auth Code flow starts, we must set a uniquely named copy of the session cookie. The cookie name should have a recognizable prefix

if (!isset($_GET['code'])) {
  // If we don't have an authorization code then get one
  $authUrl = $provider->getAuthorizationUrl();
  $oauth2state = $provider->getState();

  // Save the return URL along with the state
  $_SESSION['oauth2state'][$oauth2state] = [
      'returnUrl' => $_SERVER['REQUEST_URI']
  ];

  $sid = session_id();
  $uniq_session_name = uniqid('USID_', false);
  $params = session_get_cookie_params();
  setcookie($uniq_session_name, $sid, $params['lifetime'],
      $params['path'], $params['domain'],
      $params['secure'], $params['httponly']
  );

  header('Location: ' . $authUrl);
  exit;
}

As a result, when N tabs are started, there will be one "original" session cookie, plus N cookies with different names and session ID's opened at the time of each of N requests. We will call them "spare sessions"

When the OAuth2 state check will fail, it should try to lookup spare sessions for a valid state. If valid spare session found, its cookie will be wiped. Then we can send user back to returnUrl found in this spare state, this time he will follow redirect with correct session cookie.

if (empty($_GET['state'])) {
  die "Invalid State";
}


if (!isset($_SESSION['state']) || !array_key_exists($_GET['state'], $_SESSION['oauth2state'])) {
  if([$uniq_state_name, $returnUrl] = lookupSpareSessionReturnUrls($_GET['state'])) {
    unsetSessionCookie($uniq_state_name);
    header('Location: ' . $returnUrl);
    exit;
  }
  die ("Invalid State");
}

/**
 * @param $oauth2state
 * @return array|null
 */
function lookupSpareSessionReturnUrls($oauth2state) {
  $uniq_sessions_names = preg_grep('/USID_.*/', array_keys($_COOKIE));
  if($uniq_sessions_names) {
    foreach ($uniq_sessions_names as $usname) {
      $usid = $_COOKIE[$usname];
      if($usid !== session_id()) {
        $TMP_SESSION = sessionPeek($usid);
        if (isset($TMP_SESSION['oauth2state'][$oauth2state]['returnUrl'])) {
          return [$usname, $TMP_SESSION['oauth2state'][$oauth2state]['returnUrl']];
        }
      }
    }
  }
  return null;
}

/**
 * Sample Session Peek function 
 * for file based sessions
 * @param $sid
 * @return array
 */
function sessionPeek($sid) {
  $sess_file = session_save_path() . '/sess_' . $sid;
  $TMP_SESSION = [];
  $CURRENT_SESSION = $_SESSION;
  if(session_decode(file_get_contents($sess_file))) {
    $TMP_SESSION = $_SESSION;
  }
  $_SESSION = $CURRENT_SESSION;
  return $TMP_SESSION;
}

If no spare session found the flow will fall back to error as expected.

The tab accessing the return URL will already bear the correct session cookie shared by all tabs. However, it may reach the target page or may start an Auth Code flow again, depending on the racing conditions of Auth in other tabs.

If it comes too early, before any other tab has finished the authorization, then new Auth Code flow is started, and a new state with return_url is saved in a current session.

On the way back from Azure to Auth callback URL, the session may already be authorized in another tab. In this case we must stop the flow, and redirect to the original return_url, which may be found in a current or a spare session.

if ($_SESSION['authorizedFlag'] === true && isset($_GET['code']) && isset($_GET['state'])) {
  $returnUrl = null;
  if([$usname, $returnUrl] = lookupSpareSessionReturnUrls($_GET['state'])) {
    unsetSessionCookie($usname);
  }
  elseif (isset($_SESSION['oauth2state'][$_GET['state']]['returnUrl'])) {
    $returnUrl = $_SESSION['oauth2state'][$_GET['state']]['returnUrl'];
    unset($_SESSION['oauth2state'][$_GET['state']]);
  }
  else {
    /* Dead End, no return URL, redirect to Error or Home page.  
        Shouldn't normally happen */
  }
  header('Location: ' . $returnUrl);
  exit;
}

At this point the spare session ID may be discarded, and its cookie unset. The tab will finally get the protected page, as all other tabs, sharing the same session cookie, as usual

/* Auth OK */
try {
  $token = $provider->getAccessToken('authorization_code', [
    'code' => $_GET['code'],
  ]);
  $_SESSION['authorizedFlag'] = true;
}
catch (IdentityProviderException $e) {
  die ( $e->getMessage() );
}


$uniq_sessions_names = preg_grep('/USID_.*/', array_keys($_COOKIE));
if(!empty($uniq_sessions_names)) {
  foreach ($uniq_sessions_names as $usname) {
    $usid = $_COOKIE[$usname];
    if ($usid === session_id()) {
      $unsetSessionCookie($usname);
    }
  }
}

/* Regenerate session ID for security but DO NOT discard the old session [ file ] - it may be needed as a spare now */
session_regenerate_id(false);

Full code sample

if( $_SESSION['authorizedFlag'] !== true ) {
  if (!isset($_GET['code'])) {
    // If we don't have an authorization code then get one
    $authUrl = $provider->getAuthorizationUrl();
    $oauth2state = $provider->getState();
  
    // Save the return URL along with the state
    $_SESSION['oauth2state'][$oauth2state] = [
        'returnUrl' => $_SERVER['REQUEST_URI']
    ];
  
    $sid = session_id();
    $uniq_session_name = uniqid('USID_', false);
    $params = session_get_cookie_params();
    setcookie($uniq_session_name, $sid, $params['lifetime'],
        $params['path'], $params['domain'],
        $params['secure'], $params['httponly']
    );
  
    header('Location: ' . $authUrl);
    exit;
  }
  
  if (empty($_GET['state'])) {
    die "Invalid State";
  }
  
  if (!isset($_SESSION['state']) || !array_key_exists($_GET['state'], $_SESSION['oauth2state'])) {
    if([$uniq_state_name, $returnUrl] = lookupSpareSessionReturnUrls($_GET['state'])) {
      unsetSessionCookie($uniq_state_name);
      header('Location: ' . $returnUrl);
      exit;
    }
    die ("Invalid State");
  }
  
  
  /* Auth OK */
  try {
    $token = $provider->getAccessToken('authorization_code', [
      'code' => $_GET['code'],
    ]);
    $_SESSION['authorizedFlag'] = true;
  }
  catch (IdentityProviderException $e) {
    die ( $e->getMessage() );
  }
  
  
  $uniq_sessions_names = preg_grep('/USID_.*/', array_keys($_COOKIE));
  if(!empty($uniq_sessions_names)) {
    foreach ($uniq_sessions_names as $usname) {
      $usid = $_COOKIE[$usname];
      if ($usid === session_id()) {
        unsetSessionCookie($usname);
      }
    }
  }
  
/* Regenerate session ID for security but DO NOT discard the old session [ file ] - it may be needed as a spare now */
  session_regenerate_id(false);

}
else if ($_SESSION['authorizedFlag'] === true && isset($_GET['code']) && isset($_GET['state'])) {
  $returnUrl = null;
  if([$usname, $returnUrl] = lookupSpareSessionReturnUrls($_GET['state'])) {
    unsetSessionCookie($usname);
  }
  elseif (isset($_SESSION['oauth2state'][$_GET['state']]['returnUrl'])) {
    $returnUrl = $_SESSION['oauth2state'][$_GET['state']]['returnUrl'];
    unset($_SESSION['oauth2state'][$_GET['state']]);
  }
  else {
    /* Dead End, no return URL, redirect to Error or Home page.  
        Shouldn't normally happen */
  }
  header('Location: ' . $returnUrl);
  exit;
}

/* Authorization finished - continue to protected resource */



/**
 * @param $oauth2state
 * @return array|null
 */
function lookupSpareSessionReturnUrls($oauth2state) {
  $uniq_sessions_names = preg_grep('/USID_.*/', array_keys($_COOKIE));
  if($uniq_sessions_names) {
    foreach ($uniq_sessions_names as $usname) {
      $usid = $_COOKIE[$usname];
      if($usid !== session_id()) {
        $TMP_SESSION = sessionPeek($usid);
        if (isset($TMP_SESSION['oauth2state'][$oauth2state]['returnUrl'])) {
          return [$usname, $TMP_SESSION['oauth2state'][$oauth2state]['returnUrl']];
        }
      }
    }
  }
  return null;
}

/**
 * Sample Session Peek function 
 * for file based sessions
 * may be not the best practice
 * @param $sid
 * @return array
 */
function sessionPeek($sid) {
  $sess_file = session_save_path() . '/sess_' . $sid;
  $TMP_SESSION = [];
  $CURRENT_SESSION = $_SESSION;
  if(session_decode(file_get_contents($sess_file))) {
    $TMP_SESSION = $_SESSION;
  }
  $_SESSION = $CURRENT_SESSION;
  return $TMP_SESSION;
}
Liphtier
  • 552
  • 6
  • 13