From a4ff98bbb53c892e06410d1d44357b00f7080d29 Mon Sep 17 00:00:00 2001 From: Gladwin Johnson <90415114+gladjohn@users.noreply.github.com> Date: Sun, 12 Oct 2025 04:17:35 -0700 Subject: [PATCH 1/4] Adds a MSI v2 Demo App Adds a demo app for MSI v2 --- .../MsiV2DemoApp/ManagedIdentity-mTLS-Demo.md | 150 ++++ prototype/MsiV2DemoApp/MsiV2DemoApp.csproj | 10 + prototype/MsiV2DemoApp/Program.cs | 805 ++++++++++++++++++ 3 files changed, 965 insertions(+) create mode 100644 prototype/MsiV2DemoApp/ManagedIdentity-mTLS-Demo.md create mode 100644 prototype/MsiV2DemoApp/MsiV2DemoApp.csproj create mode 100644 prototype/MsiV2DemoApp/Program.cs diff --git a/prototype/MsiV2DemoApp/ManagedIdentity-mTLS-Demo.md b/prototype/MsiV2DemoApp/ManagedIdentity-mTLS-Demo.md new file mode 100644 index 0000000000..0cb785b9af --- /dev/null +++ b/prototype/MsiV2DemoApp/ManagedIdentity-mTLS-Demo.md @@ -0,0 +1,150 @@ +# Managed Identity + mTLS PoP Demo (SAMI & UAMI) + +A compact console demo that shows **Azure Managed Identity** issuing an **mTLS PoP** token and then +uses the **binding certificate** to call an **mTLS-protected** Graph test endpoint. + +> **mTLS target**: `https://mtlstb.graph.microsoft.com/v1.0/applications` +> **Output**: one selected property from the `applications` collection (default: `displayName`). + +--- + +## What this demo highlights + +- **System Assigned (SAMI)** and **User Assigned (UAMI)** Managed Identity. +- **IDP vs Cache** acquisition paths (force-refresh vs cache-only). +- **mTLS PoP bound proof**: verifies `cnf.x5t#S256` in the token matches the SHA-256 of + the binding certificate used in the TLS handshake. +- **mTLS call** with a success panel (status, latency, HTTP version, response size). +- **Toggleable MSAL logging** (off by default) and a **full-token view** (press `F`). + +--- + +## Required packages (IDDP feed) + +This demo consumes **preview builds** from the **IDDP** Azure Artifacts feed. + +- **Core MSAL** + *Package Details*: Azure Artifacts → Microsoft.Identity.Client → `4.77.0-msi-v2-pkg2` + Link: https://identitydivision.visualstudio.com/Engineering/_artifacts/feed/IDDP/NuGet/Microsoft.Identity.Client/overview/4.77.0-msi-v2-pkg2 + +- **mTLS PoP helper** + *Package Details*: Azure Artifacts → Microsoft.Identity.Client.MtlsPop → `4.77.0-msi-v2-pkg2-preview` + Link: https://identitydivision.visualstudio.com/Engineering/_artifacts/feed/IDDP/NuGet/Microsoft.Identity.Client.MtlsPop/overview/4.77.0-msi-v2-pkg2-preview + +**NuGet source (IDDP feed)** +`https://pkgs.dev.azure.com/identitydivision/Engineering/_packaging/IDDP/nuget/v3/index.json` + +> You need an Azure DevOps **PAT** with at least **Packaging: Read** scope to restore from this feed. + +### Configure the feed (dotnet CLI) + +```bash +# 1) Add the IDDP feed (one-time) +dotnet nuget add source "https://pkgs.dev.azure.com/identitydivision/Engineering/_packaging/IDDP/nuget/v3/index.json" --name IDDP --username azdo --password --store-password-in-clear-text + +# 2) Verify +dotnet nuget list source + +# 3) Restore +dotnet restore +``` + +> **Security note**: prefer using a **machine-scoped** token store (e.g., Windows Credential Manager) where available, +and always treat your PAT like a secret. + +### Optional: `nuget.config` snippet + +```xml + + + + + +``` + +--- + +## Build & Run + +```bash +dotnet build -c Release +dotnet run -c Release --project MsiV2DemoApp +``` + +### Menu quick map + +``` +Acquire Tokens + 1 SAMI → Token from IDP (force refresh) + 1a SAMI → Token from Cache + 2 UAMI → Token from IDP (force refresh) + 2a UAMI → Token from Cache + +Call Resource + 3 SAMI token + cert → Call resource (mTLS) + 4 UAMI token + cert → Call resource (mTLS) + +Display & Toggles + F Toggle Full Token view (ON/OFF) + L Toggle MSAL logging (ON/OFF; default OFF) + +Settings + set-uami Change UAMI client id + set-resource Change resource URL (defaults to mTLStb applications endpoint) + set-prop Change single property shown from 'value[]' (default: displayName) + +System + C/cls/clear Clear screen + M Maximize console (Windows) + Q Quit +``` + +--- + +## Side‑by‑side demo (App1 + App2) + +**Goal:** Show that the **binding certificate** minted by Managed Identity is **reused** across processes, so +a second app can present the same cert over mTLS **without** re-provisioning it. + +1. **Start App1** and acquire a token (e.g., `2` for UAMI **IDP**) to trigger IMDS to mint the binding certificate. + You’ll see the cert subject like `CN=, DC=` and a thumbprint. +2. **Call the resource** from App1 (`4`). Confirm the green **mTLS call SUCCESS** panel and the item list. +3. **Start App2** (a second instance of the same console). + - Acquire a token via cache mode where applicable (`1a`/`2a`) or IDP if needed. + - Call the resource (`3`/`4`). + You’ll notice: + - The **same binding certificate thumbprint** is selected from the store. + - No extra **certificate provisioning** or **MAA interactions** are required. + - Token acquisition can be **cache**-sourced to avoid an IDP round-trip. +4. Both apps now **present the same cert** during the TLS handshake and can call the mTLS endpoint in parallel. + +> Tip: If you want App2 to *only* reuse the certificate while you manually pass a token, you can copy the token from App1 +(`F` to show full token) and adapt App2 to accept a bearer input—this is optional and not required for the standard demo. + +--- + +## Environment toggles (DEV only) + +- `ACCEPT_ANY_SERVER_CERT=1` — bypass server TLS validation (lab only). +- `UAMI_CLIENT_ID` — pre-set a UAMI client id for convenience. +- `MSI_MTLS_RESOURCE_URL` — override the resource URL (defaults to mTLStb applications). +- `MSI_DEMO_PROPERTY` — property to print from `value[]` (default `displayName`). +- `MSI_MTLS_TEST_CERT_THUMBPRINT` / `MSI_MTLS_TEST_CERT_SUBJECT` — force using a specific cert from the store. + +> **Never** use `ACCEPT_ANY_SERVER_CERT` outside of test environments. + +--- + +## Troubleshooting + +- **No managed identity available**: ensure you’re on an Azure VM/VMSS with MI enabled. +- **403/401 at the resource**: verify the MI has access to the target endpoint and the token `aud` is correct. +- **Unicode icons look odd**: your console font may not support them; the app auto-falls back to ASCII. +- **Maximize not working**: depends on the host terminal; best-effort on classic Windows Console. + +--- + +## Credits + +Built for an internal demo to showcase **Managed Identity + mTLS PoP** with a friendly console UX. +Includes a simple verification that `cnf.x5t#S256` matches the binding certificate’s SHA‑256. diff --git a/prototype/MsiV2DemoApp/MsiV2DemoApp.csproj b/prototype/MsiV2DemoApp/MsiV2DemoApp.csproj new file mode 100644 index 0000000000..206b89a9a8 --- /dev/null +++ b/prototype/MsiV2DemoApp/MsiV2DemoApp.csproj @@ -0,0 +1,10 @@ + + + + Exe + net8.0 + enable + enable + + + diff --git a/prototype/MsiV2DemoApp/Program.cs b/prototype/MsiV2DemoApp/Program.cs new file mode 100644 index 0000000000..b95dd6e9ac --- /dev/null +++ b/prototype/MsiV2DemoApp/Program.cs @@ -0,0 +1,805 @@ +// Managed Identity + mTLS PoP + mTLS resource call (Console Demo) +// .NET 8 +// +// Menu: +// Acquire Tokens +// 1 - SAMI → Token from IDP (force refresh) +// 1a - SAMI → Token from Cache +// 2 - UAMI → Token from IDP (force refresh) +// 2a - UAMI → Token from Cache +// Call Resource +// 3 - SAMI token + cert → Call resource (mTLS) +// 4 - UAMI token + cert → Call resource (mTLS) +// Display & Toggles +// F - Toggle Full Token view (ON/OFF) +// L - Toggle MSAL logging (ON/OFF; off by default) +// Settings +// set-uami - Change UAMI client id +// set-resource - Change resource URL (default: https://mtlstb.graph.microsoft.com/v1.0/applications) +// set-prop - Change single property to display from Graph 'value[]' (default: displayName) +// System +// C / cls / clear - Clear screen +// M - Maximize window (Windows best-effort) +// Q - Quit +// +// Features: +// - mTLS PoP end-to-end with “Bound” check (token cnf.x5t#S256 vs cert SHA-256). +// - Animated spinners with Unicode/ASCII fallback. +// - Single-property output from Graph Applications (default "displayName"); change with set-prop. +// - MSAL logging available but OFF by default (toggle with L). +// +// DEV toggles (optional): +// - ACCEPT_ANY_SERVER_CERT=1 → accept any server TLS cert (DEV/LAB ONLY) +// - MSI_MTLS_TEST_CERT_THUMBPRINT or MSI_MTLS_TEST_CERT_SUBJECT [+ MSI_MTLS_TEST_CERT_STORE_LOC, MSI_MTLS_TEST_CERT_STORE_NAME] +// → override client cert from Windows cert store +// +// NuGet: Microsoft.Identity.Client (>= 4.61.0) + +using Microsoft.Identity.Client; +using Microsoft.Identity.Client.AppConfig; +using Microsoft.Identity.Client.MtlsPop; +using Microsoft.IdentityModel.Abstractions; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Runtime.InteropServices; +using System.Security.Authentication; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Text.Json; + +namespace DemoConsole; + +internal class Program +{ + // ---------- Constants & P/Invoke (declare BEFORE use) ---------- + private const string DefaultScope = "https://graph.microsoft.com"; + private const string DefaultResourceUrl = "https://mtlstb.graph.microsoft.com/v1.0/applications"; + private const int SW_MAXIMIZE = 3; + + [DllImport("kernel32.dll", SetLastError = true)] private static extern IntPtr GetConsoleWindow(); + [DllImport("user32.dll")] private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + + // ---------- Entry Point ---------- + public static async Task Main(string[] args) + { + Console.OutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + Console.InputEncoding = Encoding.UTF8; + Console.Title = "Managed Identity + mTLS PoP Demo"; + TryMaximizeConsoleWindow(silent: true); // best-effort on Windows + + // Config with sensible defaults; UAMI/resource/property can be changed in-menu + string uamiClientId = Environment.GetEnvironmentVariable("UAMI_CLIENT_ID") + ?? "209b9435-3a7d-4967-8647-52c648d6f67f"; + string resourceUrl = Environment.GetEnvironmentVariable("MSI_MTLS_RESOURCE_URL") ?? DefaultResourceUrl; + string propertyName = Environment.GetEnvironmentVariable("MSI_DEMO_PROPERTY") ?? "displayName"; + + bool showLogs = args.Any(a => a.Equals("--log", StringComparison.OrdinalIgnoreCase) || a.Equals("-v", StringComparison.OrdinalIgnoreCase)); + bool showFullToken = false; // toggled live via 'F' + + var logger = new ToggleableLogger { Enabled = showLogs, Level = EventLogLevel.Informational }; + + // Build MI apps once (cache stability) + var sami = BuildMiApp(ManagedIdentityId.SystemAssigned, logger); + var uami = BuildMiApp(ManagedIdentityId.WithUserAssignedClientId(uamiClientId), logger); + + AuthenticationResult? lastSami = null; + AuthenticationResult? lastUami = null; + + WriteBanner(); + WriteAiHello(); + + while (true) + { + PrintMenu(uamiClientId, resourceUrl, propertyName, showLogs, showFullToken); + + Console.Write("> "); + var choice = (Console.ReadLine() ?? "").Trim().ToLowerInvariant(); + + try + { + switch (choice) + { + // Acquire tokens + case "1": + lastSami = await AcquireAndShowAsync("SAMI", sami, DefaultScope, forceRefresh: true, useMtls: true, showFullToken); + break; + case "1a": + lastSami = await AcquireAndShowAsync("SAMI", sami, DefaultScope, forceRefresh: false, useMtls: true, showFullToken); + break; + case "2": + lastUami = await AcquireAndShowAsync("UAMI", uami, DefaultScope, forceRefresh: true, useMtls: true, showFullToken); + break; + case "2a": + lastUami = await AcquireAndShowAsync("UAMI", uami, DefaultScope, forceRefresh: false, useMtls: true, showFullToken); + break; + + // Call resource (mTLS) + case "3": + lastSami = await CallResourceFlowAsync("SAMI", sami, lastSami, resourceUrl, propertyName, showFullToken); + break; + case "4": + lastUami = await CallResourceFlowAsync("UAMI", uami, lastUami, resourceUrl, propertyName, showFullToken); + break; + + // Display & Toggles + case "f": + showFullToken = !showFullToken; + PrintInfo($"Full token display is now {(showFullToken ? "ON" : "OFF")}."); + break; + case "l": + showLogs = !showLogs; + logger.Enabled = showLogs; + PrintInfo($"MSAL logging is now {(showLogs ? "ON" : "OFF")}."); + break; + + // Settings + case "set-uami": + Console.Write("Enter new UAMI client id: "); + { + var newId = (Console.ReadLine() ?? "").Trim(); + if (!string.IsNullOrEmpty(newId)) + { + uamiClientId = newId; + uami = BuildMiApp(ManagedIdentityId.WithUserAssignedClientId(uamiClientId), logger); + lastUami = null; + PrintInfo($"UAMI client id set to {uamiClientId}"); + } + } + break; + + case "set-resource": + Console.Write("Enter new resource URL: "); + { + var newUrl = (Console.ReadLine() ?? "").Trim(); + if (!string.IsNullOrEmpty(newUrl)) + { + resourceUrl = newUrl; + PrintInfo($"Resource URL set to {resourceUrl}"); + } + } + break; + + case "set-prop": + Console.Write("Enter property to show from 'value[]' (e.g., displayName, appId, id): "); + { + var newProp = (Console.ReadLine() ?? "").Trim(); + if (!string.IsNullOrEmpty(newProp)) + { + propertyName = newProp; + PrintInfo($"Property set to {propertyName}"); + } + } + break; + + // System + case "c": + case "cls": + case "clear": + Console.Clear(); + WriteBanner(); + WriteAiHello(); + break; + + case "m": + TryMaximizeConsoleWindow(silent: false); + break; + + case "q": + case "exit": + PrintFooter(); + return; + + case "h": + case "help": + case "?": + // just reprint the menu next loop + break; + + default: + PrintWarn("Unknown choice. Type 'help' to see options."); + break; + } + } + catch (MsalServiceException mse) + { + PrintError("MSAL service error", mse.Message); + } + catch (Exception ex) + { + PrintError("Unexpected error", ex.Message); + } + + Console.WriteLine(); + } + } + + // ---------- MSAL / MI ---------- + private static IManagedIdentityApplication BuildMiApp(ManagedIdentityId miId, IIdentityLogger logger) => + ManagedIdentityApplicationBuilder + .Create(miId) + .WithLogging(logger, enablePiiLogging: false) + .Build(); + + private static async Task AcquireAndShowAsync( + string label, + IManagedIdentityApplication app, + string scope, + bool forceRefresh, + bool useMtls, + bool showFullToken) + { + var builder = app.AcquireTokenForManagedIdentity(scope); + if (useMtls) builder = builder.WithMtlsProofOfPossession(); + if (forceRefresh) builder = builder.WithForceRefresh(true); + + var result = await Ui.WithSpinnerAsync( + $"{label}: acquiring token {(forceRefresh ? "(IDP)" : "(cache)")}{(useMtls ? " [mTLS]" : "")}", + () => builder.ExecuteAsync()).ConfigureAwait(false); + + PrintTokenSummary(label, result, useMtls, forceRefresh, showFullToken); + return result; + } + + private static void PrintTokenSummary(string label, AuthenticationResult result, bool mtlsRequested, bool forced, bool showFullToken) + { + var ts = result.AuthenticationResultMetadata?.TokenSource.ToString() ?? "Unknown"; + var tokenType = result.TokenType ?? "access_token"; + var expUtc = result.ExpiresOn.UtcDateTime; + var left = expUtc - DateTime.UtcNow; if (left < TimeSpan.Zero) left = TimeSpan.Zero; + + var aud = TryReadClaim(result.AccessToken, "aud"); + var tid = TryReadClaim(result.AccessToken, "tid"); + var appid = TryReadClaim(result.AccessToken, "appid"); + var oid = TryReadClaim(result.AccessToken, "oid"); + + // Verify mTLS PoP binding: token cnf.x5t#S256 equals SHA-256 of cert + var cnf = TryReadCnfX5tS256(result.AccessToken); + bool bound = string.Equals(tokenType, "mtls_pop", StringComparison.OrdinalIgnoreCase) + && result.BindingCertificate is X509Certificate2 bc + && cnf is not null + && string.Equals(cnf, ComputeX5tS256(bc), StringComparison.Ordinal); + + Boxed($"[{label}] Token {(forced ? "from IDP (force refresh)" : "from Cache if valid")}"); + + Console.WriteLine($" Token Source : {ts}"); + Console.WriteLine($" Token Type : {tokenType}"); + Console.WriteLine($" Audience (aud) : {aud}"); + Console.WriteLine($" Tenant (tid) : {tid}"); + Console.WriteLine($" AppId (appid): {appid}"); + Console.WriteLine($" ObjectId(oid) : {oid}"); + Console.WriteLine($" Expires : {expUtc:yyyy-MM-dd HH:mm:ss}Z (in {left:hh\\:mm\\:ss})"); + + if (bound) + Console.WriteLine($" mTLS PoP : Bound {Glyphs.Check}"); + else if (mtlsRequested) + Console.WriteLine($" mTLS PoP : Requested (not bound yet)"); + else + Console.WriteLine($" mTLS PoP : No"); + + if (!bound && mtlsRequested && result.BindingCertificate is not null && cnf is not null) + { + Console.WriteLine($" token x5t#S256={cnf}"); + Console.WriteLine($" cert x5t#S256={ComputeX5tS256(result.BindingCertificate!)}"); + } + + if (showFullToken) + { + Console.WriteLine(); + Console.WriteLine(" Access Token :"); + Console.WriteLine($" {result.AccessToken}"); + } + else + { + Console.WriteLine($" Access Token : {Abbrev(result.AccessToken)} (press F to show full)"); + } + + if (result.BindingCertificate is X509Certificate2 cert) + { + Console.WriteLine(); + Console.WriteLine(" Binding Certificate:"); + Console.WriteLine($" Subject : {cert.Subject}"); + Console.WriteLine($" Thumbprint : {cert.Thumbprint}"); + Console.WriteLine($" NotBefore : {cert.NotBefore.ToUniversalTime():yyyy-MM-dd HH:mm:ss}Z"); + Console.WriteLine($" NotAfter : {cert.NotAfter.ToUniversalTime():yyyy-MM-dd HH:mm:ss}Z"); + Console.WriteLine(" Note : Presented in the client TLS handshake."); + } + else + { + Console.WriteLine(); + Console.WriteLine(" Binding Certificate: (none)"); + } + + Console.WriteLine(); + Console.WriteLine($" {Glyphs.Check} Token acquisition complete."); + } + + private static async Task CallResourceFlowAsync( + string label, + IManagedIdentityApplication app, + AuthenticationResult? lastResult, + string resourceUrl, + string propertyName, + bool showFullToken) + { + // Ensure token+cert (use cache if valid, else fetch) + if (lastResult == null || lastResult.ExpiresOn <= DateTimeOffset.UtcNow.AddMinutes(2)) + { + lastResult = await AcquireAndShowAsync(label, app, DefaultScope, forceRefresh: false, useMtls: true, showFullToken); + if (lastResult == null) return lastResult; + } + + var effectiveCert = TryLoadStoreCertOverride() ?? lastResult.BindingCertificate; + if (effectiveCert == null) + { + PrintError("No binding certificate available", "mTLS call requires the binding certificate."); + return lastResult; + } + + await CallResourceWithMtlsAsync(new Uri(resourceUrl), effectiveCert, lastResult.TokenType, lastResult.AccessToken, propertyName).ConfigureAwait(false); + return lastResult; + } + + private static async Task CallResourceWithMtlsAsync( + Uri url, + X509Certificate2 clientCertificate, + string tokenType, + string accessToken, + string propertyName) + { + using var handler = new HttpClientHandler + { + ClientCertificateOptions = ClientCertificateOption.Manual, + SslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13 + }; + handler.ClientCertificates.Add(clientCertificate); + + // DEV ONLY: accept any server cert (lab scenarios) + if (Environment.GetEnvironmentVariable("ACCEPT_ANY_SERVER_CERT") == "1") + { + handler.ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator; + PrintWarn("Accepting any server certificate (DEV ONLY)."); + } + + using var http = new HttpClient(handler, disposeHandler: true); + http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(tokenType, accessToken); + http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + // Header + Boxed("Calling mTLS resource"); + Console.WriteLine($" URL : {url}"); + Console.WriteLine($" Client Cert : {clientCertificate.Subject} (Thumbprint={clientCertificate.Thumbprint})"); + Console.WriteLine($" TLS : {handler.SslProtocols}"); + + // Mini phase readout (jigna!) + WithColor(ConsoleColor.DarkGray, () => + { + Console.WriteLine($" Phase 1 : Present client certificate {Glyphs.Check}"); + Console.WriteLine($" Phase 2 : Attach {tokenType} access token {Glyphs.Check}"); + Console.WriteLine($" Phase 3 : Send GET to {url.Host} over mTLS …"); + }); + + // Execute with spinner + timing + var sw = System.Diagnostics.Stopwatch.StartNew(); + using var response = await Ui.WithSpinnerAsync( + $"Calling {url.Host} over mTLS", + () => http.GetAsync(url)).ConfigureAwait(false); + sw.Stop(); + + var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + var ok = response.IsSuccessStatusCode; + + WithColor(ok ? ConsoleColor.Green : ConsoleColor.Red, () => + { + Console.WriteLine($" Status : {(int)response.StatusCode} {response.StatusCode} • HTTP/{response.Version} • {sw.ElapsedMilliseconds} ms"); + }); + + if (!ok) + { + Console.WriteLine(" Response Body :"); + Console.WriteLine(Indent(content, " ")); + Console.WriteLine(); + Console.WriteLine($" {Glyphs.Cross} mTLS resource call failed."); + return; + } + + // Extract single property from JSON array: value[] + var items = new List(); + int sizeBytes = Encoding.UTF8.GetByteCount(content); + try + { + using var doc = JsonDocument.Parse(content); + if (doc.RootElement.TryGetProperty("value", out var arr) && arr.ValueKind == JsonValueKind.Array) + { + foreach (var item in arr.EnumerateArray()) + { + if (item.TryGetProperty(propertyName, out var val)) + { + items.Add(val.ToString()); + } + } + } + } + catch + { + Console.WriteLine(" Response Body (non-JSON or parse error):"); + Console.WriteLine(Indent(content, " ")); + Console.WriteLine(); + Console.WriteLine($" {Glyphs.Check} mTLS resource call complete."); + return; + } + + // Fancy success panel (auto-sizes to window width, ASCII fallback if needed) + DrawSuccessPanel( + total: items.Count, + ms: sw.ElapsedMilliseconds, + ver: response.Version, + bytes: sizeBytes, + host: url.Host); + + Console.WriteLine(); + WithColor(ConsoleColor.Cyan, () => + Console.WriteLine($" Showing single property from result: {propertyName}")); + Console.WriteLine($" Count: {items.Count}"); + + int max = Math.Min(items.Count, 12); + for (int i = 0; i < max; i++) + { + Console.WriteLine($" {Glyphs.Bullet} {items[i]}"); + } + if (items.Count > max) + { + Console.WriteLine($" ... (+{items.Count - max} more)"); + } + + Console.WriteLine(); + Console.WriteLine($" {Glyphs.Check} mTLS resource call complete."); + + // ---------- local helpers (scoped to this method) ---------- + + static void DrawSuccessPanel(int total, long ms, Version ver, int bytes, string host) + { + var bc = GetBoxChars(); + int width = GetPanelWidth(); // dynamic width for current console + string horiz = new string(bc.H, Math.Max(4, width - 4)); + + string line1 = $" {bc.V} mTLS call SUCCESS {Glyphs.Bullet} Items: {total,-5} {Glyphs.Bullet} {ms,5} ms {Glyphs.Bullet} HTTP/{ver} "; + string line2 = $" {bc.V} Host: {host,-40} Size: {bytes,10:n0} bytes "; + + WithColor(ConsoleColor.Green, () => + { + Console.WriteLine(); + Console.WriteLine($" {bc.TL}{horiz}{bc.TR}"); + PanelLine(line1); + PanelLine(line2); + Console.WriteLine($" {bc.BL}{horiz}{bc.BR}"); + }); + + void PanelLine(string s) + { + int inner = Math.Max(0, width - 4); // space between left/right borders + string payload = s.Length - 3 > inner ? s[..(inner - 1)] + "…" : s; + Console.WriteLine(payload.PadRight(inner + 3) + bc.V); + } + + static int GetPanelWidth() + { + try + { + // Leave a small margin; clamp between 60 and 120 for aesthetics + int w = Math.Clamp(Console.WindowWidth - 4, 60, 120); + return w; + } + catch { return 80; } // safe default + } + + static (char TL, char TR, char BL, char BR, char H, char V) GetBoxChars() + { + if (Glyphs.Unicode) + return ('╔', '╗', '╚', '╝', '═', '║'); + else + return ('+', '+', '+', '+', '-', '|'); + } + } + } + + // ---------- Token/JWT helpers ---------- + private static string Abbrev(string token) + { + if (string.IsNullOrEmpty(token)) return "(empty)"; + if (token.Length <= 80) return token; + return token[..60] + "..." + token[^20..]; + } + + private static string? TryReadClaim(string jwt, string claim) + { + try + { + var parts = jwt.Split('.'); + if (parts.Length < 2) return null; + var payload = parts[1].PadRight(parts[1].Length + (4 - parts[1].Length % 4) % 4, '='); + var json = Encoding.UTF8.GetString(Convert.FromBase64String(payload.Replace('-', '+').Replace('_', '/'))); + using var doc = JsonDocument.Parse(json); + return doc.RootElement.TryGetProperty(claim, out var val) ? val.ToString() : null; + } + catch { return null; } + } + + private static string? TryReadCnfX5tS256(string jwt) + { + try + { + var parts = jwt.Split('.'); + if (parts.Length < 2) return null; + var payload = parts[1].PadRight(parts[1].Length + (4 - parts[1].Length % 4) % 4, '='); + var json = Encoding.UTF8.GetString(Convert.FromBase64String(payload.Replace('-', '+').Replace('_', '/'))); + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("cnf", out var cnf) + && cnf.TryGetProperty("x5t#S256", out var x5t)) + { + return x5t.GetString(); + } + return null; + } + catch { return null; } + } + + private static string ComputeX5tS256(X509Certificate2 cert) + { + var hash = SHA256.HashData(cert.RawData); + return ToBase64Url(hash); + } + + private static string ToBase64Url(byte[] data) => + Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + // ---------- Cert override (optional, DEV) ---------- + private static X509Certificate2? TryLoadStoreCertOverride() + { + var thumb = Environment.GetEnvironmentVariable("MSI_MTLS_TEST_CERT_THUMBPRINT"); + var subject = Environment.GetEnvironmentVariable("MSI_MTLS_TEST_CERT_SUBJECT"); + + if (string.IsNullOrWhiteSpace(thumb) && string.IsNullOrWhiteSpace(subject)) + return null; // no override requested + + var locStr = Environment.GetEnvironmentVariable("MSI_MTLS_TEST_CERT_STORE_LOC") ?? "LocalMachine"; + var nameStr = Environment.GetEnvironmentVariable("MSI_MTLS_TEST_CERT_STORE_NAME") ?? "My"; + + if (!Enum.TryParse(locStr, out StoreLocation location)) location = StoreLocation.LocalMachine; + if (!Enum.TryParse(nameStr, out StoreName storeName)) storeName = StoreName.My; + + try + { + using var store = new X509Store(storeName, location); + store.Open(OpenFlags.ReadOnly | OpenFlags.OpenExistingOnly); + + var certs = store.Certificates.Find(X509FindType.FindByTimeValid, DateTime.Now, false); + + X509Certificate2Collection? found = null; + if (!string.IsNullOrWhiteSpace(thumb)) + found = certs.Find(X509FindType.FindByThumbprint, thumb, false); + else if (!string.IsNullOrWhiteSpace(subject)) + found = certs.Find(X509FindType.FindBySubjectDistinguishedName, subject, false); + + if (found == null || found.Count == 0) + { + PrintWarn($"Cert override requested but not found in {location}/{storeName}."); + return null; + } + + var cert = found[0]; + if (!cert.HasPrivateKey) + { + PrintWarn($"Override cert has no private key: {cert.Subject} ({cert.Thumbprint})."); + return null; + } + + PrintInfo($"Using override client certificate: {cert.Subject} (Thumbprint={cert.Thumbprint})"); + return cert; + } + catch (Exception ex) + { + PrintWarn($"Failed to read certificate override: {ex.Message}"); + return null; + } + } + + // ---------- Pretty console output + colored menu ---------- + private static void WriteBanner() + { + Console.WriteLine(); + WithColor(ConsoleColor.Cyan, () => + { + Console.WriteLine("════════════════════════════════════════════════════════════════════"); + Console.WriteLine(" Managed Identity + mTLS PoP Demo"); + Console.WriteLine("════════════════════════════════════════════════════════════════════"); + }); + } + + private static void WriteAiHello() + { + Console.WriteLine(); + Console.WriteLine($" {Glyphs.Bullet} Good Day, hope your day is going well."); + Console.WriteLine($" {Glyphs.Bullet} Here are some options for you for today's demo."); + Console.WriteLine(); + } + + private static void PrintMenu(string uamiClientId, string resourceUrl, string property, bool showLogs, bool showFullToken) + { + WriteSection("Acquire Tokens", ConsoleColor.Green); + WriteItem(" 1 - Use SAMI → Token from IDP (force refresh)", ConsoleColor.Gray); + WriteItem(" 1a - Use SAMI → Token from Cache", ConsoleColor.Gray); + WriteItem(" 2 - Use UAMI → Token from IDP (force refresh)", ConsoleColor.Gray); + WriteItem(" 2a - Use UAMI → Token from Cache", ConsoleColor.Gray); + + WriteSection("Call Resource", ConsoleColor.Cyan); + WriteItem(" 3 - Use SAMI token + cert → Call resource", ConsoleColor.Gray); + WriteItem(" 4 - Use UAMI token + cert → Call resource", ConsoleColor.Gray); + + WriteSection("Display & Toggles", ConsoleColor.Yellow); + WriteItem($" F - Toggle Full Token view (currently {(showFullToken ? "ON" : "OFF")})", ConsoleColor.Yellow); + WriteItem($" L - Toggle MSAL logging (currently {(showLogs ? "ON" : "OFF")})", ConsoleColor.Yellow); + + WriteSection("Settings", ConsoleColor.Magenta); + WriteItem($" set-uami - Change UAMI client id (current: {uamiClientId})", ConsoleColor.Magenta); + WriteItem($" set-resource - Change resource URL (current: {resourceUrl})", ConsoleColor.Magenta); + WriteItem($" set-prop - Change single property to display (current: {property})", ConsoleColor.Magenta); + + WriteSection("System", ConsoleColor.White); + WriteItem(" C / cls / clear - Clear screen", ConsoleColor.White); + WriteItem(" M - Maximize window", ConsoleColor.White); + WriteItem(" Q - Quit", ConsoleColor.Red); + } + + private static void PrintFooter() + { + Console.WriteLine(); + WithColor(ConsoleColor.Cyan, () => + { + Console.WriteLine("Done. Thanks!"); + Console.WriteLine("════════════════════════════════════════════════════════════════════"); + }); + } + + private static void Boxed(string title) + { + Console.WriteLine(); + WithColor(ConsoleColor.DarkCyan, () => + { + Console.WriteLine("────────────────────────────────────────────────────────────────────"); + Console.WriteLine($" {title}"); + Console.WriteLine("────────────────────────────────────────────────────────────────────"); + }); + } + + private static void WriteSection(string title, ConsoleColor color) + { + Console.WriteLine(); + WithColor(color, () => Console.WriteLine($"[{title}]")); + } + + private static void WriteItem(string text, ConsoleColor color) => + WithColor(color, () => Console.WriteLine(text)); + + private static void WithColor(ConsoleColor color, Action action) + { + var old = Console.ForegroundColor; + Console.ForegroundColor = color; + try { action(); } + finally { Console.ForegroundColor = old; } + } + + private static void PrintInfo(string msg) => Console.WriteLine($"[INFO] {msg}"); + private static void PrintWarn(string msg) => WithColor(ConsoleColor.Yellow, () => Console.WriteLine($"[WARN] {msg}")); + private static void PrintError(string title, string detail) => + WithColor(ConsoleColor.Red, () => Console.WriteLine($"[ERROR] {title}: {detail}")); + + private static string Indent(string s, string prefix) + { + using var sr = new StringReader(s); + var sb = new StringBuilder(); + string? line; + while ((line = sr.ReadLine()) != null) + { + sb.Append(prefix).AppendLine(line); + } + return sb.ToString(); + } + + // ---------- Glyphs + Spinner (Unicode → ASCII fallback) ---------- + private static class Glyphs + { + public static bool Unicode => Console.OutputEncoding.CodePage == 65001; + public static string Check => Unicode ? "✔" : "OK"; + public static string Cross => Unicode ? "✖" : "X"; + public static string Warn => Unicode ? "⚠" : "!"; + public static string Bullet => Unicode ? "•" : "*"; + public static string[] SpinnerFrames => Unicode + ? new[] { "⠋", "⠙", "⠸", "⠼", "⠴", "⠦", "⠇", "⠏" } // Braille spinner + : new[] { "-", "\\", "|", "/" }; // ASCII spinner + } + + private static class Ui + { + public static async Task WithSpinnerAsync(string message, Func> work, int intervalMs = 80) + { + var frames = Glyphs.SpinnerFrames; + using var cts = new CancellationTokenSource(); + var spin = Task.Run(async () => + { + var i = 0; + while (!cts.IsCancellationRequested) + { + var frame = frames[i++ % frames.Length]; + Console.Write($"\r{frame} {message} "); + try { await Task.Delay(intervalMs, cts.Token); } catch { /* ignore */ } + } + }); + + try + { + var result = await work(); + cts.Cancel(); + Console.WriteLine($"\r{Glyphs.Check} {message} "); + return result; + } + catch + { + cts.Cancel(); + Console.WriteLine($"\r{Glyphs.Cross} {message} "); + throw; + } + } + + public static async Task WithSpinnerAsync(string message, Func work, int intervalMs = 80) => + await WithSpinnerAsync(message, async () => { await work(); return new object(); }, intervalMs); + } + + // ---------- Toggleable MSAL logger (OFF by default) ---------- + private sealed class ToggleableLogger : IIdentityLogger + { + public bool Enabled { get; set; } = false; + public EventLogLevel Level { get; set; } = EventLogLevel.Informational; + public EventLogLevel MinLogLevel => Level; + public bool IsEnabled(EventLogLevel eventLogLevel) => Enabled && eventLogLevel <= Level; + public void Log(LogEntry entry) + { + if (!Enabled) return; + Console.WriteLine(entry.Message); + } + } + + // ---------- Maximize console window (Windows only) ---------- + private static void TryMaximizeConsoleWindow(bool silent) + { + try + { + if (!OperatingSystem.IsWindows()) + { + if (!silent) PrintWarn("Maximize is only supported on Windows; skipping."); + return; + } + + try + { + var targetWidth = Console.LargestWindowWidth; + var targetHeight = Console.LargestWindowHeight; + + if (Console.BufferWidth < targetWidth) Console.BufferWidth = targetWidth; + if (Console.BufferHeight < targetHeight) Console.BufferHeight = targetHeight; + + Console.SetWindowSize(targetWidth, targetHeight); + } + catch { /* ignore */ } + + IntPtr hWnd = GetConsoleWindow(); + if (hWnd != IntPtr.Zero) ShowWindow(hWnd, SW_MAXIMIZE); + + if (!silent) PrintInfo("Tried to maximize window (host terminal may limit this)."); + } + catch + { + if (!silent) PrintWarn("Maximize not supported in this host."); + } + } +} From 3569f9183b45c3653f2516f5a5b6caf62c4fc7f7 Mon Sep 17 00:00:00 2001 From: Gladwin Johnson <90415114+gladjohn@users.noreply.github.com> Date: Sun, 12 Oct 2025 18:16:35 -0700 Subject: [PATCH 2/4] Update ManagedIdentity-mTLS-Demo.md --- prototype/MsiV2DemoApp/ManagedIdentity-mTLS-Demo.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/prototype/MsiV2DemoApp/ManagedIdentity-mTLS-Demo.md b/prototype/MsiV2DemoApp/ManagedIdentity-mTLS-Demo.md index 0cb785b9af..b97d0fa081 100644 --- a/prototype/MsiV2DemoApp/ManagedIdentity-mTLS-Demo.md +++ b/prototype/MsiV2DemoApp/ManagedIdentity-mTLS-Demo.md @@ -24,12 +24,12 @@ uses the **binding certificate** to call an **mTLS-protected** Graph test endpoi This demo consumes **preview builds** from the **IDDP** Azure Artifacts feed. - **Core MSAL** - *Package Details*: Azure Artifacts → Microsoft.Identity.Client → `4.77.0-msi-v2-pkg2` - Link: https://identitydivision.visualstudio.com/Engineering/_artifacts/feed/IDDP/NuGet/Microsoft.Identity.Client/overview/4.77.0-msi-v2-pkg2 + *Package Details*: Azure Artifacts → Microsoft.Identity.Client → `4.77.0-msi-v2-package1` + Link: https://identitydivision.visualstudio.com/Engineering/_artifacts/feed/IDDP/NuGet/Microsoft.Identity.Client/overview/4.77.0-msi-v2-package1 - **mTLS PoP helper** - *Package Details*: Azure Artifacts → Microsoft.Identity.Client.MtlsPop → `4.77.0-msi-v2-pkg2-preview` - Link: https://identitydivision.visualstudio.com/Engineering/_artifacts/feed/IDDP/NuGet/Microsoft.Identity.Client.MtlsPop/overview/4.77.0-msi-v2-pkg2-preview + *Package Details*: Azure Artifacts → Microsoft.Identity.Client.MtlsPop → `4.77.0-msi-v2-package1-preview` + Link: https://identitydivision.visualstudio.com/Engineering/_artifacts/feed/IDDP/NuGet/Microsoft.Identity.Client.MtlsPop/overview/4.77.0-msi-v2-package1-preview **NuGet source (IDDP feed)** `https://pkgs.dev.azure.com/identitydivision/Engineering/_packaging/IDDP/nuget/v3/index.json` From 394c1e346f307d7d10af2d89219d2245451dbbf0 Mon Sep 17 00:00:00 2001 From: Gladwin Johnson <90415114+gladjohn@users.noreply.github.com> Date: Sun, 12 Oct 2025 18:17:13 -0700 Subject: [PATCH 3/4] Update Program.cs --- prototype/MsiV2DemoApp/Program.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/prototype/MsiV2DemoApp/Program.cs b/prototype/MsiV2DemoApp/Program.cs index b95dd6e9ac..6cd6f9b32a 100644 --- a/prototype/MsiV2DemoApp/Program.cs +++ b/prototype/MsiV2DemoApp/Program.cs @@ -33,7 +33,7 @@ // - MSI_MTLS_TEST_CERT_THUMBPRINT or MSI_MTLS_TEST_CERT_SUBJECT [+ MSI_MTLS_TEST_CERT_STORE_LOC, MSI_MTLS_TEST_CERT_STORE_NAME] // → override client cert from Windows cert store // -// NuGet: Microsoft.Identity.Client (>= 4.61.0) +// NuGet: Microsoft.Identity.Client (Internal -Preview. Refer to the .md file) using Microsoft.Identity.Client; using Microsoft.Identity.Client.AppConfig; @@ -803,3 +803,4 @@ private static void TryMaximizeConsoleWindow(bool silent) } } } + From eb8dda8cdc9b7688e30dc61588e74572cd347da2 Mon Sep 17 00:00:00 2001 From: Gladwin Johnson <90415114+gladjohn@users.noreply.github.com> Date: Wed, 22 Oct 2025 09:04:59 -0700 Subject: [PATCH 4/4] pr comments --- prototype/MsiV2DemoApp/MsiV2DemoApp.csproj | 5 + prototype/MsiV2DemoApp/Program.cs | 192 ++++++++++++++++-- ...ManagedIdentity-mTLS-Demo.md => readme.md} | 15 ++ 3 files changed, 195 insertions(+), 17 deletions(-) rename prototype/MsiV2DemoApp/{ManagedIdentity-mTLS-Demo.md => readme.md} (88%) diff --git a/prototype/MsiV2DemoApp/MsiV2DemoApp.csproj b/prototype/MsiV2DemoApp/MsiV2DemoApp.csproj index 206b89a9a8..e8ae98ddef 100644 --- a/prototype/MsiV2DemoApp/MsiV2DemoApp.csproj +++ b/prototype/MsiV2DemoApp/MsiV2DemoApp.csproj @@ -7,4 +7,9 @@ enable + + + + + diff --git a/prototype/MsiV2DemoApp/Program.cs b/prototype/MsiV2DemoApp/Program.cs index 6cd6f9b32a..02cd5adce7 100644 --- a/prototype/MsiV2DemoApp/Program.cs +++ b/prototype/MsiV2DemoApp/Program.cs @@ -1,4 +1,7 @@ -// Managed Identity + mTLS PoP + mTLS resource call (Console Demo) +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Managed Identity + mTLS PoP + mTLS resource call (Console Demo) // .NET 8 // // Menu: @@ -56,6 +59,8 @@ internal class Program private const string DefaultScope = "https://graph.microsoft.com"; private const string DefaultResourceUrl = "https://mtlstb.graph.microsoft.com/v1.0/applications"; private const int SW_MAXIMIZE = 3; + // Warn once per run when full-token view is enabled + private static bool sFullTokenWarned = false; [DllImport("kernel32.dll", SetLastError = true)] private static extern IntPtr GetConsoleWindow(); [DllImport("user32.dll")] private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); @@ -102,31 +107,42 @@ public static async Task Main(string[] args) { // Acquire tokens case "1": - lastSami = await AcquireAndShowAsync("SAMI", sami, DefaultScope, forceRefresh: true, useMtls: true, showFullToken); + lastSami = await AcquireAndShowAsync("SAMI", sami, DefaultScope, forceRefresh: true, useMtls: true, showFullToken).ConfigureAwait(false); break; + case "1a": - lastSami = await AcquireAndShowAsync("SAMI", sami, DefaultScope, forceRefresh: false, useMtls: true, showFullToken); + lastSami = await AcquireAndShowAsync("SAMI", sami, DefaultScope, forceRefresh: false, useMtls: true, showFullToken).ConfigureAwait(false); break; + case "2": - lastUami = await AcquireAndShowAsync("UAMI", uami, DefaultScope, forceRefresh: true, useMtls: true, showFullToken); + lastUami = await AcquireAndShowAsync("UAMI", uami, DefaultScope, forceRefresh: true, useMtls: true, showFullToken).ConfigureAwait(false); break; + case "2a": - lastUami = await AcquireAndShowAsync("UAMI", uami, DefaultScope, forceRefresh: false, useMtls: true, showFullToken); + lastUami = await AcquireAndShowAsync("UAMI", uami, DefaultScope, forceRefresh: false, useMtls: true, showFullToken).ConfigureAwait(false); break; - // Call resource (mTLS) + // Call resource (mTLS) - SAMI case "3": - lastSami = await CallResourceFlowAsync("SAMI", sami, lastSami, resourceUrl, propertyName, showFullToken); + lastSami = await CallResourceFlowAsync("SAMI", sami, lastSami, resourceUrl, propertyName, showFullToken).ConfigureAwait(false); break; + + // Call resource (mTLS) - UAMI case "4": - lastUami = await CallResourceFlowAsync("UAMI", uami, lastUami, resourceUrl, propertyName, showFullToken); + lastUami = await CallResourceFlowAsync("UAMI", uami, lastUami, resourceUrl, propertyName, showFullToken).ConfigureAwait(false); break; // Display & Toggles case "f": showFullToken = !showFullToken; PrintInfo($"Full token display is now {(showFullToken ? "ON" : "OFF")}."); + if (showFullToken && !sFullTokenWarned) + { + ShowFullTokenWarning(); + sFullTokenWarned = true; + } break; + case "l": showLogs = !showLogs; logger.Enabled = showLogs; @@ -221,6 +237,16 @@ private static IManagedIdentityApplication BuildMiApp(ManagedIdentityId miId, II .WithLogging(logger, enablePiiLogging: false) .Build(); + /// + /// acquire token and show summary. + /// + /// + /// + /// + /// + /// + /// + /// private static async Task AcquireAndShowAsync( string label, IManagedIdentityApplication app, @@ -241,6 +267,14 @@ private static IManagedIdentityApplication BuildMiApp(ManagedIdentityId miId, II return result; } + /// + /// print token summary info. + /// + /// + /// + /// + /// + /// private static void PrintTokenSummary(string label, AuthenticationResult result, bool mtlsRequested, bool forced, bool showFullToken) { var ts = result.AuthenticationResultMetadata?.TokenSource.ToString() ?? "Unknown"; @@ -287,6 +321,7 @@ private static void PrintTokenSummary(string label, AuthenticationResult result, { Console.WriteLine(); Console.WriteLine(" Access Token :"); + WithColor(ConsoleColor.Yellow, () => Console.WriteLine(" [Sensitive] Handle with care. Do not share/copy.")); Console.WriteLine($" {result.AccessToken}"); } else @@ -314,6 +349,16 @@ private static void PrintTokenSummary(string label, AuthenticationResult result, Console.WriteLine($" {Glyphs.Check} Token acquisition complete."); } + /// + /// call resource flow: ensure token+cert, then call resource over mTLS. + /// + /// + /// + /// + /// + /// + /// + /// private static async Task CallResourceFlowAsync( string label, IManagedIdentityApplication app, @@ -325,7 +370,7 @@ private static void PrintTokenSummary(string label, AuthenticationResult result, // Ensure token+cert (use cache if valid, else fetch) if (lastResult == null || lastResult.ExpiresOn <= DateTimeOffset.UtcNow.AddMinutes(2)) { - lastResult = await AcquireAndShowAsync(label, app, DefaultScope, forceRefresh: false, useMtls: true, showFullToken); + lastResult = await AcquireAndShowAsync(label, app, DefaultScope, forceRefresh: false, useMtls: true, showFullToken).ConfigureAwait(false); if (lastResult == null) return lastResult; } @@ -340,6 +385,15 @@ private static void PrintTokenSummary(string label, AuthenticationResult result, return lastResult; } + /// + /// call resource over mTLS with given cert + token. + /// + /// + /// + /// + /// + /// + /// private static async Task CallResourceWithMtlsAsync( Uri url, X509Certificate2 clientCertificate, @@ -511,6 +565,12 @@ private static string Abbrev(string token) return token[..60] + "..." + token[^20..]; } + /// + /// read a claim from JWT token (null if not present). + /// + /// + /// + /// private static string? TryReadClaim(string jwt, string claim) { try @@ -525,6 +585,11 @@ private static string Abbrev(string token) catch { return null; } } + /// + /// read cnf.x5t#S256 from JWT token (null if not present). + /// + /// + /// private static string? TryReadCnfX5tS256(string jwt) { try @@ -544,16 +609,29 @@ private static string Abbrev(string token) catch { return null; } } + /// + /// compute cert SHA-256 and return Base64Url-encoded string for x5t#S256. + /// + /// + /// private static string ComputeX5tS256(X509Certificate2 cert) { var hash = SHA256.HashData(cert.RawData); return ToBase64Url(hash); } + /// + /// to Base64Url encoding (no padding, - and _). + /// + /// + /// private static string ToBase64Url(byte[] data) => Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_'); - // ---------- Cert override (optional, DEV) ---------- + /// + /// optionally load cert override from Windows cert store (DEV only). + /// + /// private static X509Certificate2? TryLoadStoreCertOverride() { var thumb = Environment.GetEnvironmentVariable("MSI_MTLS_TEST_CERT_THUMBPRINT"); @@ -604,7 +682,9 @@ private static string ToBase64Url(byte[] data) => } } - // ---------- Pretty console output + colored menu ---------- + /// + /// print banner. + /// private static void WriteBanner() { Console.WriteLine(); @@ -616,6 +696,9 @@ private static void WriteBanner() }); } + /// + /// write AI-style hello message. + /// private static void WriteAiHello() { Console.WriteLine(); @@ -624,6 +707,14 @@ private static void WriteAiHello() Console.WriteLine(); } + /// + /// print menu. + /// + /// + /// + /// + /// + /// private static void PrintMenu(string uamiClientId, string resourceUrl, string property, bool showLogs, bool showFullToken) { WriteSection("Acquire Tokens", ConsoleColor.Green); @@ -651,6 +742,9 @@ private static void PrintMenu(string uamiClientId, string resourceUrl, string pr WriteItem(" Q - Quit", ConsoleColor.Red); } + /// + /// print footer. + /// private static void PrintFooter() { Console.WriteLine(); @@ -661,6 +755,10 @@ private static void PrintFooter() }); } + /// + /// boxed title. + /// + /// private static void Boxed(string title) { Console.WriteLine(); @@ -672,15 +770,30 @@ private static void Boxed(string title) }); } + /// + /// write a section header with color. + /// + /// + /// private static void WriteSection(string title, ConsoleColor color) { Console.WriteLine(); WithColor(color, () => Console.WriteLine($"[{title}]")); } + /// + /// write an item line with color. + /// + /// + /// private static void WriteItem(string text, ConsoleColor color) => WithColor(color, () => Console.WriteLine(text)); + /// + /// with temporary console color. + /// + /// + /// private static void WithColor(ConsoleColor color, Action action) { var old = Console.ForegroundColor; @@ -689,11 +802,30 @@ private static void WithColor(ConsoleColor color, Action action) finally { Console.ForegroundColor = old; } } + /// + /// print an info message. + /// + /// private static void PrintInfo(string msg) => Console.WriteLine($"[INFO] {msg}"); + /// + /// print a warning message. + /// + /// private static void PrintWarn(string msg) => WithColor(ConsoleColor.Yellow, () => Console.WriteLine($"[WARN] {msg}")); + /// + /// print an error message. + /// + /// + /// private static void PrintError(string title, string detail) => WithColor(ConsoleColor.Red, () => Console.WriteLine($"[ERROR] {title}: {detail}")); + /// + /// Indent each line of a string with the given prefix. + /// + /// + /// + /// private static string Indent(string s, string prefix) { using var sr = new StringReader(s); @@ -706,7 +838,9 @@ private static string Indent(string s, string prefix) return sb.ToString(); } - // ---------- Glyphs + Spinner (Unicode → ASCII fallback) ---------- + /// + /// Glyphs for console output (Unicode vs ASCII). + /// private static class Glyphs { public static bool Unicode => Console.OutputEncoding.CodePage == 65001; @@ -719,6 +853,9 @@ private static class Glyphs : new[] { "-", "\\", "|", "/" }; // ASCII spinner } + /// + /// Utility for showing an animated spinner during async work. + /// private static class Ui { public static async Task WithSpinnerAsync(string message, Func> work, int intervalMs = 80) @@ -732,13 +869,13 @@ public static async Task WithSpinnerAsync(string message, Func> wo { var frame = frames[i++ % frames.Length]; Console.Write($"\r{frame} {message} "); - try { await Task.Delay(intervalMs, cts.Token); } catch { /* ignore */ } + try { await Task.Delay(intervalMs, cts.Token).ConfigureAwait(false); } catch { /* ignore */ } } }); try { - var result = await work(); + var result = await work().ConfigureAwait(false); cts.Cancel(); Console.WriteLine($"\r{Glyphs.Check} {message} "); return result; @@ -752,10 +889,12 @@ public static async Task WithSpinnerAsync(string message, Func> wo } public static async Task WithSpinnerAsync(string message, Func work, int intervalMs = 80) => - await WithSpinnerAsync(message, async () => { await work(); return new object(); }, intervalMs); + await WithSpinnerAsync(message, async () => { await work().ConfigureAwait(false); return new object(); }, intervalMs).ConfigureAwait(false); } - // ---------- Toggleable MSAL logger (OFF by default) ---------- + /// + /// toggleable MSAL logger for console output. + /// private sealed class ToggleableLogger : IIdentityLogger { public bool Enabled { get; set; } = false; @@ -769,7 +908,10 @@ public void Log(LogEntry entry) } } - // ---------- Maximize console window (Windows only) ---------- + /// + /// maximize console window (Windows only). + /// + /// private static void TryMaximizeConsoleWindow(bool silent) { try @@ -802,5 +944,21 @@ private static void TryMaximizeConsoleWindow(bool silent) if (!silent) PrintWarn("Maximize not supported in this host."); } } + + /// + /// Show security warning about full token display. + /// + private static void ShowFullTokenWarning() + { + WithColor(ConsoleColor.Yellow, () => + { + Console.WriteLine(); + Console.WriteLine(" ⚠ SECURITY REMINDER: Full access tokens are highly sensitive."); + Console.WriteLine(" • Avoid screenshots and screen sharing while visible."); + Console.WriteLine(" • Do not paste into chats, tickets, or logs."); + Console.WriteLine(" • Treat like a password/secret; clear the screen after use (C)."); + Console.WriteLine(); + }); + } } diff --git a/prototype/MsiV2DemoApp/ManagedIdentity-mTLS-Demo.md b/prototype/MsiV2DemoApp/readme.md similarity index 88% rename from prototype/MsiV2DemoApp/ManagedIdentity-mTLS-Demo.md rename to prototype/MsiV2DemoApp/readme.md index b97d0fa081..4b73c1588f 100644 --- a/prototype/MsiV2DemoApp/ManagedIdentity-mTLS-Demo.md +++ b/prototype/MsiV2DemoApp/readme.md @@ -8,6 +8,19 @@ uses the **binding certificate** to call an **mTLS-protected** Graph test endpoi --- +## Prerequisites (read first) + +- **Azure subscription (allow listed for feature)** and one of: + - An **Azure VM/VMSS** with **System‑Assigned Managed Identity** enabled + *(For UAMI, note the **clientId** you plan to use.)* +- **Target resource access** (e.g., Graph test slice) permitting the MI to call it. +- **.NET 8 SDK** on your dev machine. +- **NuGet access to the internal IDDP feed** (Azure DevOps **PAT** with Packaging **Read**). + +> New to Managed Identity? In the Azure Portal, enable **System assigned** on your VM/App and save; for **User assigned**, create the identity and **assign** it to the compute resource. + +--- + ## What this demo highlights - **System Assigned (SAMI)** and **User Assigned (UAMI)** Managed Identity. @@ -17,6 +30,8 @@ uses the **binding certificate** to call an **mTLS-protected** Graph test endpoi - **mTLS call** with a success panel (status, latency, HTTP version, response size). - **Toggleable MSAL logging** (off by default) and a **full-token view** (press `F`). +> **Sensitive**: If you enable full-token view, treat tokens like passwords—avoid screenshots, copying, or sharing. + --- ## Required packages (IDDP feed)