Merge pull request #1 from kristofer84/cursor/o365-additional-account-support-18e6

O365 additional account support
This commit is contained in:
Kristofer Nilsson 2025-12-22 13:12:56 +01:00 committed by GitHub
commit 2cf752931e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 501 additions and 95 deletions

View file

@ -1,38 +1,37 @@
((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 allowAnyDomain = !!rl.pluginSettingsGet("login-o365", "allow_any_domain"),
tenant = rl.pluginSettingsGet("login-o365", "tenant"), isSupportedEmail = (email) => {
login = () => { email = (email || "").toLowerCase();
document.location = "https://login.microsoftonline.com/" + if (!email.includes("@")) return false;
tenant + if (allowAnyDomain) return true;
"/oauth2/v2.0/authorize?" + return /@(outlook\.com|hotmail\.com|live\.com)$/.test(email);
new URLSearchParams({ },
response_type: "code", startOAuth = (op, opts = {}) => {
client_id: client_id, const email = (opts.email || "").toLowerCase();
redirect_uri: const name = opts.name || "";
document.location.href.replace(/\/$/, "") + "/LoginO365", const returnHash = opts.return || "";
scope: [
// Associate personal info // Server mints a signed state + correct redirect_uri and returns full authUrl.
"openid", rl.pluginRemoteRequest((iError, data) => {
"offline_access", const url = data?.Result?.authUrl;
"email", if (!iError && url) {
"profile", document.location = url;
// Access IMAP and SMTP through OAUTH }
"https://outlook.office.com/IMAP.AccessAsUser.All", }, "LoginO365AuthUrl", {
"https://outlook.office.com/SMTP.Send", op: op,
].join(" "), email: email,
state: "o365", name: name,
access_type: "offline_access" return: returnHash,
// prompt: "consent",
}); });
}; };
if (client_id) { if (client_id) {
addEventListener("sm-user-login", (e) => { addEventListener("sm-user-login", (e) => {
const email = (e.detail.get("Email") || "").toLowerCase(); const email = (e.detail.get("Email") || "").toLowerCase();
if (/@(outlook\.com|hotmail\.com|live\.com)$/.test(email)) { if (isSupportedEmail(email)) {
e.preventDefault(); e.preventDefault();
login(); startOAuth("login", { email });
} }
}); });
@ -42,10 +41,51 @@
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 = () => {
// Best-effort: try to read the email field if present.
const input = e.detail.viewModelDom.querySelector('input[type="email"], input[name="Email"], input[name="email"], input');
const email = (input?.value || "").toLowerCase();
if (!email || isSupportedEmail(email)) {
startOAuth("login", { email });
}
};
div.append(btn); div.append(btn);
container && container.append(div); container && container.append(div);
} }
// "Add account" popup (Settings → Accounts → Add account)
if ("Account" === e.detail.viewModelTemplateID) {
// Only for the "Add account" mode, not "Edit account".
if (typeof e.detail.isNew === "function" && !e.detail.isNew()) {
return;
}
const root = e.detail.viewModelDom;
if (!root) return;
const footer = root.querySelector("footer");
const form = root.querySelector("#accountform");
const addButton = root.querySelector("button.buttonAddAccount");
if (!footer || !form || !addButton) return;
// Avoid inserting duplicates when view model is re-rendered.
if (root.querySelector(".plugin-o365-add-account")) return;
const btn = Element.fromHTML(
'<button type="button" class="btn plugin-o365-add-account" style="margin-left: 6px;">Outlook</button>'
);
btn.onclick = () => {
const email = (form.querySelector('input[name="email"]')?.value || "").trim().toLowerCase();
const name = (form.querySelector('input[name="name"]')?.value || "").trim();
if (!email || !isSupportedEmail(email)) {
return;
}
startOAuth("add", { email, name, return: document.location.hash || "#/settings/accounts" });
};
// Put the button next to the default Add Account submit button.
footer.insertBefore(btn, addButton.nextSibling);
}
}); });
} }
})(window.rl); })(window.rl);

View file

@ -7,8 +7,8 @@
* https://outlook.office.com/SMTP.Send * https://outlook.office.com/SMTP.Send
* openid offline_access email profile * 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 * Query: redirect_uri=https://{DOMAIN}/?LoginO365
* Personal: redirect_uri=https://{DOMAIN}/LoginO365 * Path: redirect_uri=https://{DOMAIN}/LoginO365
* *
* If running behind nginx reverse proxy you might * If running behind nginx reverse proxy you might
* need to add the following to your nginx config: * need to add the following to your nginx config:
@ -24,8 +24,8 @@ class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin
{ {
const const
NAME = 'Office365/Outlook OAuth2', NAME = 'Office365/Outlook OAuth2',
VERSION = '0.3', VERSION = '0.4',
RELEASE = '2025-12-18', RELEASE = '2025-12-22',
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';
@ -35,7 +35,17 @@ class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin
AUTH_URI = 'https://login.microsoftonline.com/{{tenant}}/oauth2/v2.0/authorize', 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; /**
* In-request cache of decrypted token bundles, keyed by lowercase email.
* This avoids re-decrypting the same blob multiple times during a single request.
*
* Shape:
* [
* 'user@outlook.com' => ['access_token'=>..., 'refresh_token'=>..., 'expires'=>..., 'expires_in'=>...],
* ...
* ]
*/
private static array $auth = [];
public function Init() : void public function Init() : void
{ {
@ -46,9 +56,14 @@ class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin
$this->addHook('sieve.before-login', 'clientLogin'); $this->addHook('sieve.before-login', 'clientLogin');
$this->addPartHook('LoginO365', 'ServiceLoginO365'); $this->addPartHook('LoginO365', 'ServiceLoginO365');
// Used by JS to obtain an auth URL with signed state (for both login + add-account flows).
$this->addJsonHook('LoginO365AuthUrl', 'DoLoginO365AuthUrl');
// Prevent Disallowed Sec-Fetch Dest: document Mode: navigate Site: cross-site User: true // Prevent Disallowed Sec-Fetch Dest: document Mode: navigate Site: cross-site User: true
$this->addHook('filter.http-paths', 'httpPaths'); $this->addHook('filter.http-paths', 'httpPaths');
// Cleanup: when an additional account is removed, also remove its encrypted refresh token bundle.
$this->addHook('json.after-AccountDelete', 'afterAccountDelete');
} }
public function httpPaths(array &$aPaths) : void public function httpPaths(array &$aPaths) : void
@ -79,7 +94,7 @@ class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin
} }
// Must have code + state // Must have code + state
if (!isset($_GET['code']) || empty($_GET['state']) || 'o365' !== $_GET['state']) { if (!isset($_GET['code']) || empty($_GET['state'])) {
$oActions->Location(\RainLoop\Utils::WebPath()); $oActions->Location(\RainLoop\Utils::WebPath());
exit; exit;
} }
@ -92,23 +107,17 @@ class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin
$iNow = \time(); $iNow = \time();
// Build absolute base URL (works behind nginx reverse proxy) $redirectUri = $this->redirectUri();
$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'); $tenant = $this->Config()->Get('plugin', 'tenant', 'common');
$state = (string) $_GET['state'];
$statePayload = $this->verifyAndConsumeState($state);
if (!$statePayload) {
$oActions->Location(\RainLoop\Utils::WebPath());
exit;
}
$aTokenWrap = $oO365->getAccessToken( $aTokenWrap = $oO365->getAccessToken(
\str_replace('{{tenant}}', $tenant, static::TOKEN_URI), \str_replace('{{tenant}}', $tenant, static::TOKEN_URI),
'authorization_code', 'authorization_code',
@ -161,13 +170,69 @@ class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin
throw new \RuntimeException('unknown email address from id_token'); throw new \RuntimeException('unknown email address from id_token');
} }
static::$auth = [ if (!$this->isSupportedEmail(\strtolower($email))) {
throw new \RuntimeException('Unsupported email domain for this plugin');
}
$tokenBundle = [
'access_token' => $accessToken, 'access_token' => $accessToken,
'refresh_token' => $refreshToken, 'refresh_token' => $refreshToken,
'expires_in' => $expiresIn, 'expires_in' => $expiresIn,
'expires' => $iNow + $expiresIn 'expires' => $iNow + $expiresIn
]; ];
$op = $statePayload['op'] ?? 'login';
if ('add' === $op) {
$oMainAccount = $oActions->getMainAccountFromToken(false);
if (!$oMainAccount) {
throw new \RuntimeException('Add-account flow requires logged in main account');
}
if (!empty($statePayload['main']) && $statePayload['main'] !== $oMainAccount->Email()) {
throw new \RuntimeException('Add-account state does not match current main account');
}
// Store token bundle encrypted with MAIN account crypt key (never store refresh_token unencrypted).
// This is later used by imap/smtp/sieve.before-login for *additional* accounts.
$this->storeAccountTokens($oMainAccount, $email, $tokenBundle);
// Create/validate an AdditionalAccount entry exactly like SnappyMail expects in "additionalaccounts".
// We set the "password" to the OAuth subject (sub) as an opaque secret; the plugin will inject XOAUTH2.
$oPassword = new \SnappyMail\SensitiveString($sub);
$oAdditional = $oActions->LoginProcess($email, $oPassword, false);
if (!$oAdditional instanceof \RainLoop\Model\AdditionalAccount) {
throw new \RuntimeException('Failed to create additional account');
}
$asciiEmail = \SnappyMail\IDN::emailToAscii($oAdditional->Email());
if ($asciiEmail === $oMainAccount->Email()) {
throw new \RuntimeException('Cannot add main account as additional');
}
$aAccounts = $oActions->GetAccounts($oMainAccount);
$aEntry = $oAdditional->asTokenArray($oMainAccount);
if (!empty($statePayload['name']) && \is_string($statePayload['name'])) {
$aEntry['name'] = \trim($statePayload['name']);
} else if (isset($aAccounts[$asciiEmail]['name'])) {
// Preserve previous custom label if re-adding/updating.
$aEntry['name'] = (string) $aAccounts[$asciiEmail]['name'];
}
$aAccounts[$asciiEmail] = $aEntry;
$oActions->SetAccounts($oMainAccount, $aAccounts);
// Cache for this request (used during LoginProcess() above and any subsequent logins).
static::$auth[\strtolower($asciiEmail)] = $tokenBundle;
$returnHash = '';
if (!empty($statePayload['return']) && \is_string($statePayload['return']) && \str_starts_with($statePayload['return'], '#')) {
$returnHash = $statePayload['return'];
}
$oActions->Location(\RainLoop\Utils::WebPath() . $returnHash);
exit;
}
// Default: "login" flow (preserve existing behavior)
static::$auth[\strtolower($email)] = $tokenBundle;
// SnappyMail uses password as opaque string; plugin injects XOAUTH2 later. // SnappyMail uses password as opaque string; plugin injects XOAUTH2 later.
$oPassword = new \SnappyMail\SensitiveString($sub); $oPassword = new \SnappyMail\SensitiveString($sub);
$oAccount = $oActions->LoginProcess($email, $oPassword); $oAccount = $oActions->LoginProcess($email, $oPassword);
@ -177,7 +242,7 @@ class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin
$oAccount, $oAccount,
StorageType::SESSION, StorageType::SESSION,
\RainLoop\Utils::GetSessionToken(), \RainLoop\Utils::GetSessionToken(),
\SnappyMail\Crypt::EncryptToJSON(static::$auth, $oAccount->CryptKey()) \SnappyMail\Crypt::EncryptToJSON($tokenBundle, $oAccount->CryptKey())
); );
} }
} }
@ -206,7 +271,14 @@ class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin
->SetDefaultValue(['common','consumers','organizations']) ->SetDefaultValue(['common','consumers','organizations'])
->SetAllowedInJs(), ->SetAllowedInJs(),
\RainLoop\Plugins\Property::NewInstance('personal') \RainLoop\Plugins\Property::NewInstance('personal')
->SetLabel('Use /LoginO365 redirect path') // When true: redirect URI uses query parameter form "/?LoginO365" (Azure supports it).
// When false: redirect URI uses path form "/LoginO365" (useful behind reverse proxies).
->SetLabel('Use "/?LoginO365" redirect URI')
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL)
->SetDefaultValue(false)
->SetAllowedInJs(),
\RainLoop\Plugins\Property::NewInstance('allow_any_domain')
->SetLabel('Allow any domain (not only outlook.com/hotmail.com/live.com)')
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL) ->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL)
->SetDefaultValue(false) ->SetDefaultValue(false)
->SetAllowedInJs() ->SetAllowedInJs()
@ -217,34 +289,45 @@ class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin
{ {
$email = \strtolower($oAccount->Email()); $email = \strtolower($oAccount->Email());
if ( if (!$this->isSupportedEmail($email)) {
$oAccount instanceof MainAccount return;
&& ( }
\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();
$aData = static::$auth[$email] ?? null;
if (!$aData) {
try { try {
if ($oAccount instanceof MainAccount) {
$blob = $oActions->StorageProvider()->Get( $blob = $oActions->StorageProvider()->Get(
$oAccount, $oAccount,
StorageType::SESSION, StorageType::SESSION,
\RainLoop\Utils::GetSessionToken() \RainLoop\Utils::GetSessionToken()
); );
$aData = \SnappyMail\Crypt::DecryptFromJSON($blob, $oAccount->CryptKey());
$aData = static::$auth ?: \SnappyMail\Crypt::DecryptFromJSON($blob, $oAccount->CryptKey()); } else if ($oAccount instanceof \RainLoop\Model\AdditionalAccount) {
$oMain = $oActions->getMainAccountFromToken(false);
if (!$oMain) {
return;
}
$blob = $oActions->StorageProvider()->Get(
$oMain,
StorageType::CONFIG,
$this->tokenStorageKey($email)
);
$aData = \SnappyMail\Crypt::DecryptFromJSON($blob, $oMain->CryptKey());
}
} catch (\Throwable $e) { } catch (\Throwable $e) {
return; return;
} }
}
if (empty($aData['access_token']) || empty($aData['refresh_token']) || empty($aData['expires'])) { if (empty($aData['access_token']) || empty($aData['refresh_token']) || empty($aData['expires'])) {
return; return;
} }
// Refresh if expired // Refresh if expired (or close to expiry)
if (\time() >= (int)$aData['expires']) { if (\time() >= ((int)$aData['expires'] - 30)) {
$oO365 = $this->o365Connector(); $oO365 = $this->o365Connector();
if ($oO365) { if ($oO365) {
$tenant = $this->Config()->Get('plugin', 'tenant', 'common'); $tenant = $this->Config()->Get('plugin', 'tenant', 'common');
@ -265,22 +348,57 @@ class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin
$expiresIn = (int)($r['expires_in'] ?? 0); $expiresIn = (int)($r['expires_in'] ?? 0);
if ($expiresIn > 0) { if ($expiresIn > 0) {
$aData['expires'] = \time() + $expiresIn; $aData['expires'] = \time() + $expiresIn;
$aData['expires_in'] = $expiresIn;
} }
// Persist updated bundle (encrypted).
if ($oAccount instanceof MainAccount) {
$oActions->StorageProvider()->Put( $oActions->StorageProvider()->Put(
$oAccount, $oAccount,
StorageType::SESSION, StorageType::SESSION,
\RainLoop\Utils::GetSessionToken(), \RainLoop\Utils::GetSessionToken(),
\SnappyMail\Crypt::EncryptToJSON($aData, $oAccount->CryptKey()) \SnappyMail\Crypt::EncryptToJSON($aData, $oAccount->CryptKey())
); );
} else if ($oAccount instanceof \RainLoop\Model\AdditionalAccount) {
$oMain = $oActions->getMainAccountFromToken(false);
if ($oMain) {
$oActions->StorageProvider()->Put(
$oMain,
StorageType::CONFIG,
$this->tokenStorageKey($email),
\SnappyMail\Crypt::EncryptToJSON($aData, $oMain->CryptKey())
);
}
}
} }
} }
} }
static::$auth[$email] = $aData;
// Inject XOAUTH2/OAUTHBEARER // 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');
} }
/**
* Server-side cleanup hook: after a successful AccountDelete, remove stored token bundle for that email.
* This prevents leaving encrypted refresh tokens behind when an additional account is removed.
*/
public function afterAccountDelete(array &$aResponse) : void
{
if (empty($aResponse['Result'])) {
return;
}
$oActions = \RainLoop\Api::Actions();
$oMain = $oActions->getMainAccountFromToken(false);
if (!$oMain) {
return;
}
$email = \strtolower(\SnappyMail\IDN::emailToAscii(\trim((string) $oActions->GetActionParam('emailToDelete', ''))));
if ($email && $this->isSupportedEmail($email)) {
$oActions->StorageProvider()->Clear($oMain, StorageType::CONFIG, $this->tokenStorageKey($email));
}
} }
protected function o365Connector() : ?\OAuth2\Client protected function o365Connector() : ?\OAuth2\Client
@ -326,4 +444,252 @@ class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin
$data = \json_decode($json, true); $data = \json_decode($json, true);
return \is_array($data) ? $data : null; return \is_array($data) ? $data : null;
} }
/**
* JSON action called by JS to obtain an MS authorize URL with signed state.
* This avoids exposing any signing secret to JS and keeps redirect_uri consistent with server logic.
*/
public function DoLoginO365AuthUrl() : array
{
$oActions = \RainLoop\Api::Actions();
$op = (string) $this->jsonParam('op', 'login');
if (!\in_array($op, ['login', 'add'], true)) {
return $this->jsonResponse(__FUNCTION__, false);
}
$email = \strtolower(\trim((string) $this->jsonParam('email', '')));
$name = \trim((string) $this->jsonParam('name', ''));
$returnHash = (string) $this->jsonParam('return', '');
if ($returnHash && !\str_starts_with($returnHash, '#')) {
$returnHash = '';
}
// For add-account flow, require a logged-in main account (we must write to its additionalaccounts storage).
$oMainAccount = null;
if ('add' === $op) {
$oMainAccount = $oActions->getMainAccountFromToken(false);
if (!$oMainAccount) {
return $this->jsonResponse(__FUNCTION__, false);
}
}
// Optional server-side guard: only permit supported consumer domains unless configured otherwise.
if ($email && !$this->isSupportedEmail($email)) {
return $this->jsonResponse(__FUNCTION__, false);
}
$oConfig = $this->Config();
$client_id = \trim($oConfig->Get('plugin', 'client_id', ''));
if (!$client_id) {
return $this->jsonResponse(__FUNCTION__, false);
}
$nonce = $this->b64url(\random_bytes(16));
// Store nonce server-side to prevent replay; consumed on callback.
$oActions->StorageProvider()->Put(
null,
StorageType::NOBODY,
$this->stateNonceKey($nonce),
(string) \time()
);
$payload = [
'v' => 1,
'op' => $op,
'csrf' => \RainLoop\Utils::GetCsrfToken(),
'nonce' => $nonce,
'ts' => \time()
];
if ('add' === $op && $oMainAccount) {
$payload['main'] = $oMainAccount->Email();
if ($name) {
$payload['name'] = \substr($name, 0, 100);
}
if ($returnHash) {
$payload['return'] = \substr($returnHash, 0, 200);
}
}
$state = $this->signState($payload);
$tenant = $oConfig->Get('plugin', 'tenant', 'common');
$redirectUri = $this->redirectUri();
$params = [
'response_type' => 'code',
'client_id' => $client_id,
'redirect_uri' => $redirectUri,
'scope' => \implode(' ', [
'openid',
'offline_access',
'email',
'profile',
'https://outlook.office.com/IMAP.AccessAsUser.All',
'https://outlook.office.com/SMTP.Send',
]),
'state' => $state,
// Helps MS UI prefill, but does not change server-side validation.
];
if ($email) {
$params['login_hint'] = $email;
}
$authUrl = \str_replace('{{tenant}}', $tenant, static::AUTH_URI)
. '?'
. \http_build_query($params, '', '&', PHP_QUERY_RFC3986);
return $this->jsonResponse(__FUNCTION__, [
'authUrl' => $authUrl
]);
}
private function isSupportedEmail(string $email) : bool
{
if ((bool)$this->Config()->Get('plugin', 'allow_any_domain', false)) {
return \str_contains($email, '@');
}
return \str_ends_with($email, '@hotmail.com')
|| \str_ends_with($email, '@outlook.com')
|| \str_ends_with($email, '@live.com');
}
/**
* Build absolute base URL (works behind nginx reverse proxy).
*/
private function baseUrl() : string
{
$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');
}
return $scheme . '://' . $host;
}
/**
* Redirect URI used for the Azure app registration.
* When plugin.personal=true -> "/?LoginO365"
* When plugin.personal=false -> "/LoginO365"
*/
private function redirectUri() : string
{
$base = \rtrim($this->baseUrl(), '/');
$useQuery = (bool)$this->Config()->Get('plugin', 'personal', false);
return $useQuery ? ($base . '/?LoginO365') : ($base . '/LoginO365');
}
private function tokenStorageKey(string $emailLower) : string
{
// Stored under MAIN account StorageType::CONFIG (encrypted with main CryptKey).
// Email is hashed to avoid path/encoding issues across storage backends.
return 'login-o365.tokens.' . \sha1($emailLower);
}
private function storeAccountTokens(MainAccount $oMainAccount, string $email, array $tokenBundle) : void
{
$emailLower = \strtolower(\SnappyMail\IDN::emailToAscii($email));
\RainLoop\Api::Actions()->StorageProvider()->Put(
$oMainAccount,
StorageType::CONFIG,
$this->tokenStorageKey($emailLower),
\SnappyMail\Crypt::EncryptToJSON($tokenBundle, $oMainAccount->CryptKey())
);
}
private function stateNonceKey(string $nonce) : string
{
return 'login-o365.state.' . $nonce;
}
private function b64url(string $bin) : string
{
return \rtrim(\strtr(\base64_encode($bin), '+/', '-_'), '=');
}
private function b64urlDecode(string $b64url) /*: string|false*/
{
$pad = (4 - (\strlen($b64url) % 4)) % 4;
return \base64_decode(\strtr($b64url . \str_repeat('=', $pad), '-_', '+/'), true);
}
private function stateHmacKey() : string
{
// Uses the plugin client_secret (server-side only) as HMAC key.
// This prevents any user-controlled tampering of the state payload.
$key = \trim($this->Config()->getDecrypted('plugin', 'client_secret', ''));
if (!$key) {
// Fallback for misconfiguration; keeps behavior deterministic.
$key = 'login-o365';
}
return $key;
}
private function signState(array $payload) : string
{
$json = \json_encode($payload);
if (!$json) {
$json = '{}';
}
$payloadB64 = $this->b64url($json);
$sig = \hash_hmac('sha256', $payloadB64, $this->stateHmacKey(), true);
return $payloadB64 . '.' . $this->b64url($sig);
}
/**
* Verify signature + CSRF + nonce, then consumes nonce to prevent replay.
* Returns decoded payload on success, null on failure.
*/
private function verifyAndConsumeState(string $state) : ?array
{
$parts = \explode('.', $state, 2);
if (2 !== \count($parts)) {
return null;
}
[$payloadB64, $sigB64] = $parts;
$sig = $this->b64urlDecode($sigB64);
if ($sig === false) {
return null;
}
$expected = \hash_hmac('sha256', $payloadB64, $this->stateHmacKey(), true);
if (!\hash_equals($expected, $sig)) {
return null;
}
$payloadJson = $this->b64urlDecode($payloadB64);
if ($payloadJson === false) {
return null;
}
$payload = \json_decode($payloadJson, true);
if (!\is_array($payload) || empty($payload['csrf']) || empty($payload['nonce']) || empty($payload['op'])) {
return null;
}
// Must match the current browser session.
if ($payload['csrf'] !== \RainLoop\Utils::GetCsrfToken()) {
return null;
}
// Replay protection: nonce must exist server-side and is consumed once.
$oActions = \RainLoop\Api::Actions();
$key = $this->stateNonceKey((string) $payload['nonce']);
$seen = $oActions->StorageProvider()->Get(null, StorageType::NOBODY, $key);
if (!$seen) {
return null;
}
$ts = (int) ($payload['ts'] ?? 0);
if ($ts && \abs(\time() - $ts) > 900) { // 15 minutes
// Expired: clear nonce to avoid accumulating stale entries.
$oActions->StorageProvider()->Clear(null, StorageType::NOBODY, $key);
return null;
}
$oActions->StorageProvider()->Clear(null, StorageType::NOBODY, $key);
return $payload;
}
} }