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:
JiriUhlir
2026-09-09 11:52:52 +02:00
co-authored by Claude Fable 5.1
parent 275bd6c467
commit fe3c3a736a
20 changed files with 877 additions and 9 deletions
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
+128
View File
@@ -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('/', '_');
}
@@ -0,0 +1,4 @@
// <autogenerated />
using System;
using System.Reflection;
[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETCoreApp,Version=v8.0", FrameworkDisplayName = ".NET 8.0")]
@@ -0,0 +1,22 @@
//------------------------------------------------------------------------------
// <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.
@@ -0,0 +1 @@
502171e0ece4b0be631032bff07cea1c6b79151ad77b399268a548f2c009e1d0
@@ -0,0 +1,18 @@
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 =
@@ -0,0 +1,8 @@
// <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;
@@ -0,0 +1,77 @@
{
"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"
}
}
}
}
}
@@ -0,0 +1,16 @@
<?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>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<Project ToolsVersion="14.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003" />
@@ -0,0 +1,83 @@
{
"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"
}
}
}
}
@@ -0,0 +1,8 @@
{
"version": 2,
"dgSpecHash": "4FfrW7UlSBU=",
"success": true,
"projectFilePath": "D:\\GitHubRepository\\Hracicky\\x\\ms365\\microsoft-365-service\\examples\\csharp\\ListUsersTop10\\ListUsersTop10.csproj",
"expectedPackageFiles": [],
"logs": []
}