From 76cb7a9229de0fb12df5474e62cfe41dbb2c79fa Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 11 Jul 2024 11:32:48 -0400 Subject: [PATCH 01/12] Update application to support secretless auth Update the SeQuester C# application to support secretless authentication. --- actions/sequester/ImportIssues/Program.cs | 17 ++++++++++++++--- .../AzDoClientServices/QuestClient.cs | 9 ++++++--- .../sequester/Quest2GitHub/Options/ApiKeys.cs | 12 ++++++++++++ .../Options/EnvironmentVariableReader.cs | 7 ++++++- .../Quest2GitHub/QuestGitHubService.cs | 5 +++-- 5 files changed, 41 insertions(+), 9 deletions(-) diff --git a/actions/sequester/ImportIssues/Program.cs b/actions/sequester/ImportIssues/Program.cs index 2a582934..ff7e257e 100644 --- a/actions/sequester/ImportIssues/Program.cs +++ b/actions/sequester/ImportIssues/Program.cs @@ -2,6 +2,7 @@ using System.CommandLine.Parsing; using DotNetDocs.Tools.GitHubCommunications; using Microsoft.DotnetOrg.Ospo; +using Microsoft.Extensions.Options; (string org, string repo, int? issue, int? duration, string? questConfigPath, string? branch) = ParseArguments(args); @@ -41,8 +42,17 @@ throw new ApplicationException( $"Unable to load Quest import configuration options."); } + bool useBearerToken = (importOptions.ApiKeys.QuestAccessToken is not null); + string? token = useBearerToken ? + importOptions.ApiKeys.QuestAccessToken : + importOptions.ApiKeys.QuestKey; - using QuestGitHubService serviceWorker = await CreateService(importOptions, !singleIssue); + if (string.IsNullOrWhiteSpace(token)) + { + throw new InvalidOperationException("Azure DevOps token is missing."); + } + + using QuestGitHubService serviceWorker = await CreateService(importOptions, !singleIssue, useBearerToken); if (singleIssue) { @@ -70,7 +80,7 @@ await serviceWorker.ProcessIssues( } return 0; -static async Task CreateService(ImportOptions options, bool bulkImport) +static async Task CreateService(ImportOptions options, bool bulkImport, bool useBearerToken) { ArgumentNullException.ThrowIfNull(options.ApiKeys, nameof(options)); @@ -90,7 +100,8 @@ static async Task CreateService(ImportOptions options, bool return new QuestGitHubService( gitHubClient, ospoClient, - options); + options, + useBearerToken); } static (string org, string repo, int? issue, int? duration, string? questConfigPath, string? branch) ParseArguments(string[] args) diff --git a/actions/sequester/Quest2GitHub/AzDoClientServices/QuestClient.cs b/actions/sequester/Quest2GitHub/AzDoClientServices/QuestClient.cs index 3434916b..eed96d4d 100644 --- a/actions/sequester/Quest2GitHub/AzDoClientServices/QuestClient.cs +++ b/actions/sequester/Quest2GitHub/AzDoClientServices/QuestClient.cs @@ -1,4 +1,5 @@ -using Polly; +using System.Net.Http; +using Polly; using Polly.Contrib.WaitAndRetry; using Polly.Retry; @@ -35,14 +36,16 @@ public sealed class QuestClient : IDisposable /// The personal access token /// The Azure DevOps organization /// The Azure DevOps project - public QuestClient(string token, string org, string project) + /// True to use a just in time bearer token, false assumes PAT + public QuestClient(string token, string org, string project, bool useBearerToken) { QuestOrg = org; QuestProject = project; _client = new HttpClient(); _client.DefaultRequestHeaders.Accept.Add( new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); - _client.DefaultRequestHeaders.Authorization = + _client.DefaultRequestHeaders.Authorization = useBearerToken ? + new AuthenticationHeaderValue("Bearer", token) : new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($":{token}"))); diff --git a/actions/sequester/Quest2GitHub/Options/ApiKeys.cs b/actions/sequester/Quest2GitHub/Options/ApiKeys.cs index 4667c245..11eeab55 100644 --- a/actions/sequester/Quest2GitHub/Options/ApiKeys.cs +++ b/actions/sequester/Quest2GitHub/Options/ApiKeys.cs @@ -36,6 +36,18 @@ public sealed record class ApiKeys /// public string? AzureAccessToken { get; init; } + /// + /// The client ID for identifying this app with AzureDevOps. + /// + /// + /// Assign this from an environment variable with the following key, ImportOptions__ApiKeys__AzureAccessToken: + /// + /// env: + /// ImportOptions__ApiKeys__QuestAccessToken: ${{ secrets.QUEST_ACCESS_TOKEN }} + /// + /// + public string? QuestAccessToken { get; init; } + /// /// The Azure DevOps API key. /// diff --git a/actions/sequester/Quest2GitHub/Options/EnvironmentVariableReader.cs b/actions/sequester/Quest2GitHub/Options/EnvironmentVariableReader.cs index e360ab33..ad242525 100644 --- a/actions/sequester/Quest2GitHub/Options/EnvironmentVariableReader.cs +++ b/actions/sequester/Quest2GitHub/Options/EnvironmentVariableReader.cs @@ -5,7 +5,8 @@ internal sealed class EnvironmentVariableReader internal static ApiKeys GetApiKeys() { var githubToken = CoalesceEnvVar(("ImportOptions__ApiKeys__GitHubToken", "GitHubKey")); - var questKey = CoalesceEnvVar(("ImportOptions__ApiKeys__QuestKey", "QuestKey")); + // This is optional so that developers can run the app locally without setting up the devOps token. + var questToken = CoalesceEnvVar(("ImportOptions__ApiKeys__QuestAccessToken", "QuestAccessToken"), false); // These keys are used when the app is run as an org enabled action. They are optional. // If missing, the action runs using repo-only rights. @@ -14,11 +15,15 @@ internal static ApiKeys GetApiKeys() var azureAccessToken = CoalesceEnvVar(("ImportOptions__ApiKeys__AzureAccessToken", "AZURE_ACCESS_TOKEN"), false); + // This key is the PAT for Quest access. It's now a legacy key. Secretless should be better. + var questKey = CoalesceEnvVar(("ImportOptions__ApiKeys__QuestKey", "QuestKey"), false); + if (!int.TryParse(appIDString, out int appID)) appID = 0; return new ApiKeys() { GitHubToken = githubToken, + QuestAccessToken = questToken, AzureAccessToken = azureAccessToken, QuestKey = questKey, SequesterPrivateKey = oauthPrivateKey, diff --git a/actions/sequester/Quest2GitHub/QuestGitHubService.cs b/actions/sequester/Quest2GitHub/QuestGitHubService.cs index 983ecf81..1b72beee 100644 --- a/actions/sequester/Quest2GitHub/QuestGitHubService.cs +++ b/actions/sequester/Quest2GitHub/QuestGitHubService.cs @@ -26,10 +26,11 @@ namespace Quest2GitHub; public class QuestGitHubService( IGitHubClient ghClient, OspoClient? ospoClient, - ImportOptions importOptions) : IDisposable + ImportOptions importOptions, + bool useBearerToken) : IDisposable { private const string LinkedWorkItemComment = "Associated WorkItem - "; - private readonly QuestClient _azdoClient = new(importOptions.ApiKeys.QuestKey, importOptions.AzureDevOps.Org, importOptions.AzureDevOps.Project); + private readonly QuestClient _azdoClient = new(importOptions.ApiKeys.QuestKey, importOptions.AzureDevOps.Org, importOptions.AzureDevOps.Project, useBearerToken); private readonly OspoClient? _ospoClient = ospoClient; private readonly string _questLinkString = $"https://dev.azure.com/{importOptions.AzureDevOps.Org}/{importOptions.AzureDevOps.Project}/_workitems/edit/"; From 95438eec1376c31ea238b529bf671725ec794fa1 Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 11 Jul 2024 11:42:31 -0400 Subject: [PATCH 02/12] Doing the YAML thing. --- .github/workflows/quest-bulk.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/quest-bulk.yml b/.github/workflows/quest-bulk.yml index e4ea11cb..e59b2792 100644 --- a/.github/workflows/quest-bulk.yml +++ b/.github/workflows/quest-bulk.yml @@ -39,7 +39,15 @@ jobs: client-id: ${{ secrets.CLIENT_ID }} tenant-id: ${{ secrets.TENANT_ID }} audience: ${{ secrets.OSMP_API_AUDIENCE }} - + + - name: Azure DevOps OpenID Connect + id: azure-devops-oidc-auth + uses: dotnet/docs-tools/.github/actions/oidc-auth-flow@main + with: + client-id: ${{ secrets.QUEST_CLIENT_ID }} + tenant-id: ${{ secrets.TENANT_ID }} + audience: ${{ secrets.QUEST_AUDIENCE }} + - name: bulk-sequester id: bulk-sequester uses: dotnet/docs-tools/actions/sequester@main @@ -47,6 +55,7 @@ jobs: ImportOptions__ApiKeys__GitHubToken: ${{ secrets.GITHUB_TOKEN }} ImportOptions__ApiKeys__QuestKey: ${{ secrets.QUEST_KEY }} ImportOptions__ApiKeys__AzureAccessToken: ${{ steps.azure-oidc-auth.outputs.access-token }} + ImportOptions__ApiKeys__QuestAccessToken: ${{ steps.azure-devops-oidc-auth.outputs.access-token }} ImportOptions__ApiKeys__SequesterPrivateKey: ${{ secrets.SEQUESTER_PRIVATEKEY }} ImportOptions__ApiKeys__SequesterAppID: ${{ secrets.SEQUESTER_APPID }} with: From 8df83ce7969fb7a07da80985ee85bd0ac5c2abaf Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 11 Jul 2024 12:48:07 -0400 Subject: [PATCH 03/12] run the code from the new branch. (temp) --- .github/workflows/quest-bulk.yml | 2 +- .../sequester/Quest2GitHub/Options/EnvironmentVariableReader.cs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/quest-bulk.yml b/.github/workflows/quest-bulk.yml index e59b2792..fd86b75a 100644 --- a/.github/workflows/quest-bulk.yml +++ b/.github/workflows/quest-bulk.yml @@ -50,7 +50,7 @@ jobs: - name: bulk-sequester id: bulk-sequester - uses: dotnet/docs-tools/actions/sequester@main + uses: dotnet/docs-tools/actions/sequester@going-secretless env: ImportOptions__ApiKeys__GitHubToken: ${{ secrets.GITHUB_TOKEN }} ImportOptions__ApiKeys__QuestKey: ${{ secrets.QUEST_KEY }} diff --git a/actions/sequester/Quest2GitHub/Options/EnvironmentVariableReader.cs b/actions/sequester/Quest2GitHub/Options/EnvironmentVariableReader.cs index ad242525..bf65c78a 100644 --- a/actions/sequester/Quest2GitHub/Options/EnvironmentVariableReader.cs +++ b/actions/sequester/Quest2GitHub/Options/EnvironmentVariableReader.cs @@ -6,6 +6,7 @@ internal static ApiKeys GetApiKeys() { var githubToken = CoalesceEnvVar(("ImportOptions__ApiKeys__GitHubToken", "GitHubKey")); // This is optional so that developers can run the app locally without setting up the devOps token. + // In GitHub Actions, this is preferred. var questToken = CoalesceEnvVar(("ImportOptions__ApiKeys__QuestAccessToken", "QuestAccessToken"), false); // These keys are used when the app is run as an org enabled action. They are optional. From 54a7de7710a2f63b58f1e130d5108468a6419eea Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 11 Jul 2024 12:55:04 -0400 Subject: [PATCH 04/12] Use the right branch. --- .github/workflows/quest-bulk.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/quest-bulk.yml b/.github/workflows/quest-bulk.yml index fd86b75a..2fe408ca 100644 --- a/.github/workflows/quest-bulk.yml +++ b/.github/workflows/quest-bulk.yml @@ -61,5 +61,6 @@ jobs: with: org: ${{ github.repository_owner }} repo: ${{ github.repository }} + branch: 'going-secretless' issue: '-1' duration: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.duration || github.event.schedule == '30 5 6 * *' && -1 || 5 }} From 0ed29c9c999d2bda2bb4fbd0846dfb759861f5a9 Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 11 Jul 2024 13:04:08 -0400 Subject: [PATCH 05/12] Add logging for which auth in use. --- actions/sequester/ImportIssues/Program.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/actions/sequester/ImportIssues/Program.cs b/actions/sequester/ImportIssues/Program.cs index ff7e257e..0a51ee48 100644 --- a/actions/sequester/ImportIssues/Program.cs +++ b/actions/sequester/ImportIssues/Program.cs @@ -51,6 +51,19 @@ { throw new InvalidOperationException("Azure DevOps token is missing."); } + if (string.IsNullOrWhiteSpace(token)) + { + throw new InvalidOperationException("Azure DevOps token is missing."); + } + + if (useBearerToken) + { + Console.WriteLine("Using Bearer token for Azure DevOps."); + } + else + { + Console.WriteLine("Using PAT token for Azure DevOps."); + } using QuestGitHubService serviceWorker = await CreateService(importOptions, !singleIssue, useBearerToken); From 8986cc2f00f612672b4e885567b89c0476d7d5df Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 11 Jul 2024 13:04:33 -0400 Subject: [PATCH 06/12] Apply suggestions from code review Co-authored-by: David Pine --- actions/sequester/ImportIssues/Program.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/actions/sequester/ImportIssues/Program.cs b/actions/sequester/ImportIssues/Program.cs index 0a51ee48..6ec46c00 100644 --- a/actions/sequester/ImportIssues/Program.cs +++ b/actions/sequester/ImportIssues/Program.cs @@ -110,6 +110,9 @@ static async Task CreateService(ImportOptions options, bool Console.WriteLine("Warning: Imported work items won't be assigned based on GitHub assignee."); } + string? token = options.ApiKeys.QuestAccessToken + ?? options.ApiKeys.QuestKey; + return new QuestGitHubService( gitHubClient, ospoClient, From eac7c978193cf0d77dd67920e950b15cee4e1e35 Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 11 Jul 2024 13:10:23 -0400 Subject: [PATCH 07/12] fix merge issue --- actions/sequester/ImportIssues/Program.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/actions/sequester/ImportIssues/Program.cs b/actions/sequester/ImportIssues/Program.cs index 6ec46c00..341c7039 100644 --- a/actions/sequester/ImportIssues/Program.cs +++ b/actions/sequester/ImportIssues/Program.cs @@ -64,7 +64,6 @@ { Console.WriteLine("Using PAT token for Azure DevOps."); } - using QuestGitHubService serviceWorker = await CreateService(importOptions, !singleIssue, useBearerToken); if (singleIssue) From 6dabcf8268588b83e4f57adf4536c56178c59d22 Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 11 Jul 2024 13:15:42 -0400 Subject: [PATCH 08/12] logging and debugging --- .../sequester/Quest2GitHub/AzDoClientServices/QuestClient.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/actions/sequester/Quest2GitHub/AzDoClientServices/QuestClient.cs b/actions/sequester/Quest2GitHub/AzDoClientServices/QuestClient.cs index eed96d4d..ca4cde76 100644 --- a/actions/sequester/Quest2GitHub/AzDoClientServices/QuestClient.cs +++ b/actions/sequester/Quest2GitHub/AzDoClientServices/QuestClient.cs @@ -156,6 +156,10 @@ static async Task HandleResponseAsync(HttpResponseMessage response) { if (response.IsSuccessStatusCode) { + // Temporary debugging code: + + string packet = await response.Content.ReadAsStringAsync(); + Console.WriteLine($"Response: {packet}"); JsonDocument jsonDocument = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); return jsonDocument.RootElement; } From 85bcca8f1ceacdb5834d8d3f684bbe1c80716488 Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 11 Jul 2024 14:17:43 -0400 Subject: [PATCH 09/12] encode token --- .github/workflows/quest-bulk.yml | 1 - .../sequester/Quest2GitHub/AzDoClientServices/QuestClient.cs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/quest-bulk.yml b/.github/workflows/quest-bulk.yml index 2fe408ca..fd86b75a 100644 --- a/.github/workflows/quest-bulk.yml +++ b/.github/workflows/quest-bulk.yml @@ -61,6 +61,5 @@ jobs: with: org: ${{ github.repository_owner }} repo: ${{ github.repository }} - branch: 'going-secretless' issue: '-1' duration: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.duration || github.event.schedule == '30 5 6 * *' && -1 || 5 }} diff --git a/actions/sequester/Quest2GitHub/AzDoClientServices/QuestClient.cs b/actions/sequester/Quest2GitHub/AzDoClientServices/QuestClient.cs index ca4cde76..d0d6c609 100644 --- a/actions/sequester/Quest2GitHub/AzDoClientServices/QuestClient.cs +++ b/actions/sequester/Quest2GitHub/AzDoClientServices/QuestClient.cs @@ -45,7 +45,7 @@ public QuestClient(string token, string org, string project, bool useBearerToken _client.DefaultRequestHeaders.Accept.Add( new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); _client.DefaultRequestHeaders.Authorization = useBearerToken ? - new AuthenticationHeaderValue("Bearer", token) : + new AuthenticationHeaderValue("Bearer", Convert.ToBase64String(Encoding.ASCII.GetBytes($":{token}"))) : new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($":{token}"))); From 80ecdb5ee5f4806e8ac8fad3af16606efbb68144 Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 11 Jul 2024 14:25:28 -0400 Subject: [PATCH 10/12] revert last change --- .../sequester/Quest2GitHub/AzDoClientServices/QuestClient.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions/sequester/Quest2GitHub/AzDoClientServices/QuestClient.cs b/actions/sequester/Quest2GitHub/AzDoClientServices/QuestClient.cs index d0d6c609..ca4cde76 100644 --- a/actions/sequester/Quest2GitHub/AzDoClientServices/QuestClient.cs +++ b/actions/sequester/Quest2GitHub/AzDoClientServices/QuestClient.cs @@ -45,7 +45,7 @@ public QuestClient(string token, string org, string project, bool useBearerToken _client.DefaultRequestHeaders.Accept.Add( new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); _client.DefaultRequestHeaders.Authorization = useBearerToken ? - new AuthenticationHeaderValue("Bearer", Convert.ToBase64String(Encoding.ASCII.GetBytes($":{token}"))) : + new AuthenticationHeaderValue("Bearer", token) : new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($":{token}"))); From 33e6e9facdd5c00e5c39963f98793ea1096b2191 Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Tue, 23 Jul 2024 10:18:40 -0400 Subject: [PATCH 11/12] testung --- actions/sequester/ImportIssues/Program.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/actions/sequester/ImportIssues/Program.cs b/actions/sequester/ImportIssues/Program.cs index 341c7039..c10506c9 100644 --- a/actions/sequester/ImportIssues/Program.cs +++ b/actions/sequester/ImportIssues/Program.cs @@ -112,6 +112,19 @@ static async Task CreateService(ImportOptions options, bool string? token = options.ApiKeys.QuestAccessToken ?? options.ApiKeys.QuestKey; + if (string.IsNullOrWhiteSpace(token)) + { + throw new InvalidOperationException("Azure DevOps token is missing."); + } + + if (useBearerToken) + { + Console.WriteLine("Using secretless for Azure DevOps."); + } + else + { + Console.WriteLine("Using PAT for Azure DevOps."); + } return new QuestGitHubService( gitHubClient, ospoClient, From c3a3496f5dfb254fada6878eb876c6cb161dc277 Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Fri, 6 Mar 2026 16:14:15 -0500 Subject: [PATCH 12/12] fix merge issues --- actions/sequester/ImportIssues/Program.cs | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/actions/sequester/ImportIssues/Program.cs b/actions/sequester/ImportIssues/Program.cs index c10506c9..7e5ff0f8 100644 --- a/actions/sequester/ImportIssues/Program.cs +++ b/actions/sequester/ImportIssues/Program.cs @@ -51,10 +51,6 @@ { throw new InvalidOperationException("Azure DevOps token is missing."); } - if (string.IsNullOrWhiteSpace(token)) - { - throw new InvalidOperationException("Azure DevOps token is missing."); - } if (useBearerToken) { @@ -117,14 +113,6 @@ static async Task CreateService(ImportOptions options, bool throw new InvalidOperationException("Azure DevOps token is missing."); } - if (useBearerToken) - { - Console.WriteLine("Using secretless for Azure DevOps."); - } - else - { - Console.WriteLine("Using PAT for Azure DevOps."); - } return new QuestGitHubService( gitHubClient, ospoClient,