credentials do hlavicky + crypto + docu

This commit is contained in:
JiriUhlir
2026-05-28 13:10:01 +02:00
parent f3553dec26
commit 41fb87cefa
5 changed files with 338 additions and 31 deletions
+176 -20
View File
@@ -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: <encoded MS365_TENANT_ID>
X-MS365-Client-Id: <encoded MS365_CLIENT_ID>
X-MS365-Client-Secret: <encoded 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.<base64url nonce>.<base64url ciphertext-and-auth-tag>
```
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<byte>();
var previous = Array.Empty<byte>();
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<string, string>
{
["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`.
+10 -1
View File
@@ -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()
+141
View File
@@ -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.<base64url nonce>.<base64url ciphertext+tag>.",
),
] = 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>.",
),
] = 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.",
),
] = 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"],
)
+10 -10
View File
@@ -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,
}
+1
View File
@@ -1,4 +1,5 @@
fastapi
httpx
cryptography
pydantic[email]
uvicorn[standard]