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
+4
View File
@@ -174,3 +174,7 @@ cython_debug/
# PyPI configuration file # PyPI configuration file
.pypirc .pypirc
# ---> .NET (examples)
bin/
obj/
+25 -149
View File
@@ -10,7 +10,6 @@ V Microsoft Entra ID vytvořte app registration, přidělte požadovaná aplika
MS365_TENANT_ID=00000000-0000-0000-0000-000000000000 MS365_TENANT_ID=00000000-0000-0000-0000-000000000000
MS365_CLIENT_ID=00000000-0000-0000-0000-000000000000 MS365_CLIENT_ID=00000000-0000-0000-0000-000000000000
MS365_CLIENT_SECRET=secret-value 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_BASE_URL=https://graph.microsoft.com/v1.0
MS365_GRAPH_SCOPE=https://graph.microsoft.com/.default MS365_GRAPH_SCOPE=https://graph.microsoft.com/.default
MS365_REQUEST_TIMEOUT_SECONDS=30 MS365_REQUEST_TIMEOUT_SECONDS=30
@@ -20,157 +19,28 @@ Služba používá OAuth2 client credentials flow, takže oprávnění pro Micro
## Credentials v request hlavičkách ## Credentials v request hlavičkách
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. Credentials se posílají v každém requestu v hlavičkách jako obyčejné hodnoty:
Doporučené hlavičky:
```http ```http
X-MS365-Tenant-Id: <encoded MS365_TENANT_ID> X-MS365-Tenant-Id: <tenant id klienta>
X-MS365-Client-Id: <encoded MS365_CLIENT_ID> X-MS365-Client-Id: <client id aplikace CSBOT>
X-MS365-Client-Secret: <encoded MS365_CLIENT_SECRET> X-MS365-Client-Secret: <client secret VALUE aplikace CSBOT, ne secret id>
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á. Všechny tři hlavičky musí být pohromadě, jinak služba vrátí 400. Když se nepošle
žádná, použijí se `MS365_TENANT_ID`, `MS365_CLIENT_ID` a `MS365_CLIENT_SECRET`
z prostředí služby.
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. Příklad v PowerShellu:
Doporučený formát zakódované hodnoty: ```powershell
$Headers = @{
```text "X-MS365-Tenant-Id" = $TenantId
v1.<base64url nonce>.<base64url ciphertext-and-auth-tag> "X-MS365-Client-Id" = $ClientId
``` "X-MS365-Client-Secret" = $ClientSecret
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"
} }
``` Invoke-RestMethod -Method Get -Headers $Headers `
-Uri "https://services.csbot.cz/apps/microsoft-365-service/users/$UserMailbox/calendar"
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();
``` ```
## Multitenant napojení klientů (CSBOT MS365 Connector) ## Multitenant napojení klientů (CSBOT MS365 Connector)
@@ -192,7 +62,8 @@ Microsoft 365 tenanty jednotlivých klientů. Podrobné zadání je v Notion str
zákazníkovi. Tenant isolation (customer id -> tenant id, mailbox, plan id, bucket id) zákazníkovi. Tenant isolation (customer id -> tenant id, mailbox, plan id, bucket id)
musí držet volající backend, hodnoty nesmí přicházet přímo od LLM. musí držet volající backend, hodnoty nesmí přicházet přímo od LLM.
Postup testu po consentu klienta: `GET /status`, `GET /users/{mailbox}/calendar/view`, Postup testu po consentu klienta: `GET /status`, `GET /users/{mailbox}/calendar`
(vrátí id a name kalendáře, nejlevnější ověření přístupu), `GET /users/{mailbox}/calendar/view`,
`POST /users/{mailbox}/calendar/events` + `DELETE`, `GET /planner/plans/{plan_id}/tasks`, `POST /users/{mailbox}/calendar/events` + `DELETE`, `GET /planner/plans/{plan_id}/tasks`,
`POST /planner/tasks` + `DELETE`. `POST /planner/tasks` + `DELETE`.
@@ -200,7 +71,7 @@ Postup testu po consentu klienta: `GET /status`, `GET /users/{mailbox}/calendar/
- Users: výpis a načtení uživatelů. - Users: výpis a načtení uživatelů.
- Mail: výpis zpráv a odesílání e-mailů včetně příloh. - Mail: výpis zpráv a odesílání e-mailů včetně příloh.
- Calendar: výpis událostí, calendarView v zadaném rozsahu, volné termíny (getSchedule), - Calendar: načtení kalendáře schránky (id, name), výpis událostí, calendarView v zadaném rozsahu, volné termíny (getSchedule),
načtení, vytvoření, úprava a smazání události včetně Teams online meetingů. načtení, vytvoření, úprava a smazání události včetně Teams online meetingů.
- Planner: výpis plánů skupiny, bucketů a tasků plánu, načtení, vytvoření, úprava - Planner: výpis plánů skupiny, bucketů a tasků plánu, načtení, vytvoření, úprava
a smazání tasku. Úprava a smazání používají ETag (`If-Match`). Když hlavička chybí, a smazání tasku. Úprava a smazání používají ETag (`If-Match`). Když hlavička chybí,
@@ -220,6 +91,7 @@ GET /users/{user_id}
GET /users/{user_id}/mail/messages?folder=Inbox&top=25 GET /users/{user_id}/mail/messages?folder=Inbox&top=25
POST /users/{user_id}/mail/send POST /users/{user_id}/mail/send
GET /admin-consent/url?redirect_uri=...&state=... GET /admin-consent/url?redirect_uri=...&state=...
GET /users/{user_id}/calendar
GET /users/{user_id}/calendar/events?top=25 GET /users/{user_id}/calendar/events?top=25
POST /users/{user_id}/calendar/events POST /users/{user_id}/calendar/events
GET /users/{user_id}/calendar/view?start=...&end=...&top=25&time_zone=Europe/Prague GET /users/{user_id}/calendar/view?start=...&end=...&top=25&time_zone=Europe/Prague
@@ -267,8 +139,7 @@ omezení na konkrétní mailbox se řeší na straně klienta v Exchange Online
## Ukázky pro klienty ## Ukázky pro klienty
- `examples/csharp/ListUsersTop10`: konzolová aplikace .NET 8, volá `GET /users?top=10` - `examples/csharp/ListUsersTop10`: konzolová aplikace .NET 8, volá `GET /users?top=10`
a obsahuje třídu `CredentialEncoder` pro šifrování hlaviček `X-MS365-*`. s hlavičkami `X-MS365-*`. Popis v `examples/csharp/README.md`.
Popis v `examples/csharp/README.md`.
## Lokální spuštění ## Lokální spuštění
@@ -284,6 +155,11 @@ Vygenerované OpenAPI UI otevřete na `http://localhost:8000/docs`.
## Záznam změn ## Záznam změn
- 2026-09-22: šifrování hlaviček `X-MS365-*` odstraněno, hodnoty se posílají tak, jak jsou.
Zrušena proměnná `MS365_CREDENTIAL_ENCODING_SECRET` a závislost `cryptography`.
- 2026-09-22: `GET /users/{user_id}/calendar` (objekt kalendáře schránky, id a name) pro
read-only ověření přístupu ke kalendáři klienta přes e-mail schránky.
- 2026-09-09: verze 1.1.0. Podle Notion zadání "MS365 práva konektoru" doplněn Calendar - 2026-09-09: verze 1.1.0. Podle Notion zadání "MS365 práva konektoru" doplněn Calendar
(calendarView, getSchedule, get/patch/delete události), Planner (plány, buckety, tasky, (calendarView, getSchedule, get/patch/delete události), Planner (plány, buckety, tasky,
create/patch/delete s ETag) a `GET /admin-consent/url`. Kód ověřen jen importem aplikace create/patch/delete s ETag) a `GET /admin-consent/url`. Kód ověřen jen importem aplikace
-1
View File
@@ -10,7 +10,6 @@ class Settings:
tenant_id: str = os.getenv("MS365_TENANT_ID", "") tenant_id: str = os.getenv("MS365_TENANT_ID", "")
client_id: str = os.getenv("MS365_CLIENT_ID", "") client_id: str = os.getenv("MS365_CLIENT_ID", "")
client_secret: str = os.getenv("MS365_CLIENT_SECRET", "") 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_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") 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")) 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 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 fastapi import Header, HTTPException, status
from .config import Settings, settings 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( async def get_request_settings(
tenant_id: Annotated[ tenant_id: Annotated[
str | None, str | None,
Header( Header(
alias="X-MS365-Tenant-Id", 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, ] = None,
client_id: Annotated[ client_id: Annotated[
str | None, str | None,
Header( Header(
alias="X-MS365-Client-Id", 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, ] = None,
client_secret: Annotated[ client_secret: Annotated[
str | None, str | None,
Header( Header(
alias="X-MS365-Client-Secret", 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>.", description="Client secret VALUE of the CSBOT Entra app registration (not the secret id).",
),
] = None,
credential_version: Annotated[
str | None,
Header(
alias="X-MS365-Credential-Version",
description="Credential encoding version. Currently supported value: v1.",
), ),
] = None, ] = None,
) -> Settings: ) -> Settings:
"""Per-request credentials. All three headers together, or none (then env values are used)."""
header_values = { header_values = {
"X-MS365-Tenant-Id": tenant_id, "X-MS365-Tenant-Id": tenant_id,
"X-MS365-Client-Id": client_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}, 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( return settings.with_credentials(
tenant_id=decoded["X-MS365-Tenant-Id"], tenant_id=provided["X-MS365-Tenant-Id"],
client_id=decoded["X-MS365-Client-Id"], client_id=provided["X-MS365-Client-Id"],
client_secret=decoded["X-MS365-Client-Secret"], 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 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") @router.get("/users/{user_id}/calendar/events")
async def list_events( async def list_events(
user_id: str, user_id: str,
+4
View File
@@ -91,6 +91,10 @@ class CalendarService:
def __init__(self, graph: MicrosoftGraphClient) -> None: def __init__(self, graph: MicrosoftGraphClient) -> None:
self._graph = graph 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: async def list_events(self, user_id: str, top: int = 25) -> Any:
params = { params = {
"$top": top, "$top": top,
+8 -62
View File
@@ -3,29 +3,22 @@
// Sluzba: https://services.csbot.cz/apps/microsoft-365-service // Sluzba: https://services.csbot.cz/apps/microsoft-365-service
// Dokumentace (Swagger): https://services.csbot.cz/apps/microsoft-365-service/docs // Dokumentace (Swagger): https://services.csbot.cz/apps/microsoft-365-service/docs
// //
// Credentials se posilaji v hlavickach X-MS365-*. Kazda hodnota je zasifrovana // Credentials se posilaji v hlavickach X-MS365-* jako obycejne hodnoty.
// AES-256-GCM klicem odvozenym pres HKDF-SHA256 ze sdileneho secretu
// (MS365_CREDENTIAL_ENCODING_SECRET na strane sluzby). Format hodnoty:
// v1.<base64url nonce>.<base64url ciphertext+tag>
// //
// Spusteni: // Spusteni:
// set MS365_SERVICE_BASE_URL=https://services.csbot.cz/apps/microsoft-365-service // set MS365_SERVICE_BASE_URL=https://services.csbot.cz/apps/microsoft-365-service
// set MS365_TENANT_ID=<tenant id> // set MS365_TENANT_ID=<tenant id klienta>
// set MS365_CLIENT_ID=<client id> // set MS365_CLIENT_ID=<client id aplikace CSBOT>
// set MS365_CLIENT_SECRET=<client secret> // set MS365_CLIENT_SECRET=<client secret VALUE, ne secret id>
// set MS365_CREDENTIAL_ENCODING_SECRET=<sdileny secret>
// dotnet run // dotnet run
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json; using System.Text.Json;
var baseUrl = Env("MS365_SERVICE_BASE_URL", "https://services.csbot.cz/apps/microsoft-365-service"); var baseUrl = Env("MS365_SERVICE_BASE_URL", "https://services.csbot.cz/apps/microsoft-365-service");
var tenantId = Env("MS365_TENANT_ID"); var tenantId = Env("MS365_TENANT_ID");
var clientId = Env("MS365_CLIENT_ID"); var clientId = Env("MS365_CLIENT_ID");
var clientSecret = Env("MS365_CLIENT_SECRET"); var clientSecret = Env("MS365_CLIENT_SECRET");
var sharedSecret = Env("MS365_CREDENTIAL_ENCODING_SECRET");
const int top = 10; const int top = 10;
@@ -33,17 +26,16 @@ using var http = new HttpClient { BaseAddress = new Uri(baseUrl.TrimEnd('/') + "
http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
using var request = new HttpRequestMessage(HttpMethod.Get, $"users?top={top}"); using var request = new HttpRequestMessage(HttpMethod.Get, $"users?top={top}");
request.Headers.Add("X-MS365-Tenant-Id", CredentialEncoder.Encode(tenantId, "X-MS365-Tenant-Id", sharedSecret)); request.Headers.Add("X-MS365-Tenant-Id", tenantId);
request.Headers.Add("X-MS365-Client-Id", CredentialEncoder.Encode(clientId, "X-MS365-Client-Id", sharedSecret)); request.Headers.Add("X-MS365-Client-Id", clientId);
request.Headers.Add("X-MS365-Client-Secret", CredentialEncoder.Encode(clientSecret, "X-MS365-Client-Secret", sharedSecret)); request.Headers.Add("X-MS365-Client-Secret", clientSecret);
request.Headers.Add("X-MS365-Credential-Version", "v1");
using var response = await http.SendAsync(request); using var response = await http.SendAsync(request);
var body = await response.Content.ReadAsStringAsync(); var body = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
// 400 = spatne/prosle hlavicky, 502 = chyba z Microsoft Graph (detail je v tele), // 400 = neuplne hlavicky, 502 = chyba z Microsoft Graph (detail je v tele),
// 403 text/plain = request neprosel pres reverzni proxy (IP allowlist pro GET). // 403 text/plain = request neprosel pres reverzni proxy (IP allowlist pro GET).
Console.Error.WriteLine($"HTTP {(int)response.StatusCode} {response.ReasonPhrase}"); Console.Error.WriteLine($"HTTP {(int)response.StatusCode} {response.ReasonPhrase}");
Console.Error.WriteLine(body); Console.Error.WriteLine(body);
@@ -80,49 +72,3 @@ static string Env(string name, string? fallback = null)
} }
throw new InvalidOperationException($"Chybi environment promenna {name}."); throw new InvalidOperationException($"Chybi environment promenna {name}.");
} }
/// <summary>
/// Sifrovani hodnot pro hlavicky X-MS365-*. Musi presne odpovidat dekodovani na strane sluzby
/// (app/credentials.py): HKDF-SHA256 se salt "microsoft-365-service.credentials.v1"
/// a info = nazev hlavicky, AES-256-GCM s 12B nonce, 16B tagem a AAD = nazev hlavicky.
/// </summary>
static class CredentialEncoder
{
private static readonly byte[] HkdfSalt = Encoding.UTF8.GetBytes("microsoft-365-service.credentials.v1");
public static string Encode(string value, string headerName, string sharedSecret, TimeSpan? validity = null)
{
var now = DateTimeOffset.UtcNow;
var payload = JsonSerializer.Serialize(new
{
value,
issued_at = now.ToString("O"),
expires_at = now.Add(validity ?? TimeSpan.FromHours(1)).ToString("O"),
});
var headerBytes = Encoding.UTF8.GetBytes(headerName);
var key = HKDF.DeriveKey(
HashAlgorithmName.SHA256,
Encoding.UTF8.GetBytes(sharedSecret),
outputLength: 32,
salt: HkdfSalt,
info: headerBytes);
var nonce = RandomNumberGenerator.GetBytes(12);
var plaintext = Encoding.UTF8.GetBytes(payload);
var ciphertext = new byte[plaintext.Length];
var tag = new byte[16];
using var aes = new AesGcm(key, tagSizeInBytes: 16);
aes.Encrypt(nonce, plaintext, ciphertext, tag, associatedData: headerBytes);
var ciphertextAndTag = new byte[ciphertext.Length + tag.Length];
ciphertext.CopyTo(ciphertextAndTag, 0);
tag.CopyTo(ciphertextAndTag, ciphertext.Length);
return $"v1.{Base64Url(nonce)}.{Base64Url(ciphertextAndTag)}";
}
private static string Base64Url(byte[] data) =>
Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
@@ -1,4 +0,0 @@
// <autogenerated />
using System;
using System.Reflection;
[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETCoreApp,Version=v8.0", FrameworkDisplayName = ".NET 8.0")]
@@ -1,22 +0,0 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
using System;
using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("ListUsersTop10")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+275bd6c46764ec8db008fa2fa8c30673bb96f3d8")]
[assembly: System.Reflection.AssemblyProductAttribute("ListUsersTop10")]
[assembly: System.Reflection.AssemblyTitleAttribute("ListUsersTop10")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
// Generated by the MSBuild WriteCodeFragment class.
@@ -1 +0,0 @@
502171e0ece4b0be631032bff07cea1c6b79151ad77b399268a548f2c009e1d0
@@ -1,18 +0,0 @@
is_global = true
build_property.TargetFramework = net8.0
build_property.TargetFrameworkIdentifier = .NETCoreApp
build_property.TargetFrameworkVersion = v8.0
build_property.TargetPlatformMinVersion =
build_property.UsingMicrosoftNETSdkWeb =
build_property.ProjectTypeGuids =
build_property.InvariantGlobalization =
build_property.PlatformNeutralAssembly =
build_property.EnforceExtendedAnalyzerRules =
build_property.EntryPointFilePath =
build_property._SupportedPlatformList = Linux,macOS,Windows
build_property.RootNamespace = ListUsersTop10
build_property.ProjectDir = D:\GitHubRepository\Hracicky\x\ms365\microsoft-365-service\examples\csharp\ListUsersTop10\
build_property.EnableComHosting =
build_property.EnableGeneratedComInterfaceComImportInterop =
build_property.EffectiveAnalysisLevelStyle = 8.0
build_property.EnableCodeStyleSeverity =
@@ -1,8 +0,0 @@
// <auto-generated/>
global using System;
global using System.Collections.Generic;
global using System.IO;
global using System.Linq;
global using System.Net.Http;
global using System.Threading;
global using System.Threading.Tasks;
@@ -1,77 +0,0 @@
{
"format": 1,
"restore": {
"D:\\GitHubRepository\\Hracicky\\x\\ms365\\microsoft-365-service\\examples\\csharp\\ListUsersTop10\\ListUsersTop10.csproj": {}
},
"projects": {
"D:\\GitHubRepository\\Hracicky\\x\\ms365\\microsoft-365-service\\examples\\csharp\\ListUsersTop10\\ListUsersTop10.csproj": {
"version": "1.0.0",
"restore": {
"projectUniqueName": "D:\\GitHubRepository\\Hracicky\\x\\ms365\\microsoft-365-service\\examples\\csharp\\ListUsersTop10\\ListUsersTop10.csproj",
"projectName": "ListUsersTop10",
"projectPath": "D:\\GitHubRepository\\Hracicky\\x\\ms365\\microsoft-365-service\\examples\\csharp\\ListUsersTop10\\ListUsersTop10.csproj",
"packagesPath": "C:\\Users\\GamingPC\\.nuget\\packages\\",
"outputPath": "D:\\GitHubRepository\\Hracicky\\x\\ms365\\microsoft-365-service\\examples\\csharp\\ListUsersTop10\\obj\\",
"projectStyle": "PackageReference",
"fallbackFolders": [
"C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages"
],
"configFilePaths": [
"C:\\Users\\GamingPC\\AppData\\Roaming\\NuGet\\NuGet.Config",
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.FallbackLocation.config",
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config"
],
"originalTargetFrameworks": [
"net8.0"
],
"sources": {
"C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackages\\": {},
"C:\\Program Files\\dotnet\\library-packs": {},
"D:\\CustomNuGetPackages": {},
"https://api.nuget.org/v3/index.json": {}
},
"frameworks": {
"net8.0": {
"framework": "net8.0",
"targetAlias": "net8.0",
"projectReferences": {}
}
},
"warningProperties": {
"warnAsError": [
"NU1605"
]
},
"restoreAuditProperties": {
"enableAudit": "true",
"auditLevel": "low",
"auditMode": "direct"
},
"SdkAnalysisLevel": "10.0.400"
},
"frameworks": {
"net8.0": {
"framework": "net8.0",
"targetAlias": "net8.0",
"imports": [
"net461",
"net462",
"net47",
"net471",
"net472",
"net48",
"net481"
],
"assetTargetFallback": true,
"warn": true,
"frameworkReferences": {
"Microsoft.NETCore.App": {
"privateAssets": "all"
}
},
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.400/PortableRuntimeIdentifierGraph.json"
}
}
}
}
}
@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<Project ToolsVersion="14.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<RestoreSuccess Condition=" '$(RestoreSuccess)' == '' ">True</RestoreSuccess>
<RestoreTool Condition=" '$(RestoreTool)' == '' ">NuGet</RestoreTool>
<ProjectAssetsFile Condition=" '$(ProjectAssetsFile)' == '' ">$(MSBuildThisFileDirectory)project.assets.json</ProjectAssetsFile>
<NuGetPackageRoot Condition=" '$(NuGetPackageRoot)' == '' ">$(UserProfile)\.nuget\packages\</NuGetPackageRoot>
<NuGetPackageFolders Condition=" '$(NuGetPackageFolders)' == '' ">C:\Users\GamingPC\.nuget\packages\;C:\Program Files (x86)\Microsoft Visual Studio\Shared\NuGetPackages</NuGetPackageFolders>
<NuGetProjectStyle Condition=" '$(NuGetProjectStyle)' == '' ">PackageReference</NuGetProjectStyle>
<NuGetToolVersion Condition=" '$(NuGetToolVersion)' == '' ">7.0.0</NuGetToolVersion>
</PropertyGroup>
<ItemGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<SourceRoot Include="C:\Users\GamingPC\.nuget\packages\" />
<SourceRoot Include="C:\Program Files (x86)\Microsoft Visual Studio\Shared\NuGetPackages\" />
</ItemGroup>
</Project>
@@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<Project ToolsVersion="14.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003" />
@@ -1,83 +0,0 @@
{
"version": 4,
"targets": {
"net8.0": {}
},
"libraries": {},
"projectFileDependencyGroups": {
"net8.0": []
},
"packageFolders": {
"C:\\Users\\GamingPC\\.nuget\\packages\\": {},
"C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages": {}
},
"project": {
"version": "1.0.0",
"restore": {
"projectUniqueName": "D:\\GitHubRepository\\Hracicky\\x\\ms365\\microsoft-365-service\\examples\\csharp\\ListUsersTop10\\ListUsersTop10.csproj",
"projectName": "ListUsersTop10",
"projectPath": "D:\\GitHubRepository\\Hracicky\\x\\ms365\\microsoft-365-service\\examples\\csharp\\ListUsersTop10\\ListUsersTop10.csproj",
"packagesPath": "C:\\Users\\GamingPC\\.nuget\\packages\\",
"outputPath": "D:\\GitHubRepository\\Hracicky\\x\\ms365\\microsoft-365-service\\examples\\csharp\\ListUsersTop10\\obj\\",
"projectStyle": "PackageReference",
"fallbackFolders": [
"C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages"
],
"configFilePaths": [
"C:\\Users\\GamingPC\\AppData\\Roaming\\NuGet\\NuGet.Config",
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.FallbackLocation.config",
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config"
],
"originalTargetFrameworks": [
"net8.0"
],
"sources": {
"C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackages\\": {},
"C:\\Program Files\\dotnet\\library-packs": {},
"D:\\CustomNuGetPackages": {},
"https://api.nuget.org/v3/index.json": {}
},
"frameworks": {
"net8.0": {
"framework": "net8.0",
"targetAlias": "net8.0",
"projectReferences": {}
}
},
"warningProperties": {
"warnAsError": [
"NU1605"
]
},
"restoreAuditProperties": {
"enableAudit": "true",
"auditLevel": "low",
"auditMode": "direct"
},
"SdkAnalysisLevel": "10.0.400"
},
"frameworks": {
"net8.0": {
"framework": "net8.0",
"targetAlias": "net8.0",
"imports": [
"net461",
"net462",
"net47",
"net471",
"net472",
"net48",
"net481"
],
"assetTargetFallback": true,
"warn": true,
"frameworkReferences": {
"Microsoft.NETCore.App": {
"privateAssets": "all"
}
},
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.400/PortableRuntimeIdentifierGraph.json"
}
}
}
}
@@ -1,8 +0,0 @@
{
"version": 2,
"dgSpecHash": "4FfrW7UlSBU=",
"success": true,
"projectFilePath": "D:\\GitHubRepository\\Hracicky\\x\\ms365\\microsoft-365-service\\examples\\csharp\\ListUsersTop10\\ListUsersTop10.csproj",
"expectedPackageFiles": [],
"logs": []
}
+7 -12
View File
@@ -3,31 +3,26 @@
## ListUsersTop10 ## ListUsersTop10
Konzolova aplikace (.NET 8), ktera zavola `GET /users?top=10` a vypise Konzolova aplikace (.NET 8), ktera zavola `GET /users?top=10` a vypise
displayName, userPrincipalName, mail a id kazdeho uzivatele. displayName, userPrincipalName, mail a id kazdeho uzivatele. Credentials
posila v hlavickach `X-MS365-Tenant-Id`, `X-MS365-Client-Id`
Obsahuje tridu `CredentialEncoder`, ktera sifruje hodnoty pro hlavicky a `X-MS365-Client-Secret` jako obycejne hodnoty. Nema zadne externi zavislosti.
`X-MS365-Tenant-Id`, `X-MS365-Client-Id` a `X-MS365-Client-Secret` presne tak,
jak je sluzba dekoduje v `app/credentials.py`. Tridu lze zkopirovat do
libovolneho projektu, nema zadne externi zavislosti.
### Spusteni ### Spusteni
```powershell ```powershell
cd examples/csharp/ListUsersTop10 cd examples/csharp/ListUsersTop10
$env:MS365_SERVICE_BASE_URL = "https://services.csbot.cz/apps/microsoft-365-service" $env:MS365_SERVICE_BASE_URL = "https://services.csbot.cz/apps/microsoft-365-service"
$env:MS365_TENANT_ID = "<tenant id>" $env:MS365_TENANT_ID = "<tenant id klienta>"
$env:MS365_CLIENT_ID = "<client id>" $env:MS365_CLIENT_ID = "<client id aplikace CSBOT>"
$env:MS365_CLIENT_SECRET = "<client secret>" $env:MS365_CLIENT_SECRET = "<client secret VALUE, ne secret id>"
$env:MS365_CREDENTIAL_ENCODING_SECRET = "<sdileny secret, stejny jako na serveru>"
dotnet run dotnet run
``` ```
### Co ocekavat ### Co ocekavat
- `200` a JSON ve tvaru Microsoft Graph: `{ "value": [ ... ], "@odata.nextLink": "..." }`. - `200` a JSON ve tvaru Microsoft Graph: `{ "value": [ ... ], "@odata.nextLink": "..." }`.
- `400` kdyz hlavicky chybi, jsou neuplne, prosle nebo je spatny sdileny secret. - `400` kdyz hlavicky chybi nebo jsou neuplne.
- `502` kdyz Microsoft Graph nebo prihlaseni k Entra ID selze, detail je v tele odpovedi. - `502` kdyz Microsoft Graph nebo prihlaseni k Entra ID selze, detail je v tele odpovedi.
- `503` kdyz sluzba nema nastaveny `MS365_CREDENTIAL_ENCODING_SECRET`.
- `403 text/plain` kdyz GET neprojde pres reverzni proxy (IP allowlist), tj. problem - `403 text/plain` kdyz GET neprojde pres reverzni proxy (IP allowlist), tj. problem
neni ve sluzbe, ale v sitovem pristupu klienta. neni ve sluzbe, ale v sitovem pristupu klienta.
-1
View File
@@ -1,5 +1,4 @@
fastapi fastapi
httpx httpx
cryptography
pydantic[email] pydantic[email]
uvicorn[standard] uvicorn[standard]