Liphtier
Liphtier

Reputation: 662

Authorization Code Flow - Concurrent Requests from Multiple Tabs

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

Upvotes: 6

Views: 1570

Answers (2)

Liphtier
Liphtier

Reputation: 662

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;
}

Upvotes: 0

Gary Archer
Gary Archer

Reputation: 29263

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

Upvotes: 2

Related Questions