From 41fb87cefa7e6e8291aa241742dfab78e179410b Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Thu, 28 May 2026 13:10:01 +0200 Subject: [PATCH] credentials do hlavicky + crypto + docu --- README.md | 196 ++++++++++++++++++++++++++++++++++++++++----- app/config.py | 11 ++- app/credentials.py | 141 ++++++++++++++++++++++++++++++++ app/routes.py | 20 ++--- requirements.txt | 1 + 5 files changed, 338 insertions(+), 31 deletions(-) create mode 100644 app/credentials.py diff --git a/README.md b/README.md index 49c05d5..0a1c1c2 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,185 @@ # Microsoft 365 Service -FastAPI service for server-to-server communication with Microsoft 365 through Microsoft Graph. +FastAPI služba pro server-to-server komunikaci s Microsoft 365 přes Microsoft Graph. -## Configuration +## Konfigurace -Create an app registration in Microsoft Entra ID, grant the required Microsoft Graph application permissions, and expose these environment variables: +V Microsoft Entra ID vytvořte app registration, přidělte požadovaná aplikační oprávnění pro Microsoft Graph a nastavte tyto environment proměnné: ```env MS365_TENANT_ID=00000000-0000-0000-0000-000000000000 MS365_CLIENT_ID=00000000-0000-0000-0000-000000000000 MS365_CLIENT_SECRET=secret-value +MS365_CREDENTIAL_ENCODING_SECRET=shared-secret-for-header-credentials MS365_GRAPH_BASE_URL=https://graph.microsoft.com/v1.0 MS365_GRAPH_SCOPE=https://graph.microsoft.com/.default MS365_REQUEST_TIMEOUT_SECONDS=30 ``` -The service uses the OAuth2 client credentials flow, so Microsoft Graph permissions must be application permissions approved by an administrator. +Služba používá OAuth2 client credentials flow, takže oprávnění pro Microsoft Graph musí být aplikační oprávnění schválená administrátorem. -## Implemented Services +## Credentials v request hlavičkách -- Users: list and read users. -- Mail: list messages and send email, including file attachments. -- Calendar: list and create events, including Teams online meetings. -- OneDrive: list root files and upload small files. -- Groups and Teams: list groups and list team channels. +Pokud se credentials předávají pro každý request v hlavičkách místo environment proměnných, použijte vlastní HTTP hlavičky s prefixem `X-`. Názvy hlaviček musí odpovídat vzoru `X-MS365-(NAME)` a hodnoty hlaviček musí obsahovat zakódované credentials, ne plaintext secrety. + +Doporučené hlavičky: + +```http +X-MS365-Tenant-Id: +X-MS365-Client-Id: +X-MS365-Client-Secret: +X-MS365-Credential-Version: v1 +``` + +Tyto hlavičky nesou stejné Microsoft Entra aplikační credentials, které by jinak byly nastavené přes `MS365_TENANT_ID`, `MS365_CLIENT_ID` a `MS365_CLIENT_SECRET`. Rozdíl je pouze ve způsobu přenosu: každá hodnota je před vložením do HTTP hlavičky zašifrovaná. + +Zakódovaná hodnota musí být službou replikovatelně zpracovatelná, ale nesmí být čitelná bez sdíleného secretu, který zná volající i služba. Nepoužívejte samotné Base64, URL encoding, ROT encoding ani jinou reverzibilní obfuskaci bez tajného klíče. + +Doporučený formát zakódované hodnoty: + +```text +v1.. +``` + +Pravidla zpracování: + +- Dekódovat pouze hlavičky s prefixem `X-MS365-`. +- Před dekódováním credentials ověřit verzi. +- Každou zakódovanou hodnotu dešifrovat a autentizovat pomocí AES-256-GCM. +- Šifrovací klíč odvodit z `MS365_CREDENTIAL_ENCODING_SECRET` pomocí HKDF-SHA256. +- Použít associated data navázaná na název hlavičky, například `X-MS365-Client-Secret`, aby zakódovanou hodnotu nešlo přesunout mezi hlavičkami. +- Odmítnout chybějící, poškozené, expirované hodnoty nebo hodnoty, které neprojdou autentizací. +- Nikdy nelogovat dekódované credentials ani celé zakódované hodnoty hlaviček. Maximálně logovat název hlavičky, verzi credentials a krátký fingerprint. + +Příklad payloadu před šifrováním: + +```json +{ + "value": "tenant-id-client-id-or-client-secret", + "issued_at": "2026-05-28T00:00:00Z", + "expires_at": "2026-05-28T01:00:00Z" +} +``` + +Tím zůstanou hodnoty v hlavičkách bezpečné i při průchodu systémy, které vidí HTTP metadata, a zároveň je může deterministicky zpracovat každá instance služby, která zná sdílený secret. + +### Příprava header credentials v .NET + +Volající musí použít stejný sdílený secret, jaký má služba v `MS365_CREDENTIAL_ENCODING_SECRET`. Každá credential hodnota se šifruje samostatně a váže se na cílový název hlavičky. + +Příklad pro .NET 8+: + +```csharp +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +static string Base64Url(byte[] value) +{ + return Convert.ToBase64String(value) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); +} + +static byte[] HkdfSha256(byte[] inputKeyMaterial, byte[] salt, byte[] info, int length) +{ + using var hmacExtract = new HMACSHA256(salt); + var pseudoRandomKey = hmacExtract.ComputeHash(inputKeyMaterial); + + var output = new List(); + var previous = Array.Empty(); + var counter = 1; + + while (output.Count < length) + { + using var hmacExpand = new HMACSHA256(pseudoRandomKey); + var blockInput = previous + .Concat(info) + .Concat(new[] { (byte)counter }) + .ToArray(); + + previous = hmacExpand.ComputeHash(blockInput); + output.AddRange(previous); + counter++; + } + + return output.Take(length).ToArray(); +} + +static string EncodeCredential(string value, string headerName, string sharedSecret) +{ + var payload = JsonSerializer.Serialize(new + { + value, + issued_at = DateTimeOffset.UtcNow.ToString("O"), + expires_at = DateTimeOffset.UtcNow.AddHours(1).ToString("O") + }); + + var salt = Encoding.UTF8.GetBytes("microsoft-365-service.credentials.v1"); + var key = HkdfSha256( + Encoding.UTF8.GetBytes(sharedSecret), + salt, + Encoding.UTF8.GetBytes(headerName), + 32); + + var nonce = RandomNumberGenerator.GetBytes(12); + var plaintext = Encoding.UTF8.GetBytes(payload); + var ciphertext = new byte[plaintext.Length]; + var tag = new byte[16]; + var aad = Encoding.UTF8.GetBytes(headerName); + + using var aes = new AesGcm(key, tagSizeInBytes: 16); + aes.Encrypt(nonce, plaintext, ciphertext, tag, aad); + + var ciphertextAndTag = ciphertext.Concat(tag).ToArray(); + return $"v1.{Base64Url(nonce)}.{Base64Url(ciphertextAndTag)}"; +} + +var sharedSecret = "same-value-as-MS365_CREDENTIAL_ENCODING_SECRET"; + +var headers = new Dictionary +{ + ["X-MS365-Tenant-Id"] = EncodeCredential( + "00000000-0000-0000-0000-000000000000", + "X-MS365-Tenant-Id", + sharedSecret), + ["X-MS365-Client-Id"] = EncodeCredential( + "00000000-0000-0000-0000-000000000000", + "X-MS365-Client-Id", + sharedSecret), + ["X-MS365-Client-Secret"] = EncodeCredential( + "client-secret-value", + "X-MS365-Client-Secret", + sharedSecret), + ["X-MS365-Credential-Version"] = "v1" +}; +``` + +Příklad requestu: + +```csharp +using var http = new HttpClient(); +using var request = new HttpRequestMessage(HttpMethod.Get, "https://service.example.com/users?top=25"); + +foreach (var header in headers) +{ + request.Headers.Add(header.Key, header.Value); +} + +using var response = await http.SendAsync(request); +response.EnsureSuccessStatusCode(); +``` + +## Implementované služby + +- Users: výpis a načtení uživatelů. +- Mail: výpis zpráv a odesílání e-mailů včetně příloh. +- Calendar: výpis a vytváření událostí včetně Teams online meetingů. +- OneDrive: výpis souborů v rootu a upload malých souborů. +- Groups a Teams: výpis skupin a výpis kanálů týmu. ## API @@ -43,24 +199,24 @@ GET /groups?top=25 GET /teams/{team_id}/channels ``` -`user_id` can be a Microsoft Graph user id or user principal name, for example `jane@example.com`. +`user_id` může být Microsoft Graph user id nebo user principal name, například `jane@example.com`. -## Required Graph Permissions +## Požadovaná Graph oprávnění -Grant only the permissions your deployment actually uses: +Přidělte pouze oprávnění, která dané nasazení skutečně používá: - Users: `User.Read.All` -- Mail read: `Mail.Read` -- Mail send: `Mail.Send` -- Calendar read/write: `Calendars.ReadWrite` -- OneDrive read/write: `Files.ReadWrite.All` -- Groups and Teams channel listing: `Group.Read.All`, `Team.ReadBasic.All`, `Channel.ReadBasic.All` +- Čtení mailů: `Mail.Read` +- Odesílání mailů: `Mail.Send` +- Čtení a zápis do kalendáře: `Calendars.ReadWrite` +- Čtení a zápis do OneDrive: `Files.ReadWrite.All` +- Výpis skupin a Teams kanálů: `Group.Read.All`, `Team.ReadBasic.All`, `Channel.ReadBasic.All` -## Run Locally +## Lokální spuštění ```bash pip install -r requirements.txt uvicorn app.main:app --reload ``` -Open `http://localhost:8000/docs` for the generated OpenAPI UI. +Vygenerované OpenAPI UI otevřete na `http://localhost:8000/docs`. diff --git a/app/config.py b/app/config.py index 89f0c3d..59032e5 100644 --- a/app/config.py +++ b/app/config.py @@ -1,5 +1,5 @@ import os -from dataclasses import dataclass +from dataclasses import dataclass, replace @dataclass(frozen=True) @@ -10,6 +10,7 @@ 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")) @@ -22,5 +23,13 @@ class Settings: def is_configured(self) -> bool: return bool(self.tenant_id and self.client_id and self.client_secret) + def with_credentials(self, tenant_id: str, client_id: str, client_secret: str) -> "Settings": + return replace( + self, + tenant_id=tenant_id, + client_id=client_id, + client_secret=client_secret, + ) + settings = Settings() diff --git a/app/credentials.py b/app/credentials.py new file mode 100644 index 0000000..0fd2d65 --- /dev/null +++ b/app/credentials.py @@ -0,0 +1,141 @@ +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...", + ), + ] = 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...", + ), + ] = 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...", + ), + ] = None, + credential_version: Annotated[ + str | None, + Header( + alias="X-MS365-Credential-Version", + description="Credential encoding version. Currently supported value: v1.", + ), + ] = None, +) -> Settings: + header_values = { + "X-MS365-Tenant-Id": tenant_id, + "X-MS365-Client-Id": client_id, + "X-MS365-Client-Secret": client_secret, + } + provided = {name: value for name, value in header_values.items() if value} + if not provided: + return settings + + missing = [name for name, value in header_values.items() if not value] + if missing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + 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"], + ) diff --git a/app/routes.py b/app/routes.py index 3becec9..133ab72 100644 --- a/app/routes.py +++ b/app/routes.py @@ -2,17 +2,17 @@ from typing import Any from fastapi import APIRouter, Body, Depends, Query, Response, status -from .config import settings +from .config import Settings +from .credentials import get_request_settings from .graph_client import MicrosoftGraphClient from .schemas import CalendarEventRequest, DriveUploadRequest, SendMailRequest from .services import CalendarService, DriveService, GroupsService, MailService, UsersService router = APIRouter(tags=["microsoft365"]) -graph_client = MicrosoftGraphClient(settings) -def get_graph_client() -> MicrosoftGraphClient: - return graph_client +def get_graph_client(request_settings: Settings = Depends(get_request_settings)) -> MicrosoftGraphClient: + return MicrosoftGraphClient(request_settings) def get_users_service(graph: MicrosoftGraphClient = Depends(get_graph_client)) -> UsersService: @@ -36,13 +36,13 @@ def get_groups_service(graph: MicrosoftGraphClient = Depends(get_graph_client)) @router.get("/status") -def microsoft365_status() -> dict[str, Any]: +def microsoft365_status(request_settings: Settings = Depends(get_request_settings)) -> dict[str, Any]: return { - "configured": settings.is_configured, - "tenant_id": bool(settings.tenant_id), - "client_id": bool(settings.client_id), - "client_secret": bool(settings.client_secret), - "graph_base_url": settings.graph_base_url, + "configured": request_settings.is_configured, + "tenant_id": bool(request_settings.tenant_id), + "client_id": bool(request_settings.client_id), + "client_secret": bool(request_settings.client_secret), + "graph_base_url": request_settings.graph_base_url, } diff --git a/requirements.txt b/requirements.txt index 8780c48..a729554 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ fastapi httpx +cryptography pydantic[email] uvicorn[standard]