From b483f2ee76a17f92fa6a45d964bcb2d18a2f7464 Mon Sep 17 00:00:00 2001 From: Kristofer Nilsson Date: Fri, 19 Dec 2025 11:46:35 +0100 Subject: [PATCH] Corrected OAuth2 login flow and added better error handling --- plugins/login-o365/LoginOAuth2.js | 101 +++--- plugins/login-o365/index.php | 517 +++++++++++++++++------------- 2 files changed, 348 insertions(+), 270 deletions(-) diff --git a/plugins/login-o365/LoginOAuth2.js b/plugins/login-o365/LoginOAuth2.js index dd8c843ed..8b1294892 100644 --- a/plugins/login-o365/LoginOAuth2.js +++ b/plugins/login-o365/LoginOAuth2.js @@ -1,54 +1,51 @@ -(rl => { - const client_id = rl.pluginSettingsGet('login-o365', 'client_id'), - // https://learn.microsoft.com/en-us/entra/identity-platform/reply-url#query-parameter-support-in-redirect-uris - query = rl.pluginSettingsGet('login-o365', 'personal') ? '' : '?', - tenant = rl.pluginSettingsGet('login-o365', 'tenant'), - login = () => { - document.location = 'https://login.microsoftonline.com/'+tenant+'/oauth2/v2.0/authorize?' + (new URLSearchParams({ - response_type: 'code', - client_id: client_id, - redirect_uri: document.location.href.replace(/\/$/, '') + '/' + query + 'LoginO365', - scope: [ - // Associate personal info - 'openid', - 'offline_access', - 'email', - 'profile', - // Access IMAP and SMTP through OAUTH - 'https://graph.microsoft.com/IMAP.AccessAsUser.All', -// 'https://graph.microsoft.com/Mail.ReadWrite' - 'https://graph.microsoft.com/Mail.Send' -/* // Legacy: - 'https://outlook.office.com/SMTP.Send', - 'https://outlook.office.com/IMAP.AccessAsUser.All' -*/ - ].join(' '), - state: 'o365', // + rl.settings.app('token') + localStorage.getItem('smctoken') - // Force authorize screen, so we always get a refresh_token - access_type: 'offline', - prompt: 'consent' - })); - }; +((rl) => { + const client_id = rl.pluginSettingsGet("login-o365", "client_id"), + // https://learn.microsoft.com/en-us/entra/identity-platform/reply-url#query-parameter-support-in-redirect-uris + tenant = rl.pluginSettingsGet("login-o365", "tenant"), + login = () => { + document.location = "https://login.microsoftonline.com/" + + tenant + + "/oauth2/v2.0/authorize?" + + new URLSearchParams({ + response_type: "code", + client_id: client_id, + redirect_uri: + document.location.href.replace(/\/$/, "") + "/LoginO365", + scope: [ + // Associate personal info + "openid", + "offline_access", + "email", + "profile", + // Access IMAP and SMTP through OAUTH + "https://outlook.office.com/IMAP.AccessAsUser.All", + "https://outlook.office.com/SMTP.Send", + ].join(" "), + state: "o365", + access_type: "offline_access" + // prompt: "consent", + }); + }; - if (client_id) { - addEventListener('sm-user-login', e => { - if (event.detail.get('Email').includes('@hotmail.com')) { - e.preventDefault(); - login(); - } - }); + if (client_id) { + addEventListener("sm-user-login", (e) => { + const email = (e.detail.get("Email") || "").toLowerCase(); + if (/@(outlook\.com|hotmail\.com|live\.com)$/.test(email)) { + e.preventDefault(); + login(); + } + }); - addEventListener('rl-view-model', e => { - if ('Login' === e.detail.viewModelTemplateID) { - const - container = e.detail.viewModelDom.querySelector('#plugin-Login-BottomControlGroup'), - btn = Element.fromHTML(''), - div = Element.fromHTML('
'); - btn.onclick = login; - div.append(btn); - container && container.append(div); - } - }); - } - -})(window.rl); + addEventListener("rl-view-model", (e) => { + if ("Login" === e.detail.viewModelTemplateID) { + const + container = e.detail.viewModelDom.querySelector("#plugin-Login-BottomControlGroup"), + btn = Element.fromHTML(''), + div = Element.fromHTML('
'); + btn.onclick = login; + div.append(btn); + container && container.append(div); + } + }); + } +})(window.rl); \ No newline at end of file diff --git a/plugins/login-o365/index.php b/plugins/login-o365/index.php index 62050f429..101cf55d7 100644 --- a/plugins/login-o365/index.php +++ b/plugins/login-o365/index.php @@ -1,15 +1,20 @@ UseLangs(true); - $this->addJs('LoginOAuth2.js'); - $this->addHook('imap.before-login', 'clientLogin'); - $this->addHook('smtp.before-login', 'clientLogin'); - $this->addHook('sieve.before-login', 'clientLogin'); + public function Init() : void + { + $this->UseLangs(true); + $this->addJs('LoginOAuth2.js'); + $this->addHook('imap.before-login', 'clientLogin'); + $this->addHook('smtp.before-login', 'clientLogin'); + $this->addHook('sieve.before-login', 'clientLogin'); - $this->addPartHook('LoginO365', 'ServiceLoginO365'); + $this->addPartHook('LoginO365', 'ServiceLoginO365'); - // Prevent Disallowed Sec-Fetch Dest: document Mode: navigate Site: cross-site User: true - $this->addHook('filter.http-paths', 'httpPaths'); - } + // Prevent Disallowed Sec-Fetch Dest: document Mode: navigate Site: cross-site User: true + $this->addHook('filter.http-paths', 'httpPaths'); + } - public function httpPaths(array &$aPaths) : void - { - // Personal accounts workaround - if (!empty($_SERVER['PATH_INFO']) && \str_ends_with($_SERVER['PATH_INFO'], 'LoginO365')) { - $aPaths = ['LoginO365']; - } + public function httpPaths(array &$aPaths) : void + { + if (!empty($_SERVER['PATH_INFO']) && \str_ends_with($_SERVER['PATH_INFO'], 'LoginO365')) { + $aPaths = ['LoginO365']; + } - if (!empty($aPaths[0]) && 'LoginO365' === $aPaths[0]) { - $oConfig = \RainLoop\Api::Config(); - $oConfig->Set('security', 'secfetch_allow', - \trim($oConfig->Get('security', 'secfetch_allow', '') . ';site=cross-site', ';') - ); - } - } + if (!empty($aPaths[0]) && 'LoginO365' === $aPaths[0]) { + $oConfig = \RainLoop\Api::Config(); + $oConfig->Set('security', 'secfetch_allow', + \trim($oConfig->Get('security', 'secfetch_allow', '') . ';site=cross-site', ';') + ); + } + } - public function ServiceLoginO365() : string - { - $oActions = \RainLoop\Api::Actions(); - $oHttp = $oActions->Http(); - $oHttp->ServerNoCache(); + public function ServiceLoginO365() : string + { + $oActions = \RainLoop\Api::Actions(); + $oHttp = $oActions->Http(); + $oHttp->ServerNoCache(); - try + try { - if (isset($_GET['error'])) { - throw new \RuntimeException("{$_GET['error']}: {$_GET['error_description']}"); - } - if (!isset($_GET['code']) || empty($_GET['state']) || 'o365' !== $_GET['state']) { - $oActions->Location(\RainLoop\Utils::WebPath()); - exit; - } - $oO365 = $this->o365Connector(); - if (!$oO365) { - $oActions->Location(\RainLoop\Utils::WebPath()); - exit; - } + if (isset($_GET['error'])) { + $desc = $_GET['error_description'] ?? ''; + throw new \RuntimeException("{$_GET['error']}: {$desc}"); + } - $iExpires = \time(); - $aResponse = $oO365->getAccessToken( - \str_replace('{{tenant}}', $this->Config()->Get('plugin', 'tenant', 'common'), static::TOKEN_URI), - 'authorization_code', - array( - 'code' => $_GET['code'], - 'redirect_uri' => $oHttp->GetFullUrl().'?LoginO365' - ) - ); - if (200 != $aResponse['code']) { - if (isset($aResponse['result']['error'])) { - throw new \RuntimeException( - $aResponse['code'] - . ': ' - . $aResponse['result']['error'] - . ' / ' - . $aResponse['result']['error_description'] - ); - } - throw new \RuntimeException("HTTP: {$aResponse['code']}"); - } - $aResponse = $aResponse['result']; - if (empty($aResponse['access_token'])) { - throw new \RuntimeException('access_token missing'); - } - if (empty($aResponse['refresh_token'])) { - throw new \RuntimeException('refresh_token missing'); - } + // Must have code + state + if (!isset($_GET['code']) || empty($_GET['state']) || 'o365' !== $_GET['state']) { + $oActions->Location(\RainLoop\Utils::WebPath()); + exit; + } - $sAccessToken = $aResponse['access_token']; - $iExpires += $aResponse['expires_in']; + $oO365 = $this->o365Connector(); + if (!$oO365) { + $oActions->Location(\RainLoop\Utils::WebPath()); + exit; + } - $oO365->setAccessToken($sAccessToken); - $aUserInfo = $oO365->fetch('https://graph.microsoft.com/oidc/userinfo'); - if (200 != $aUserInfo['code']) { - throw new \RuntimeException("HTTP: {$aResponse['code']}"); - } - $aUserInfo = $aUserInfo['result']; - if (empty($aUserInfo['id'])) { - throw new \RuntimeException('unknown id'); - } - if (empty($aUserInfo['email'])) { - throw new \RuntimeException('unknown email address'); - } + $iNow = \time(); - static::$auth = [ - 'access_token' => $sAccessToken, - 'refresh_token' => $aResponse['refresh_token'], - 'expires_in' => $aResponse['expires_in'], - 'expires' => $iExpires - ]; + // Build absolute base URL (works behind nginx reverse proxy) + $scheme = (!empty($_SERVER['HTTP_X_FORWARDED_PROTO'])) + ? $_SERVER['HTTP_X_FORWARDED_PROTO'] + : ((!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'); - $oPassword = new \SnappyMail\SensitiveString($aUserInfo['id']); - $oAccount = $oActions->LoginProcess($aUserInfo['email'], $oPassword); -// $oAccount = MainAccount::NewInstanceFromCredentials($oActions, $aUserInfo['email'], $aUserInfo['email'], $oPassword, true); - if ($oAccount) { -// $oActions->SetMainAuthAccount($oAccount); -// $oActions->SetAuthToken($oAccount); - $oActions->StorageProvider()->Put($oAccount, StorageType::SESSION, \RainLoop\Utils::GetSessionToken(), - \SnappyMail\Crypt::EncryptToJSON(static::$auth, $oAccount->CryptKey()) - ); - } - } - catch (\Exception $oException) - { - $oActions->Logger()->WriteException($oException, \LOG_ERR); - } - $oActions->Location(\RainLoop\Utils::WebPath()); - exit; - } + $host = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? ''; + if (!$host) { + throw new \RuntimeException('Cannot determine HTTP_HOST'); + } + $base = $scheme . '://' . $host; - public function configMapping() : array - { - return [ - \RainLoop\Plugins\Property::NewInstance('personal') - ->SetLabel('Use with personal accounts') - ->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL) - ->SetDefaultValue(true) - ->SetAllowedInJs() - ->SetDescription('Sign in users with personal Microsoft accounts such as Outlook.com (Hotmail)'), - \RainLoop\Plugins\Property::NewInstance('client_id') - ->SetLabel('Client ID') - ->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING) - ->SetAllowedInJs() - ->SetDescription('https://github.com/the-djmaze/snappymail/wiki/FAQ#o365'), - \RainLoop\Plugins\Property::NewInstance('client_secret') - ->SetLabel('Client Secret') - ->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING) - ->SetEncrypted(), - \RainLoop\Plugins\Property::NewInstance('tenant_id') - ->SetLabel('Tenant ID') - ->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING), - \RainLoop\Plugins\Property::NewInstance('tenant')->SetLabel('Tenant') - ->SetType(\RainLoop\Enumerations\PluginPropertyType::SELECTION) - ->SetDefaultValue(['common','consumers','organizations']) - ->SetAllowedInJs() - ]; - } + // IMPORTANT: default personal=false to match the JS default behavior + $personal = (bool)$this->Config()->Get('plugin', 'personal', false); + $redirectUri = $personal ? ($base . '/?LoginO365') : ($base . '/LoginO365'); - public function clientLogin(\RainLoop\Model\Account $oAccount, \MailSo\Net\NetClient $oClient, \MailSo\Net\ConnectSettings $oSettings) : void - { - if ($oAccount instanceof MainAccount && \str_ends_with($oAccount->Email(), '@hotmail.com')) { - $oActions = \RainLoop\Api::Actions(); - try { - $aData = static::$auth ?: \SnappyMail\Crypt::DecryptFromJSON( - $oActions->StorageProvider()->Get($oAccount, StorageType::SESSION, \RainLoop\Utils::GetSessionToken()), - $oAccount->CryptKey() - ); - } catch (\Throwable $oException) { -// $oActions->Logger()->WriteException($oException, \LOG_ERR); - return; - } - if (!empty($aData['expires']) && !empty($aData['access_token']) && !empty($aData['refresh_token'])) { - if (\time() >= $aData['expires']) { - $iExpires = \time(); - $oO365 = $this->o365Connector(); - if ($oO365) { - $aRefreshTokenResponse = $oO365->getAccessToken( - \str_replace('{{tenant}}', $this->Config()->Get('plugin', 'tenant', 'common'), static::TOKEN_URI), - 'refresh_token', - array('refresh_token' => $aData['refresh_token']) - ); - if (!empty($aRefreshTokenResponse['result']['access_token'])) { - $aData['access_token'] = $aRefreshTokenResponse['result']['access_token']; - $aResponse['expires'] = $iExpires + $aResponse['expires_in']; - $oActions->StorageProvider()->Put($oAccount, StorageType::SESSION, \RainLoop\Utils::GetSessionToken(), - \SnappyMail\Crypt::EncryptToJSON($aData, $oAccount->CryptKey()) - ); - } - } - } - $oSettings->passphrase = $aData['access_token']; - \array_unshift($oSettings->SASLMechanisms, 'OAUTHBEARER', 'XOAUTH2'); - } - } - } + $tenant = $this->Config()->Get('plugin', 'tenant', 'common'); - protected function o365Connector() : ?\OAuth2\Client - { - $client_id = \trim($this->Config()->Get('plugin', 'client_id', '')); - $client_secret = \trim($this->Config()->getDecrypted('plugin', 'client_secret', '')); - if ($client_id && $client_secret) { - try - { - $oO365 = new \OAuth2\Client($client_id, $client_secret); - $oActions = \RainLoop\Api::Actions(); - $sProxy = $oActions->Config()->Get('labs', 'curl_proxy', ''); - if (\strlen($sProxy)) { - $oO365->setCurlOption(CURLOPT_PROXY, $sProxy); - $sProxyAuth = $oActions->Config()->Get('labs', 'curl_proxy_auth', ''); - if (\strlen($sProxyAuth)) { - $oO365->setCurlOption(CURLOPT_PROXYUSERPWD, $sProxyAuth); - } - } - return $oO365; - } - catch (\Exception $oException) - { - $oActions->Logger()->WriteException($oException, \LOG_ERR); - } - } - return null; - } -} + $aTokenWrap = $oO365->getAccessToken( + \str_replace('{{tenant}}', $tenant, static::TOKEN_URI), + 'authorization_code', + [ + 'code' => $_GET['code'], + 'redirect_uri' => $redirectUri + ] + ); + + if (!\is_array($aTokenWrap) || !isset($aTokenWrap['code'])) { + throw new \RuntimeException('Token request failed: ' . \json_encode($aTokenWrap)); + } + if (200 !== (int)$aTokenWrap['code']) { + $err = $aTokenWrap['result']['error'] ?? ''; + $desc = $aTokenWrap['result']['error_description'] ?? ''; + throw new \RuntimeException("Token HTTP {$aTokenWrap['code']}: {$err} / {$desc}"); + } + + $aToken = $aTokenWrap['result'] ?? []; + $accessToken = $aToken['access_token'] ?? ''; + $refreshToken = $aToken['refresh_token'] ?? ''; + $expiresIn = (int)($aToken['expires_in'] ?? 0); + $idToken = $aToken['id_token'] ?? ''; + + if ($accessToken === '') { + throw new \RuntimeException('access_token missing'); + } + + if ($refreshToken === '') { + throw new \RuntimeException('refresh_token missing'); + } + if ($idToken === '') { + // We rely on id_token to get email/sub without Graph. + throw new \RuntimeException('id_token missing (add openid email profile scopes)'); + } + + // Parse id_token (JWT) to get identity (sub + email) + $claims = $this->decodeJwtPayload($idToken); + if (!\is_array($claims)) { + throw new \RuntimeException('Cannot decode id_token payload'); + } + + $email = $claims['email'] ?? ($claims['preferred_username'] ?? ($claims['upn'] ?? '')); + $sub = $claims['sub'] ?? ''; + + if ($sub === '') { + throw new \RuntimeException('unknown id from id_token'); + } + if ($email === '') { + throw new \RuntimeException('unknown email address from id_token'); + } + + static::$auth = [ + 'access_token' => $accessToken, + 'refresh_token' => $refreshToken, + 'expires_in' => $expiresIn, + 'expires' => $iNow + $expiresIn + ]; + + // SnappyMail uses password as opaque string; plugin injects XOAUTH2 later. + $oPassword = new \SnappyMail\SensitiveString($sub); + $oAccount = $oActions->LoginProcess($email, $oPassword); + + if ($oAccount) { + $oActions->StorageProvider()->Put( + $oAccount, + StorageType::SESSION, + \RainLoop\Utils::GetSessionToken(), + \SnappyMail\Crypt::EncryptToJSON(static::$auth, $oAccount->CryptKey()) + ); + } + } + catch (\Throwable $e) { + $oActions->Logger()->WriteException($e, \LOG_ERR); + } + + $oActions->Location(\RainLoop\Utils::WebPath()); + exit; + } + + public function configMapping() : array + { + return [ + \RainLoop\Plugins\Property::NewInstance('client_id') + ->SetLabel('Client ID') + ->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING) + ->SetAllowedInJs(), + \RainLoop\Plugins\Property::NewInstance('client_secret') + ->SetLabel('Client Secret') + ->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING) + ->SetEncrypted(), + \RainLoop\Plugins\Property::NewInstance('tenant') + ->SetLabel('Tenant') + ->SetType(\RainLoop\Enumerations\PluginPropertyType::SELECTION) + ->SetDefaultValue(['common','consumers','organizations']) + ->SetAllowedInJs(), + \RainLoop\Plugins\Property::NewInstance('personal') + ->SetLabel('Use /LoginO365 redirect path') + ->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL) + ->SetDefaultValue(false) + ->SetAllowedInJs() + ]; + } + + public function clientLogin(\RainLoop\Model\Account $oAccount, \MailSo\Net\NetClient $oClient, \MailSo\Net\ConnectSettings $oSettings) : void + { + $email = \strtolower($oAccount->Email()); + + if ( + $oAccount instanceof MainAccount + && ( + \str_ends_with($email, '@hotmail.com') + || \str_ends_with($email, '@outlook.com') + || \str_ends_with($email, '@live.com') + ) + ) { + $oActions = \RainLoop\Api::Actions(); + + try { + $blob = $oActions->StorageProvider()->Get( + $oAccount, + StorageType::SESSION, + \RainLoop\Utils::GetSessionToken() + ); + + $aData = static::$auth ?: \SnappyMail\Crypt::DecryptFromJSON($blob, $oAccount->CryptKey()); + } catch (\Throwable $e) { + return; + } + + if (empty($aData['access_token']) || empty($aData['refresh_token']) || empty($aData['expires'])) { + return; + } + + // Refresh if expired + if (\time() >= (int)$aData['expires']) { + $oO365 = $this->o365Connector(); + if ($oO365) { + $tenant = $this->Config()->Get('plugin', 'tenant', 'common'); + $aRefreshWrap = $oO365->getAccessToken( + \str_replace('{{tenant}}', $tenant, static::TOKEN_URI), + 'refresh_token', + ['refresh_token' => $aData['refresh_token']] + ); + + if (\is_array($aRefreshWrap) && isset($aRefreshWrap['code']) && 200 === (int)$aRefreshWrap['code']) { + $r = $aRefreshWrap['result'] ?? []; + if (!empty($r['access_token'])) { + $aData['access_token'] = $r['access_token']; + } + if (!empty($r['refresh_token'])) { + $aData['refresh_token'] = $r['refresh_token']; + } + $expiresIn = (int)($r['expires_in'] ?? 0); + if ($expiresIn > 0) { + $aData['expires'] = \time() + $expiresIn; + } + + $oActions->StorageProvider()->Put( + $oAccount, + StorageType::SESSION, + \RainLoop\Utils::GetSessionToken(), + \SnappyMail\Crypt::EncryptToJSON($aData, $oAccount->CryptKey()) + ); + } + } + } + + // Inject XOAUTH2/OAUTHBEARER + $oSettings->passphrase = $aData['access_token']; + \array_unshift($oSettings->SASLMechanisms, 'OAUTHBEARER', 'XOAUTH2'); + } + } + + protected function o365Connector() : ?\OAuth2\Client + { + $client_id = \trim($this->Config()->Get('plugin', 'client_id', '')); + $client_secret = \trim($this->Config()->getDecrypted('plugin', 'client_secret', '')); + + if ($client_id && $client_secret) { + try { + $oO365 = new \OAuth2\Client($client_id, $client_secret); + + $oActions = \RainLoop\Api::Actions(); + $sProxy = $oActions->Config()->Get('labs', 'curl_proxy', ''); + if (\strlen($sProxy)) { + $oO365->setCurlOption(CURLOPT_PROXY, $sProxy); + $sProxyAuth = $oActions->Config()->Get('labs', 'curl_proxy_auth', ''); + if (\strlen($sProxyAuth)) { + $oO365->setCurlOption(CURLOPT_PROXYUSERPWD, $sProxyAuth); + } + } + + return $oO365; + } catch (\Throwable $e) { + \RainLoop\Api::Actions()->Logger()->WriteException($e, \LOG_ERR); + } + } + + return null; + } + + private function decodeJwtPayload(string $jwt) : ?array + { + $parts = \explode('.', $jwt); + if (\count($parts) < 2) { + return null; + } + $payload = $parts[1]; + $payload .= \str_repeat('=', (4 - (\strlen($payload) % 4)) % 4); + $json = \base64_decode(\strtr($payload, '-_', '+/')); + if ($json === false) { + return null; + } + $data = \json_decode($json, true); + return \is_array($data) ? $data : null; + } +} \ No newline at end of file