Calendar a Planner endpointy, admin consent URL, C# ukazka (1.1.0)
Podle zadani "MS365 prava konektoru": - Calendar: calendarView, getSchedule, get/patch/delete udalosti - Planner: plany skupiny, buckety, tasky, create/patch/delete s ETag - GET /admin-consent/url pro onboarding klientskeho tenantu - examples/csharp/ListUsersTop10: ukazka volani GET /users?top=10 vcetne sifrovani hlavicek X-MS365-* - README: multitenant napojeni, endpointy, opravneni, zaznam zmen Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
275bd6c467
commit
fe3c3a736a
@@ -0,0 +1,128 @@
|
||||
// Ukazka volani microsoft-365-service: vypis prvnich 10 uzivatelu z GET /users.
|
||||
//
|
||||
// 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>
|
||||
//
|
||||
// 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>
|
||||
// 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;
|
||||
|
||||
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");
|
||||
|
||||
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),
|
||||
// 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);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Sluzba vraci odpoved Microsoft Graph beze zmeny: { "value": [ { ...user... } ], "@odata.nextLink": "..." }
|
||||
using var json = JsonDocument.Parse(body);
|
||||
var users = json.RootElement.GetProperty("value");
|
||||
|
||||
Console.WriteLine($"Nacteno uzivatelu: {users.GetArrayLength()}");
|
||||
foreach (var user in users.EnumerateArray())
|
||||
{
|
||||
var displayName = user.TryGetProperty("displayName", out var dn) ? dn.GetString() : null;
|
||||
var upn = user.TryGetProperty("userPrincipalName", out var up) ? up.GetString() : null;
|
||||
var mail = user.TryGetProperty("mail", out var m) && m.ValueKind == JsonValueKind.String ? m.GetString() : null;
|
||||
var id = user.GetProperty("id").GetString();
|
||||
|
||||
Console.WriteLine($"{displayName} | {upn} | {mail ?? "-"} | {id}");
|
||||
}
|
||||
|
||||
return 0;
|
||||
|
||||
static string Env(string name, string? fallback = null)
|
||||
{
|
||||
var value = Environment.GetEnvironmentVariable(name);
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
if (fallback is not null)
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
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('/', '_');
|
||||
}
|
||||
Reference in New Issue
Block a user