diff --git a/application/single_app/app.py b/application/single_app/app.py
index 1f72fd2ce..8e4b4b4b2 100644
--- a/application/single_app/app.py
+++ b/application/single_app/app.py
@@ -38,6 +38,7 @@
from functions_rate_limit import build_rate_limit_error_payload
from functions_appinsights import *
from functions_activity_logging import *
+from functions_session_store import install_merging_session_interface
import threading
import time
@@ -186,6 +187,7 @@
log_event(f"Unable to create session directory {app.config.get('SESSION_FILE_DIR')}: {e}", level=logging.ERROR)
Session(app)
+install_merging_session_interface(app)
def register_route_blueprint(name, registrar, auth_guard=None):
@@ -302,8 +304,10 @@ def configure_sessions(settings):
log_event(f"Session configuration error; falling back to filesystem: {e}", level=logging.ERROR)
app.config['SESSION_TYPE'] = 'filesystem'
- # Initialize session interface
+ # Initialize session interface. Saves merge into the latest stored session so a slow
+ # request cannot overwrite what a concurrent request saved meanwhile.
Session(app)
+ install_merging_session_interface(app)
# =================== Helper Functions ===================
def start_background_tasks():
diff --git a/application/single_app/config.py b/application/single_app/config.py
index 143f847b8..ef6a7d47c 100644
--- a/application/single_app/config.py
+++ b/application/single_app/config.py
@@ -101,7 +101,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
-VERSION = "0.261.301"
+VERSION = "0.261.302"
IS_DEVELOPMENT = is_development_env_enabled()
# Opt-out for deployments where App Service Easy Auth is active but the platform
diff --git a/application/single_app/functions_m365_connections.py b/application/single_app/functions_m365_connections.py
index 7281a3144..41c4d2760 100644
--- a/application/single_app/functions_m365_connections.py
+++ b/application/single_app/functions_m365_connections.py
@@ -22,7 +22,7 @@
import msal
import requests
from azure.core import MatchConditions
-from azure.core.exceptions import AzureError
+from azure.core.exceptions import AzureError, HttpResponseError, ResourceNotFoundError
from azure.cosmos import exceptions as cosmos_exceptions
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
@@ -43,6 +43,9 @@
KEY_SECRET_ENV = "M365_WORKFLOW_TOKEN_KEY_SECRET_NAME"
+DEFAULT_KEY_SECRET_NAME = "simplechat-m365-workflow-token-key"
+KEY_SECRET_CONTENT_TYPE = "application/vnd.simplechat.m365-workflow-key+base64"
+KEY_SECRET_TAGS = {"simplechat-purpose": "m365-workflow-token-key"}
ENCRYPTION_VERSION = 1
MAX_CACHE_BYTES = 512 * 1024
AUTH_FLOW_SECONDS = 600
@@ -51,6 +54,11 @@
CHAT_CALLBACK_PATH = "/getAToken"
CHAT_AUTH_SESSION_KEY = "m365_chat_auth_flow"
CHAT_AUTH_STATE_PREFIX = "m365-chat-"
+# Workflow sign-in returns to the registered /getAToken callback too; the prefix routes it.
+WORKFLOW_AUTH_STATE_PREFIX = "m365-workflow-"
+# How the browser that started a sign-in hears the result: a full-page redirect, or a popup
+# that reports to the window that opened it.
+COMPLETION_MODES = frozenset({"page", "popup"})
CHAT_CONNECTION_SESSION_KEY = "m365_chat_connection"
CHAT_RECONNECT_SESSION_KEY = "m365_chat_reconnect_required"
_SOURCE_SCOPE_NAMES = {
@@ -284,6 +292,33 @@ def _source_connection_scopes(sources):
return sorted(set().union(*(_SOURCE_CONNECT_SCOPE_NAMES[source] for source in sources)))
+def _granted_session_sources(cache, account, config):
+ """Sources whose permissions this cached sign-in already holds. Reads the cache only.
+
+ Chat preflight silently requests each source's connect scopes with the session's refresh
+ token, so a source is usable when a refresh token exists and Microsoft granted those
+ scopes. Microsoft returns every scope consented for the resource on each token, which is
+ what MSAL records as the cached access token's target.
+ """
+ home_account_id = account.get("home_account_id")
+
+ def own(token):
+ return token.get("home_account_id") == home_account_id and token.get("client_id") == config.client_id
+
+ if not any(own(token) for token in cache.search(msal.TokenCache.CredentialType.REFRESH_TOKEN)):
+ return []
+ prefix = f"{config.graph_resource}/".lower()
+ granted = {
+ scope.lower().removeprefix(prefix)
+ for token in cache.search(msal.TokenCache.CredentialType.ACCESS_TOKEN) if own(token)
+ for scope in str(token.get("target") or "").split()
+ }
+ return [
+ source for source in M365_SOURCES
+ if {name.lower() for name in _SOURCE_CONNECT_SCOPE_NAMES[source]} <= granted
+ ]
+
+
def mark_m365_chat_reconnect_required(context):
"""Fence rejected interactive credentials without revoking workflow connections."""
if not has_request_context() or context is None or context.workflow_id:
@@ -312,9 +347,12 @@ def _require_granted_scopes(requested_scopes, granted_scope, config):
def _validate_callback_uri(redirect_uri, *, interactive=False):
+ # Workflow sign-in uses the registered /getAToken callback; the dedicated workflow
+ # callback stays accepted for deployments that registered it.
+ allowed_paths = {CHAT_CALLBACK_PATH} if interactive else {CHAT_CALLBACK_PATH, CONNECTION_CALLBACK_PATH}
redirect = urlsplit(redirect_uri)
if (
- redirect.path != (CHAT_CALLBACK_PATH if interactive else CONNECTION_CALLBACK_PATH)
+ redirect.path not in allowed_paths
or redirect.query or redirect.fragment or redirect.username or redirect.password
or not redirect.hostname
or (redirect.scheme != "https" and not (
@@ -368,37 +406,142 @@ def _default_msal_factory(cache, config):
)
-def _default_key_provider(version=None, name=None):
+_KEY_VAULT_NAME_PATTERN = r"[A-Za-z0-9][A-Za-z0-9-]{1,22}[A-Za-z0-9]"
+_KEY_SECRET_NAME_PATTERN = r"[A-Za-z0-9-]{1,127}"
+_KEY_VAULT_SETTINGS_LOCATION = "Admin Settings > Security > Secrets > Key Vault"
+
+
+def workflow_key_secret_name():
+ """The Key Vault secret holding the deployment's one workflow encryption key.
+
+ Every user's saved workflow sign-in is encrypted separately with this key and bound to
+ that user's connection record. The app setting is optional; it renames the secret.
+ """
+ return (os.environ.get(KEY_SECRET_ENV) or "").strip() or DEFAULT_KEY_SECRET_NAME
+
+
+def workflow_connection_readiness(settings):
+ """Whether saved workflow connections can work here, from settings alone (no Key Vault call).
+
+ The encryption key itself is created on first use, so a missing key is not a reason.
+ """
+ settings = settings if isinstance(settings, dict) else {}
+ vault_name = settings.get("key_vault_name")
+ if not settings.get("enable_key_vault_secret_storage"):
+ return {
+ "available": False, "reason": "key_vault_disabled",
+ "message": (
+ "Workflow connections are not set up on this deployment yet: they need Key Vault "
+ f"secret storage. An administrator can turn it on in {_KEY_VAULT_SETTINGS_LOCATION}."
+ ),
+ }
+ if not isinstance(vault_name, str) or not re.fullmatch(_KEY_VAULT_NAME_PATTERN, vault_name.strip()):
+ return {
+ "available": False, "reason": "key_vault_name_missing",
+ "message": (
+ "Workflow connections are not set up on this deployment yet: they need a Key Vault "
+ f"name. An administrator can set it in {_KEY_VAULT_SETTINGS_LOCATION}."
+ ),
+ }
+ if not re.fullmatch(_KEY_SECRET_NAME_PATTERN, workflow_key_secret_name()):
+ return {
+ "available": False, "reason": "key_secret_name_invalid",
+ "message": (
+ f"The {KEY_SECRET_ENV} app setting is not a valid Key Vault secret name. An "
+ "administrator must correct it or remove it to use the default name."
+ ),
+ }
+ return {"available": True, "reason": None, "message": ""}
+
+
+def _log_key_created(name, version):
+ # The logger depends on config/settings; only a live key creation reaches this.
+ from functions_appinsights import log_event
+ log_event(
+ "[AUTH] Created the Microsoft 365 workflow encryption key in Key Vault.",
+ extra={"secret_name": name, "key_version": version},
+ )
+
+
+def _create_workflow_key_secret(client, name):
+ """Store a new random 256-bit key and return its properties and value.
+
+ The value is never logged and never leaves the server.
+ """
+ value = base64.b64encode(secrets.token_bytes(32)).decode("ascii")
+ try:
+ created = client.set_secret(
+ name, value, content_type=KEY_SECRET_CONTENT_TYPE, tags=dict(KEY_SECRET_TAGS),
+ )
+ except HttpResponseError as exc:
+ _log_failure("m365_key_provision_failed", exc)
+ if getattr(exc, "status_code", None) == 403:
+ raise M365ConnectionError(
+ "m365_key_provision_forbidden",
+ "SimpleChat could not create the workflow encryption key because its identity cannot "
+ "write Key Vault secrets. An administrator must grant it Key Vault Secrets Officer on the vault.",
+ ) from exc
+ if getattr(exc, "status_code", None) == 409:
+ raise M365ConnectionError(
+ "m365_key_deleted",
+ f"The workflow encryption key secret '{name}' was deleted and is still recoverable. "
+ "An administrator must recover or purge it in Key Vault.",
+ ) from exc
+ raise M365ConnectionError("m365_key_unavailable", "The workflow encryption key could not be created.") from exc
+ except AzureError as exc:
+ _log_failure("m365_key_provision_failed", exc)
+ raise M365ConnectionError("m365_key_unavailable", "The workflow encryption key could not be created.") from exc
+ _log_key_created(name, created.properties.version)
+ return created.properties, value
+
+
+def _load_workflow_key(settings, version=None, name=None, *, create_missing=False):
+ """Read the workflow key from Key Vault, creating it only when asked and it does not exist.
+
+ Returns the key and whether it was just created. Decryption always pins the stored key
+ version and never creates one, so two instances creating it at once is harmless: each
+ version stays readable for what it encrypted.
+ """
# Key Vault is mandatory; there is deliberately no Flask-secret/plaintext fallback.
from azure.keyvault.secrets import SecretClient
import config as app_config
from functions_keyvault import get_keyvault_credential
- from functions_settings import get_settings
- configured_name = os.environ.get(KEY_SECRET_ENV, "")
- settings = get_settings()
- vault_name = settings.get("key_vault_name")
- if (
- not settings.get("enable_key_vault_secret_storage")
- or not isinstance(vault_name, str)
- or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9-]{1,22}[A-Za-z0-9]", vault_name.strip())
- or not re.fullmatch(r"[A-Za-z0-9-]{1,127}", configured_name)
- or (name is not None and name != configured_name)
- ):
+ readiness = workflow_connection_readiness(settings)
+ if not readiness["available"]:
+ raise M365ConnectionError("m365_key_vault_required", readiness["message"])
+ # The name becomes the request's hostname, so validate the exact string used here: a valid
+ # Key Vault name can only address a vault in the configured Key Vault domain.
+ vault_name = settings["key_vault_name"].strip()
+ if not re.fullmatch(_KEY_VAULT_NAME_PATTERN, vault_name):
raise M365ConnectionError(
"m365_key_vault_required",
- "Configure Key Vault and a dedicated workflow encryption-key secret before connecting Microsoft 365.",
+ f"The Key Vault name is not valid. An administrator can correct it in {_KEY_VAULT_SETTINGS_LOCATION}.",
+ )
+ configured_name = workflow_key_secret_name()
+ if name is not None and name != configured_name:
+ raise M365ConnectionError(
+ "m365_key_unavailable",
+ "The workflow encryption key that protected this connection is no longer configured. "
+ "Reconnect Microsoft 365 for workflows.",
)
client = SecretClient(
- vault_url=f"https://{vault_name.strip()}{app_config.KEY_VAULT_DOMAIN}",
+ vault_url=f"https://{vault_name}{app_config.KEY_VAULT_DOMAIN}",
credential=get_keyvault_credential(settings=settings),
)
+ created = False
try:
secret = client.get_secret(configured_name, version=version)
+ properties, secret_value = secret.properties, secret.value
+ except ResourceNotFoundError as exc:
+ if version is not None or not create_missing:
+ _log_failure("m365_key_unavailable", exc)
+ raise M365ConnectionError("m365_key_unavailable", "The workflow encryption key is unavailable.") from exc
+ properties, secret_value = _create_workflow_key_secret(client, configured_name)
+ created = True
except AzureError as exc:
_log_failure("m365_key_unavailable", exc)
raise M365ConnectionError("m365_key_unavailable", "The workflow encryption key is unavailable.") from exc
- properties = secret.properties
if (
properties.enabled is False
or (properties.expires_on is not None and utc_datetime(properties.expires_on) <= utc_now())
@@ -407,10 +550,46 @@ def _default_key_provider(version=None, name=None):
):
raise M365ConnectionError("m365_key_unavailable", "The workflow encryption-key version is unavailable.")
try:
- raw_key = base64.b64decode(secret.value, validate=True)
+ raw_key = base64.b64decode(secret_value, validate=True)
except (ValueError, TypeError, binascii.Error) as exc:
raise M365ConnectionError("m365_key_invalid", "The workflow encryption key must contain a base64-encoded 256-bit key.") from exc
- return M365EncryptionKey(key=raw_key, version=properties.version, name=configured_name)
+ return M365EncryptionKey(key=raw_key, version=properties.version, name=configured_name), created
+
+
+def _default_key_provider(version=None, name=None):
+ from functions_settings import get_settings
+
+ # Encrypting (no version) creates the deployment's key on first use.
+ key, _created = _load_workflow_key(get_settings(), version, name, create_missing=version is None)
+ return key
+
+
+def ensure_m365_workflow_encryption_key(settings):
+ """Create the workflow encryption key now if it is missing. Never raises.
+
+ Admin settings saves call this so the key exists before anyone connects. The result is
+ safe to show an administrator.
+ """
+ readiness = workflow_connection_readiness(settings)
+ if not readiness["available"]:
+ return {"status": "not_configured", "reason": readiness["reason"], "message": readiness["message"]}
+ try:
+ _key, created = _load_workflow_key(settings, create_missing=True)
+ except M365ConnectionError as exc:
+ return {"status": "unavailable", "reason": exc.code, "message": exc.payload["message"]}
+ except Exception as exc:
+ # Admin saves call this after storing settings; an optional key check must not fail them.
+ _log_failure("m365_key_provision_failed", exc)
+ return {
+ "status": "unavailable", "reason": "m365_key_unavailable",
+ "message": "The workflow encryption key could not be checked in Key Vault.",
+ }
+ if created:
+ return {
+ "status": "created", "reason": None,
+ "message": f"Created the Microsoft 365 workflow encryption key '{workflow_key_secret_name()}' in Key Vault.",
+ }
+ return {"status": "ready", "reason": None, "message": ""}
def sanitize_m365_connection(connection):
@@ -513,12 +692,16 @@ def _ensure_connection(self, user_id, config):
except cosmos_exceptions.CosmosResourceExistsError:
return self._read(connection_id, user_id)
- def start_connection(self, user_id, tenant_id, sources, redirect_uri, session_binding, scopes=None):
+ def start_connection(
+ self, user_id, tenant_id, sources, redirect_uri, session_binding, scopes=None, completion="page",
+ ):
config = self.config_provider()
_identifier(user_id)
_identifier(session_binding)
if tenant_id != config.tenant_id:
raise M365ConnectionError("m365_account_mismatch", "Connect only your account in this deployment's tenant.")
+ if completion not in COMPLETION_MODES:
+ raise ValueError("Unsupported sign-in completion mode.")
if (
not isinstance(sources, list) or not sources or len(sources) > len(M365_SOURCES)
or any(source not in M365_SOURCES for source in sources)
@@ -541,7 +724,7 @@ def start_connection(self, user_id, tenant_id, sources, redirect_uri, session_bi
client = self.msal_factory(cache, config)
flow = client.initiate_auth_code_flow(
scopes=required, redirect_uri=redirect_uri,
- state=secrets.token_urlsafe(32), prompt="select_account",
+ state=f"{WORKFLOW_AUTH_STATE_PREFIX}{secrets.token_urlsafe(32)}", prompt="select_account",
)
_validate_auth_flow(flow, config)
expires_at = self.clock() + timedelta(seconds=AUTH_FLOW_SECONDS)
@@ -554,7 +737,7 @@ def start_connection(self, user_id, tenant_id, sources, redirect_uri, session_bi
"session_binding": hashlib.sha256(session_binding.encode("utf-8")).hexdigest(),
"status": "pending", "sources": sorted(set(sources)),
"requested_scopes": required, "expires_at": expires_at.isoformat(),
- "ttl": AUTH_FLOW_SECONDS * 2,
+ "completion": completion, "ttl": AUTH_FLOW_SECONDS * 2,
}
record["encrypted_flow"] = encrypt_m365_cache(
json.dumps(flow, separators=(",", ":")), record, key,
@@ -565,6 +748,23 @@ def start_connection(self, user_id, tenant_id, sources, redirect_uri, session_bi
"expires_at": expires_at.isoformat(),
}
+ def connection_flow_completion(self, user_id, state):
+ """How the browser that started a workflow sign-in expects the result: page, popup or auto.
+
+ Read before completing so even a failed callback can answer a popup. It never raises:
+ an unknown or unreadable flow reports "auto" and the result page decides.
+ """
+ if not isinstance(state, str) or not 20 <= len(state) <= 256:
+ return "auto"
+ try:
+ record = self._read(f"m365-oauth-{hashlib.sha256(state.encode('utf-8')).hexdigest()}", user_id)
+ except (AzureError, ValueError, TypeError):
+ return "auto"
+ if not isinstance(record, dict) or record.get("purpose") != "m365_workflow_connection":
+ return "auto"
+ completion = record.get("completion")
+ return completion if completion in COMPLETION_MODES else "page"
+
def complete_connection(self, user_id, tenant_id, auth_response, session_binding, *, cache_writer=None):
config = self.config_provider()
state = auth_response.get("state") if isinstance(auth_response, dict) else None
@@ -660,7 +860,14 @@ def _interactive_config(self, user_id, tenant_id):
return config
def read_chat_connection(self, user_id, tenant_id):
- """Report local session state without probing Graph or exposing credential material."""
+ """Report local session state without probing Graph or exposing credential material.
+
+ ``sources`` are the sources chat can use now: the session holds a refresh token for
+ the user and Microsoft has granted each source's permissions, as recorded on the
+ cached access tokens. That includes consent given at sign-in or by an administrator,
+ not only an explicit reconnect. When a reconnect is needed, ``sources`` lists the
+ sources last connected here, to offer them again.
+ """
config = self._interactive_config(user_id, tenant_id)
metadata = session.get(CHAT_CONNECTION_SESSION_KEY)
if not isinstance(metadata, dict) or (
@@ -679,24 +886,27 @@ def read_chat_connection(self, user_id, tenant_id):
try:
cache = deserialize_m365_cache(session["token_cache"])
accounts = list(cache.search(msal.TokenCache.CredentialType.ACCOUNT))
- select_m365_account(accounts, user_id, tenant_id)
+ account = select_m365_account(accounts, user_id, tenant_id)
result["status"] = "available"
+ result["sources"] = _granted_session_sources(cache, account, config)
except M365ConnectionError:
result["status"] = "reconnect_required"
if metadata.get("connected_at"):
result["connected_at"] = metadata["connected_at"]
return result
- def start_profile_chat_connection(self, user_id, tenant_id, sources, redirect_uri):
+ def start_profile_chat_connection(self, user_id, tenant_id, sources, redirect_uri, completion="page"):
scopes = _source_connection_scopes(sources)
+ if completion not in COMPLETION_MODES:
+ raise ValueError("Unsupported sign-in completion mode.")
return self._start_interactive_connection(
user_id, tenant_id, scopes, redirect_uri,
- purpose="profile_reconnect", sources=sorted(set(sources)),
+ purpose="profile_reconnect", sources=sorted(set(sources)), completion=completion,
)
def _start_interactive_connection(
self, user_id, tenant_id, scopes, redirect_uri, *,
- purpose, request_id=None, conversation_id=None, sources=None,
+ purpose, request_id=None, conversation_id=None, sources=None, completion="page",
):
config = self._interactive_config(user_id, tenant_id)
_validate_callback_uri(redirect_uri, interactive=True)
@@ -719,7 +929,7 @@ def _start_interactive_connection(
"flow": flow, "user_id": user_id, "tenant_id": tenant_id,
"request_id": request_id, "conversation_id": conversation_id,
"configuration": config.binding(), "required_scopes": required,
- "purpose": purpose, "sources": sources,
+ "purpose": purpose, "sources": sources, "completion": completion,
"expires_at": expires_at.isoformat(),
}
return {"authorization_url": flow["auth_uri"], "expires_at": expires_at.isoformat()}
@@ -738,7 +948,10 @@ def complete_chat_connection(self, user_id, tenant_id, auth_response):
or record.get("configuration") != config.binding()
or utc_datetime(record["expires_at"]) <= self.clock()
):
- raise M365ConnectionError("m365_auth_state_invalid", "This sign-in request expired or changed. Connect again from chat.")
+ raise M365ConnectionError(
+ "m365_auth_state_invalid",
+ "This Microsoft 365 sign-in request expired or was replaced by a newer one. Start the connection again.",
+ )
session.pop(CHAT_AUTH_SESSION_KEY)
cache = msal.SerializableTokenCache()
client = self.msal_factory(cache, config)
@@ -768,6 +981,8 @@ def complete_chat_connection(self, user_id, tenant_id, auth_response):
}
session.pop(CHAT_RECONNECT_SESSION_KEY, None)
if record.get("purpose") == "profile_reconnect":
+ if record.get("completion") == "popup":
+ return {"return_to": "profile", "completion": "popup"}
return {"return_to": "profile"}
return {
"request_id": record["request_id"], "conversation_id": record["conversation_id"],
diff --git a/application/single_app/functions_session_store.py b/application/single_app/functions_session_store.py
new file mode 100644
index 000000000..c3dd085ba
--- /dev/null
+++ b/application/single_app/functions_session_store.py
@@ -0,0 +1,262 @@
+# functions_session_store.py
+"""Server-side session persistence that does not lose concurrent writes.
+
+Flask-Session 0.8 writes the whole stored session back at the end of every request, so the
+request that finishes last wins. A slow request that loaded the session before another
+request saved a change then writes its stale copy over that change. SimpleChat runs several
+instances and threads against one Redis store, and the V2 interface keeps several requests in
+flight, so this happens in practice: a pending Microsoft 365 sign-in saved by one request was
+erased by a bootstrap request that had started earlier and finished later.
+
+These interfaces keep Flask-Session's cookie and storage format and change only how a request
+writes its changes back:
+
+* A request that changed nothing only refreshes the stored session's expiry.
+* A request that changed keys writes just those keys, and removes just the keys it removed,
+ into the latest stored copy. Redis applies the merge atomically.
+* A new session, a cleared session and a session whose id changed are written whole, as
+ before.
+* A session deleted while the request ran, by sign-out or the idle timeout, is not recreated
+ from the request's stale copy.
+
+Two requests that change the same key still resolve last writer wins, as before.
+"""
+
+import copy
+import logging
+import warnings
+
+from flask_session.defaults import Defaults
+from flask_session.filesystem import FileSystemSession, FileSystemSessionInterface
+from flask_session.redis import RedisSession, RedisSessionInterface
+from redis.exceptions import WatchError
+
+from functions_appinsights import log_event
+
+
+MERGE_ATTEMPTS = 5
+PERMANENT_KEY = "_permanent"
+_BASELINE_ATTR = "_simplechat_baseline"
+_BASELINE_SID_ATTR = "_simplechat_baseline_sid"
+_REPLACED_ATTR = "_simplechat_replaced"
+
+
+def _ttl_seconds(session_lifetime):
+ """Whole seconds in the session lifetime, as Flask-Session computes its storage TTL."""
+ return session_lifetime.days * 86400 + session_lifetime.seconds
+
+
+def _has_content(data):
+ """A stored session needs at least one key besides the permanent flag."""
+ return any(key != PERMANENT_KEY for key in data)
+
+
+def _apply_changes(stored, changed, removed):
+ """The stored session with this request's changes applied, other keys untouched."""
+ merged = dict(stored) if isinstance(stored, dict) else {}
+ merged.update(changed)
+ for key in removed:
+ merged.pop(key, None)
+ return merged
+
+
+def session_changes(session):
+ """Keys this request changed and removed, or None when the session must be written whole.
+
+ A session opened by another interface carries no baseline, which happens on a worker's
+ first request when initialization swaps the session backend mid-request, and is written
+ whole as before.
+ """
+ baseline = getattr(session, _BASELINE_ATTR, None)
+ if (
+ baseline is None
+ or getattr(session, _REPLACED_ATTR, False)
+ or getattr(session, _BASELINE_SID_ATTR, None) != session.sid
+ ):
+ return None
+ current = dict(session)
+ changed = {
+ key: value for key, value in current.items()
+ if key not in baseline or baseline[key] != value
+ }
+ removed = [key for key in baseline if key not in current]
+ return changed, removed
+
+
+class _MergingSessionMixin:
+ """Record what a request loaded so the save can write only what it changed."""
+
+ def open_session(self, app, request):
+ session = super().open_session(app, request)
+ # A stored session always holds more than the permanent flag; a new one does not.
+ if session:
+ setattr(session, _BASELINE_ATTR, copy.deepcopy(dict(session)))
+ setattr(session, _BASELINE_SID_ATTR, session.sid)
+ return session
+
+ def _upsert_session(self, session_lifetime, session, store_id):
+ changes = session_changes(session)
+ if changes is None:
+ self._write_whole(session_lifetime, session, store_id)
+ return
+ changed, removed = changes
+ ttl = _ttl_seconds(session_lifetime)
+ if not changed and not removed:
+ self._refresh_expiry(store_id, ttl)
+ return
+ self._merge_changes(session_lifetime, session, store_id, changed, removed, ttl)
+
+ def _write_whole(self, session_lifetime, session, store_id):
+ """Flask-Session's own save, which replaces the stored session with this one."""
+ super()._upsert_session(session_lifetime, session, store_id)
+
+ def _refresh_expiry(self, store_id, ttl):
+ raise NotImplementedError()
+
+ def _merge_changes(self, session_lifetime, session, store_id, changed, removed, ttl):
+ raise NotImplementedError()
+
+
+class MergingRedisSession(RedisSession):
+ def clear(self):
+ setattr(self, _REPLACED_ATTR, True)
+ super().clear()
+
+
+class MergingRedisSessionInterface(_MergingSessionMixin, RedisSessionInterface):
+ """Redis sessions whose saves merge into the latest stored copy with WATCH/MULTI/EXEC."""
+
+ session_class = MergingRedisSession
+
+ def _refresh_expiry(self, store_id, ttl):
+ # EXPIRE never recreates a key, so a session deleted meanwhile stays deleted.
+ self.client.expire(store_id, ttl)
+
+ def _decode_stored(self, stored):
+ try:
+ return self.serializer.decode(stored)
+ except Exception as exc:
+ log_event(
+ "[SESSION_STORE] The stored session could not be read; writing this request's session whole.",
+ extra={"exception_type": type(exc).__name__},
+ level=logging.WARNING,
+ )
+ return None
+
+ def _merge_changes(self, session_lifetime, session, store_id, changed, removed, ttl):
+ for _attempt in range(MERGE_ATTEMPTS):
+ with self.client.pipeline() as pipe:
+ try:
+ pipe.watch(store_id)
+ stored = pipe.get(store_id)
+ if stored is None:
+ # Signed out or expired while this request ran: do not recreate it.
+ return
+ current = self._decode_stored(stored)
+ if current is None:
+ pipe.reset()
+ self._write_whole(session_lifetime, session, store_id)
+ return
+ merged = _apply_changes(current, changed, removed)
+ pipe.multi()
+ if _has_content(merged):
+ pipe.set(store_id, self.serializer.encode(merged), ex=ttl)
+ else:
+ pipe.delete(store_id)
+ pipe.execute()
+ return
+ except WatchError:
+ continue
+ log_event(
+ "[SESSION_STORE] Session save kept losing an optimistic-lock race; merging without the lock.",
+ extra={"attempts": MERGE_ATTEMPTS, "changed_key_count": len(changed), "removed_key_count": len(removed)},
+ level=logging.WARNING,
+ )
+ stored = self.client.get(store_id)
+ if stored is None:
+ return
+ current = self._decode_stored(stored)
+ if current is None:
+ self._write_whole(session_lifetime, session, store_id)
+ return
+ merged = _apply_changes(current, changed, removed)
+ if _has_content(merged):
+ self.client.set(name=store_id, value=self.serializer.encode(merged), ex=ttl)
+ else:
+ self.client.delete(store_id)
+
+
+class MergingFileSystemSession(FileSystemSession):
+ def clear(self):
+ setattr(self, _REPLACED_ATTR, True)
+ super().clear()
+
+
+class MergingFileSystemSessionInterface(_MergingSessionMixin, FileSystemSessionInterface):
+ """Filesystem sessions that merge into the latest stored copy.
+
+ The file cache has no transactions, so the merge is a read and an atomic file replace a
+ moment apart rather than the whole request apart. This backend serves single-instance
+ and development deployments, and the Redis fallback.
+ """
+
+ session_class = MergingFileSystemSession
+
+ def _refresh_expiry(self, store_id, ttl):
+ stored = self.cache.get(store_id)
+ if stored is not None:
+ self.cache.set(key=store_id, value=stored, timeout=ttl)
+
+ def _merge_changes(self, session_lifetime, session, store_id, changed, removed, ttl):
+ stored = self.cache.get(store_id)
+ if stored is None:
+ return
+ merged = _apply_changes(stored, changed, removed)
+ if _has_content(merged):
+ self.cache.set(key=store_id, value=merged, timeout=ttl)
+ else:
+ self.cache.delete(store_id)
+
+
+def install_merging_session_interface(app):
+ """Replace the interface ``Session(app)`` installed with its merging equivalent.
+
+ Call it straight after every ``Session(app)``. The replacement reuses the installed
+ interface's Redis client or file cache, so storage and existing sessions are unchanged.
+ """
+ current = app.session_interface
+ if isinstance(current, _MergingSessionMixin):
+ return current
+ config = app.config
+ common = {
+ "app": app,
+ "key_prefix": config.get("SESSION_KEY_PREFIX", Defaults.SESSION_KEY_PREFIX),
+ "use_signer": config.get("SESSION_USE_SIGNER", Defaults.SESSION_USE_SIGNER),
+ "permanent": config.get("SESSION_PERMANENT", Defaults.SESSION_PERMANENT),
+ "sid_length": config.get("SESSION_ID_LENGTH", Defaults.SESSION_ID_LENGTH),
+ "serialization_format": config.get(
+ "SESSION_SERIALIZATION_FORMAT", Defaults.SESSION_SERIALIZATION_FORMAT,
+ ),
+ }
+ # Session(app) already raised these deprecation warnings for the same configuration.
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore", DeprecationWarning)
+ if isinstance(current, RedisSessionInterface):
+ replacement = MergingRedisSessionInterface(client=current.client, **common)
+ elif isinstance(current, FileSystemSessionInterface):
+ replacement = MergingFileSystemSessionInterface(
+ cache_dir=config.get("SESSION_FILE_DIR", Defaults.SESSION_FILE_DIR),
+ threshold=config.get("SESSION_FILE_THRESHOLD", Defaults.SESSION_FILE_THRESHOLD),
+ mode=config.get("SESSION_FILE_MODE", Defaults.SESSION_FILE_MODE),
+ **common,
+ )
+ replacement.cache = current.cache
+ else:
+ log_event(
+ "[SESSION_STORE] The configured session backend has no merging interface; concurrent saves stay last writer wins.",
+ extra={"session_interface": type(current).__name__},
+ level=logging.WARNING,
+ )
+ return current
+ app.session_interface = replacement
+ return replacement
diff --git a/application/single_app/route_backend_m365.py b/application/single_app/route_backend_m365.py
index e03052009..d1dff333e 100644
--- a/application/single_app/route_backend_m365.py
+++ b/application/single_app/route_backend_m365.py
@@ -8,7 +8,7 @@
import requests
from azure.core.exceptions import AzureError
-from flask import Blueprint, jsonify, make_response, redirect, request, session
+from flask import Blueprint, jsonify, make_response, redirect, render_template, request, session
from functions_appinsights import log_event
from functions_authentication import login_required, user_required, user_required_blueprint
@@ -23,15 +23,29 @@
sanitize_m365_approval,
)
from functions_m365_connections import (
+ CHAT_AUTH_SESSION_KEY,
CHAT_AUTH_STATE_PREFIX,
CHAT_CALLBACK_PATH,
CHAT_RECONNECT_SESSION_KEY,
+ COMPLETION_MODES,
CONNECTION_CALLBACK_PATH,
get_m365_connection_service,
+ workflow_connection_readiness,
)
from swagger_wrapper import swagger_route, get_auth_security
+# Fixed classic destinations for full-page sign-ins. Popups report to their opener instead,
+# which is how the V2 interface connects, so it never lands on these pages.
+CLASSIC_CHAT_CONNECTION_URL = "/profile?tab=settings#m365-chat-connection"
+CLASSIC_CHAT_CONNECTED_URL = "/profile?tab=settings&m365_chat_connection=connected#m365-chat-connection"
+CLASSIC_WORKFLOW_CONNECTION_URL = "/profile?tab=settings#m365-connection-status"
+CLASSIC_WORKFLOW_CONNECTED_URL = "/profile?tab=settings&m365_connection=connected#m365-connection-status"
+CLASSIC_CHATS_URL = "/chats"
+CONNECTION_RESULT_TEMPLATE = "m365_connection_result.html"
+_CALLBACK_RESPONSE_FIELDS = {"state", "code", "error", "error_description", "error_uri", "session_state", "client_info"}
+
+
_conversation_authorizer = None
_decision_callback = None
_audit_conversation_resolver = None
@@ -93,7 +107,8 @@ def _page_options():
}
-def _error_response(exc):
+def _error_details(exc):
+ """The safe error payload and HTTP status for an M365 failure, shared by JSON and pages."""
if isinstance(exc, M365PolicyError):
code = exc.code
if code == "not_logged_in":
@@ -107,6 +122,7 @@ def _error_response(exc):
status = 409
elif code in {
"m365_key_vault_required", "m365_key_unavailable", "m365_key_invalid",
+ "m365_key_provision_forbidden", "m365_key_deleted",
"m365_configuration_invalid", "m365_tenant_authority_required", "m365_callback_invalid",
"m365_workflow_validation_unavailable", "m365_audit_validation_unavailable",
"m365_approval_validation_unavailable",
@@ -118,11 +134,11 @@ def _error_response(exc):
status = 404
else:
status = 400
- return jsonify({"success": False, **exc.payload}), status
+ return dict(exc.payload), status
if isinstance(exc, PermissionError):
- return jsonify({"success": False, "error": "forbidden", "message": "You cannot perform this Microsoft 365 operation."}), 403
+ return {"error": "forbidden", "message": "You cannot perform this Microsoft 365 operation."}, 403
if isinstance(exc, LookupError):
- return jsonify({"success": False, "error": "not_found", "message": "Microsoft 365 request not found."}), 404
+ return {"error": "not_found", "message": "Microsoft 365 request not found."}, 404
if isinstance(exc, ValueError):
log_event(
"[AUTH] Microsoft 365 request validation failed",
@@ -130,16 +146,21 @@ def _error_response(exc):
level=logging.WARNING,
exceptionTraceback=True,
)
- return jsonify({"success": False, "error": "invalid_request", "message": "Invalid Microsoft 365 request."}), 400
+ return {"error": "invalid_request", "message": "Invalid Microsoft 365 request."}, 400
log_event(
"[AUTH] Microsoft 365 request dependency unavailable",
extra={"exception_type": type(exc).__name__, "endpoint": request.endpoint},
level=logging.ERROR,
)
- return jsonify({
- "success": False, "error": "m365_service_unavailable",
+ return {
+ "error": "m365_service_unavailable",
"message": "Microsoft 365 request storage or authentication is temporarily unavailable.",
- }), 503
+ }, 503
+
+
+def _error_response(exc):
+ payload, status = _error_details(exc)
+ return jsonify({"success": False, **payload}), status
@user_required
@@ -192,13 +213,80 @@ def _callback_uri(callback_path=CONNECTION_CALLBACK_PATH):
return f"{origin}{callback_path}"
+def _completion_mode(data):
+ """The optional completion mode a connect request asks for; full-page by default."""
+ completion = data.get("completion", "page")
+ if completion not in COMPLETION_MODES:
+ raise ValueError("Unsupported sign-in completion mode.")
+ return completion
+
+
+def _callback_auth_response():
+ auth_response = request.args.to_dict()
+ if (
+ set(auth_response) - _CALLBACK_RESPONSE_FIELDS
+ or any(len(value) > 16384 for value in auth_response.values())
+ ):
+ raise ValueError("Invalid authorization response.")
+ return auth_response
+
+
+def _connection_result_page(*, kind, outcome, completion, continue_url, code=None, message="", status=200):
+ """A small page that ends a Microsoft 365 sign-in instead of raw JSON.
+
+ In a popup its script tells the window that opened it (same origin only) and closes;
+ on a full page it shows the outcome with a link back. Every value is server-chosen or a
+ safe error message, and continue_url is always one of this module's fixed paths.
+ """
+ if outcome == "connected":
+ message_type = "m365-profile-reconnected" if kind == "chat" else "m365-workflow-connected"
+ title = "Microsoft 365 is connected"
+ message = message or (
+ "Sign-in completed. You can return to SimpleChat." if kind == "chat"
+ else "Workflows can now run as your Microsoft 365 account once you authorize them."
+ )
+ else:
+ message_type = "m365-connect-failed"
+ title = "Microsoft 365 was not connected"
+ result = {
+ "type": message_type, "kind": kind, "outcome": outcome, "completion": completion,
+ "code": code, "message": message, "continue_url": continue_url,
+ }
+ response = make_response(render_template(
+ CONNECTION_RESULT_TEMPLATE, result=result, title=title, message=message,
+ continue_url=continue_url,
+ continue_label="Return to settings" if continue_url != CLASSIC_CHATS_URL else "Return to chat",
+ ), status)
+ response.headers["Cache-Control"] = "private, no-store"
+ response.headers["Pragma"] = "no-cache"
+ return response
+
+
+def _pending_chat_flow(state):
+ """Completion mode and purpose of the chat sign-in this callback answers, read before it is consumed."""
+ record = session.get(CHAT_AUTH_SESSION_KEY)
+ flow = record.get("flow") if isinstance(record, dict) else None
+ expected = flow.get("state") if isinstance(flow, dict) else None
+ if (
+ not isinstance(expected, str) or not isinstance(state, str)
+ or not hmac.compare_digest(expected.encode("utf-8"), state.encode("utf-8"))
+ ):
+ return "auto", None
+ completion = record.get("completion")
+ return (completion if completion in COMPLETION_MODES else "page"), record.get("purpose")
+
+
def _complete_chat_connection(user_id, tenant_id, auth_response):
# These owners are initialized before an authenticated OAuth callback.
from functions_m365_request_resume import get_m365_chat_request
from functions_m365_runtime import _conversation_access
completed = get_m365_connection_service().complete_chat_connection(user_id, tenant_id, auth_response)
if completed.get("return_to") == "profile":
- return redirect("/profile?tab=settings&m365_chat_connection=connected#m365-chat-connection")
+ if completed.get("completion") == "popup":
+ return _connection_result_page(
+ kind="chat", outcome="connected", completion="popup", continue_url=CLASSIC_CHAT_CONNECTED_URL,
+ )
+ return redirect(CLASSIC_CHAT_CONNECTED_URL)
job = get_m365_chat_request(completed["request_id"], user_id)
if job.get("conversation_id") != completed["conversation_id"]:
raise M365PolicyError("m365_request_changed", "The conversation request changed during sign-in.")
@@ -219,21 +307,60 @@ def _publish_verified_workflow_cache_to_session(serialized):
session.pop(CHAT_RECONNECT_SESSION_KEY, None)
+def _complete_workflow_connection(user_id, tenant_id, auth_response, completion):
+ session_binding = session.pop("m365_workflow_oauth_binding", None)
+ if not session_binding:
+ raise M365PolicyError(
+ "m365_auth_state_invalid",
+ "This workflow sign-in request expired or was replaced by a newer one. Start Connect for workflows again.",
+ )
+ get_m365_connection_service().complete_connection(
+ user_id, tenant_id, auth_response, session_binding,
+ cache_writer=_publish_verified_workflow_cache_to_session,
+ )
+ if completion == "popup":
+ return _connection_result_page(
+ kind="workflow", outcome="connected", completion="popup", continue_url=CLASSIC_WORKFLOW_CONNECTED_URL,
+ )
+ return redirect(CLASSIC_WORKFLOW_CONNECTED_URL)
+
+
@login_required
@user_required
def complete_m365_chat_connection_callback():
"""Use the registered login callback without replacing the SimpleChat principal."""
+ completion, purpose = _pending_chat_flow(request.args.get("state"))
+ try:
+ user_id, tenant_id = _subject()
+ result = _complete_chat_connection(user_id, tenant_id, _callback_auth_response())
+ except (M365PolicyError, PermissionError, LookupError, ValueError, AzureError, requests.RequestException) as exc:
+ payload, status = _error_details(exc)
+ result = _connection_result_page(
+ kind="chat", outcome="failed", completion=completion, status=status,
+ code=payload["error"], message=payload["message"],
+ continue_url=CLASSIC_CHATS_URL if purpose == "chat_request" else CLASSIC_CHAT_CONNECTION_URL,
+ )
+ response = make_response(result)
+ response.headers["Cache-Control"] = "private, no-store"
+ response.headers["Pragma"] = "no-cache"
+ return response
+
+
+@login_required
+@user_required
+def complete_m365_workflow_connection_callback():
+ """Finish a workflow sign-in that returned to the registered /getAToken callback."""
+ completion = "auto"
try:
user_id, tenant_id = _subject()
- auth_response = request.args.to_dict()
- if (
- set(auth_response) - {"state", "code", "error", "error_description", "error_uri", "session_state", "client_info"}
- or any(len(value) > 16384 for value in auth_response.values())
- ):
- raise ValueError("Invalid authorization response.")
- result = _complete_chat_connection(user_id, tenant_id, auth_response)
+ completion = get_m365_connection_service().connection_flow_completion(user_id, request.args.get("state"))
+ result = _complete_workflow_connection(user_id, tenant_id, _callback_auth_response(), completion)
except (M365PolicyError, PermissionError, LookupError, ValueError, AzureError, requests.RequestException) as exc:
- result = _error_response(exc)
+ payload, status = _error_details(exc)
+ result = _connection_result_page(
+ kind="workflow", outcome="failed", completion=completion, status=status,
+ code=payload["error"], message=payload["message"], continue_url=CLASSIC_WORKFLOW_CONNECTION_URL,
+ )
response = make_response(result)
response.headers["Cache-Control"] = "private, no-store"
response.headers["Pragma"] = "no-cache"
@@ -328,10 +455,11 @@ def reconnect_m365_chat_connection():
user_id, tenant_id = _subject()
validate_m365_csrf()
data = _body()
- if set(data) != {"sources"}:
+ if "sources" not in data or set(data) - {"sources", "completion"}:
raise ValueError("Select only the Microsoft 365 sources to reconnect.")
result = get_m365_connection_service().start_profile_chat_connection(
user_id, tenant_id, data["sources"], _callback_uri(CHAT_CALLBACK_PATH),
+ completion=_completion_mode(data),
)
return jsonify({"success": True, **result})
@@ -431,10 +559,13 @@ def list_m365_conversation_audit(conversation_id):
@login_required
@user_required
def read_m365_profile_connection():
+ # Settings are initialized before an authenticated profile request.
+ from functions_settings import get_settings
user_id, tenant_id = _subject()
return jsonify({
"success": True,
"connection": get_m365_connection_service().current_connection(user_id, tenant_id),
+ "workflow_connections": workflow_connection_readiness(get_settings()),
"csrf_token": get_m365_csrf_token(),
})
@@ -446,13 +577,16 @@ def connect_m365_profile_account():
user_id, tenant_id = _subject()
validate_m365_csrf()
data = _body()
- if set(data) - {"sources", "scopes"}:
+ if set(data) - {"sources", "scopes", "completion"}:
raise ValueError("Invalid connection fields.")
+ completion = _completion_mode(data)
session_binding = secrets.token_urlsafe(32)
session["m365_workflow_oauth_binding"] = session_binding
+ # The registered sign-in callback; its state prefix routes the result here.
result = get_m365_connection_service().start_connection(
user_id, tenant_id, data.get("sources"),
- _callback_uri(), session_binding, scopes=data.get("scopes"),
+ _callback_uri(CHAT_CALLBACK_PATH), session_binding, scopes=data.get("scopes"),
+ completion=completion,
)
return jsonify({"success": True, **result})
@@ -461,24 +595,10 @@ def connect_m365_profile_account():
@login_required
@user_required
def complete_m365_profile_connection():
- user_id, tenant_id = _subject()
- auth_response = request.args.to_dict()
- if (
- set(auth_response) - {"state", "code", "error", "error_description", "error_uri", "session_state", "client_info"}
- or any(len(value) > 16384 for value in auth_response.values())
- ):
- raise ValueError("Invalid authorization response.")
- service = get_m365_connection_service()
- if (auth_response.get("state") or "").startswith(CHAT_AUTH_STATE_PREFIX):
- return _complete_chat_connection(user_id, tenant_id, auth_response)
- session_binding = session.pop("m365_workflow_oauth_binding", None)
- if not session_binding:
- raise M365PolicyError("m365_auth_state_invalid", "Start Connect again from Profile.")
- service.complete_connection(
- user_id, tenant_id, auth_response, session_binding,
- cache_writer=_publish_verified_workflow_cache_to_session,
- )
- return redirect("/profile?m365_connection=connected")
+ # Kept for deployments that registered this callback; new sign-ins use /getAToken.
+ if (request.args.get("state") or "").startswith(CHAT_AUTH_STATE_PREFIX):
+ return complete_m365_chat_connection_callback()
+ return complete_m365_workflow_connection_callback()
@bp.route("/api/m365/connections/disconnect", methods=["POST"])
@swagger_route(security=get_auth_security())
diff --git a/application/single_app/route_backend_v2.py b/application/single_app/route_backend_v2.py
index edde09277..ec304d463 100644
--- a/application/single_app/route_backend_v2.py
+++ b/application/single_app/route_backend_v2.py
@@ -171,6 +171,7 @@
keyvault_model_endpoint_save_helper,
)
from functions_keyvault_errors import KeyVaultSecretStorageError
+from functions_m365_connections import ensure_m365_workflow_encryption_key
from functions_safety_remediation import count_pending_safety_warnings
from functions_source_review import (
get_source_review_runtime_capabilities,
@@ -2115,6 +2116,21 @@ def v2_admin_patch_settings():
# a branding change has to refresh them.
_refresh_branding_static_files()
+ # Saved Microsoft 365 workflow connections need the deployment's encryption key.
+ # Create it now rather than when someone first connects; a failure is reported
+ # beside the Key Vault settings only when this save changed them.
+ m365_workflow_key = ensure_m365_workflow_encryption_key(get_settings())
+ if m365_workflow_key["status"] == "unavailable" and {
+ "enable_key_vault_secret_storage", "key_vault_name", "key_vault_identity",
+ } & normalized.keys():
+ warnings = {
+ **warnings,
+ "key_vault_name": (
+ "Microsoft 365 workflow connections are not ready. "
+ f"{m365_workflow_key['message']}"
+ ),
+ }
+
# The response reflects the draft back into the page's stored state, so a
# resolved secret has to be re-redacted on the way out, which
# _redact_admin_settings_for_v2 does below.
diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py
index d12c889d8..4a82c3201 100644
--- a/application/single_app/route_frontend_admin_settings.py
+++ b/application/single_app/route_frontend_admin_settings.py
@@ -78,6 +78,7 @@
from functions_document_actions import normalize_document_action_capabilities
from functions_model_capabilities import ModelTokenBudgetError, is_vision_capable_model
from functions_m365_transport import M365ProviderError, normalize_m365_transport_settings
+from functions_m365_connections import ensure_m365_workflow_encryption_key
from functions_orchestration_registry import (
build_capability_client_projection,
capabilities_for_contract,
@@ -3296,6 +3297,22 @@ def is_valid_url(url):
else:
print("ERROR: Could not fetch settings after update to ensure logo/favicon files.")
+ # Saved Microsoft 365 workflow connections need the deployment's encryption key.
+ # Create it now rather than when someone first connects; failures are reported
+ # only when this save changed the Key Vault configuration.
+ m365_workflow_key = ensure_m365_workflow_encryption_key(updated_settings_for_file or new_settings)
+ key_vault_changed = any(
+ settings.get(key) != new_settings.get(key)
+ for key in ('enable_key_vault_secret_storage', 'key_vault_name', 'key_vault_identity')
+ )
+ if m365_workflow_key['status'] == 'created':
+ flash(m365_workflow_key['message'], 'info')
+ elif m365_workflow_key['status'] == 'unavailable' and key_vault_changed:
+ flash(
+ f"Microsoft 365 workflow connections are not ready. {m365_workflow_key['message']}",
+ 'warning',
+ )
+
if governance_toggle_changes:
try:
log_governance_change(
diff --git a/application/single_app/route_frontend_authentication.py b/application/single_app/route_frontend_authentication.py
index 76fba8f30..9cab91872 100644
--- a/application/single_app/route_frontend_authentication.py
+++ b/application/single_app/route_frontend_authentication.py
@@ -17,7 +17,7 @@
from functions_authentication import _build_msal_app, _load_cache, _save_cache, clear_requested_oauth_scopes, create_ci_bearer_session, get_graph_authority, get_graph_endpoint, get_requested_oauth_scopes
from functions_debug import debug_print
from functions_settings import get_settings, sanitize_settings_for_user
-from functions_m365_connections import CHAT_AUTH_STATE_PREFIX
+from functions_m365_connections import CHAT_AUTH_STATE_PREFIX, WORKFLOW_AUTH_STATE_PREFIX
from swagger_wrapper import swagger_route, get_auth_security
def build_front_door_urls(front_door_url):
@@ -239,6 +239,10 @@ def authorized():
# The M365 flow owns state/nonce validation and preserves the current app session.
from route_backend_m365 import complete_m365_chat_connection_callback
return complete_m365_chat_connection_callback()
+ if (request.args.get('state') or '').startswith(WORKFLOW_AUTH_STATE_PREFIX):
+ # Saved workflow sign-in reuses this registered callback; its flow is validated server-side.
+ from route_backend_m365 import complete_m365_workflow_connection_callback
+ return complete_m365_workflow_connection_callback()
# Check for errors passed back from Azure AD
if request.args.get('error'):
error = request.args.get('error')
diff --git a/application/single_app/static/js/chat/chat-m365-connect.js b/application/single_app/static/js/chat/chat-m365-connect.js
index b7248e909..81766142a 100644
--- a/application/single_app/static/js/chat/chat-m365-connect.js
+++ b/application/single_app/static/js/chat/chat-m365-connect.js
@@ -213,9 +213,14 @@
}
}
function onMessage(event) {
- if (event.origin === window.location.origin && event.source === popup
- && event.data?.type === 'm365-profile-reconnected') {
+ if (event.origin !== window.location.origin || event.source !== popup) {
+ return;
+ }
+ if (event.data?.type === 'm365-profile-reconnected') {
finish();
+ } else if (event.data?.type === 'm365-connect-failed' && event.data?.kind === 'chat') {
+ const detail = typeof event.data.message === 'string' ? event.data.message.slice(0, 500) : '';
+ finish(new Error(`${detail || 'Microsoft 365 sign-in did not complete.'} No action was sent.`));
}
}
function onAbort() {
@@ -242,8 +247,9 @@
return;
}
// Profile reconnect saves credentials only; the saved-request connect/resume path would rerun the agent.
+ // A popup completion ends on a small result page that reports back here and closes.
const result = await api.requestJson('/api/m365/chat/connection/connect', {
- method: 'POST', body: { sources: selectedSources },
+ method: 'POST', body: { sources: selectedSources, completion: 'popup' },
});
if (!finished) {
popup.location.replace(authorizationUrl(result.authorization_url));
diff --git a/application/single_app/static/js/profile/profile-m365-connection-result.js b/application/single_app/static/js/profile/profile-m365-connection-result.js
new file mode 100644
index 000000000..eefc6aae5
--- /dev/null
+++ b/application/single_app/static/js/profile/profile-m365-connection-result.js
@@ -0,0 +1,83 @@
+// profile-m365-connection-result.js
+// Ends a Microsoft 365 sign-in on the page the callback renders.
+//
+// A sign-in started in a popup reports its outcome to the same-origin window that opened it,
+// then closes. The opener (V2 settings, a V2 chat notice, or the classic chat) shows the result,
+// so the popup never strands the user on another interface. A full-page sign-in shows the
+// outcome here and, after success, continues to the fixed settings page the server chose.
+
+(() => {
+ 'use strict';
+
+ // Only a same-origin absolute path; never another origin or a script URL.
+ const SAFE_PATH_PATTERN = /^\/(?![/\\])[^\s\\]*$/;
+
+ function readResult() {
+ const element = document.getElementById('m365-connection-result-data');
+ if (!element) {
+ return null;
+ }
+ try {
+ const value = JSON.parse(element.textContent || 'null');
+ return value && typeof value === 'object' ? value : null;
+ } catch {
+ return null;
+ }
+ }
+
+ function hasOpener() {
+ try {
+ return Boolean(window.opener) && window.opener !== window && !window.opener.closed;
+ } catch {
+ return false;
+ }
+ }
+
+ function openerMessage(result) {
+ const message = {
+ type: typeof result.type === 'string' ? result.type : 'm365-connect-failed',
+ kind: result.kind === 'workflow' ? 'workflow' : 'chat',
+ outcome: result.outcome === 'connected' ? 'connected' : 'failed',
+ };
+ if (message.outcome !== 'connected') {
+ message.code = typeof result.code === 'string' ? result.code : '';
+ message.message = typeof result.message === 'string' ? result.message : '';
+ }
+ return message;
+ }
+
+ function initialize() {
+ const result = readResult();
+ if (!result) {
+ return;
+ }
+ const closeButton = document.getElementById('m365-connection-result-close');
+ const closingHint = document.getElementById('m365-connection-result-closing');
+ closeButton?.addEventListener('click', () => window.close());
+
+ const popup = result.completion === 'popup' || (result.completion === 'auto' && hasOpener());
+ if (popup) {
+ closeButton?.classList.remove('d-none');
+ if (hasOpener()) {
+ window.opener.postMessage(openerMessage(result), window.location.origin);
+ closingHint?.classList.remove('d-none');
+ window.close();
+ }
+ return;
+ }
+
+ if (
+ result.outcome === 'connected'
+ && typeof result.continue_url === 'string'
+ && SAFE_PATH_PATTERN.test(result.continue_url)
+ ) {
+ window.location.replace(result.continue_url);
+ }
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', initialize, { once: true });
+ } else {
+ initialize();
+ }
+})();
diff --git a/application/single_app/static/js/profile/profile-m365.js b/application/single_app/static/js/profile/profile-m365.js
index a42f12e20..58f14aea9 100644
--- a/application/single_app/static/js/profile/profile-m365.js
+++ b/application/single_app/static/js/profile/profile-m365.js
@@ -85,10 +85,12 @@
function showChatConnectionReturn() {
const currentUrl = new URL(window.location.href);
const result = currentUrl.searchParams.get('m365_chat_connection');
- if (result === null) {
+ const workflowResult = currentUrl.searchParams.get('m365_connection');
+ if (result === null && workflowResult === null) {
return;
}
currentUrl.searchParams.delete('m365_chat_connection');
+ currentUrl.searchParams.delete('m365_connection');
window.history.replaceState(window.history.state, '', `${currentUrl.pathname}${currentUrl.search}${currentUrl.hash}`);
if (result === 'connected') {
window.opener?.postMessage({ type: 'm365-profile-reconnected' }, window.location.origin);
@@ -96,6 +98,23 @@
'Microsoft 365 sign-in completed. Return to your conversation and retry your original question. No past requests were retried.',
'success');
}
+ if (workflowResult === 'connected') {
+ showStatus('m365-connection-notice',
+ 'Microsoft 365 is connected for workflows. Each Run as workflow revision still needs your authorization.',
+ 'success');
+ }
+ }
+
+ function describeChatConnection(status, labels) {
+ if (status === 'available') {
+ return labels
+ ? `Signed in to Microsoft 365 for this session. Chat can use: ${labels}.`
+ : 'Signed in to Microsoft 365 for this session, but no source\'s permissions are granted yet. Connect the sources chat should use.';
+ }
+ if (status === 'reconnect_required') {
+ return `Microsoft 365 needs you to sign in again before chat can use ${labels || 'your sources'}.`;
+ }
+ return 'Not signed in to Microsoft 365 in this session. Chat asks you to connect the first time it needs a source, or connect here.';
}
async function loadChatConnection() {
@@ -107,26 +126,25 @@
try {
const response = await api.requestJson('/api/m365/chat/connection');
const chatConnection = response.connection;
- const descriptions = {
- available: 'Sign-in saved for this session',
- not_connected: 'No Microsoft 365 sign-in is saved for this session',
- reconnect_required: 'Reconnect Microsoft 365 before using these sources in chat'
- };
- if (!Object.prototype.hasOwnProperty.call(descriptions, chatConnection?.status)
+ const statuses = ['available', 'not_connected', 'reconnect_required'];
+ if (!statuses.includes(chatConnection?.status)
|| !Array.isArray(chatConnection.sources)
|| !chatConnection.sources.every(source => typeof source === 'string'
&& Object.prototype.hasOwnProperty.call(api.sourceLabels, source))) {
throw new Error('The chat sign-in status could not be verified. Refresh before trying again.');
}
+ const selected = chatConnection.sources;
root.querySelectorAll('[data-m365-chat-connect-source]').forEach(checkbox => {
- checkbox.checked = chatConnection.sources.includes(checkbox.dataset.m365ChatConnectSource);
+ checkbox.checked = selected.includes(checkbox.dataset.m365ChatConnectSource);
});
- document.getElementById('m365-chat-connection-details').textContent =
- `Sources saved for this session: ${chatConnection.sources.map(source => api.sourceLabels[source]).join(', ') || 'None'}.`;
+ const labels = chatConnection.sources.map(source => api.sourceLabels[source]).join(', ');
+ document.getElementById('m365-chat-connection-details').textContent = chatConnection.status === 'available'
+ ? 'Reconnect to renew this sign-in or to add sources.'
+ : '';
fields.disabled = false;
- showStatus('m365-chat-connection-status',
- `${descriptions[chatConnection.status]}. Access is checked when a source runs.`,
- chatConnection.status === 'reconnect_required' ? 'warning' : 'info');
+ showStatus('m365-chat-connection-status', describeChatConnection(chatConnection.status, labels),
+ chatConnection.status === 'reconnect_required' ? 'warning'
+ : chatConnection.status === 'available' ? 'success' : 'info');
} catch (error) {
showStatus('m365-chat-connection-status', error.message, 'danger');
}
@@ -202,8 +220,22 @@
root.querySelectorAll('[data-m365-connect-source]').forEach(checkbox => {
checkbox.checked = (connection?.sources || []).includes(checkbox.dataset.m365ConnectSource);
});
+ const readiness = response.workflow_connections;
+ const blocked = readiness?.available === false;
+ const readinessElement = document.getElementById('m365-connection-readiness');
+ if (blocked) {
+ showStatus('m365-connection-readiness',
+ typeof readiness.message === 'string' && readiness.message
+ ? readiness.message
+ : 'Workflow connections are not set up on this deployment yet. Ask an administrator.',
+ 'warning');
+ } else {
+ readinessElement.classList.add('d-none');
+ }
fields.disabled = false;
- document.getElementById('m365-connect-btn').textContent = connection?.id ? 'Reconnect Microsoft 365 for workflows' : 'Connect Microsoft 365 for workflows';
+ const connectButton = document.getElementById('m365-connect-btn');
+ connectButton.disabled = blocked;
+ connectButton.textContent = connection?.id && status !== 'disconnected' ? 'Reconnect Microsoft 365 for workflows' : 'Connect Microsoft 365 for workflows';
document.getElementById('m365-disconnect-btn').disabled = !connection?.id || status === 'disconnected';
showStatus('m365-connection-status', `Workflow connection: ${status.replaceAll('_', ' ')}. Connecting is separate from approving a workflow.`, status === 'connected' ? 'success' : 'info');
} catch (error) {
diff --git a/application/single_app/templates/m365_connection_result.html b/application/single_app/templates/m365_connection_result.html
new file mode 100644
index 000000000..ff9b3b996
--- /dev/null
+++ b/application/single_app/templates/m365_connection_result.html
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+ {{ title }}
+
+
+
+
+
+
+
+
+
+
{{ title }}
+
{{ message }}
+
+ SimpleChat has the result. You can close this window if it stays open.
+
Reconnecting does not change sharing approvals or preferences, saved workflow credentials, or workflow Run as authorizations. It does not enable disabled action capabilities or fix unrelated model or storage configuration errors.
Loading chat sign-in status...
-
This status reflects local saved sign-in state, not a live test of Microsoft 365 access.
+
Sources listed are those this session's sign-in already has permission for, including consent given at sign-in or by an administrator. Microsoft still checks your access each time a source runs.