Corrected OAuth2 login flow and added better error handling

This commit is contained in:
Kristofer Nilsson 2025-12-19 11:46:35 +01:00
parent ba496919dd
commit b483f2ee76
2 changed files with 348 additions and 270 deletions

View file

@ -1,47 +1,45 @@
(rl => { ((rl) => {
const client_id = rl.pluginSettingsGet('login-o365', 'client_id'), 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 // 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"),
tenant = rl.pluginSettingsGet('login-o365', 'tenant'),
login = () => { login = () => {
document.location = 'https://login.microsoftonline.com/'+tenant+'/oauth2/v2.0/authorize?' + (new URLSearchParams({ document.location = "https://login.microsoftonline.com/" +
response_type: 'code', tenant +
"/oauth2/v2.0/authorize?" +
new URLSearchParams({
response_type: "code",
client_id: client_id, client_id: client_id,
redirect_uri: document.location.href.replace(/\/$/, '') + '/' + query + 'LoginO365', redirect_uri:
document.location.href.replace(/\/$/, "") + "/LoginO365",
scope: [ scope: [
// Associate personal info // Associate personal info
'openid', "openid",
'offline_access', "offline_access",
'email', "email",
'profile', "profile",
// Access IMAP and SMTP through OAUTH // Access IMAP and SMTP through OAUTH
'https://graph.microsoft.com/IMAP.AccessAsUser.All', "https://outlook.office.com/IMAP.AccessAsUser.All",
// 'https://graph.microsoft.com/Mail.ReadWrite' "https://outlook.office.com/SMTP.Send",
'https://graph.microsoft.com/Mail.Send' ].join(" "),
/* // Legacy: state: "o365",
'https://outlook.office.com/SMTP.Send', access_type: "offline_access"
'https://outlook.office.com/IMAP.AccessAsUser.All' // prompt: "consent",
*/ });
].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'
}));
}; };
if (client_id) { if (client_id) {
addEventListener('sm-user-login', e => { addEventListener("sm-user-login", (e) => {
if (event.detail.get('Email').includes('@hotmail.com')) { const email = (e.detail.get("Email") || "").toLowerCase();
if (/@(outlook\.com|hotmail\.com|live\.com)$/.test(email)) {
e.preventDefault(); e.preventDefault();
login(); login();
} }
}); });
addEventListener('rl-view-model', e => { addEventListener("rl-view-model", (e) => {
if ('Login' === e.detail.viewModelTemplateID) { if ("Login" === e.detail.viewModelTemplateID) {
const const
container = e.detail.viewModelDom.querySelector('#plugin-Login-BottomControlGroup'), container = e.detail.viewModelDom.querySelector("#plugin-Login-BottomControlGroup"),
btn = Element.fromHTML('<button type="button">Outlook</button>'), btn = Element.fromHTML('<button type="button">Outlook</button>'),
div = Element.fromHTML('<div class="controls"></div>'); div = Element.fromHTML('<div class="controls"></div>');
btn.onclick = login; btn.onclick = login;
@ -50,5 +48,4 @@
} }
}); });
} }
})(window.rl); })(window.rl);

View file

@ -1,15 +1,20 @@
<?php <?php
/** /**
* Microsoft requires an Azure account that has an active subscription * SnappyMail login-o365 plugin
* I'm not going to pay, so feel free to fix this code yourself. * You need to register an app in Azure portal and add
* https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth * a secret, redirect URIs and the following API permissions:
* https://answers.microsoft.com/en-us/msoffice/forum/all/configuration-for-imap-pop-and-smtp-with-oauth-in/3db47d43-25ac-4e0b-b957-22585e6caf15 * https://outlook.office.com/IMAP.AccessAsUser.All
* * https://outlook.office.com/SMTP.Send
* https://portal.azure.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/RegisteredApps * openid offline_access email profile
*
* https://learn.microsoft.com/en-us/entra/identity-platform/reply-url#query-parameter-support-in-redirect-uris * https://learn.microsoft.com/en-us/entra/identity-platform/reply-url#query-parameter-support-in-redirect-uris
* Azure: redirect_uri=https://{DOMAIN}/?LoginO365 * Azure: redirect_uri=https://{DOMAIN}/?LoginO365
* Personal: redirect_uri=https://{DOMAIN}/LoginO365 * Personal: redirect_uri=https://{DOMAIN}/LoginO365
*
* If running behind nginx reverse proxy you might
* need to add the following to your nginx config:
* location = /LoginO365 {
* return 302 /?LoginO365&$args;
* }
*/ */
use RainLoop\Model\MainAccount; use RainLoop\Model\MainAccount;
@ -20,14 +25,14 @@ class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin
const const
NAME = 'Office365/Outlook OAuth2', NAME = 'Office365/Outlook OAuth2',
VERSION = '0.3', VERSION = '0.3',
RELEASE = '2024-09-29', RELEASE = '2025-12-18',
REQUIRED = '2.36.1', REQUIRED = '2.36.1',
CATEGORY = 'Login', CATEGORY = 'Login',
DESCRIPTION = 'Office365/Outlook IMAP, Sieve & SMTP login using RFC 7628 OAuth2'; DESCRIPTION = 'Office365/Outlook IMAP, Sieve & SMTP login using RFC 7628 OAuth2';
// https://login.microsoftonline.com/{{tenant}}/v2.0/.well-known/openid-configuration // v2 endpoints
const const
LOGIN_URI = 'https://login.microsoftonline.com/{{tenant}}/oauth2/v2.0/auth', AUTH_URI = 'https://login.microsoftonline.com/{{tenant}}/oauth2/v2.0/authorize',
TOKEN_URI = 'https://login.microsoftonline.com/{{tenant}}/oauth2/v2.0/token'; TOKEN_URI = 'https://login.microsoftonline.com/{{tenant}}/oauth2/v2.0/token';
private static ?array $auth = null; private static ?array $auth = null;
@ -48,7 +53,6 @@ class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin
public function httpPaths(array &$aPaths) : void public function httpPaths(array &$aPaths) : void
{ {
// Personal accounts workaround
if (!empty($_SERVER['PATH_INFO']) && \str_ends_with($_SERVER['PATH_INFO'], 'LoginO365')) { if (!empty($_SERVER['PATH_INFO']) && \str_ends_with($_SERVER['PATH_INFO'], 'LoginO365')) {
$aPaths = ['LoginO365']; $aPaths = ['LoginO365'];
} }
@ -70,85 +74,117 @@ class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin
try try
{ {
if (isset($_GET['error'])) { if (isset($_GET['error'])) {
throw new \RuntimeException("{$_GET['error']}: {$_GET['error_description']}"); $desc = $_GET['error_description'] ?? '';
throw new \RuntimeException("{$_GET['error']}: {$desc}");
} }
// Must have code + state
if (!isset($_GET['code']) || empty($_GET['state']) || 'o365' !== $_GET['state']) { if (!isset($_GET['code']) || empty($_GET['state']) || 'o365' !== $_GET['state']) {
$oActions->Location(\RainLoop\Utils::WebPath()); $oActions->Location(\RainLoop\Utils::WebPath());
exit; exit;
} }
$oO365 = $this->o365Connector(); $oO365 = $this->o365Connector();
if (!$oO365) { if (!$oO365) {
$oActions->Location(\RainLoop\Utils::WebPath()); $oActions->Location(\RainLoop\Utils::WebPath());
exit; exit;
} }
$iExpires = \time(); $iNow = \time();
$aResponse = $oO365->getAccessToken(
\str_replace('{{tenant}}', $this->Config()->Get('plugin', 'tenant', 'common'), static::TOKEN_URI), // 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');
$host = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '';
if (!$host) {
throw new \RuntimeException('Cannot determine HTTP_HOST');
}
$base = $scheme . '://' . $host;
// IMPORTANT: default personal=false to match the JS default behavior
$personal = (bool)$this->Config()->Get('plugin', 'personal', false);
$redirectUri = $personal ? ($base . '/?LoginO365') : ($base . '/LoginO365');
$tenant = $this->Config()->Get('plugin', 'tenant', 'common');
$aTokenWrap = $oO365->getAccessToken(
\str_replace('{{tenant}}', $tenant, static::TOKEN_URI),
'authorization_code', 'authorization_code',
array( [
'code' => $_GET['code'], 'code' => $_GET['code'],
'redirect_uri' => $oHttp->GetFullUrl().'?LoginO365' 'redirect_uri' => $redirectUri
) ]
);
if (200 != $aResponse['code']) {
if (isset($aResponse['result']['error'])) {
throw new \RuntimeException(
$aResponse['code']
. ': '
. $aResponse['result']['error']
. ' / '
. $aResponse['result']['error_description']
); );
if (!\is_array($aTokenWrap) || !isset($aTokenWrap['code'])) {
throw new \RuntimeException('Token request failed: ' . \json_encode($aTokenWrap));
} }
throw new \RuntimeException("HTTP: {$aResponse['code']}"); if (200 !== (int)$aTokenWrap['code']) {
$err = $aTokenWrap['result']['error'] ?? '';
$desc = $aTokenWrap['result']['error_description'] ?? '';
throw new \RuntimeException("Token HTTP {$aTokenWrap['code']}: {$err} / {$desc}");
} }
$aResponse = $aResponse['result'];
if (empty($aResponse['access_token'])) { $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'); throw new \RuntimeException('access_token missing');
} }
if (empty($aResponse['refresh_token'])) {
if ($refreshToken === '') {
throw new \RuntimeException('refresh_token missing'); throw new \RuntimeException('refresh_token missing');
} }
if ($idToken === '') {
$sAccessToken = $aResponse['access_token']; // We rely on id_token to get email/sub without Graph.
$iExpires += $aResponse['expires_in']; throw new \RuntimeException('id_token missing (add openid email profile scopes)');
$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'])) { // Parse id_token (JWT) to get identity (sub + email)
throw new \RuntimeException('unknown id'); $claims = $this->decodeJwtPayload($idToken);
if (!\is_array($claims)) {
throw new \RuntimeException('Cannot decode id_token payload');
} }
if (empty($aUserInfo['email'])) {
throw new \RuntimeException('unknown email address'); $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 = [ static::$auth = [
'access_token' => $sAccessToken, 'access_token' => $accessToken,
'refresh_token' => $aResponse['refresh_token'], 'refresh_token' => $refreshToken,
'expires_in' => $aResponse['expires_in'], 'expires_in' => $expiresIn,
'expires' => $iExpires 'expires' => $iNow + $expiresIn
]; ];
$oPassword = new \SnappyMail\SensitiveString($aUserInfo['id']); // SnappyMail uses password as opaque string; plugin injects XOAUTH2 later.
$oAccount = $oActions->LoginProcess($aUserInfo['email'], $oPassword); $oPassword = new \SnappyMail\SensitiveString($sub);
// $oAccount = MainAccount::NewInstanceFromCredentials($oActions, $aUserInfo['email'], $aUserInfo['email'], $oPassword, true); $oAccount = $oActions->LoginProcess($email, $oPassword);
if ($oAccount) { if ($oAccount) {
// $oActions->SetMainAuthAccount($oAccount); $oActions->StorageProvider()->Put(
// $oActions->SetAuthToken($oAccount); $oAccount,
$oActions->StorageProvider()->Put($oAccount, StorageType::SESSION, \RainLoop\Utils::GetSessionToken(), StorageType::SESSION,
\RainLoop\Utils::GetSessionToken(),
\SnappyMail\Crypt::EncryptToJSON(static::$auth, $oAccount->CryptKey()) \SnappyMail\Crypt::EncryptToJSON(static::$auth, $oAccount->CryptKey())
); );
} }
} }
catch (\Exception $oException) catch (\Throwable $e) {
{ $oActions->Logger()->WriteException($e, \LOG_ERR);
$oActions->Logger()->WriteException($oException, \LOG_ERR);
} }
$oActions->Location(\RainLoop\Utils::WebPath()); $oActions->Location(\RainLoop\Utils::WebPath());
exit; exit;
} }
@ -156,77 +192,106 @@ class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin
public function configMapping() : array public function configMapping() : array
{ {
return [ 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') \RainLoop\Plugins\Property::NewInstance('client_id')
->SetLabel('Client ID') ->SetLabel('Client ID')
->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING) ->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING)
->SetAllowedInJs() ->SetAllowedInJs(),
->SetDescription('https://github.com/the-djmaze/snappymail/wiki/FAQ#o365'),
\RainLoop\Plugins\Property::NewInstance('client_secret') \RainLoop\Plugins\Property::NewInstance('client_secret')
->SetLabel('Client Secret') ->SetLabel('Client Secret')
->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING) ->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING)
->SetEncrypted(), ->SetEncrypted(),
\RainLoop\Plugins\Property::NewInstance('tenant_id') \RainLoop\Plugins\Property::NewInstance('tenant')
->SetLabel('Tenant ID') ->SetLabel('Tenant')
->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING),
\RainLoop\Plugins\Property::NewInstance('tenant')->SetLabel('Tenant')
->SetType(\RainLoop\Enumerations\PluginPropertyType::SELECTION) ->SetType(\RainLoop\Enumerations\PluginPropertyType::SELECTION)
->SetDefaultValue(['common','consumers','organizations']) ->SetDefaultValue(['common','consumers','organizations'])
->SetAllowedInJs(),
\RainLoop\Plugins\Property::NewInstance('personal')
->SetLabel('Use /LoginO365 redirect path')
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL)
->SetDefaultValue(false)
->SetAllowedInJs() ->SetAllowedInJs()
]; ];
} }
public function clientLogin(\RainLoop\Model\Account $oAccount, \MailSo\Net\NetClient $oClient, \MailSo\Net\ConnectSettings $oSettings) : void 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')) { $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(); $oActions = \RainLoop\Api::Actions();
try { try {
$aData = static::$auth ?: \SnappyMail\Crypt::DecryptFromJSON( $blob = $oActions->StorageProvider()->Get(
$oActions->StorageProvider()->Get($oAccount, StorageType::SESSION, \RainLoop\Utils::GetSessionToken()), $oAccount,
$oAccount->CryptKey() StorageType::SESSION,
\RainLoop\Utils::GetSessionToken()
); );
} catch (\Throwable $oException) {
// $oActions->Logger()->WriteException($oException, \LOG_ERR); $aData = static::$auth ?: \SnappyMail\Crypt::DecryptFromJSON($blob, $oAccount->CryptKey());
} catch (\Throwable $e) {
return; return;
} }
if (!empty($aData['expires']) && !empty($aData['access_token']) && !empty($aData['refresh_token'])) {
if (\time() >= $aData['expires']) { if (empty($aData['access_token']) || empty($aData['refresh_token']) || empty($aData['expires'])) {
$iExpires = \time(); return;
}
// Refresh if expired
if (\time() >= (int)$aData['expires']) {
$oO365 = $this->o365Connector(); $oO365 = $this->o365Connector();
if ($oO365) { if ($oO365) {
$aRefreshTokenResponse = $oO365->getAccessToken( $tenant = $this->Config()->Get('plugin', 'tenant', 'common');
\str_replace('{{tenant}}', $this->Config()->Get('plugin', 'tenant', 'common'), static::TOKEN_URI), $aRefreshWrap = $oO365->getAccessToken(
\str_replace('{{tenant}}', $tenant, static::TOKEN_URI),
'refresh_token', 'refresh_token',
array('refresh_token' => $aData['refresh_token']) ['refresh_token' => $aData['refresh_token']]
); );
if (!empty($aRefreshTokenResponse['result']['access_token'])) {
$aData['access_token'] = $aRefreshTokenResponse['result']['access_token']; if (\is_array($aRefreshWrap) && isset($aRefreshWrap['code']) && 200 === (int)$aRefreshWrap['code']) {
$aResponse['expires'] = $iExpires + $aResponse['expires_in']; $r = $aRefreshWrap['result'] ?? [];
$oActions->StorageProvider()->Put($oAccount, StorageType::SESSION, \RainLoop\Utils::GetSessionToken(), 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()) \SnappyMail\Crypt::EncryptToJSON($aData, $oAccount->CryptKey())
); );
} }
} }
} }
// Inject XOAUTH2/OAUTHBEARER
$oSettings->passphrase = $aData['access_token']; $oSettings->passphrase = $aData['access_token'];
\array_unshift($oSettings->SASLMechanisms, 'OAUTHBEARER', 'XOAUTH2'); \array_unshift($oSettings->SASLMechanisms, 'OAUTHBEARER', 'XOAUTH2');
} }
} }
}
protected function o365Connector() : ?\OAuth2\Client protected function o365Connector() : ?\OAuth2\Client
{ {
$client_id = \trim($this->Config()->Get('plugin', 'client_id', '')); $client_id = \trim($this->Config()->Get('plugin', 'client_id', ''));
$client_secret = \trim($this->Config()->getDecrypted('plugin', 'client_secret', '')); $client_secret = \trim($this->Config()->getDecrypted('plugin', 'client_secret', ''));
if ($client_id && $client_secret) { if ($client_id && $client_secret) {
try try {
{
$oO365 = new \OAuth2\Client($client_id, $client_secret); $oO365 = new \OAuth2\Client($client_id, $client_secret);
$oActions = \RainLoop\Api::Actions(); $oActions = \RainLoop\Api::Actions();
$sProxy = $oActions->Config()->Get('labs', 'curl_proxy', ''); $sProxy = $oActions->Config()->Get('labs', 'curl_proxy', '');
if (\strlen($sProxy)) { if (\strlen($sProxy)) {
@ -236,13 +301,29 @@ class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin
$oO365->setCurlOption(CURLOPT_PROXYUSERPWD, $sProxyAuth); $oO365->setCurlOption(CURLOPT_PROXYUSERPWD, $sProxyAuth);
} }
} }
return $oO365; return $oO365;
} } catch (\Throwable $e) {
catch (\Exception $oException) \RainLoop\Api::Actions()->Logger()->WriteException($e, \LOG_ERR);
{
$oActions->Logger()->WriteException($oException, \LOG_ERR);
} }
} }
return null; 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;
}
} }