diff --git a/plugins/login-o365/LoginOAuth2.js b/plugins/login-o365/LoginOAuth2.js index 8b1294892..0e5a8b697 100644 --- a/plugins/login-o365/LoginOAuth2.js +++ b/plugins/login-o365/LoginOAuth2.js @@ -1,38 +1,37 @@ ((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", - }); + allowAnyDomain = !!rl.pluginSettingsGet("login-o365", "allow_any_domain"), + isSupportedEmail = (email) => { + email = (email || "").toLowerCase(); + if (!email.includes("@")) return false; + if (allowAnyDomain) return true; + return /@(outlook\.com|hotmail\.com|live\.com)$/.test(email); + }, + startOAuth = (op, opts = {}) => { + const email = (opts.email || "").toLowerCase(); + const name = opts.name || ""; + const returnHash = opts.return || ""; + + // Server mints a signed state + correct redirect_uri and returns full authUrl. + rl.pluginRemoteRequest((iError, data) => { + const url = data?.Result?.authUrl; + if (!iError && url) { + document.location = url; + } + }, "LoginO365AuthUrl", { + op: op, + email: email, + name: name, + return: returnHash, + }); }; if (client_id) { addEventListener("sm-user-login", (e) => { const email = (e.detail.get("Email") || "").toLowerCase(); - if (/@(outlook\.com|hotmail\.com|live\.com)$/.test(email)) { + if (isSupportedEmail(email)) { e.preventDefault(); - login(); + startOAuth("login", { email }); } }); @@ -42,10 +41,51 @@ container = e.detail.viewModelDom.querySelector("#plugin-Login-BottomControlGroup"), btn = Element.fromHTML(''), div = Element.fromHTML('
'); - 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); 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( + '' + ); + + 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); \ No newline at end of file diff --git a/plugins/login-o365/index.php b/plugins/login-o365/index.php index 101cf55d7..bb8ae7b69 100644 --- a/plugins/login-o365/index.php +++ b/plugins/login-o365/index.php @@ -7,8 +7,8 @@ * https://outlook.office.com/SMTP.Send * openid offline_access email profile * https://learn.microsoft.com/en-us/entra/identity-platform/reply-url#query-parameter-support-in-redirect-uris - * Azure: redirect_uri=https://{DOMAIN}/?LoginO365 - * Personal: redirect_uri=https://{DOMAIN}/LoginO365 + * Query: redirect_uri=https://{DOMAIN}/?LoginO365 + * Path: redirect_uri=https://{DOMAIN}/LoginO365 * * If running behind nginx reverse proxy you might * need to add the following to your nginx config: @@ -24,8 +24,8 @@ class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin { const NAME = 'Office365/Outlook OAuth2', - VERSION = '0.3', - RELEASE = '2025-12-18', + VERSION = '0.4', + RELEASE = '2025-12-22', REQUIRED = '2.36.1', CATEGORY = 'Login', 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', 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 { @@ -46,9 +56,14 @@ class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin $this->addHook('sieve.before-login', 'clientLogin'); $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 $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 @@ -79,7 +94,7 @@ class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin } // 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()); exit; } @@ -92,23 +107,17 @@ class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin $iNow = \time(); - // 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'); + $redirectUri = $this->redirectUri(); $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( \str_replace('{{tenant}}', $tenant, static::TOKEN_URI), 'authorization_code', @@ -161,13 +170,69 @@ class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin 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, 'refresh_token' => $refreshToken, 'expires_in' => $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. $oPassword = new \SnappyMail\SensitiveString($sub); $oAccount = $oActions->LoginProcess($email, $oPassword); @@ -177,7 +242,7 @@ class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin $oAccount, StorageType::SESSION, \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']) ->SetAllowedInJs(), \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) ->SetDefaultValue(false) ->SetAllowedInJs() @@ -217,69 +289,115 @@ class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin { $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(); + if (!$this->isSupportedEmail($email)) { + return; + } + $oActions = \RainLoop\Api::Actions(); + + $aData = static::$auth[$email] ?? null; + if (!$aData) { try { - $blob = $oActions->StorageProvider()->Get( - $oAccount, - StorageType::SESSION, - \RainLoop\Utils::GetSessionToken() - ); - - $aData = static::$auth ?: \SnappyMail\Crypt::DecryptFromJSON($blob, $oAccount->CryptKey()); + if ($oAccount instanceof MainAccount) { + $blob = $oActions->StorageProvider()->Get( + $oAccount, + StorageType::SESSION, + \RainLoop\Utils::GetSessionToken() + ); + $aData = \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) { return; } + } - if (empty($aData['access_token']) || empty($aData['refresh_token']) || empty($aData['expires'])) { - 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']] - ); + // Refresh if expired (or close to expiry) + if (\time() >= ((int)$aData['expires'] - 30)) { + $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; - } + 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; + $aData['expires_in'] = $expiresIn; + } + // Persist updated bundle (encrypted). + if ($oAccount instanceof MainAccount) { $oActions->StorageProvider()->Put( $oAccount, StorageType::SESSION, \RainLoop\Utils::GetSessionToken(), \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()) + ); + } } } } + } - // Inject XOAUTH2/OAUTHBEARER - $oSettings->passphrase = $aData['access_token']; - \array_unshift($oSettings->SASLMechanisms, 'OAUTHBEARER', 'XOAUTH2'); + static::$auth[$email] = $aData; + + // Inject XOAUTH2/OAUTHBEARER + $oSettings->passphrase = $aData['access_token']; + \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)); } } @@ -326,4 +444,252 @@ class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin $data = \json_decode($json, true); 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; + } } \ No newline at end of file