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:
co-authored by
Claude Fable 5.1
parent
fe3c3a736a
commit
d80193da8b
@@ -174,3 +174,7 @@ cython_debug/
|
|||||||
# PyPI configuration file
|
# PyPI configuration file
|
||||||
.pypirc
|
.pypirc
|
||||||
|
|
||||||
|
|
||||||
|
# ---> .NET (examples)
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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"],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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('/', '_');
|
|
||||||
}
|
|
||||||
|
|||||||
-4
@@ -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
@@ -1 +0,0 @@
|
|||||||
502171e0ece4b0be631032bff07cea1c6b79151ad77b399268a548f2c009e1d0
|
|
||||||
-18
@@ -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;
|
|
||||||
Binary file not shown.
@@ -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": []
|
|
||||||
}
|
|
||||||
@@ -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,5 +1,4 @@
|
|||||||
fastapi
|
fastapi
|
||||||
httpx
|
httpx
|
||||||
cryptography
|
|
||||||
pydantic[email]
|
pydantic[email]
|
||||||
uvicorn[standard]
|
uvicorn[standard]
|
||||||
|
|||||||
Reference in New Issue
Block a user