Hlavicky X-MS365-* jako obycejne hodnoty, GET /users/{user_id}/calendar

- credentials: sifrovani AES-GCM/HKDF odstraneno, tri hlavicky se berou
  tak, jak jsou; bez hlavicek se pouziji hodnoty z prostredi
- zrusena promenna MS365_CREDENTIAL_ENCODING_SECRET a zavislost cryptography
- novy endpoint GET /users/{user_id}/calendar (objekt kalendare schranky,
  id a name) pro read-only overeni pristupu pres e-mail schranky
- C# ukazka posila hodnoty primo, CredentialEncoder smazan
- README: sekce o hlavickach vcetne PowerShell ukazky, zaznam zmen
- .gitignore: bin/ a obj/, zaverzovane obj/ soubory ukazky odstraneny z gitu

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-09-22 12:49:25 +02:00
co-authored by Claude Fable 5.1
parent fe3c3a736a
commit d80193da8b
20 changed files with 61 additions and 560 deletions
-1
View File
@@ -10,7 +10,6 @@ class Settings:
tenant_id: str = os.getenv("MS365_TENANT_ID", "")
client_id: str = os.getenv("MS365_CLIENT_ID", "")
client_secret: str = os.getenv("MS365_CLIENT_SECRET", "")
credential_encoding_secret: str = os.getenv("MS365_CREDENTIAL_ENCODING_SECRET", "")
graph_base_url: str = os.getenv("MS365_GRAPH_BASE_URL", "https://graph.microsoft.com/v1.0")
graph_scope: str = os.getenv("MS365_GRAPH_SCOPE", "https://graph.microsoft.com/.default")
request_timeout_seconds: float = float(os.getenv("MS365_REQUEST_TIMEOUT_SECONDS", "30"))
+7 -96
View File
@@ -1,96 +1,34 @@
import base64
import binascii
import json
from datetime import datetime, timezone
from typing import Annotated
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from fastapi import Header, HTTPException, status
from .config import Settings, settings
CREDENTIAL_VERSION = "v1"
_HKDF_SALT = b"microsoft-365-service.credentials.v1"
def _b64url_decode(value: str) -> bytes:
padding = "=" * (-len(value) % 4)
try:
return base64.urlsafe_b64decode((value + padding).encode("ascii"))
except (binascii.Error, UnicodeEncodeError) as exc:
raise ValueError("invalid base64url") from exc
def _derive_key(shared_secret: str, header_name: str) -> bytes:
return HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=_HKDF_SALT,
info=header_name.encode("utf-8"),
).derive(shared_secret.encode("utf-8"))
def decode_credential_header(encoded_value: str, *, header_name: str, shared_secret: str) -> str:
parts = encoded_value.split(".")
if len(parts) != 3 or parts[0] != CREDENTIAL_VERSION:
raise ValueError("unsupported credential encoding version")
nonce = _b64url_decode(parts[1])
ciphertext = _b64url_decode(parts[2])
if len(nonce) != 12:
raise ValueError("invalid nonce length")
key = _derive_key(shared_secret, header_name)
aad = header_name.encode("utf-8")
plaintext = AESGCM(key).decrypt(nonce, ciphertext, aad)
payload = json.loads(plaintext.decode("utf-8"))
expires_at = payload.get("expires_at")
if expires_at:
expires_at_datetime = datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
if expires_at_datetime <= datetime.now(timezone.utc):
raise ValueError("credential value is expired")
value = payload.get("value")
if not isinstance(value, str) or not value:
raise ValueError("credential payload must contain a non-empty value")
return value
async def get_request_settings(
tenant_id: Annotated[
str | None,
Header(
alias="X-MS365-Tenant-Id",
description="Encoded value of MS365_TENANT_ID, the Microsoft Entra tenant id. Format: v1.<base64url nonce>.<base64url ciphertext+tag>.",
description="Microsoft Entra tenant id of the client (Directory / tenant ID).",
),
] = None,
client_id: Annotated[
str | None,
Header(
alias="X-MS365-Client-Id",
description="Encoded value of MS365_CLIENT_ID, the Microsoft Entra application client id. Format: v1.<base64url nonce>.<base64url ciphertext+tag>.",
description="Application (client) ID of the CSBOT Entra app registration.",
),
] = None,
client_secret: Annotated[
str | None,
Header(
alias="X-MS365-Client-Secret",
description="Encoded value of MS365_CLIENT_SECRET, the Microsoft Entra application client secret. Format: v1.<base64url nonce>.<base64url ciphertext+tag>.",
),
] = None,
credential_version: Annotated[
str | None,
Header(
alias="X-MS365-Credential-Version",
description="Credential encoding version. Currently supported value: v1.",
description="Client secret VALUE of the CSBOT Entra app registration (not the secret id).",
),
] = None,
) -> Settings:
"""Per-request credentials. All three headers together, or none (then env values are used)."""
header_values = {
"X-MS365-Tenant-Id": tenant_id,
"X-MS365-Client-Id": client_id,
@@ -107,35 +45,8 @@ async def get_request_settings(
detail={"message": "Incomplete Microsoft 365 credential headers.", "missing_headers": missing},
)
if credential_version != CREDENTIAL_VERSION:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"X-MS365-Credential-Version must be {CREDENTIAL_VERSION}.",
)
if not settings.credential_encoding_secret:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="MS365_CREDENTIAL_ENCODING_SECRET is not configured.",
)
try:
decoded = {
name: decode_credential_header(
value,
header_name=name,
shared_secret=settings.credential_encoding_secret,
)
for name, value in provided.items()
}
except (InvalidTag, ValueError, json.JSONDecodeError, UnicodeDecodeError) as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Microsoft 365 credential headers are malformed or cannot be authenticated.",
) from exc
return settings.with_credentials(
tenant_id=decoded["X-MS365-Tenant-Id"],
client_id=decoded["X-MS365-Client-Id"],
client_secret=decoded["X-MS365-Client-Secret"],
tenant_id=provided["X-MS365-Tenant-Id"],
client_id=provided["X-MS365-Client-Id"],
client_secret=provided["X-MS365-Client-Secret"],
)
+6
View File
@@ -119,6 +119,12 @@ async def send_mail(
response.status_code = status.HTTP_202_ACCEPTED
@router.get("/users/{user_id}/calendar")
async def get_calendar(user_id: str, service: CalendarService = Depends(get_calendar_service)) -> Any:
"""user_id is a Graph user id or the mailbox address, e.g. servis@firma.cz."""
return await service.get_calendar(user_id=user_id)
@router.get("/users/{user_id}/calendar/events")
async def list_events(
user_id: str,
+4
View File
@@ -91,6 +91,10 @@ class CalendarService:
def __init__(self, graph: MicrosoftGraphClient) -> None:
self._graph = graph
async def get_calendar(self, user_id: str) -> Any:
"""Default calendar of the mailbox (id, name, owner). Cheapest read-only access check."""
return await self._graph.request("GET", f"/users/{graph_segment(user_id)}/calendar")
async def list_events(self, user_id: str, top: int = 25) -> Any:
params = {
"$top": top,