Files
cowork-local/core/ms365_auth.py
T
anhtnm1andClaude Opus 5 e29a0ccdbd refactor: vá 4 hồi quy, tách 4 file chạm trần LOC, docstring lên 100%
Hồi quy đã vá
-------------
F-12  Kéo–thả hoặc dán tệp vào ô chat ném NameError. R08 tách `_Input` sang
      `chat_input_box.py` nhưng để `_paths_from_mime()` ở lại
      `composer_widget.py`, nên hai hàm sự kiện Qt gọi một cái tên không tồn
      tại. Bốn hàm dùng chung chuyển sang `composer_mime.py` — module thứ ba
      là chỗ duy nhất không lặp lại được lỗi này. Đo lại: cả thả lẫn dán đều
      gắn 1 tệp, khớp bản trước refactor.

F-01  Đổi provider thì bộ chọn model AI-Edit không làm gì. Hook cũ kiểm
      `folder.ai_model_combo`, thuộc tính R08-T12 đã dời sang
      `ai_panel.resolver`. Làm mới vô điều kiện, đúng như tab cũ: lần lấy đầu
      tiên hỏng thì đổi provider chính là lúc phải thử lại.

F-07  Hàng chọn kỳ của Dashboard bị đẩy xuống dưới các thẻ số liệu. Hàng này
      lọc CẢ BA thẻ con chứ không riêng biểu đồ, nên để nó nằm dưới là bắt
      người dùng đọc con số trước khi thấy con số đó tính cho kỳ nào. Kèm
      theo: `TokenUsageCardWidget` bị bỏ sót `setContentsMargins(0,0,0,0)`
      mà hai thẻ con còn lại đã có, đẩy cả hàng thẻ lệch 9px.
      `check_layout_geometry` nay khớp TỪNG BYTE với bản trước refactor.

F-11  Hai lớp khai trùng tên phương thức; Python giữ bản sau nên bản đầu là
      mã chết. `co4e_tab.py::showEvent` bản đầu gọi `_narrow_guard.attach()`
      và không bao giờ chạy.

Tách file (F-09)
----------------
Bốn file chạm trần 400 dòng, mỗi lần cắt ra một trách nhiệm thật:

    graph_renderer.py         -> graph_scene_builder.py + graph_export.py
    co4e_workflow_service.py  -> co4e_run_history.py
    json_config_repository.py -> config_sections.py
    agents_admin_tab.py       -> shared/agent_kind_visuals.py

File cuối còn xoá 3 bản sao của hàm đã có trong `shared/formatters.py`,
giống hệt đến từng dòng — nay định dạng thời gian và avatar không lệch nhau
giữa các bảng Giám sát nữa.

Docstring
---------
41,6% -> 100% (3.478/3.478 định nghĩa production), kể cả module dormant và
phương thức dunder. Toàn bộ phần bổ sung viết bằng tiếng Việt; comment tiếng
Anh có sẵn giữ nguyên — dịch ngược là một đợt riêng.

Seam chưa nối dây (F-05)
------------------------
9 seam mang nhãn `SEAM · dựng <ngày>` kèm hai câu: được nối khi nào, và để
dormant thì hỏng gì. Ngày lấy từ lịch sử git, không phải hạn tự đặt. Gate O
đọc nhãn đó và nhắc khi quá 30 ngày.

859 test xanh · 4/4 cổng CASAN · 19/24 checker khớp từng byte bản cũ.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:41:45 +09:00

247 lines
11 KiB
Python

"""Microsoft 365 sign-in — real OAuth via MSAL's device-code flow.
ZERO-CONFIG "connect like Claude": the user just clicks Sign in, opens a short
URL, enters a one-time code, and signs in with their own Microsoft account
(SSO/MFA as their org normally does) — NO Tenant ID / Client ID to type. This
works because we ship a well-known Microsoft first-party PUBLIC multi-tenant
client (``DEFAULT_CLIENT_ID`` — the Microsoft Graph PowerShell client, same
technique the Azure CLI / Graph CLI use) against the ``common`` authority, so
any work/school (or personal) account can consent to the delegated Graph
scopes interactively. Device-code flow needs no client secret and no embedded
browser / redirect URI.
Orgs that require their OWN app registration can still override tenant_id /
client_id in config (``ms365.tenant_id`` / ``ms365.client_id``); when both are
blank the bundled defaults are used. The signed-in token (+ refresh token) is
cached in the OS credential store (Windows Credential Manager / macOS Keychain
/ Linux Secret Service, via ``keyring``) — never written into config.json, and
never stored in plaintext. With no OS credential store (e.g. a headless Linux
box), it falls back to a local file at ``TOKEN_CACHE_PATH``.
"""
from __future__ import annotations
from typing import Callable, List, Optional
from ..config import CONFIG_DIR
TOKEN_CACHE_PATH = CONFIG_DIR / "ms365_token_cache.bin" # fallback only — see module docstring
_KEYRING_SERVICE = "cowork_local_ms365"
_KEYRING_KEY = "token_cache"
# Bundled zero-config sign-in identity. This is Microsoft's OWN public,
# multi-tenant "Microsoft Graph PowerShell" client — a first-party client that
# permits the device-code public-client flow and is broadly pre-consented for
# delegated Graph scopes, so users need not register (or type) any app id. The
# same well-known-public-client approach the Azure CLI, Graph CLI and many
# tools use. NOT a secret (public clients have none). Override via config only
# if the tenant blocks it and mandates a private app registration.
DEFAULT_CLIENT_ID = "14d82eec-204b-4c2f-b7e8-296a70dab67e"
# "common" = any Microsoft account (work/school or personal); the user picks
# which account at sign-in. Use a specific tenant id only to restrict to one org.
DEFAULT_TENANT = "common"
# One shared scope set for every connector — MSAL requests them all at sign-in
# so switching a connector on later doesn't force a second sign-in. Some
# (ChannelMessage.Send, OnlineMeetingTranscript.Read.All) need the tenant
# admin to have consented the app already.
SCOPES: List[str] = [
"User.Read",
"Mail.Read", "Mail.Send",
"Calendars.Read",
"Team.ReadBasic.All", "Channel.ReadBasic.All",
"ChannelMessage.Read.All", "ChannelMessage.Send",
"Files.Read.All", "Files.ReadWrite.All",
"Sites.Read.All",
"OnlineMeetings.Read", "OnlineMeetingTranscript.Read.All",
]
class Ms365AuthError(Exception):
"""Lỗi khi đăng nhập hoặc lấy token Microsoft 365."""
pass
def _load_cache():
"""Nạp kho token đã lưu từ đĩa (nếu có)."""
import msal
cache = msal.SerializableTokenCache()
serialized = None
try:
import keyring
serialized = keyring.get_password(_KEYRING_SERVICE, _KEYRING_KEY)
except Exception: # noqa: BLE001 - no OS credential store available
serialized = None
if serialized is None and TOKEN_CACHE_PATH.exists():
try:
serialized = TOKEN_CACHE_PATH.read_text(encoding="utf-8")
except OSError:
serialized = None
if serialized:
try:
cache.deserialize(serialized)
except ValueError:
pass
return cache
def _save_cache(cache) -> None:
"""Ghi kho token xuống đĩa, chỉ khi nó thật sự thay đổi."""
if not cache.has_state_changed:
return
serialized = cache.serialize()
try:
import keyring
keyring.set_password(_KEYRING_SERVICE, _KEYRING_KEY, serialized)
# Migrated to the OS credential store — drop any older plaintext file
# so the token isn't left duplicated on disk.
if TOKEN_CACHE_PATH.exists():
try:
TOKEN_CACHE_PATH.unlink()
except OSError:
pass
return
except Exception: # noqa: BLE001 - no OS credential store available
pass
TOKEN_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
TOKEN_CACHE_PATH.write_text(serialized, encoding="utf-8")
def _app(tenant_id: str, client_id: str):
"""Dựng ứng dụng MSAL cho tenant/client đã cấu hình; thiếu ``msal`` thì báo lỗi
kèm hướng dẫn cài.
"""
try:
import msal
except ImportError as exc:
raise Ms365AuthError(
"The 'msal' package isn't installed. Run: pip install msal") from exc
# Zero-config: blank tenant/client → the bundled public client + "common"
# authority, so sign-in works with no Azure app registration to enter.
tenant_id = (tenant_id or "").strip() or DEFAULT_TENANT
client_id = (client_id or "").strip() or DEFAULT_CLIENT_ID
cache = _load_cache()
authority = f"https://login.microsoftonline.com/{tenant_id}"
try:
# validate_authority=False: skip MSAL's authority-discovery network call at
# construction time — the host is always our own hardcoded, trusted
# login.microsoftonline.com, so there is nothing to validate. Without this,
# simply building the app object (e.g. to check "is anyone signed in?")
# would silently reach out to Microsoft even when the user turned "Allow
# external internet access" off, and a mistyped tenant id would raise here
# instead of at an explicit sign-in action.
app = msal.PublicClientApplication(
client_id, authority=authority, token_cache=cache, validate_authority=False)
except Exception as exc: # noqa: BLE001 - malformed tenant/client id, etc.
raise Ms365AuthError(f"Invalid Tenant ID / Client ID: {exc}") from exc
return app, cache
def signed_in_account(tenant_id: str, client_id: str) -> Optional[dict]:
"""The cached account, if any — a local cache lookup, no network call."""
try:
app, _cache = _app(tenant_id, client_id)
except Ms365AuthError:
return None
accounts = app.get_accounts()
return accounts[0] if accounts else None
def sign_in_device_code(tenant_id: str, client_id: str, on_code: Callable[[dict], None]) -> dict:
"""Blocking device-code sign-in — call this off the UI thread.
``on_code`` is invoked once with the MSAL device-flow dict so the caller can
both auto-open the browser and show a copyable code. Useful keys:
``user_code`` (the code to enter), ``verification_uri`` (the page to open),
``verification_uri_complete`` (URL with the code pre-filled, when the tenant
returns it) and ``message`` (the full human-readable instruction). Returns
the MSAL token result dict; raises Ms365AuthError on failure/timeout."""
app, cache = _app(tenant_id, client_id)
flow = app.initiate_device_flow(scopes=SCOPES)
if "user_code" not in flow:
raise Ms365AuthError(flow.get("error_description") or "Could not start device sign-in.")
on_code(flow)
result = app.acquire_token_by_device_flow(flow) # blocks, polling until done/expired
_save_cache(cache)
if not result or "access_token" not in result:
desc = (result or {}).get("error_description") or "Sign-in failed."
raise Ms365AuthError(desc)
return result
def identity_from_result(result: dict) -> str:
"""The signed-in user's UPN/email from a ``sign_in_device_code()`` result —
used by ``login_dialog.py`` to map an SSO sign-in to a provisioned
``accounts.Account`` by username. MSAL requests ``openid``/``profile``
implicitly on every token request, so ``id_token_claims`` is present
alongside the resource scopes in ``SCOPES``."""
claims = (result or {}).get("id_token_claims") or {}
return claims.get("preferred_username") or claims.get("email") or ""
def get_access_token(tenant_id: str, client_id: str) -> str:
"""Silently reuse the cached sign-in. Raises Ms365AuthError when there is
no valid session — the caller (a Graph call) should surface that as a
normal tool failure telling the user to sign in again from Settings."""
app, cache = _app(tenant_id, client_id)
accounts = app.get_accounts()
if not accounts:
raise Ms365AuthError("Not signed in to Microsoft 365 — sign in from Settings first.")
result = app.acquire_token_silent(SCOPES, account=accounts[0])
_save_cache(cache)
if not result or "access_token" not in result:
raise Ms365AuthError("Microsoft 365 sign-in expired — sign in again from Settings.")
return result["access_token"]
# ---- zero-config convenience wrappers (use the bundled default identity) ----
# The UI calls these with no args for the "connect like Claude" flow; they read
# the optional config overrides so a custom Azure app still works.
def _ids(config=None):
"""Cặp (tenant_id, client_id) đọc từ cấu hình MS365."""
ms365 = (config.ms365 if config is not None else {}) or {}
return ms365.get("tenant_id", ""), ms365.get("client_id", "")
def current_identity(config=None) -> str:
"""Signed-in account's UPN/email, or '' if not signed in (no network)."""
acc = signed_in_account(*_ids(config))
return (acc or {}).get("username", "") if acc else ""
def is_signed_in(config=None) -> bool:
"""Đã có tài khoản MS365 đăng nhập sẵn hay chưa."""
return signed_in_account(*_ids(config)) is not None
def sign_in(on_code: Callable[[dict], None], config=None) -> dict:
"""Zero-config device-code sign-in — blocking, call off the UI thread.
``on_code`` receives the MSAL device-flow dict (user_code/verification_uri/…)."""
return sign_in_device_code(*_ids(config), on_code)
def sign_out_default(config=None) -> None:
"""Đăng xuất tài khoản MS365 theo cấu hình hiện tại."""
sign_out(*_ids(config))
def sign_out(tenant_id: str, client_id: str) -> None:
"""Đăng xuất và xoá token của một tenant/client khỏi kho."""
try:
app, cache = _app(tenant_id, client_id)
for acc in app.get_accounts():
app.remove_account(acc)
_save_cache(cache)
except Ms365AuthError:
pass
try:
import keyring
keyring.delete_password(_KEYRING_SERVICE, _KEYRING_KEY)
except Exception: # noqa: BLE001 - nothing stored there, or no credential store
pass
try:
if TOKEN_CACHE_PATH.exists():
TOKEN_CACHE_PATH.unlink()
except OSError:
pass