diff --git a/BadgeReleaseDemo/.gitignore b/BadgeReleaseDemo/.gitignore new file mode 100644 index 0000000..098b6a3 --- /dev/null +++ b/BadgeReleaseDemo/.gitignore @@ -0,0 +1,9 @@ +bin/ +obj/ + +# Debug/comparison artifacts +*.bin +*.txt +compare.csx +program-comparison-snippet.txt +IppOperations/IppRequestComparison.cs diff --git a/BadgeReleaseDemo/Auth/AuthHelper.cs b/BadgeReleaseDemo/Auth/AuthHelper.cs new file mode 100644 index 0000000..7c6c5f6 --- /dev/null +++ b/BadgeReleaseDemo/Auth/AuthHelper.cs @@ -0,0 +1,253 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// + +using System.IdentityModel.Tokens.Jwt; +using System.Security.Cryptography; +using System.Text.Json; +using BadgeReleaseDemo.GraphApi; +using BadgeReleaseDemo.Helpers; +using Microsoft.Identity.Client; +using Microsoft.IdentityModel.Tokens; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Security; + +namespace BadgeReleaseDemo.Auth; + +/// +/// Handles authentication for both user (Printer Admin) and printer device flows. +/// Printer certificate and keys are kept in memory only. +/// +public class AuthHelper +{ + private readonly string appId; + private readonly string tenantId; + private readonly string graphBaseUrl; + private IPublicClientApplication? publicClient; + private AuthenticationResult? userAuthResult; + private AuthenticationResult? graphAuthResult; + + // Printer identity (in memory only) + private PrinterRegistrationResult? registrationResult; + private AsymmetricCipherKeyPair? printerKeyPair; + private string? printerToken; + + public string UserUpn => userAuthResult?.Account?.Username ?? "unknown"; + + public string UserAccessToken => userAuthResult?.AccessToken ?? throw new InvalidOperationException("User not signed in"); + + public string PrinterToken => printerToken ?? throw new InvalidOperationException("Printer token not acquired"); + + public AuthHelper(string appId, string tenantId, string graphBaseUrl) + { + this.appId = appId; + this.tenantId = tenantId; + this.graphBaseUrl = graphBaseUrl; + } + + /// + /// Signs in the user using interactive browser-based authentication. + /// + public async Task SignInUserAsync() + { + var builder = PublicClientApplicationBuilder + .Create(appId) + .WithRedirectUri("http://localhost"); + + if (!string.IsNullOrEmpty(tenantId)) + { + builder = builder.WithAuthority($"https://login.microsoftonline.com/{tenantId}"); + } + + publicClient = builder.Build(); + + var scopes = new[] { "https://print.print.microsoft.com/.default" }; + + userAuthResult = await publicClient.AcquireTokenInteractive(scopes) + .WithPrompt(Prompt.SelectAccount) + .ExecuteAsync(); + + return UserUpn; + } + + /// + /// Gets a fresh user access token, refreshing if needed. + /// + public async Task GetUserTokenAsync() + { + if (publicClient == null || userAuthResult == null) + { + throw new InvalidOperationException("User not signed in. Call SignInUserAsync first."); + } + + try + { + var accounts = await publicClient.GetAccountsAsync(); + var scopes = new[] { "https://print.print.microsoft.com/.default" }; + userAuthResult = await publicClient.AcquireTokenSilent(scopes, accounts.FirstOrDefault()) + .ExecuteAsync(); + } + catch (MsalUiRequiredException) + { + var scopes = new[] { "https://print.print.microsoft.com/.default" }; + userAuthResult = await publicClient.AcquireTokenInteractive(scopes) + .ExecuteAsync(); + } + + return UserAccessToken; + } + + /// + /// Gets a Microsoft Graph token for Graph API calls (sharing, badges, jobs). + /// Uses silent acquisition if possible, otherwise prompts interactively. + /// + public async Task GetGraphTokenAsync() + { + if (publicClient == null) + { + throw new InvalidOperationException("User not signed in. Call SignInUserAsync first."); + } + + var scopes = new[] { "https://graph.microsoft.com/.default" }; + + try + { + var accounts = await publicClient.GetAccountsAsync(); + graphAuthResult = await publicClient.AcquireTokenSilent(scopes, accounts.FirstOrDefault()) + .ExecuteAsync(); + } + catch (MsalUiRequiredException) + { + graphAuthResult = await publicClient.AcquireTokenInteractive(scopes) + .ExecuteAsync(); + } + + return graphAuthResult.AccessToken; + } + + /// + /// Stores the printer registration result and keypair for device token acquisition. + /// Certificate and keys are kept in memory only — not saved to disk. + /// + public void SetPrinterCredentials(PrinterRegistrationResult result, AsymmetricCipherKeyPair keyPair) + { + registrationResult = result; + printerKeyPair = keyPair; + } + + /// + /// Acquires a device token for the printer using the JWT-bearer flow: + /// 1. POST grant_type=srv_challenge to get a nonce + /// 2. Create a JWT signed with the printer's private key + /// 3. POST grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer with the JWT + /// + public async Task GetPrinterTokenAsync() + { + if (registrationResult == null || printerKeyPair == null) + { + throw new InvalidOperationException("Printer credentials not set. Register printer first."); + } + + using var httpClient = new HttpClient(); + var tokenUrl = registrationResult.DeviceTokenUrl; + + // Step 1: Request a nonce (srv_challenge) + var challengeBody = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "srv_challenge", + ["windows_api_version"] = "2.0", + }); + + var challengeResp = await httpClient.PostAsync(tokenUrl, challengeBody); + var challengeContent = await challengeResp.Content.ReadAsStringAsync(); + + if (!challengeResp.IsSuccessStatusCode) + { + throw new HttpRequestException($"Nonce request failed: {challengeResp.StatusCode} - {challengeContent}"); + } + + var challengeDoc = JsonSerializer.Deserialize(challengeContent); + var nonce = challengeDoc.GetProperty("Nonce").GetString() + ?? throw new InvalidOperationException("No Nonce in srv_challenge response."); + + // Step 2: Create JWT signed with printer's private key + var jwt = CreateDeviceJwt(nonce); + + // Step 3: Exchange JWT for access token + var tokenBody = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "urn:ietf:params:oauth:grant-type:jwt-bearer", + ["request"] = jwt, + }); + + var tokenResp = await httpClient.PostAsync(tokenUrl, tokenBody); + var tokenContent = await tokenResp.Content.ReadAsStringAsync(); + + if (!tokenResp.IsSuccessStatusCode) + { + throw new HttpRequestException($"Device token request failed: {tokenResp.StatusCode} - {tokenContent}"); + } + + var tokenDoc = JsonSerializer.Deserialize(tokenContent); + printerToken = tokenDoc.GetProperty("access_token").GetString() + ?? throw new InvalidOperationException("No access_token in device token response."); + + return printerToken; + } + + /// + /// Refreshes the printer device token if needed. + /// + public async Task RefreshPrinterTokenAsync() + { + return await GetPrinterTokenAsync(); + } + + /// + /// Creates a JWT signed with the printer's private key for the device token flow. + /// + private string CreateDeviceJwt(string nonce) + { + var privateKeyParams = (RsaPrivateCrtKeyParameters)printerKeyPair!.Private; + var rsaParams = DotNetUtilities.ToRSAParameters(privateKeyParams); + var securityKey = new RsaSecurityKey(rsaParams); + var signingCredentials = new SigningCredentials(securityKey, SecurityAlgorithms.RsaSha256); + + // x5c must be the base64 DER of the public cert (no PEM headers) + var certBase64 = registrationResult!.CertificatePem + .Replace("-----BEGIN CERTIFICATE-----", string.Empty) + .Replace("-----END CERTIFICATE-----", string.Empty) + .Replace("\r", string.Empty) + .Replace("\n", string.Empty) + .Trim(); + + var header = new JwtHeader(signingCredentials) + { + { "x5c", new[] { certBase64 } }, + }; + + // Use the app's client_id and the configured desktop redirect URI. + var clientId = string.IsNullOrEmpty(registrationResult!.PrinterClientId) + ? appId + : registrationResult.PrinterClientId; + var redirectUri = string.IsNullOrEmpty(registrationResult.PrinterRedirectUri) + ? "http://localhost" + : registrationResult.PrinterRedirectUri; + + var claims = new[] + { + new System.Security.Claims.Claim("request_nonce", nonce), + new System.Security.Claims.Claim("grant_type", "device_token"), + new System.Security.Claims.Claim("resource", registrationResult.PrintServiceResourceId), + new System.Security.Claims.Claim("client_id", clientId), + new System.Security.Claims.Claim("redirect_uri", redirectUri), + new System.Security.Claims.Claim("iss", registrationResult.PrinterId), + }; + + var payload = new JwtPayload(claims); + var jwt = new JwtSecurityToken(header, payload); + var handler = new JwtSecurityTokenHandler(); + return handler.WriteToken(jwt); + } +} diff --git a/BadgeReleaseDemo/BadgeReleaseDemo.csproj b/BadgeReleaseDemo/BadgeReleaseDemo.csproj new file mode 100644 index 0000000..c0982d0 --- /dev/null +++ b/BadgeReleaseDemo/BadgeReleaseDemo.csproj @@ -0,0 +1,28 @@ + + + + Exe + net8.0 + BadgeReleaseDemo + BadgeReleaseDemo + enable + enable + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/BadgeReleaseDemo/BadgeReleaseDemo.sln b/BadgeReleaseDemo/BadgeReleaseDemo.sln new file mode 100644 index 0000000..f64a8e0 --- /dev/null +++ b/BadgeReleaseDemo/BadgeReleaseDemo.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BadgeReleaseDemo", "BadgeReleaseDemo.csproj", "{C8E0786E-1129-36F5-7092-E300E7CF2EA2}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C8E0786E-1129-36F5-7092-E300E7CF2EA2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C8E0786E-1129-36F5-7092-E300E7CF2EA2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C8E0786E-1129-36F5-7092-E300E7CF2EA2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C8E0786E-1129-36F5-7092-E300E7CF2EA2}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {EDDF716F-FB42-40B6-9578-912FDE8C5DB8} + EndGlobalSection +EndGlobal diff --git a/BadgeReleaseDemo/GraphApi/BadgeManagement.cs b/BadgeReleaseDemo/GraphApi/BadgeManagement.cs new file mode 100644 index 0000000..1b2454c --- /dev/null +++ b/BadgeReleaseDemo/GraphApi/BadgeManagement.cs @@ -0,0 +1,176 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// + +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using BadgeReleaseDemo.Helpers; + +namespace BadgeReleaseDemo.GraphApi; + +/// +/// Handles badge collection and badge CRUD via MS Graph API. +/// +public class BadgeManagement +{ + private readonly string graphBaseUrl; + private readonly HttpClient httpClient; + + public BadgeManagement(string graphBaseUrl) + { + this.graphBaseUrl = graphBaseUrl; + httpClient = new HttpClient(); + } + + /// + /// Creates a badge collection. Handles 409 Conflict if it already exists. + /// Returns the actual collection ID from the service. + /// + public async Task CreateBadgeCollectionAsync(string accessToken) + { + using var request = new HttpRequestMessage(HttpMethod.Post, $"{graphBaseUrl}/print/badgeCollections"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + request.Content = new StringContent("{}", Encoding.UTF8, "application/json"); + var response = await httpClient.SendAsync(request); + var responseBody = await response.Content.ReadAsStringAsync(); + + if (response.StatusCode == HttpStatusCode.Conflict) + { + ConsoleHelper.WriteInfo("Badge collection already exists (this is OK)."); + return await GetBadgeCollectionIdAsync(accessToken); + } + + if (!response.IsSuccessStatusCode && response.StatusCode != HttpStatusCode.Accepted) + { + throw new HttpRequestException($"Failed to create badge collection: {response.StatusCode} - {responseBody}"); + } + + ConsoleHelper.WriteInfo("Badge collection creation initiated."); + + // Poll until the badge collection is provisioned (can take up to 10 minutes) + if (response.StatusCode == HttpStatusCode.Accepted) + { + ConsoleHelper.WriteProgress("Waiting for badge collection to be provisioned (this can take up to 10 minutes)..."); + return await WaitForBadgeCollectionProvisioningAsync(accessToken); + } + + return await GetBadgeCollectionIdAsync(accessToken); + } + + private async Task WaitForBadgeCollectionProvisioningAsync(string accessToken) + { + const int maxAttempts = 60; + const int delayMilliseconds = 10000; + + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + var collectionId = await TryGetBadgeCollectionIdAsync(accessToken); + + if (!string.IsNullOrEmpty(collectionId)) + { + return collectionId; + } + + if (attempt < maxAttempts) + { + await Task.Delay(delayMilliseconds); + } + } + + throw new TimeoutException("Timed out waiting for badge collection provisioning to complete."); + } + + private async Task GetBadgeCollectionIdAsync(string accessToken) + { + var collectionId = await TryGetBadgeCollectionIdAsync(accessToken); + return collectionId ?? throw new InvalidOperationException("No badge collection ID was returned by the service."); + } + + private async Task TryGetBadgeCollectionIdAsync(string accessToken) + { + using var request = new HttpRequestMessage(HttpMethod.Get, $"{graphBaseUrl}/print/badgeCollections"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + var response = await httpClient.SendAsync(request); + var responseBody = await response.Content.ReadAsStringAsync(); + + if (response.StatusCode == HttpStatusCode.NotFound) + { + return null; + } + + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException( + $"Failed to list badge collections: {response.StatusCode} - {responseBody}"); + } + + var listDoc = JsonSerializer.Deserialize(responseBody); + if (!listDoc.TryGetProperty("value", out var collections) || collections.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException("Badge collections response did not include a valid value array."); + } + + foreach (var collection in collections.EnumerateArray()) + { + if (collection.TryGetProperty("id", out var idProperty)) + { + var collectionId = idProperty.GetString(); + if (!string.IsNullOrWhiteSpace(collectionId)) + { + return collectionId; + } + } + } + + return null; + } + + /// + /// Adds a badge to the collection with the given badge ID and user UPN. + /// + public async Task AddBadgeAsync(string accessToken, string collectionId, string badgeId, string upn) + { + var requestBody = new + { + id = badgeId, + upn + }; + + var json = JsonSerializer.Serialize(requestBody); + using var request = new HttpRequestMessage(HttpMethod.Post, + $"{graphBaseUrl}/print/badgeCollections/{collectionId}/badges"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + var response = await httpClient.SendAsync(request); + var responseBody = await response.Content.ReadAsStringAsync(); + + if (response.StatusCode == HttpStatusCode.Conflict) + { + throw new InvalidOperationException( + $"Badge '{badgeId}' already exists. Choose a unique badge ID to avoid overwriting an existing user mapping."); + } + + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException($"Failed to add badge: {response.StatusCode} - {responseBody}"); + } + } + /// + /// Deletes a badge from the collection. + /// + public async Task DeleteBadgeAsync(string accessToken, string collectionId, string badgeId) + { + using var request = new HttpRequestMessage(HttpMethod.Delete, + $"{graphBaseUrl}/print/badgeCollections/{collectionId}/badges/{badgeId}"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + var response = await httpClient.SendAsync(request); + if (!response.IsSuccessStatusCode && response.StatusCode != HttpStatusCode.NotFound) + { + var body = await response.Content.ReadAsStringAsync(); + ConsoleHelper.WriteWarning($"Failed to delete badge: {response.StatusCode} - {body}"); + } + } +} diff --git a/BadgeReleaseDemo/GraphApi/PrintJobSubmission.cs b/BadgeReleaseDemo/GraphApi/PrintJobSubmission.cs new file mode 100644 index 0000000..8219550 --- /dev/null +++ b/BadgeReleaseDemo/GraphApi/PrintJobSubmission.cs @@ -0,0 +1,173 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// + +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using BadgeReleaseDemo.Helpers; + +namespace BadgeReleaseDemo.GraphApi; + +/// +/// Handles print job creation, document upload, and job start via MS Graph API. +/// +public class PrintJobSubmission +{ + private readonly string graphBaseUrl; + private readonly HttpClient httpClient; + + public PrintJobSubmission(string graphBaseUrl) + { + this.graphBaseUrl = graphBaseUrl; + httpClient = new HttpClient(); + } + + /// + /// Creates a print job on a printer share. + /// Matches the functional test pattern: configuration only, no documents in initial creation. + /// Returns (jobId, documentId). + /// + public async Task<(string JobId, string DocumentId)> CreateJobAsync( + string accessToken, string shareId, string displayName) + { + using var request = new HttpRequestMessage(HttpMethod.Post, + $"{graphBaseUrl}/print/shares/{shareId}/jobs"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + // Match functional test pattern: configuration with copies + dpi, no documents + var requestBody = new Dictionary + { + ["configuration"] = new Dictionary + { + ["copies"] = 1, + ["dpi"] = 600, + }, + }; + + var json = JsonSerializer.Serialize(requestBody, new JsonSerializerOptions + { + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + }); + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + + ConsoleHelper.WriteInfo($"POST {graphBaseUrl}/print/shares/{shareId}/jobs"); + ConsoleHelper.WriteInfo($"Body: {json}"); + + var response = await httpClient.SendAsync(request); + var responseBody = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException($"Failed to create print job: {response.StatusCode} - {responseBody}"); + } + + var jobDoc = JsonSerializer.Deserialize(responseBody); + var jobId = jobDoc.GetProperty("id").GetString()!; + + // The Graph API auto-creates a document when creating a print job + var documents = jobDoc.GetProperty("documents"); + if (documents.GetArrayLength() == 0) + { + throw new InvalidOperationException("Expected at least one document in the print job response."); + } + + var documentId = documents[0].GetProperty("id").GetString()!; + + return (jobId, documentId); + } + + /// + /// Creates an upload session for a document. + /// Returns the upload URL. + /// + public async Task CreateUploadSessionAsync( + string accessToken, string shareId, string jobId, string documentId, long documentSize) + { + using var request = new HttpRequestMessage(HttpMethod.Post, + $"{graphBaseUrl}/print/shares/{shareId}/jobs/{jobId}/documents/{documentId}/createUploadSession"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + var requestBody = new Dictionary + { + ["properties"] = new Dictionary + { + ["documentName"] = "SampleDocument.pdf", + ["contentType"] = "application/pdf", + ["size"] = documentSize, + }, + }; + + var json = JsonSerializer.Serialize(requestBody); + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await httpClient.SendAsync(request); + var responseBody = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException($"Failed to create upload session: {response.StatusCode} - {responseBody}"); + } + + var sessionDoc = JsonSerializer.Deserialize(responseBody); + return sessionDoc.GetProperty("uploadUrl").GetString() + ?? throw new InvalidOperationException("No uploadUrl in response."); + } + + /// + /// Uploads a PDF document to the upload session in chunks smaller than 10 MB. + /// + public async Task UploadDocumentAsync(string uploadUrl, byte[] pdfData) + { + // Universal Print upload sessions require each PUT to be less than 10 MB. + const int maxChunkSizeBytes = 9 * 1024 * 1024; + var totalLength = pdfData.Length; + var offset = 0; + + while (offset < totalLength) + { + var chunkLength = Math.Min(maxChunkSizeBytes, totalLength - offset); + var rangeStart = offset; + var rangeEnd = offset + chunkLength - 1; + + using var request = new HttpRequestMessage(HttpMethod.Put, uploadUrl); + request.Content = new ByteArrayContent(pdfData, offset, chunkLength); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf"); + request.Content.Headers.ContentLength = chunkLength; + request.Content.Headers.Add("Content-Range", $"bytes {rangeStart}-{rangeEnd}/{totalLength}"); + + var response = await httpClient.SendAsync(request); + var responseBody = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException($"Failed to upload document range {rangeStart}-{rangeEnd}: {response.StatusCode} - {responseBody}"); + } + + offset += chunkLength; + } + + ConsoleHelper.WriteInfo($"Uploaded {totalLength} bytes."); + } + + /// + /// Starts the print job after document upload. + /// + public async Task StartJobAsync(string accessToken, string shareId, string jobId) + { + using var request = new HttpRequestMessage(HttpMethod.Post, + $"{graphBaseUrl}/print/shares/{shareId}/jobs/{jobId}/start"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + request.Content = new StringContent("{}", Encoding.UTF8, "application/json"); + + var response = await httpClient.SendAsync(request); + var responseBody = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException($"Failed to start print job: {response.StatusCode} - {responseBody}"); + } + + ConsoleHelper.WriteInfo("Print job started."); + } +} diff --git a/BadgeReleaseDemo/GraphApi/PrinterRegistration.cs b/BadgeReleaseDemo/GraphApi/PrinterRegistration.cs new file mode 100644 index 0000000..b96043b --- /dev/null +++ b/BadgeReleaseDemo/GraphApi/PrinterRegistration.cs @@ -0,0 +1,162 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// + +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using BadgeReleaseDemo.Helpers; + +namespace BadgeReleaseDemo.GraphApi; + +/// +/// Handles printer registration via the Universal Print Registration Service +/// (https://register.print.microsoft.com). +/// +public class PrinterRegistration +{ + private readonly string registrationBaseUrl; + private readonly HttpClient httpClient; + + public PrinterRegistration(string registrationBaseUrl) + { + this.registrationBaseUrl = registrationBaseUrl.TrimEnd('/'); + httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(2) }; + } + + /// + /// Registers a new printer with Universal Print via the registration service. + /// Returns a record with all fields needed for device token acquisition. + /// + public async Task RegisterPrinterAsync( + string accessToken, + string displayName, + string csrContent, + string transportKey) + { + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + // Step 1: Initiate registration via POST /api/v1.0/register + // Registration API uses snake_case field names + var requestBody = new Dictionary + { + ["name"] = displayName, + ["manufacturer"] = "Badge Release Demo", + ["model"] = "Virtual Printer", + ["device_type"] = "printer", + ["device_capabilities"] = new[] { "print" }, + ["preferred_lang"] = "en-us", + ["certificate_request"] = new Dictionary + { + ["type"] = "pkcs10", + ["data"] = csrContent, + ["transport_key"] = transportKey, + }, + ["has_physical_device"] = false, + }; + + var json = JsonSerializer.Serialize(requestBody); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var regUrl = $"{registrationBaseUrl}/api/v1.0/register"; + var response = await httpClient.PostAsync(regUrl, content); + var responseBody = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException($"Failed to initiate printer registration: {response.StatusCode} - {responseBody}"); + } + + // Parse the registration ID from the response + var regResp = JsonSerializer.Deserialize(responseBody); + var registrationId = regResp.GetProperty("registration_id").GetString() + ?? throw new InvalidOperationException("No registration_id in registration response."); + + ConsoleHelper.WriteInfo($"Registration initiated (ID: {registrationId})"); + + // Step 2: Poll GET /api/v1.0/register?registration_id={id} until 200 OK + ConsoleHelper.WriteProgress("Polling registration status..."); + var statusUrl = $"{registrationBaseUrl}/api/v1.0/register?registration_id={registrationId}"; + + var startTime = DateTime.UtcNow; + var maxWait = TimeSpan.FromMinutes(5); + + while (DateTime.UtcNow - startTime < maxWait) + { + var statusResp = await httpClient.GetAsync(statusUrl); + var statusBody = await statusResp.Content.ReadAsStringAsync(); + + if (statusResp.StatusCode == HttpStatusCode.OK) + { + // Registration succeeded — parse printer info + var printerInfo = JsonSerializer.Deserialize(statusBody); + var printerId = printerInfo.GetProperty("cloud_device_id").GetString()!; + + var certPem = string.Empty; + if (printerInfo.TryGetProperty("certificate", out var certProp)) + { + certPem = certProp.GetString() ?? string.Empty; + } + + var deviceTokenUrl = string.Empty; + if (printerInfo.TryGetProperty("device_token_url", out var tokenUrlProp)) + { + deviceTokenUrl = tokenUrlProp.GetString() ?? string.Empty; + } + + var printerClientId = string.Empty; + if (printerInfo.TryGetProperty("printer_client_id", out var clientIdProp)) + { + printerClientId = clientIdProp.GetString() ?? string.Empty; + } + + var printerRedirectUri = string.Empty; + if (printerInfo.TryGetProperty("printer_redirect_uri", out var redirectProp)) + { + printerRedirectUri = redirectProp.GetString() ?? string.Empty; + } + + var printServiceResourceId = string.Empty; + if (printerInfo.TryGetProperty("mcp_svc_resource_id", out var resourceProp)) + { + printServiceResourceId = resourceProp.GetString() ?? string.Empty; + } + + return new PrinterRegistrationResult( + printerId, certPem, deviceTokenUrl, + printerClientId, printerRedirectUri, printServiceResourceId); + } + else if (statusResp.StatusCode == HttpStatusCode.Accepted) + { + // Still processing — wait for the interval specified in the response + var statusDoc = JsonSerializer.Deserialize(statusBody); + int interval = 3; + if (statusDoc.TryGetProperty("interval", out var intervalProp)) + { + interval = intervalProp.GetInt32(); + } + + ConsoleHelper.WriteInfo($"Registration in progress, retrying in {interval}s..."); + await Task.Delay(interval * 1000); + } + else + { + throw new HttpRequestException($"Registration polling failed: {statusResp.StatusCode} - {statusBody}"); + } + } + + throw new TimeoutException("Printer registration timed out after 5 minutes."); + } +} + +/// +/// Contains all fields returned from printer registration needed for device token acquisition. +/// +public record PrinterRegistrationResult( + string PrinterId, + string CertificatePem, + string DeviceTokenUrl, + string PrinterClientId, + string PrinterRedirectUri, + string PrintServiceResourceId); diff --git a/BadgeReleaseDemo/GraphApi/PrinterSharing.cs b/BadgeReleaseDemo/GraphApi/PrinterSharing.cs new file mode 100644 index 0000000..486a30e --- /dev/null +++ b/BadgeReleaseDemo/GraphApi/PrinterSharing.cs @@ -0,0 +1,123 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// + +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using BadgeReleaseDemo.Helpers; + +namespace BadgeReleaseDemo.GraphApi; + +/// +/// Handles printer sharing via MS Graph API. +/// +public class PrinterSharing +{ + private readonly string graphBaseUrl; + private readonly HttpClient httpClient; + + public PrinterSharing(string graphBaseUrl) + { + this.graphBaseUrl = graphBaseUrl; + httpClient = new HttpClient(); + } + + /// + /// Creates a printer share with allowAllUsers=true. + /// Returns the share ID. + /// + public async Task CreateShareAsync(string accessToken, string printerId, string displayName) + { + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + var requestBody = new Dictionary + { + ["displayName"] = displayName, + ["allowAllUsers"] = true, + ["printer@odata.bind"] = $"{graphBaseUrl}/print/printers/{printerId}", + }; + + var json = JsonSerializer.Serialize(requestBody); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await httpClient.PostAsync($"{graphBaseUrl}/print/shares", content); + var responseBody = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException($"Failed to create printer share: {response.StatusCode} - {responseBody}"); + } + + var shareDoc = JsonSerializer.Deserialize(responseBody); + return shareDoc.GetProperty("id").GetString() + ?? throw new InvalidOperationException("No share ID in response."); + } + + /// + /// Enables badge release on the printer via Graph PATCH /print/printers/{id}. + /// Sets releaseMechanisms to qrCode which enables pull-print/badge release. + /// + public async Task EnableBadgeReleaseAsync(string graphToken, string printerId) + { + var patchBody = new + { + releaseMechanisms = new[] + { + new + { + releaseType = "qrCode", + }, + }, + }; + + var json = JsonSerializer.Serialize(patchBody); + using var request = new HttpRequestMessage(HttpMethod.Patch, + $"{graphBaseUrl}/print/printers/{printerId}"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", graphToken); + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await httpClient.SendAsync(request); + var responseBody = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException( + $"Failed to enable badge release: {response.StatusCode} - {responseBody}"); + } + } + + /// + /// Deletes a printer share. + /// + public async Task DeleteShareAsync(string accessToken, string shareId) + { + using var request = new HttpRequestMessage(HttpMethod.Delete, + $"{graphBaseUrl}/print/shares/{shareId}"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + var response = await httpClient.SendAsync(request); + if (!response.IsSuccessStatusCode && response.StatusCode != System.Net.HttpStatusCode.NotFound) + { + var body = await response.Content.ReadAsStringAsync(); + ConsoleHelper.WriteWarning($"Failed to delete share: {response.StatusCode} - {body}"); + } + } + + /// + /// Deletes a printer. + /// + public async Task DeletePrinterAsync(string accessToken, string printerId) + { + using var request = new HttpRequestMessage(HttpMethod.Delete, + $"{graphBaseUrl}/print/printers/{printerId}"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + var response = await httpClient.SendAsync(request); + if (!response.IsSuccessStatusCode && response.StatusCode != System.Net.HttpStatusCode.NotFound) + { + var body = await response.Content.ReadAsStringAsync(); + ConsoleHelper.WriteWarning($"Failed to delete printer: {response.StatusCode} - {body}"); + } + } +} diff --git a/BadgeReleaseDemo/Helpers/ConsoleHelper.cs b/BadgeReleaseDemo/Helpers/ConsoleHelper.cs new file mode 100644 index 0000000..1d44b93 --- /dev/null +++ b/BadgeReleaseDemo/Helpers/ConsoleHelper.cs @@ -0,0 +1,92 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// + +namespace BadgeReleaseDemo.Helpers; + +/// +/// Lightweight console helpers with Unicode indicators and colored text. +/// +public static class ConsoleHelper +{ + public static void WriteHeader(string title) + { + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine(); + Console.WriteLine(new string('═', 60)); + Console.WriteLine($" {title}"); + Console.WriteLine(new string('═', 60)); + Console.ResetColor(); + } + + public static void WriteStep(string emoji, string description) + { + Console.ForegroundColor = ConsoleColor.White; + Console.WriteLine(); + Console.Write($" {emoji} "); + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine(description); + Console.ResetColor(); + } + + public static void WriteSuccess(string message) + { + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine($" ✅ {message}"); + Console.ResetColor(); + } + + public static void WriteError(string message) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($" ❌ {message}"); + Console.ResetColor(); + } + + public static void WriteInfo(string message) + { + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine($" {message}"); + Console.ResetColor(); + } + + public static void WriteWarning(string message) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($" ⚠️ {message}"); + Console.ResetColor(); + } + + public static void WriteProgress(string message) + { + Console.ForegroundColor = ConsoleColor.DarkYellow; + Console.WriteLine($" ⏳ {message}"); + Console.ResetColor(); + } + + public static string Prompt(string message) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.Write($" ➤ {message}: "); + Console.ResetColor(); + return Console.ReadLine()?.Trim() ?? string.Empty; + } + + public static bool PromptYesNo(string message) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.Write($" ➤ {message} (y/n): "); + Console.ResetColor(); + var input = Console.ReadLine()?.Trim().ToLowerInvariant(); + return input == "y" || input == "yes"; + } + + public static void WriteKeyValue(string key, string? value) + { + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.Write($" {key}: "); + Console.ForegroundColor = ConsoleColor.White; + Console.WriteLine(value ?? "(null)"); + Console.ResetColor(); + } +} diff --git a/BadgeReleaseDemo/Helpers/CryptoHelper.cs b/BadgeReleaseDemo/Helpers/CryptoHelper.cs new file mode 100644 index 0000000..d43ae3a --- /dev/null +++ b/BadgeReleaseDemo/Helpers/CryptoHelper.cs @@ -0,0 +1,82 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// + +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Crypto.Operators; +using Org.BouncyCastle.OpenSsl; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using Org.BouncyCastle.X509; + +namespace BadgeReleaseDemo.Helpers; + +/// +/// Generates CSR and keypair for printer registration. +/// Certificate and keys are kept in memory only. +/// +public static class CryptoHelper +{ + private const int KeySize = 2048; + + public static AsymmetricCipherKeyPair GenerateKeyPair() + { + var generator = new RsaKeyPairGenerator(); + generator.Init(new KeyGenerationParameters(new SecureRandom(), KeySize)); + return generator.GenerateKeyPair(); + } + + public static string GenerateCsr(AsymmetricCipherKeyPair keyPair) + { + var subject = new X509Name("CN=Universal Print Badge Release Demo"); + var csr = new Pkcs10CertificationRequest( + new Asn1SignatureFactory("SHA256WithRSA", keyPair.Private), + subject, + keyPair.Public, + null); + + using var writer = new StringWriter(); + var pemWriter = new PemWriter(writer); + pemWriter.WriteObject(csr); + pemWriter.Writer.Flush(); + + // API expects base64-encoded DER without PEM headers + var pem = writer.ToString(); + return pem + .Replace("-----BEGIN CERTIFICATE REQUEST-----", string.Empty) + .Replace("-----END CERTIFICATE REQUEST-----", string.Empty) + .Replace("\r", string.Empty) + .Replace("\n", string.Empty) + .Trim(); + } + + public static string GetTransportKey(AsymmetricCipherKeyPair keyPair) + { + using var writer = new StringWriter(); + var pemWriter = new PemWriter(writer); + pemWriter.WriteObject(keyPair.Public); + pemWriter.Writer.Flush(); + + var pem = writer.ToString(); + // Remove PEM header/footer for the transport key + return pem + .Replace("-----BEGIN PUBLIC KEY-----", string.Empty) + .Replace("-----END PUBLIC KEY-----", string.Empty) + .Replace("\r", string.Empty) + .Replace("\n", string.Empty) + .Trim(); + } + + /// + /// Parses a PEM certificate string into an X509Certificate. + /// Used to extract the printer certificate from the registration response. + /// + public static X509Certificate ParseCertificate(string pemCertificate) + { + using var reader = new StringReader(pemCertificate); + var pemReader = new PemReader(reader); + return (X509Certificate)pemReader.ReadObject(); + } +} diff --git a/BadgeReleaseDemo/IppOperations/MinimalIpp.cs b/BadgeReleaseDemo/IppOperations/MinimalIpp.cs new file mode 100644 index 0000000..ec4b5cd --- /dev/null +++ b/BadgeReleaseDemo/IppOperations/MinimalIpp.cs @@ -0,0 +1,606 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// + +using System.Globalization; +using System.Text; + +namespace BadgeReleaseDemo.IppOperations; + +/// +/// Minimal, auditable IPP (Internet Printing Protocol) implementation for Universal Print. +/// Implements only the 5 operations needed: Get-Jobs, Fetch-Job, Acknowledge-Job, Fetch-Document, Update-Job-Status. +/// Based on RFC 8011 (IPP/1.1) with Microsoft-specific extensions for Badge Release. +/// +/// This is NOT a general-purpose IPP library. It is custom, minimal code with the narrowest possible +/// attack surface for Badge Release Demo operations. +/// +public static class MinimalIpp +{ + // IPP Protocol Constants (RFC 8011) + private const byte IPP_VERSION_MAJOR = 2; + private const byte IPP_VERSION_MINOR = 0; + + // Operation codes (RFC 8011 + Microsoft extensions) + private const ushort OP_GET_JOBS = 0x000a; + private const ushort OP_FETCH_JOB = 0x0043; + private const ushort OP_ACKNOWLEDGE_JOB = 0x0041; + private const ushort OP_FETCH_DOCUMENT = 0x0042; + private const ushort OP_UPDATE_JOB_STATUS = 0x0048; + + // Attribute group tags + private const byte TAG_OPERATION_ATTRIBUTES = 0x01; + private const byte TAG_PRINTER_ATTRIBUTES = 0x04; + private const byte TAG_JOB_ATTRIBUTES = 0x02; + private const byte TAG_END_OF_ATTRIBUTES = 0x03; + + // Attribute value tags + private const byte TAG_INTEGER = 0x21; + private const byte TAG_BOOLEAN = 0x22; + private const byte TAG_ENUM = 0x23; + private const byte TAG_STRING = 0x30; + private const byte TAG_KEYWORD = 0x44; + private const byte TAG_URI = 0x45; + private const byte TAG_NAME_WITHOUT_LANGUAGE = 0x42; + private const byte TAG_TEXT_WITHOUT_LANGUAGE = 0x41; + + // Microsoft custom tags (used in Universal Print IPP extensions) + private const byte TAG_MS_CHARSET = 0x47; // Custom: attributes-charset, output-device-uuid + private const byte TAG_MS_LANGUAGE = 0x48; // Custom: attributes-natural-language + + /// + /// Builds a Get-Jobs IPP request to query fetchable jobs for a user. + /// Operation code: 0x000a (Get-Jobs) + /// Attribute order must match IppLibrary for compatibility with Universal Print service. + /// + public static byte[] BuildGetJobsRequest( + ushort requestId, string printerUri, string jobType, string requestingUserUri, string outputDeviceUuid = "") + { + using var stream = new MemoryStream(); + + // IPP Header + WriteIppHeader(stream, requestId, OP_GET_JOBS); + + // Operation Attributes Group + stream.WriteByte(TAG_OPERATION_ATTRIBUTES); + + // Must start with charset and language (required by RFC 8011) + WriteAttributeWithTag(stream, TAG_MS_CHARSET, "attributes-charset", "UTF-8"); + WriteAttributeWithTag(stream, TAG_MS_LANGUAGE, "attributes-natural-language", "en-us"); + + // Add requesting-user-uri (can be empty but attribute should be present) + if (!string.IsNullOrEmpty(requestingUserUri)) + { + WriteAttributeWithTag(stream, TAG_URI, "requesting-user-uri", requestingUserUri); + } + + // Add my-jobs attribute (boolean) AFTER requesting-user-uri when it's provided + if (!string.IsNullOrEmpty(requestingUserUri)) + { + WriteAttributeWithTag(stream, TAG_BOOLEAN, "my-jobs", "true"); + } + + // Add printer-uri + WriteAttributeWithTag(stream, TAG_URI, "printer-uri", printerUri); + + // Add output-device-uuid if provided + if (!string.IsNullOrEmpty(outputDeviceUuid)) + { + WriteAttributeWithTag(stream, TAG_MS_CHARSET, "output-device-uuid", $"urn:uuid:{outputDeviceUuid}"); + } + + // Add which-jobs attribute (keyword) AFTER printer-uri/output-device-uuid + if (!string.IsNullOrEmpty(jobType)) + { + WriteAttributeWithTag(stream, TAG_KEYWORD, "which-jobs", jobType); + } + + // Requested attributes (always add) + WriteAttributeWithTag(stream, TAG_KEYWORD, "requested-attributes", "all"); + + // End of attributes + stream.WriteByte(TAG_END_OF_ATTRIBUTES); + + return stream.ToArray(); + } + + /// + /// Writes an attribute with a specific value tag. + /// + private static void WriteAttributeWithTag(MemoryStream stream, byte valueTag, string name, string value) + { + // Special handling for boolean values + if (valueTag == TAG_BOOLEAN) + { + WriteBooleanAttribute(stream, name, value == "true" || value == "1"); + return; + } + + stream.WriteByte(valueTag); + WriteString(stream, name); + WriteString(stream, value); + } + + private static void WriteBooleanAttribute(MemoryStream stream, string name, bool value) + { + stream.WriteByte(TAG_BOOLEAN); + WriteString(stream, name); + WriteUInt16BigEndian(stream, 1); // length is always 1 for boolean + stream.WriteByte(value ? (byte)0x01 : (byte)0x00); + } + /// + /// Builds a Fetch-Job IPP request to retrieve job metadata. + /// Operation code: 0x0029 (Fetch-Job) + /// + public static byte[] BuildFetchJobRequest( + ushort requestId, string printerUri, int jobId, string outputDeviceUuid = "", string requestingUserUri = "") + { + using var stream = new MemoryStream(); + + WriteIppHeader(stream, requestId, OP_FETCH_JOB); + + stream.WriteByte(TAG_OPERATION_ATTRIBUTES); + + // Mandatory attributes + WriteAttributeWithTag(stream, TAG_MS_CHARSET, "attributes-charset", "UTF-8"); + WriteAttributeWithTag(stream, TAG_MS_LANGUAGE, "attributes-natural-language", "en-us"); + + // Add requesting-user-uri if provided + if (!string.IsNullOrEmpty(requestingUserUri)) + { + WriteAttributeWithTag(stream, TAG_URI, "requesting-user-uri", requestingUserUri); + } + + // Printer URI + WriteAttributeWithTag(stream, TAG_URI, "printer-uri", printerUri); + + // Add output-device-uuid if provided + if (!string.IsNullOrEmpty(outputDeviceUuid)) + { + WriteAttributeWithTag(stream, TAG_MS_CHARSET, "output-device-uuid", $"urn:uuid:{outputDeviceUuid}"); + } + + // Job ID + WriteIntegerAttribute(stream, "job-id", jobId); + + stream.WriteByte(TAG_END_OF_ATTRIBUTES); + + return stream.ToArray(); + } + + /// + /// Builds an Acknowledge-Job IPP request to confirm job receipt. + /// Operation code: 0x0041 (AcknowledgeJob) + /// + public static byte[] BuildAcknowledgeJobRequest( + ushort requestId, string printerUri, int jobId, string statusMessage, string outputDeviceUuid = "", string requestingUserUri = "") + { + using var stream = new MemoryStream(); + + WriteIppHeader(stream, requestId, OP_ACKNOWLEDGE_JOB); + + stream.WriteByte(TAG_OPERATION_ATTRIBUTES); + + // Mandatory attributes + WriteAttributeWithTag(stream, TAG_MS_CHARSET, "attributes-charset", "UTF-8"); + WriteAttributeWithTag(stream, TAG_MS_LANGUAGE, "attributes-natural-language", "en-us"); + + // Add requesting-user-uri if provided + if (!string.IsNullOrEmpty(requestingUserUri)) + { + WriteAttributeWithTag(stream, TAG_URI, "requesting-user-uri", requestingUserUri); + } + + // Printer URI + WriteAttributeWithTag(stream, TAG_URI, "printer-uri", printerUri); + + // Add output-device-uuid if provided + if (!string.IsNullOrEmpty(outputDeviceUuid)) + { + WriteAttributeWithTag(stream, TAG_MS_CHARSET, "output-device-uuid", $"urn:uuid:{outputDeviceUuid}"); + } + + // Job ID + WriteIntegerAttribute(stream, "job-id", jobId); + + // Fetch status message (if provided) + if (!string.IsNullOrEmpty(statusMessage)) + { + WriteStringAttribute(stream, "fetch-status-message", statusMessage); + } + + stream.WriteByte(TAG_END_OF_ATTRIBUTES); + + return stream.ToArray(); + } + + /// + /// Builds a Fetch-Document IPP request to download the print document. + /// Operation code: 0x0042 (Fetch-Document) + /// + public static byte[] BuildFetchDocumentRequest( + ushort requestId, string printerUri, int jobId, int documentNumber, string outputDeviceUuid = "", string requestingUserUri = "", string jobUri = "") + { + using var stream = new MemoryStream(); + + WriteIppHeader(stream, requestId, OP_FETCH_DOCUMENT); + + stream.WriteByte(TAG_OPERATION_ATTRIBUTES); + + // Mandatory attributes + WriteAttributeWithTag(stream, TAG_MS_CHARSET, "attributes-charset", "UTF-8"); + WriteAttributeWithTag(stream, TAG_MS_LANGUAGE, "attributes-natural-language", "en-us"); + + // Requesting user URI (matches other working operations) + if (!string.IsNullOrEmpty(requestingUserUri)) + { + WriteAttributeWithTag(stream, TAG_URI, "requesting-user-uri", requestingUserUri); + } + + // Printer URI + WriteAttributeWithTag(stream, TAG_URI, "printer-uri", printerUri); + + // Output device UUID (matches other working operations) + if (!string.IsNullOrEmpty(outputDeviceUuid)) + { + WriteAttributeWithTag(stream, TAG_MS_CHARSET, "output-device-uuid", $"urn:uuid:{outputDeviceUuid}"); + } + + // Job URI (some services require it explicitly for Fetch-Document) + if (!string.IsNullOrEmpty(jobUri)) + { + WriteAttributeWithTag(stream, TAG_URI, "job-uri", jobUri); + } + + // Job ID and document number + WriteIntegerAttribute(stream, "job-id", jobId); + WriteIntegerAttribute(stream, "document-number", documentNumber); + + stream.WriteByte(TAG_END_OF_ATTRIBUTES); + + return stream.ToArray(); + } + + /// + /// Builds an Update-Job-Status IPP request to mark a job as completed. + /// Operation code: 0x0045 (UpdateActiveJobs) — Microsoft extension + /// + public static byte[] BuildUpdateJobStatusRequest( + ushort requestId, string printerUri, int jobId, int jobState, string outputDeviceUuid = "", string requestingUserUri = "") + { + using var stream = new MemoryStream(); + + WriteIppHeader(stream, requestId, OP_UPDATE_JOB_STATUS); + + stream.WriteByte(TAG_OPERATION_ATTRIBUTES); + + // Mandatory attributes + WriteAttributeWithTag(stream, TAG_MS_CHARSET, "attributes-charset", "UTF-8"); + WriteAttributeWithTag(stream, TAG_MS_LANGUAGE, "attributes-natural-language", "en-us"); + + // Requesting user URI (matches other working operations) + if (!string.IsNullOrEmpty(requestingUserUri)) + { + WriteAttributeWithTag(stream, TAG_URI, "requesting-user-uri", requestingUserUri); + } + + // Printer URI + WriteAttributeWithTag(stream, TAG_URI, "printer-uri", printerUri); + + // Output device UUID (matches other working operations) + if (!string.IsNullOrEmpty(outputDeviceUuid)) + { + WriteAttributeWithTag(stream, TAG_MS_CHARSET, "output-device-uuid", $"urn:uuid:{outputDeviceUuid}"); + } + + // Job ID in operation attributes + WriteIntegerAttribute(stream, "job-id", jobId); + + // Job state in job attributes group + stream.WriteByte(TAG_JOB_ATTRIBUTES); + WriteEnumAttribute(stream, "output-device-job-state", jobState); + + stream.WriteByte(TAG_END_OF_ATTRIBUTES); + + return stream.ToArray(); + } + + /// + /// Parses an IPP response and extracts status code and job attributes. + /// Returns (statusCode, jobAttributes) where jobAttributes is a collection of dictionaries per job. + /// + public static (ushort StatusCode, List> JobAttributes) ParseGetJobsResponse(byte[] responseData) + { + if (responseData.Length < 8) + { + throw new InvalidDataException("Invalid IPP response: expected at least 8 bytes for header."); + } + + using var stream = new MemoryStream(responseData); + using var reader = new BinaryReader(stream); + + // IPP Header (4 bytes + 4 bytes + 4 bytes = 12 bytes minimum) + byte versionMajor = reader.ReadByte(); + byte versionMinor = reader.ReadByte(); + ushort statusCode = ReadUInt16BigEndian(reader); + uint requestId = ReadUInt32BigEndian(reader); + + var jobs = new List>(); + + // Parse attribute groups + while (stream.Position < stream.Length) + { + byte tag = reader.ReadByte(); + + if (tag == TAG_END_OF_ATTRIBUTES) + break; + + if (tag == TAG_JOB_ATTRIBUTES) + { + var jobAttrs = ParseAttributeGroup(reader); + jobs.Add(jobAttrs); + } + else + { + // Skip unknown attribute group + SkipAttributeGroup(reader); + } + } + + return (statusCode, jobs); + } + + /// + /// Parses an IPP response for Fetch-Job and extracts job attributes. + /// + public static (ushort StatusCode, Dictionary JobAttributes, byte[]? DocumentData) ParseFetchJobResponse(byte[] responseData) + { + if (responseData.Length < 8) + { + throw new InvalidDataException("Invalid IPP response: expected at least 8 bytes for header."); + } + + using var stream = new MemoryStream(responseData); + using var reader = new BinaryReader(stream); + + byte versionMajor = reader.ReadByte(); + byte versionMinor = reader.ReadByte(); + ushort statusCode = ReadUInt16BigEndian(reader); + uint requestId = ReadUInt32BigEndian(reader); + + var jobAttrs = new Dictionary(); + + // Parse attribute groups + while (stream.Position < stream.Length) + { + byte tag = reader.ReadByte(); + + if (tag == TAG_END_OF_ATTRIBUTES) + break; + + if (tag == TAG_JOB_ATTRIBUTES) + { + jobAttrs = ParseAttributeGroup(reader); + } + else + { + SkipAttributeGroup(reader); + } + } + + // Remaining bytes are the document data (if any) + byte[] documentData = reader.ReadBytes((int)(stream.Length - stream.Position)); + return (statusCode, jobAttrs, documentData.Length > 0 ? documentData : null); + } + + /// + /// Parses an IPP response and extracts status code. + /// + public static ushort ParseStatusCodeResponse(byte[] responseData) + { + if (responseData.Length < 8) + { + throw new InvalidDataException("Invalid IPP response: expected at least 8 bytes for header."); + } + + return (ushort)((responseData[2] << 8) | responseData[3]); + } + + // ---- Private Helpers ---- + + private static void WriteIppHeader(MemoryStream stream, ushort requestId, ushort operationCode) + { + stream.WriteByte(IPP_VERSION_MAJOR); + stream.WriteByte(IPP_VERSION_MINOR); + WriteUInt16BigEndian(stream, operationCode); + WriteUInt32BigEndian(stream, requestId); + } + + private static void WriteKeywordAttribute(MemoryStream stream, string name, string value) + { + stream.WriteByte(TAG_KEYWORD); + WriteString(stream, name); + WriteString(stream, value); + } + + private static void WriteStringAttribute(MemoryStream stream, string name, string value) + { + stream.WriteByte(TAG_TEXT_WITHOUT_LANGUAGE); + WriteString(stream, name); + WriteString(stream, value); + } + + private static void WriteIntegerAttribute(MemoryStream stream, string name, int value) + { + stream.WriteByte(TAG_INTEGER); + WriteString(stream, name); + WriteUInt16BigEndian(stream, 4); // 4 bytes for integer value + WriteInt32BigEndian(stream, value); + } + + private static void WriteEnumAttribute(MemoryStream stream, string name, int value) + { + stream.WriteByte(TAG_ENUM); + WriteString(stream, name); + WriteUInt16BigEndian(stream, 4); // 4 bytes for enum value + WriteInt32BigEndian(stream, value); + } + + private static void WriteString(MemoryStream stream, string value) + { + byte[] bytes = Encoding.UTF8.GetBytes(value); + WriteUInt16BigEndian(stream, (ushort)bytes.Length); + stream.Write(bytes, 0, bytes.Length); + } + + private static Dictionary ParseAttributeGroup(BinaryReader reader) + { + var attrs = new Dictionary(); + string? lastAttributeName = null; + + while (reader.BaseStream.Position < reader.BaseStream.Length) + { + byte tag = reader.ReadByte(); + + if (IsDelimiterTag(tag)) + { + reader.BaseStream.Seek(-1, SeekOrigin.Current); + break; + } + + string name = ReadString(reader); + object value = ReadValue(reader, tag); + + var attributeName = string.IsNullOrEmpty(name) ? lastAttributeName : name; + if (string.IsNullOrEmpty(attributeName)) + { + throw new InvalidDataException("Malformed IPP attribute group: encountered empty attribute name without a previous name."); + } + + if (!string.IsNullOrEmpty(name)) + { + lastAttributeName = name; + } + + if (attrs.TryGetValue(attributeName, out var existingValue)) + { + if (existingValue is List existingList) + { + existingList.Add(value); + } + else + { + attrs[attributeName] = new List { existingValue, value }; + } + } + else + { + attrs[attributeName] = value; + } + } + + return attrs; + } + + private static void SkipAttributeGroup(BinaryReader reader) + { + while (reader.BaseStream.Position < reader.BaseStream.Length) + { + byte tag = reader.ReadByte(); + + if (IsDelimiterTag(tag)) + { + reader.BaseStream.Seek(-1, SeekOrigin.Current); + break; + } + + string name = ReadString(reader); + SkipValue(reader); + } + } + + private static string ReadString(BinaryReader reader) + { + ushort length = ReadUInt16BigEndian(reader); + byte[] bytes = reader.ReadBytes(length); + return Encoding.UTF8.GetString(bytes); + } + + private static object ReadValue(BinaryReader reader, byte tag) + { + ushort length = ReadUInt16BigEndian(reader); + byte[] valueBytes = reader.ReadBytes(length); + if (valueBytes.Length != length) + { + throw new InvalidDataException("Malformed IPP value: insufficient bytes for declared length."); + } + + return tag switch + { + TAG_INTEGER => length == 4 + ? (int)((valueBytes[0] << 24) | (valueBytes[1] << 16) | (valueBytes[2] << 8) | valueBytes[3]) + : valueBytes, + TAG_BOOLEAN => length == 1 + ? valueBytes[0] != 0 + : valueBytes, + TAG_ENUM => length == 4 + ? (int)((valueBytes[0] << 24) | (valueBytes[1] << 16) | (valueBytes[2] << 8) | valueBytes[3]) + : valueBytes, + TAG_KEYWORD or TAG_NAME_WITHOUT_LANGUAGE or TAG_TEXT_WITHOUT_LANGUAGE or TAG_URI or TAG_STRING => + Encoding.UTF8.GetString(valueBytes), + _ => valueBytes + }; + } + + private static void SkipValue(BinaryReader reader) + { + ushort length = ReadUInt16BigEndian(reader); + reader.ReadBytes(length); + } + + private static bool IsDelimiterTag(byte tag) + { + // IPP delimiter tags are in the low range (0x01-0x0F): operation/job/printer/document groups and end tag. + return tag <= 0x0F; + } + + private static ushort ReadUInt16BigEndian(BinaryReader reader) + { + byte[] bytes = reader.ReadBytes(2); + return (ushort)((bytes[0] << 8) | bytes[1]); + } + + private static void WriteUInt16BigEndian(MemoryStream stream, ushort value) + { + stream.WriteByte((byte)((value >> 8) & 0xFF)); + stream.WriteByte((byte)(value & 0xFF)); + } + + private static uint ReadUInt32BigEndian(BinaryReader reader) + { + byte[] bytes = reader.ReadBytes(4); + return (uint)((bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]); + } + + private static void WriteUInt32BigEndian(MemoryStream stream, uint value) + { + stream.WriteByte((byte)((value >> 24) & 0xFF)); + stream.WriteByte((byte)((value >> 16) & 0xFF)); + stream.WriteByte((byte)((value >> 8) & 0xFF)); + stream.WriteByte((byte)(value & 0xFF)); + } + + private static int ReadInt32BigEndian(BinaryReader reader) + { + byte[] bytes = reader.ReadBytes(4); + return (int)((bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]); + } + + private static void WriteInt32BigEndian(MemoryStream stream, int value) + { + stream.WriteByte((byte)((value >> 24) & 0xFF)); + stream.WriteByte((byte)((value >> 16) & 0xFF)); + stream.WriteByte((byte)((value >> 8) & 0xFF)); + stream.WriteByte((byte)(value & 0xFF)); + } +} diff --git a/BadgeReleaseDemo/IppOperations/PrinterIppClient.cs b/BadgeReleaseDemo/IppOperations/PrinterIppClient.cs new file mode 100644 index 0000000..f8ff559 --- /dev/null +++ b/BadgeReleaseDemo/IppOperations/PrinterIppClient.cs @@ -0,0 +1,276 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// + +using System.Diagnostics; +using System.Net.Http.Headers; +using BadgeReleaseDemo.Helpers; + +namespace BadgeReleaseDemo.IppOperations; + +/// +/// Performs IPP INFRA operations as a printer: Get-Jobs, Fetch-Job, +/// Acknowledge-Job, Fetch-Document, and Update-Job-Status. +/// Uses the minimal custom IPP implementation for a focused, auditable approach. +/// +public class PrinterIppClient +{ + private const int JOB_STATE_COMPLETED = 9; + + private readonly string ippServiceBaseUrl; + private readonly string ippServicePrinterPath; + private readonly string badgesApiPath; + private readonly HttpClient httpClient; + + public PrinterIppClient(string ippServiceBaseUrl, string ippServicePrinterPath, string badgesApiPath) + { + this.ippServiceBaseUrl = ippServiceBaseUrl.TrimEnd('/'); + this.ippServicePrinterPath = ippServicePrinterPath; + this.badgesApiPath = badgesApiPath; + httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(5) }; + } + + /// + /// Calls the IPPService BadgesController to resolve a badge ID to a user. + /// GET /api/v1.0/badges/{badgeId} + /// Returns (badgeId, userUri, userId) or null if not found. + /// + public async Task<(string BadgeId, string UserUri, string? UserId)?> ResolveBadgeAsync( + string printerToken, string badgeId) + { + using var request = new HttpRequestMessage(HttpMethod.Get, + $"{ippServiceBaseUrl}{badgesApiPath}/{badgeId}"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", printerToken); + + var response = await httpClient.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return null; + } + + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException($"Badge resolution failed: {response.StatusCode} - {body}"); + } + + var doc = System.Text.Json.JsonSerializer.Deserialize(body); + var resolvedBadgeId = doc.GetProperty("badgeId").GetString()!; + var userUri = doc.GetProperty("userURI").GetString()!; + string? userId = doc.TryGetProperty("userId", out var uidProp) ? uidProp.GetString() : null; + + return (resolvedBadgeId, userUri, userId); + } + + /// + /// Sends Get-Jobs IPP request as the printer to find fetchable jobs for a user. + /// Returns list of (jobId, jobUri) tuples. + /// + public async Task> GetJobsAsync( + string printerToken, string printerId, string requestingUserUri) + { + var ippHost = new Uri(ippServiceBaseUrl).Host; + var printerUri = $"ipps://{ippHost}/printers/{printerId}"; + + var ippRequest = MinimalIpp.BuildGetJobsRequest( + requestId: 1, + printerUri: printerUri, + jobType: "fetchable", + requestingUserUri: requestingUserUri, + outputDeviceUuid: printerId); + + var responseData = await SendIppRequestAsync(printerToken, ippRequest); + + var (statusCode, jobAttributes) = MinimalIpp.ParseGetJobsResponse(responseData); + + if (statusCode != 0x0000) // 0x0000 = successful-ok + { + ConsoleHelper.WriteWarning($"Get-Jobs returned status: {statusCode:X4}"); + return new List<(int, string)>(); + } + + var jobs = new List<(int JobId, string JobUri)>(); + + foreach (var jobAttrs in jobAttributes) + { + int jobId = 0; + string jobUri = string.Empty; + + if (jobAttrs.TryGetValue("job-id", out var jobIdObj) && jobIdObj is int id) + { + jobId = id; + } + + if (jobAttrs.TryGetValue("job-uri", out var jobUriObj) && jobUriObj is string uri) + { + jobUri = uri; + } + + if (jobId > 0) + { + jobs.Add((jobId, jobUri)); + } + } + + return jobs; + } + + /// + /// Sends Fetch-Job IPP request to get job metadata. + /// + public async Task<(ushort StatusCode, Dictionary JobAttributes, byte[]? DocumentData)> FetchJobAsync( + string printerToken, string printerId, int jobId, string requestingUserUri) + { + var ippHost = new Uri(ippServiceBaseUrl).Host; + var printerUri = $"ipps://{ippHost}/printers/{printerId}"; + + var ippRequest = MinimalIpp.BuildFetchJobRequest( + requestId: 2, + printerUri: printerUri, + jobId: jobId, + outputDeviceUuid: printerId, + requestingUserUri: requestingUserUri); + + var responseData = await SendIppRequestAsync(printerToken, ippRequest); + return MinimalIpp.ParseFetchJobResponse(responseData); + } + + /// + /// Sends Acknowledge-Job IPP request to confirm receipt of the job. + /// + public async Task AcknowledgeJobAsync( + string printerToken, string printerId, int jobId, string requestingUserUri) + { + var ippHost = new Uri(ippServiceBaseUrl).Host; + var printerUri = $"ipps://{ippHost}/printers/{printerId}"; + + var ippRequest = MinimalIpp.BuildAcknowledgeJobRequest( + requestId: 3, + printerUri: printerUri, + jobId: jobId, + statusMessage: "Badge release demo - job acknowledged", + outputDeviceUuid: printerId, + requestingUserUri: requestingUserUri); + + var responseData = await SendIppRequestAsync(printerToken, ippRequest); + return MinimalIpp.ParseStatusCodeResponse(responseData); + } + + /// + /// Sends Fetch-Document IPP request to download the print document. + /// Returns the document payload bytes. + /// + public async Task FetchDocumentAsync( + string printerToken, string printerId, int jobId, string requestingUserUri, string jobUri = "") + { + var ippHost = new Uri(ippServiceBaseUrl).Host; + var printerUri = $"ipps://{ippHost}/printers/{printerId}"; + var ippRequest = MinimalIpp.BuildFetchDocumentRequest( + requestId: 4, + printerUri: printerUri, + jobId: jobId, + documentNumber: 1, + outputDeviceUuid: printerId, + requestingUserUri: requestingUserUri, + jobUri: jobUri); + + var responseData = await SendIppRequestAsync(printerToken, ippRequest); + var (statusCode, _, documentData) = MinimalIpp.ParseFetchJobResponse(responseData); + + if (statusCode != 0x0000) + { + ConsoleHelper.WriteError($"Fetch-Document failed: {statusCode:X4}"); + return null; + } + + if (documentData == null) + { + ConsoleHelper.WriteError("Fetch-Document response contained no document data."); + return null; + } + + return documentData; + } + + /// + /// Sends Update-Job-Status to mark the job as completed. + /// + public async Task UpdateJobStatusAsync( + string printerToken, string printerId, int jobId, string requestingUserUri) + { + var ippHost = new Uri(ippServiceBaseUrl).Host; + var printerUri = $"ipps://{ippHost}/printers/{printerId}"; + + var ippRequest = MinimalIpp.BuildUpdateJobStatusRequest( + requestId: 5, + printerUri: printerUri, + jobId: jobId, + jobState: JOB_STATE_COMPLETED, + outputDeviceUuid: printerId, + requestingUserUri: requestingUserUri); + + var responseData = await SendIppRequestAsync(printerToken, ippRequest); + return MinimalIpp.ParseStatusCodeResponse(responseData); + } + + /// + /// Saves document bytes to a local file and opens it with the default viewer. + /// Returns the saved file path for cleanup. + /// + public static string SaveAndOpenDocument(byte[] documentData, string fileName = "PrintedDocument.pdf") + { + var outputPath = Path.Combine(Environment.CurrentDirectory, fileName); + File.WriteAllBytes(outputPath, documentData); + ConsoleHelper.WriteInfo($"Document saved to: {outputPath}"); + + try + { + Process.Start(new ProcessStartInfo + { + FileName = outputPath, + UseShellExecute = true + }); + ConsoleHelper.WriteInfo("Opening document with default viewer..."); + } + catch (Exception ex) + { + ConsoleHelper.WriteWarning($"Could not open document automatically: {ex.Message}"); + ConsoleHelper.WriteInfo($"Please open manually: {outputPath}"); + } + + return outputPath; + } + + /// + /// Sends a minimal IPP request over HTTP, returns the raw response bytes. + /// + private async Task SendIppRequestAsync(string accessToken, byte[] ippRequest) + { + var printerEndpoint = $"{ippServiceBaseUrl}{ippServicePrinterPath}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, printerEndpoint); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + request.Headers.Add("User-Agent", "BadgeReleaseDemo/1.0"); + request.Content = new ByteArrayContent(ippRequest); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/ipp"); + + var response = await httpClient.SendAsync(request); + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(); + ConsoleHelper.WriteError($"IPP HTTP {(int)response.StatusCode}: {errorBody}"); + + if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) + { + throw new UnauthorizedAccessException($"Printer token expired or invalid: {errorBody}"); + } + + throw new HttpRequestException( + $"IPP request failed: {(int)response.StatusCode} {response.StatusCode} - {errorBody}"); + } + + return await response.Content.ReadAsByteArrayAsync(); + } +} diff --git a/BadgeReleaseDemo/Program.cs b/BadgeReleaseDemo/Program.cs new file mode 100644 index 0000000..e8e2b1f --- /dev/null +++ b/BadgeReleaseDemo/Program.cs @@ -0,0 +1,443 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// + +using System.Text.Json; +using BadgeReleaseDemo.Auth; +using BadgeReleaseDemo.GraphApi; +using BadgeReleaseDemo.Helpers; +using BadgeReleaseDemo.IppOperations; + +namespace BadgeReleaseDemo; + +/// +/// Badge Release Demo — demonstrates the Universal Print Badge Release API lifecycle. +/// +/// Flow: +/// 1. Sign in as Printer Admin +/// 2. Register a virtual printer +/// 3. Share the printer +/// 4. Create a badge collection and add a badge +/// 5. Submit a PDF print job +/// 6. Simulate badge scan → resolve badge → IPP fetch → open document → complete job +/// +public class Program +{ + public static async Task Main(string[] args) + { + Console.OutputEncoding = System.Text.Encoding.UTF8; + + ConsoleHelper.WriteHeader("🏷️ Universal Print — Badge Release Demo"); + + // Load configuration + var config = LoadConfiguration(); + var appId = config.GetProperty("AppId").GetString()!; + var tenantId = config.TryGetProperty("Tenant", out var tid) ? tid.GetString() ?? string.Empty : string.Empty; + var graphBaseUrl = config.GetProperty("GraphBaseUrl").GetString()!; + var graphPrintBaseUrl = config.GetProperty("GraphPrintBaseUrl").GetString()!; + var registrationBaseUrl = config.GetProperty("RegistrationBaseUrl").GetString()!; + var ippServiceBaseUrl = config.GetProperty("IppServiceBaseUrl").GetString()!; + var ippServicePrinterPath = config.GetProperty("IppServicePrinterPath").GetString()!; + var badgesApiPath = config.GetProperty("BadgesApiPath").GetString()!; + + if (appId == "YOUR_APP_ID_HERE" || tenantId == "YOUR_TENANT_HERE") + { + ConsoleHelper.WriteError("Please set your App ID and Tenant in appsettings.json before running this demo."); + return; + } + + // Initialize services + var auth = new AuthHelper(appId, tenantId, graphBaseUrl); + var printerReg = new PrinterRegistration(registrationBaseUrl); + var printerShare = new PrinterSharing(graphBaseUrl); + var badgeMgmt = new BadgeManagement(graphPrintBaseUrl); + var jobSubmission = new PrintJobSubmission(graphBaseUrl); + var ippClient = new PrinterIppClient(ippServiceBaseUrl, ippServicePrinterPath, badgesApiPath); + + + string printerId= string.Empty; + string shareId = string.Empty; + string badgeCollectionId = string.Empty; + string createdBadgeId = string.Empty; + string? savedDocumentPath = null; + + try + { + // ═══════════════════════════════════════════════════════════ + // Step 1: Sign in as Printer Admin + // ═══════════════════════════════════════════════════════════ + ConsoleHelper.WriteStep("🔑", "Signing in as Printer Administrator..."); + var upn = await auth.SignInUserAsync(); + ConsoleHelper.WriteSuccess($"Signed in as: {upn}"); + + // ═══════════════════════════════════════════════════════════ + // Step 2: Register a virtual printer + // ═══════════════════════════════════════════════════════════ + ConsoleHelper.WriteStep("🖨️", "Registering a virtual printer..."); + var keyPair = CryptoHelper.GenerateKeyPair(); + var csr = CryptoHelper.GenerateCsr(keyPair); + var transportKey = CryptoHelper.GetTransportKey(keyPair); + + var token = await auth.GetUserTokenAsync(); + var printerName = $"BadgeReleaseDemo-{DateTime.UtcNow:yyyyMMdd-HHmmss}"; + var regResult = await printerReg.RegisterPrinterAsync( + token, printerName, csr, transportKey); + + printerId = regResult.PrinterId; + ConsoleHelper.WriteSuccess($"Printer registered: {printerName}"); + ConsoleHelper.WriteKeyValue("Printer ID", printerId); + + // Store printer credentials in memory + auth.SetPrinterCredentials(regResult, keyPair); + + // ═══════════════════════════════════════════════════════════ + // Step 3: Share the printer + // ═══════════════════════════════════════════════════════════ + ConsoleHelper.WriteStep("🖨️", "Creating printer share..."); + var graphToken = await auth.GetGraphTokenAsync(); + shareId = await printerShare.CreateShareAsync(graphToken, printerId, printerName); + ConsoleHelper.WriteSuccess("Printer shared with all users."); + ConsoleHelper.WriteKeyValue("Share ID", shareId); + + // ═══════════════════════════════════════════════════════════ + // Step 4: Create badge collection (if needed) + // ═══════════════════════════════════════════════════════════ + ConsoleHelper.WriteStep("🏷️", "Creating badge collection..."); + token = await auth.GetUserTokenAsync(); + badgeCollectionId = await badgeMgmt.CreateBadgeCollectionAsync(token); + ConsoleHelper.WriteSuccess($"Badge collection ready (ID: {badgeCollectionId})."); + + // ═══════════════════════════════════════════════════════════ + // Step 5: Prompt for badge ID and create badge + // ═══════════════════════════════════════════════════════════ + ConsoleHelper.WriteStep("🏷️", "Badge registration"); + ConsoleHelper.WriteInfo($"The signed-in user is: {auth.UserUpn}"); + var badgeId = ConsoleHelper.Prompt("Enter a badge ID to associate with this user"); + + if (string.IsNullOrWhiteSpace(badgeId)) + { + ConsoleHelper.WriteError("Badge ID cannot be empty."); + return; + } + + // ═══════════════════════════════════════════════════════════ + // Step 6: Add badge + // ═══════════════════════════════════════════════════════════ + ConsoleHelper.WriteStep("🏷️", $"Adding badge '{badgeId}' → {auth.UserUpn}"); + token = await auth.GetUserTokenAsync(); + await badgeMgmt.AddBadgeAsync(token, badgeCollectionId, badgeId, auth.UserUpn); + createdBadgeId = badgeId; + ConsoleHelper.WriteSuccess($"Badge '{badgeId}' mapped to {auth.UserUpn}."); + + // ═══════════════════════════════════════════════════════════ + // Step 7: Enable badge release on the printer via Graph + // ═══════════════════════════════════════════════════════════ + ConsoleHelper.WriteStep("🖨️", "Configuring printer for badge release..."); + graphToken = await auth.GetGraphTokenAsync(); + await printerShare.EnableBadgeReleaseAsync(graphToken, printerId); + ConsoleHelper.WriteSuccess("Badge release enabled on printer."); + + // ═══════════════════════════════════════════════════════════ + // Step 8: Submit a PDF print job + // ═══════════════════════════════════════════════════════════ + ConsoleHelper.WriteStep("📄", "Submitting print job..."); + + var pdfPath = ConsoleHelper.Prompt("Enter the path to a PDF file to print"); + if (string.IsNullOrWhiteSpace(pdfPath)) + { + ConsoleHelper.WriteError("PDF path cannot be empty."); + return; + } + + pdfPath = pdfPath.Trim('"'); // Remove quotes if user dragged file into console + if (!File.Exists(pdfPath)) + { + ConsoleHelper.WriteError($"File not found: {pdfPath}"); + return; + } + + graphToken = await auth.GetGraphTokenAsync(); + var (jobId, documentId) = await jobSubmission.CreateJobAsync( + graphToken, shareId, "Badge Release Demo Job"); + ConsoleHelper.WriteKeyValue("Job ID", jobId); + ConsoleHelper.WriteKeyValue("Document ID", documentId); + + // Upload the PDF + ConsoleHelper.WriteProgress("Uploading document..."); + var pdfData = await File.ReadAllBytesAsync(pdfPath); + var uploadUrl = await jobSubmission.CreateUploadSessionAsync(graphToken, shareId, jobId, documentId, pdfData.Length); + await jobSubmission.UploadDocumentAsync(uploadUrl, pdfData); + + // Start the job + await jobSubmission.StartJobAsync(graphToken, shareId, jobId); + ConsoleHelper.WriteSuccess("Print job submitted and started."); + + // ═══════════════════════════════════════════════════════════ + // Step 9: Acquire printer device token + simulate badge scan + // ═══════════════════════════════════════════════════════════ + ConsoleHelper.WriteStep("🏷️", "Simulating badge scan at the printer..."); + ConsoleHelper.WriteInfo("Imagine you are walking up to the printer and scanning your badge."); + ConsoleHelper.WriteProgress("Acquiring printer device token..."); + var printerToken = await auth.GetPrinterTokenAsync(); + ConsoleHelper.WriteSuccess("Printer authenticated."); + + // Badge scan retry loop + string? resolvedUserUri = null; + int resolvedJobId = 0; + string resolvedJobUri = string.Empty; + + while (true) + { + var scannedBadgeId = ConsoleHelper.Prompt("Scan badge (enter badge ID)"); + if (string.IsNullOrWhiteSpace(scannedBadgeId)) + { + ConsoleHelper.WriteError("Badge ID cannot be empty. Try again."); + continue; + } + + // ═══════════════════════════════════════════════════════ + // Step 9: Resolve badge via IPPService BadgesController + // ═══════════════════════════════════════════════════════ + ConsoleHelper.WriteStep("🔍", $"Resolving badge '{scannedBadgeId}'..."); + try + { + var badgeResult = await ippClient.ResolveBadgeAsync(printerToken, scannedBadgeId); + + if (badgeResult == null) + { + ConsoleHelper.WriteError($"Badge '{scannedBadgeId}' not found. Try again."); + continue; + } + + resolvedUserUri = badgeResult.Value.UserUri; + ConsoleHelper.WriteSuccess($"Badge resolved!"); + ConsoleHelper.WriteKeyValue("Badge ID", badgeResult.Value.BadgeId); + ConsoleHelper.WriteKeyValue("User URI", resolvedUserUri); + ConsoleHelper.WriteKeyValue("User ID", badgeResult.Value.UserId); + break; + } + catch (Exception ex) + { + ConsoleHelper.WriteError($"Badge resolution failed: {ex.Message}"); + ConsoleHelper.WriteInfo("Try scanning again."); + continue; + } + } + + // ═══════════════════════════════════════════════════════════ + // Step 10: Get-Jobs as printer with requesting-user-uri + // ═══════════════════════════════════════════════════════════ + ConsoleHelper.WriteStep("🖨️", "Printer: Getting fetchable jobs..."); + var jobs = await ippClient.GetJobsAsync(printerToken, printerId, resolvedUserUri!); + + if (jobs.Count == 0) + { + ConsoleHelper.WriteWarning("No fetchable jobs found for this user."); + ConsoleHelper.WriteInfo("The job may not be ready yet. In production, the printer would poll."); + return; + } + + var selectedJob = jobs[0]; + if (int.TryParse(jobId, out var submittedJobId)) + { + var attemptsRemaining = 6; + while (true) + { + bool found = false; + foreach (var candidate in jobs) + { + if (candidate.JobId == submittedJobId) + { + selectedJob = candidate; + found = true; + break; + } + } + + if (found) + { + break; + } + + attemptsRemaining--; + if (attemptsRemaining <= 0) + { + ConsoleHelper.WriteError($"Submitted job {submittedJobId} was not found in fetchable jobs."); + return; + } + + ConsoleHelper.WriteInfo($"Submitted job {submittedJobId} not fetchable yet. Polling again in 5 seconds..."); + await Task.Delay(TimeSpan.FromSeconds(5)); + jobs = await ippClient.GetJobsAsync(printerToken, printerId, resolvedUserUri!); + } + } + else + { + ConsoleHelper.WriteWarning($"Could not parse Graph job ID '{jobId}' as IPP integer. Using the first fetchable job."); + } + + resolvedJobId = selectedJob.JobId; + resolvedJobUri = selectedJob.JobUri; + ConsoleHelper.WriteSuccess($"Found {jobs.Count} fetchable job(s)."); + ConsoleHelper.WriteKeyValue("Fetching Job ID", resolvedJobId.ToString()); + if (!string.IsNullOrEmpty(resolvedJobUri)) + { + ConsoleHelper.WriteKeyValue("Fetching Job URI", resolvedJobUri); + } + + // ═══════════════════════════════════════════════════════════ + // Step 11: Fetch-Job (get job metadata) + // ═══════════════════════════════════════════════════════════ + ConsoleHelper.WriteStep("🖨️", "Printer: Fetching job metadata..."); + var (fetchJobStatusCode, _, _) = await ippClient.FetchJobAsync( + printerToken, printerId, resolvedJobId, resolvedUserUri!); + + if (fetchJobStatusCode != 0x0000) // 0x0000 = successful-ok + { + ConsoleHelper.WriteError($"Fetch-Job failed: {fetchJobStatusCode:X4}"); + return; + } + + ConsoleHelper.WriteSuccess("Job metadata received."); + + // ═══════════════════════════════════════════════════════════ + // Step 12: Acknowledge-Job + // ═══════════════════════════════════════════════════════════ + ConsoleHelper.WriteStep("🖨️", "Printer: Acknowledging job..."); + var ackStatus = await ippClient.AcknowledgeJobAsync( + printerToken, printerId, resolvedJobId, resolvedUserUri!); + + if (ackStatus != 0x0000) // 0x0000 = successful-ok + { + ConsoleHelper.WriteError($"Acknowledge-Job failed: {ackStatus:X4}"); + return; + } + + ConsoleHelper.WriteSuccess("Job acknowledged."); + + // ═══════════════════════════════════════════════════════════ + // Step 13: Fetch-Document (download PDF) + // ═══════════════════════════════════════════════════════════ + ConsoleHelper.WriteStep("📄", "Printer: Downloading document..."); + var documentData = await ippClient.FetchDocumentAsync( + printerToken, printerId, resolvedJobId, resolvedUserUri!, resolvedJobUri); + + if (documentData == null || documentData.Length == 0) + { + ConsoleHelper.WriteError("Failed to download document."); + return; + } + + ConsoleHelper.WriteSuccess($"Document downloaded ({documentData.Length} bytes)."); + + // Save and open the document + savedDocumentPath = PrinterIppClient.SaveAndOpenDocument(documentData); + + // ═══════════════════════════════════════════════════════════ + // Step 14: Update-Job-Status → Completed + // ═══════════════════════════════════════════════════════════ + ConsoleHelper.WriteStep("✅", "Printer: Marking job as completed..."); + var completeStatus = await ippClient.UpdateJobStatusAsync( + printerToken, printerId, resolvedJobId, resolvedUserUri!); + + if (completeStatus != 0x0000) // 0x0000 = successful-ok + { + ConsoleHelper.WriteError($"Update-Job-Status failed: {completeStatus:X4}"); + return; + } + + ConsoleHelper.WriteSuccess("Job marked as completed! 🎉"); + + ConsoleHelper.WriteHeader("🎉 Demo Complete!"); + ConsoleHelper.WriteInfo("The Badge Release flow completed successfully."); + ConsoleHelper.WriteInfo("Press any key to exit."); + Console.ReadKey(); + } + catch (Exception ex) + { + ConsoleHelper.WriteError($"Demo failed: {ex.Message}"); + ConsoleHelper.WriteInfo(ex.StackTrace ?? string.Empty); + } + finally + { + // Auto-cleanup: delete badge, share, printer, and downloaded document + if (!string.IsNullOrEmpty(shareId) || !string.IsNullOrEmpty(printerId) + || !string.IsNullOrEmpty(createdBadgeId) || savedDocumentPath != null) + { + ConsoleHelper.WriteHeader("🧹 Cleaning up demo resources..."); + + if (savedDocumentPath != null && File.Exists(savedDocumentPath)) + { + try + { + ConsoleHelper.WriteProgress("Deleting downloaded document..."); + File.Delete(savedDocumentPath); + ConsoleHelper.WriteSuccess("Document deleted."); + } + catch (IOException) + { + ConsoleHelper.WriteWarning($"Could not delete document (may be open in another app): {savedDocumentPath}"); + } + } + + if (!string.IsNullOrEmpty(createdBadgeId) && !string.IsNullOrEmpty(badgeCollectionId)) + { + try + { + ConsoleHelper.WriteProgress($"Deleting badge '{createdBadgeId}'..."); + var printToken = await auth.GetUserTokenAsync(); + await badgeMgmt.DeleteBadgeAsync(printToken, badgeCollectionId, createdBadgeId); + ConsoleHelper.WriteSuccess("Badge deleted."); + } + catch (Exception badgeCleanupEx) + { + ConsoleHelper.WriteWarning($"Failed to delete badge: {badgeCleanupEx.Message}"); + } + } + + if (!string.IsNullOrEmpty(shareId)) + { + try + { + var graphToken = await auth.GetGraphTokenAsync(); + ConsoleHelper.WriteProgress($"Deleting share {shareId}..."); + await printerShare.DeleteShareAsync(graphToken, shareId); + ConsoleHelper.WriteSuccess("Share deleted."); + } + catch (Exception shareCleanupEx) + { + ConsoleHelper.WriteWarning($"Failed to delete share: {shareCleanupEx.Message}"); + } + } + + if (!string.IsNullOrEmpty(printerId)) + { + try + { + var graphToken = await auth.GetGraphTokenAsync(); + ConsoleHelper.WriteProgress($"Deleting printer {printerId}..."); + await printerShare.DeletePrinterAsync(graphToken, printerId); + ConsoleHelper.WriteSuccess("Printer deleted."); + } + catch (Exception printerCleanupEx) + { + ConsoleHelper.WriteWarning($"Failed to delete printer: {printerCleanupEx.Message}"); + } + } + } + } + } + + private static JsonElement LoadConfiguration() + { + var configPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); + + if (!File.Exists(configPath)) + { + throw new FileNotFoundException("appsettings.json not found. Make sure it's in the output directory.", configPath); + } + + var json = File.ReadAllText(configPath); + return JsonSerializer.Deserialize(json); + } +} diff --git a/BadgeReleaseDemo/README.md b/BadgeReleaseDemo/README.md new file mode 100644 index 0000000..c292974 --- /dev/null +++ b/BadgeReleaseDemo/README.md @@ -0,0 +1,207 @@ +# Badge Release Demo + +An interactive console application that demonstrates the full [Universal Print](https://learn.microsoft.com/universal-print/) badge release lifecycle — from printer registration and badge setup to badge-swipe-triggered job release. + +## What Is Badge Release? + +Secure Release lets users send print jobs to a cloud queue and release them only after authenticating at the printer. This prevents uncollected printouts and ensures only the intended recipient picks up their documents. Badge Release is a form of Secure Release that is typically done by tapping an NFC badge or RFID card. + +Typically, Badge Release requires third-party storage of the mappings from users' badge IDs to their identities. The printer looks up the badge ID in this third-party storage, then uses the user's identity to fetch their jobs. With Universal Print's Badge Management APIs, badge-to-user mappings are managed and stored within the Universal Print service. The printer calls Universal Print to resolve a badge ID to a user identity, then fetches that user's queued jobs. + +> **⚠️ Private Preview:** The Badge Release feature is currently in **Private Preview** and is only enabled for specific tenants. If you are an OEM interested in joining the Private Preview, please reach out to the Universal Print team or [Microsoft Support](https://support.microsoft.com). + +## What This Demo Does + +The app walks through the complete lifecycle interactively: + +| Step | What happens | APIs used | +|------|-------------|-----------| +| 1. **Sign in** | Authenticate as a Printer Administrator | MSAL interactive auth | +| 2. **Register printer** | Create a virtual printer with an in-memory certificate | `POST register.print.microsoft.com/api/v1.0/register` | +| 3. **Share printer** | Make the printer available to all users | `POST graph.microsoft.com/v1.0/print/shares` | +| 4. **Create badge collection** | Provision a badge collection for the tenant (idempotent) | `POST graph.print.microsoft.com/v1.0/print/badgeCollections` | +| 5. **Add badge** | Map a user-provided badge ID to the signed-in user | `POST graph.print.microsoft.com/v1.0/print/badgeCollections/{id}/badges` | +| 6. **Enable badge release** | Configure the printer to require badge authentication | `PATCH graph.microsoft.com/v1.0/print/printers/{id}` | +| 7. **Submit print job** | Upload a PDF and start a print job on the shared printer | Graph Print Job APIs | +| 8. **Acquire printer token** | Obtain a device token for the printer via JWT-bearer flow | `POST {deviceTokenUrl}` | +| 9. **Resolve badge** | Simulate a badge tap — resolve the badge ID to a user via Universal Print | `GET print.print.microsoft.com/api/v1.0/badges/{badgeId}` | +| 10. **Get-Jobs** | Find fetchable jobs for the resolved user (IPP) | IPP Get-Jobs | +| 11. **Fetch-Job** | Retrieve job metadata (IPP) | IPP Fetch-Job | +| 12. **Acknowledge-Job** | Confirm receipt of the job (IPP) | IPP Acknowledge-Job | +| 13. **Fetch-Document** | Download the print document (IPP) | IPP Fetch-Document | +| 14. **Complete job** | Mark the job as completed (IPP) | IPP Update-Job-Status | +| 15. **Clean up** | Delete badge, share, printer, and local files | Graph + Badge APIs | + +## Prerequisites + +- [.NET 8.0 SDK](https://dotnet.microsoft.com/download/dotnet/8.0) or later +- An Entra ID (Azure AD) app registration — see [App Registration](#app-registration) below +- A user account with the **Printer Administrator** directory role +- A PDF file to print during the demo + +## App Registration + +Register an application in the [Azure portal](https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade) (Entra ID → App registrations → New registration). + +### Platform Configuration + +Add a **Mobile and desktop applications** platform with redirect URI: + +``` +http://localhost +``` + +### Delegated Permissions (Microsoft Graph) + +These permissions are consented by the user at sign-in: + +| Permission | Used For | +|---|---| +| `PrinterShare.ReadWrite.All` | Creating and deleting printer shares | +| `Printer.FullControl.All` | Enabling badge release on the printer, deleting the printer during cleanup | +| `PrintJob.ReadWrite.All` | Submitting print jobs and uploading documents | + +### Delegated Permissions (Universal Print Service) + +| Permission | Used For | +|---|---| +| `Printers.Create` | Registering a virtual printer via the registration service | +| `PrintBadges.ReadWrite` | Creating, updating, and deleting badges and badge collections | + +### Application Permissions (Universal Print Service) + +These permissions are granted to the app itself (not delegated) and appear as `roles` in the printer's device token. **Admin consent is required** — a Global Administrator or Privileged Role Administrator must grant these. + +| Permission | Used For | +|---|---| +| `Printers.Read` | IPP Get-Jobs (required alongside PrintJob scopes) | +| `PrintJob.Read` | Reading fetchable jobs from the printer | +| `PrintJob.ReadWriteBasic` | Fetch-Job, Acknowledge-Job, Fetch-Document | +| `PrintBadges.Read` | Resolving badge IDs to users via the Badges API | + +> **Note:** `PrintBadges.Read` is specifically required for the badge resolution step. Without it, the printer will receive a `403 Forbidden` when calling the Badge API. See [Badge API documentation](https://learn.microsoft.com/universal-print/fundamentals/universal-print-badge-release) for details. + +## Configuration + +Edit `appsettings.json`: + +```json +{ + "AppId": "YOUR_APP_ID_HERE", + "Tenant": "YOUR_TENANT_HERE" +} +``` + +| Setting | Description | Example | +|---------|-------------|---------| +| `AppId` | Your Entra ID app registration client ID (GUID) | `a1b2c3d4-e5f6-7890-abcd-ef1234567890` | +| `Tenant` | Your tenant domain or GUID | `contoso.onmicrosoft.com` or a tenant GUID | + +The remaining settings point to commercial production Universal Print endpoints. + +### Government Cloud + +Government cloud is not supported by this demo today. Badge Release APIs in this demo target commercial Universal Print endpoints only. + +## Build & Run + +1. Update `appsettings.json` with your Entra ID **TenantId** and **ClientId** (from the app registration above). +2. Build and run: + +```powershell +dotnet build +dotnet run +``` + +The app will walk you through each step interactively, prompting for a badge ID and PDF file path. At the end, all created cloud resources (printer, share, badge) are automatically cleaned up. + +## Project Structure + +``` +BadgeReleaseDemo/ +├── Program.cs # Main orchestration — runs the 15-step flow +├── appsettings.json # App ID, tenant, and service endpoints +│ +├── Auth/ +│ └── AuthHelper.cs # MSAL interactive auth + JWT-bearer device token flow +│ +├── GraphApi/ +│ ├── PrinterRegistration.cs # Printer registration via register.print.microsoft.com +│ ├── PrinterSharing.cs # Share CRUD + enable badge release via Graph +│ ├── BadgeManagement.cs # Badge collection + badge CRUD via graph.print.microsoft.com +│ └── PrintJobSubmission.cs # Job creation, document upload, job start via Graph +│ +├── IppOperations/ +│ ├── MinimalIpp.cs # Minimal custom IPP serializer/parser for required operations +│ └── PrinterIppClient.cs # IPP INFRA operations + Badge REST API call +│ +├── Helpers/ +│ ├── ConsoleHelper.cs # Colored console output helpers +│ └── CryptoHelper.cs # RSA keypair + CSR generation (BouncyCastle) +│ +└── Resources/ + └── SampleDocument.pdf # Default test PDF (or supply your own) +``` + +## Authentication Flows + +The demo uses three distinct token audiences: + +| Token | Audience | How Acquired | Used For | +|-------|----------|-------------|----------| +| **User print token** | `print.print.microsoft.com` | MSAL interactive (delegated) | Printer registration, badge management | +| **User graph token** | `graph.microsoft.com` | MSAL interactive (delegated) | Sharing, job submission, enable badge release | +| **Printer device token** | Dynamic (from registration) | JWT-bearer flow with printer certificate | Badge resolution, all IPP operations | + +### Printer Device Token Flow + +The printer authenticates using a certificate-based JWT-bearer flow: + +1. **`srv_challenge`** — POST to the device token URL to get a nonce +2. **Create JWT** — Sign a JWT with the printer's private key (includes nonce, resource, client_id) +3. **Exchange** — POST `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer` with the signed JWT +4. The returned access token contains the application permissions (`Printers.Read`, `PrintJob.Read`, etc.) as roles + +## Badge API Reference + +The Badge API is a REST endpoint on the Universal Print IPP Service: + +``` +GET https://print.print.microsoft.com/api/v1.0/badges/{badgeId} +Authorization: Bearer {printer-device-token} +``` + +**Success response (200 OK):** +```json +{ + "badgeId": "123", + "userURI": "mailto:john@contoso.com", + "userId": "a3f7b0aa-9f48-4f6f-a95f-0123456789ab" +} +``` + +The `userURI` (a `mailto:` URI) is then passed as the `requesting-user-uri` attribute in subsequent IPP operations (Get-Jobs, Fetch-Job, Fetch-Document) to retrieve that user's queued jobs. + +**Error responses:** + +| Status | Meaning | +|--------|---------| +| `400` | Missing or empty badge ID | +| `401` | Invalid or expired device token | +| `403` | Missing `PrintBadges.Read` permission, or the Badge Release feature is not enabled for your tenant | +| `404` | Badge ID not found | +| `500` | Server error | + +## Troubleshooting + +| Symptom | Likely Cause | Fix | +|---------|-------------|-----| +| `403 Forbidden` on badge resolution | Missing `PrintBadges.Read` app permission, or tenant not enrolled in the Badge Release preview | Grant the permission and admin-consent it in the Azure portal. If the feature is not enabled, see the note below. | +| `401 Unauthorized` on IPP operations | Printer device token expired | The demo acquires a fresh token; if it persists, re-run | +| No fetchable jobs found | Job not yet processed by the service | Wait a few seconds and retry; in production, printers poll | +| `ServerErrorInternalError` on Update-Job-Status | Job may already be in a terminal state | Check the correlation headers in the console output and investigate server-side | +| Cleanup fails to delete PDF | File locked by PDF viewer (e.g., Adobe) | Close the viewer, then delete manually | + +## License + +Copyright (c) Microsoft Corporation. All rights reserved. diff --git a/BadgeReleaseDemo/Resources/SampleDocument.pdf b/BadgeReleaseDemo/Resources/SampleDocument.pdf new file mode 100644 index 0000000..1202905 Binary files /dev/null and b/BadgeReleaseDemo/Resources/SampleDocument.pdf differ diff --git a/BadgeReleaseDemo/appsettings.json b/BadgeReleaseDemo/appsettings.json new file mode 100644 index 0000000..82df14f --- /dev/null +++ b/BadgeReleaseDemo/appsettings.json @@ -0,0 +1,10 @@ +{ + "AppId": "YOUR_APP_ID_HERE", + "Tenant": "YOUR_TENANT_HERE", + "GraphBaseUrl": "https://graph.microsoft.com/v1.0", + "GraphPrintBaseUrl": "https://graph.print.microsoft.com/v1.0", + "RegistrationBaseUrl": "https://register.print.microsoft.com", + "IppServiceBaseUrl": "https://print.print.microsoft.com", + "IppServicePrinterPath": "/printers", + "BadgesApiPath": "/api/v1.0/badges" +}