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
.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_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
@@ -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
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:
Credentials se posílají v každém requestu v hlavičkách jako obyčejné hodnoty:
```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
X-MS365-Tenant-Id: <tenant id klienta>
X-MS365-Client-Id: <client id aplikace CSBOT>
X-MS365-Client-Secret: <client secret VALUE aplikace CSBOT, ne secret id>
```
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:
```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"
```powershell
$Headers = @{
"X-MS365-Tenant-Id" = $TenantId
"X-MS365-Client-Id" = $ClientId
"X-MS365-Client-Secret" = $ClientSecret
}
```
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();
Invoke-RestMethod -Method Get -Headers $Headers `
-Uri "https://services.csbot.cz/apps/microsoft-365-service/users/$UserMailbox/calendar"
```
## 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)
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 /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ů.
- 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ů.
- 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í,
@@ -220,6 +91,7 @@ GET /users/{user_id}
GET /users/{user_id}/mail/messages?folder=Inbox&top=25
POST /users/{user_id}/mail/send
GET /admin-consent/url?redirect_uri=...&state=...
GET /users/{user_id}/calendar
GET /users/{user_id}/calendar/events?top=25
POST /users/{user_id}/calendar/events
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
- `examples/csharp/ListUsersTop10`: konzolová aplikace .NET 8, volá `GET /users?top=10`
a obsahuje třídu `CredentialEncoder` pro šifrování hlaviček `X-MS365-*`.
Popis v `examples/csharp/README.md`.
s hlavičkami `X-MS365-*`. Popis v `examples/csharp/README.md`.
## Lokální spuštění
@@ -284,6 +155,11 @@ Vygenerované OpenAPI UI otevřete na `http://localhost:8000/docs`.
## 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
(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
-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,
+8 -62
View File
@@ -3,29 +3,22 @@
// Sluzba: https://services.csbot.cz/apps/microsoft-365-service
// Dokumentace (Swagger): https://services.csbot.cz/apps/microsoft-365-service/docs
//
// Credentials se posilaji v hlavickach X-MS365-*. Kazda hodnota je zasifrovana
// 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>
// Credentials se posilaji v hlavickach X-MS365-* jako obycejne hodnoty.
//
// Spusteni:
// set MS365_SERVICE_BASE_URL=https://services.csbot.cz/apps/microsoft-365-service
// set MS365_TENANT_ID=<tenant id>
// set MS365_CLIENT_ID=<client id>
// set MS365_CLIENT_SECRET=<client secret>
// set MS365_CREDENTIAL_ENCODING_SECRET=<sdileny secret>
// set MS365_TENANT_ID=<tenant id klienta>
// set MS365_CLIENT_ID=<client id aplikace CSBOT>
// set MS365_CLIENT_SECRET=<client secret VALUE, ne secret id>
// dotnet run
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
var baseUrl = Env("MS365_SERVICE_BASE_URL", "https://services.csbot.cz/apps/microsoft-365-service");
var tenantId = Env("MS365_TENANT_ID");
var clientId = Env("MS365_CLIENT_ID");
var clientSecret = Env("MS365_CLIENT_SECRET");
var sharedSecret = Env("MS365_CREDENTIAL_ENCODING_SECRET");
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"));
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-Client-Id", CredentialEncoder.Encode(clientId, "X-MS365-Client-Id", sharedSecret));
request.Headers.Add("X-MS365-Client-Secret", CredentialEncoder.Encode(clientSecret, "X-MS365-Client-Secret", sharedSecret));
request.Headers.Add("X-MS365-Credential-Version", "v1");
request.Headers.Add("X-MS365-Tenant-Id", tenantId);
request.Headers.Add("X-MS365-Client-Id", clientId);
request.Headers.Add("X-MS365-Client-Secret", clientSecret);
using var response = await http.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
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).
Console.Error.WriteLine($"HTTP {(int)response.StatusCode} {response.ReasonPhrase}");
Console.Error.WriteLine(body);
@@ -80,49 +72,3 @@ static string Env(string name, string? fallback = null)
}
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
Konzolova aplikace (.NET 8), ktera zavola `GET /users?top=10` a vypise
displayName, userPrincipalName, mail a id kazdeho uzivatele.
Obsahuje tridu `CredentialEncoder`, ktera sifruje hodnoty pro hlavicky
`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.
displayName, userPrincipalName, mail a id kazdeho uzivatele. Credentials
posila v hlavickach `X-MS365-Tenant-Id`, `X-MS365-Client-Id`
a `X-MS365-Client-Secret` jako obycejne hodnoty. Nema zadne externi zavislosti.
### Spusteni
```powershell
cd examples/csharp/ListUsersTop10
$env:MS365_SERVICE_BASE_URL = "https://services.csbot.cz/apps/microsoft-365-service"
$env:MS365_TENANT_ID = "<tenant id>"
$env:MS365_CLIENT_ID = "<client id>"
$env:MS365_CLIENT_SECRET = "<client secret>"
$env:MS365_CREDENTIAL_ENCODING_SECRET = "<sdileny secret, stejny jako na serveru>"
$env:MS365_TENANT_ID = "<tenant id klienta>"
$env:MS365_CLIENT_ID = "<client id aplikace CSBOT>"
$env:MS365_CLIENT_SECRET = "<client secret VALUE, ne secret id>"
dotnet run
```
### Co ocekavat
- `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.
- `503` kdyz sluzba nema nastaveny `MS365_CREDENTIAL_ENCODING_SECRET`.
- `403 text/plain` kdyz GET neprojde pres reverzni proxy (IP allowlist), tj. problem
neni ve sluzbe, ale v sitovem pristupu klienta.
-1
View File
@@ -1,5 +1,4 @@
fastapi
httpx
cryptography
pydantic[email]
uvicorn[standard]