-
Notifications
You must be signed in to change notification settings - Fork 4
Add Badge Release and Badge Management API demo #14
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
adkeswani
wants to merge
7
commits into
main
Choose a base branch
from
user/akeswani/add_badge_release_demo
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
6da3822
Add demo
1e52206
Address PR #14 review comments
8743e59
Use actual badge collection IDs instead of hardcoded 0
adkeswani 8019e81
Replace IppLibrary with minimal IPP implementation
c33d9fa
Remove unused IppLibrary sources
e2664fd
Restore default appsettings placeholders
cb9a7cc
Address high-signal review findings
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| bin/ | ||
| obj/ | ||
|
|
||
| # Debug/comparison artifacts | ||
| *.bin | ||
| *.txt | ||
| compare.csx | ||
| program-comparison-snippet.txt | ||
| IppOperations/IppRequestComparison.cs |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,253 @@ | ||
| // <copyright file="AuthHelper.cs" company="Microsoft"> | ||
| // Copyright (c) Microsoft Corporation. All rights reserved. | ||
| // </copyright> | ||
|
|
||
| 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; | ||
|
|
||
| /// <summary> | ||
| /// Handles authentication for both user (Printer Admin) and printer device flows. | ||
| /// Printer certificate and keys are kept in memory only. | ||
| /// </summary> | ||
| 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; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Signs in the user using interactive browser-based authentication. | ||
| /// </summary> | ||
| public async Task<string> 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; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Gets a fresh user access token, refreshing if needed. | ||
| /// </summary> | ||
| public async Task<string> 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; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Gets a Microsoft Graph token for Graph API calls (sharing, badges, jobs). | ||
| /// Uses silent acquisition if possible, otherwise prompts interactively. | ||
| /// </summary> | ||
| public async Task<string> 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; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Stores the printer registration result and keypair for device token acquisition. | ||
| /// Certificate and keys are kept in memory only — not saved to disk. | ||
| /// </summary> | ||
| public void SetPrinterCredentials(PrinterRegistrationResult result, AsymmetricCipherKeyPair keyPair) | ||
| { | ||
| registrationResult = result; | ||
| printerKeyPair = keyPair; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// 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 | ||
| /// </summary> | ||
| public async Task<string> 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<string, string> | ||
| { | ||
| ["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<JsonElement>(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<string, string> | ||
| { | ||
| ["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<JsonElement>(tokenContent); | ||
| printerToken = tokenDoc.GetProperty("access_token").GetString() | ||
| ?? throw new InvalidOperationException("No access_token in device token response."); | ||
|
|
||
| return printerToken; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Refreshes the printer device token if needed. | ||
| /// </summary> | ||
| public async Task<string> RefreshPrinterTokenAsync() | ||
| { | ||
| return await GetPrinterTokenAsync(); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Creates a JWT signed with the printer's private key for the device token flow. | ||
| /// </summary> | ||
| 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); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| <Project Sdk="Microsoft.NET.Sdk"> | ||
|
|
||
| <PropertyGroup> | ||
| <OutputType>Exe</OutputType> | ||
| <TargetFramework>net8.0</TargetFramework> | ||
| <RootNamespace>BadgeReleaseDemo</RootNamespace> | ||
| <AssemblyName>BadgeReleaseDemo</AssemblyName> | ||
| <ImplicitUsings>enable</ImplicitUsings> | ||
| <Nullable>enable</Nullable> | ||
| </PropertyGroup> | ||
|
|
||
| <ItemGroup> | ||
| <PackageReference Include="Microsoft.Identity.Client" Version="4.74.1" /> | ||
| <PackageReference Include="System.Text.Json" Version="9.0.3" /> | ||
| <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.7.0" /> | ||
| <PackageReference Include="BouncyCastle.Cryptography" Version="2.5.1" /> | ||
| </ItemGroup> | ||
|
|
||
| <ItemGroup> | ||
| <None Update="appsettings.json"> | ||
| <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> | ||
| </None> | ||
| <Content Include="Resources\SampleDocument.pdf"> | ||
| <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> | ||
| </Content> | ||
| </ItemGroup> | ||
|
|
||
| </Project> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.