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