From b876e8c3ade6b5618042b992e59ba49966d0f38a Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 11 Jul 2024 11:32:48 -0400 Subject: [PATCH 01/13] Update application to support secretless auth Update the SeQuester C# application to support secretless authentication. --- actions/sequester/ImportIssues/Program.cs | 12 +++++++++++- .../Quest2GitHub/AzDoClientServices/QuestClient.cs | 9 ++++++--- actions/sequester/Quest2GitHub/Options/ApiKeys.cs | 12 ++++++++++++ .../Options/EnvironmentVariableReader.cs | 7 ++++++- actions/sequester/Quest2GitHub/QuestGitHubService.cs | 3 ++- 5 files changed, 37 insertions(+), 6 deletions(-) diff --git a/actions/sequester/ImportIssues/Program.cs b/actions/sequester/ImportIssues/Program.cs index a3f983fb..0fefb36f 100644 --- a/actions/sequester/ImportIssues/Program.cs +++ b/actions/sequester/ImportIssues/Program.cs @@ -107,11 +107,21 @@ private static async Task CreateService(ImportOptions option { Console.WriteLine("Warning: Imported work items won't be assigned based on GitHub assignee."); } + bool useBearerToken = (options.ApiKeys.QuestAccessToken is not null); + string? token = useBearerToken ? + options.ApiKeys.QuestAccessToken : + options.ApiKeys.QuestKey; + + if (string.IsNullOrWhiteSpace(token)) + { + throw new InvalidOperationException("Azure DevOps token is missing."); + } return new QuestGitHubService( gitHubClient, ospoClient, - options.ApiKeys.QuestKey, + token, + useBearerToken, options.AzureDevOps.Org, options.AzureDevOps.Project, options.AzureDevOps.AreaPath, 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 a966d91e..c5b375b9 100644 --- a/actions/sequester/Quest2GitHub/QuestGitHubService.cs +++ b/actions/sequester/Quest2GitHub/QuestGitHubService.cs @@ -31,6 +31,7 @@ public class QuestGitHubService( IGitHubClient ghClient, OspoClient? ospoClient, string azdoKey, + bool useBearerToken, string questOrg, string questProject, string areaPath, @@ -40,7 +41,7 @@ public class QuestGitHubService( IEnumerable tagMap) : IDisposable { private const string LinkedWorkItemComment = "Associated WorkItem - "; - private readonly QuestClient _azdoClient = new(azdoKey, questOrg, questProject); + private readonly QuestClient _azdoClient = new(azdoKey, questOrg, questProject, useBearerToken); private readonly OspoClient? _ospoClient = ospoClient; private readonly string _questLinkString = $"https://dev.azure.com/{questOrg}/{questProject}/_workitems/edit/"; From b30e31d563a3ba332e4fbf44be98745a462aee3c Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 11 Jul 2024 11:42:31 -0400 Subject: [PATCH 02/13] 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 cf8eb2b4..32d126ed 100644 --- a/.github/workflows/quest-bulk.yml +++ b/.github/workflows/quest-bulk.yml @@ -37,7 +37,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 @@ -45,6 +53,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 b16d0a592fa211c03749c0f353c85673f9b1aa1f Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 11 Jul 2024 12:48:07 -0400 Subject: [PATCH 03/13] 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 32d126ed..3cb01605 100644 --- a/.github/workflows/quest-bulk.yml +++ b/.github/workflows/quest-bulk.yml @@ -48,7 +48,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 35881095d5198e809c953de650a4ff00fc340433 Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 11 Jul 2024 12:55:04 -0400 Subject: [PATCH 04/13] 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 3cb01605..fd404f5a 100644 --- a/.github/workflows/quest-bulk.yml +++ b/.github/workflows/quest-bulk.yml @@ -59,5 +59,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 == '0 9 6 * *' && -1 || 5 }} From 1f147a6a4ba4556f31af5655259918ea1c386722 Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 11 Jul 2024 13:04:08 -0400 Subject: [PATCH 05/13] Add logging for which auth in use. --- actions/sequester/ImportIssues/Program.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/actions/sequester/ImportIssues/Program.cs b/actions/sequester/ImportIssues/Program.cs index 0fefb36f..5c1867e0 100644 --- a/actions/sequester/ImportIssues/Program.cs +++ b/actions/sequester/ImportIssues/Program.cs @@ -117,6 +117,14 @@ private static async Task CreateService(ImportOptions option 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."); + } return new QuestGitHubService( gitHubClient, ospoClient, From 70a32df851d9f2837ac21ca40d7fe36f7b8d220b Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 11 Jul 2024 13:04:33 -0400 Subject: [PATCH 06/13] Apply suggestions from code review Co-authored-by: David Pine --- actions/sequester/ImportIssues/Program.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/actions/sequester/ImportIssues/Program.cs b/actions/sequester/ImportIssues/Program.cs index 5c1867e0..3a59030e 100644 --- a/actions/sequester/ImportIssues/Program.cs +++ b/actions/sequester/ImportIssues/Program.cs @@ -107,10 +107,8 @@ private static async Task CreateService(ImportOptions option { Console.WriteLine("Warning: Imported work items won't be assigned based on GitHub assignee."); } - bool useBearerToken = (options.ApiKeys.QuestAccessToken is not null); - string? token = useBearerToken ? - options.ApiKeys.QuestAccessToken : - options.ApiKeys.QuestKey; + string? token = options.ApiKeys.QuestAccessToken + ?? options.ApiKeys.QuestKey; if (string.IsNullOrWhiteSpace(token)) { From 4557a958413183fe286f3270b9ca249eaacaa0ae Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 11 Jul 2024 13:10:23 -0400 Subject: [PATCH 07/13] fix merge issue --- actions/sequester/ImportIssues/Program.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/actions/sequester/ImportIssues/Program.cs b/actions/sequester/ImportIssues/Program.cs index 3a59030e..cf8784d3 100644 --- a/actions/sequester/ImportIssues/Program.cs +++ b/actions/sequester/ImportIssues/Program.cs @@ -109,6 +109,7 @@ private static async Task CreateService(ImportOptions option } string? token = options.ApiKeys.QuestAccessToken ?? options.ApiKeys.QuestKey; + bool useBearerToken = options.ApiKeys.QuestAccessToken is not null; if (string.IsNullOrWhiteSpace(token)) { From 00d101171c08ad77a73b71a2d93b296ac5c6970d Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 11 Jul 2024 13:15:42 -0400 Subject: [PATCH 08/13] 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 237994a25147a4ea6a49d035874d4062d3b7b171 Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 11 Jul 2024 13:29:55 -0400 Subject: [PATCH 09/13] more log --- actions/sequester/ImportIssues/Program.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/actions/sequester/ImportIssues/Program.cs b/actions/sequester/ImportIssues/Program.cs index cf8784d3..3ceb3593 100644 --- a/actions/sequester/ImportIssues/Program.cs +++ b/actions/sequester/ImportIssues/Program.cs @@ -118,11 +118,11 @@ private static async Task CreateService(ImportOptions option if (useBearerToken) { - Console.WriteLine("Using Bearer token for Azure DevOps."); + Console.WriteLine("Using secretless for Azure DevOps."); } else { - Console.WriteLine("Using PAT token for Azure DevOps."); + Console.WriteLine("Using PAT for Azure DevOps."); } return new QuestGitHubService( gitHubClient, From ad82de66434567453db9bb7c6c835c2439f43227 Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 11 Jul 2024 14:17:43 -0400 Subject: [PATCH 10/13] 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 fd404f5a..3cb01605 100644 --- a/.github/workflows/quest-bulk.yml +++ b/.github/workflows/quest-bulk.yml @@ -59,6 +59,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 == '0 9 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 a13343c98e0667320d7a24b0d3fec84768dfcebc Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 11 Jul 2024 14:25:28 -0400 Subject: [PATCH 11/13] 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 47f007a9f85ec1a4dc925ca54b852efacfca7ca9 Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Tue, 23 Jul 2024 10:18:40 -0400 Subject: [PATCH 12/13] testung --- actions/sequester/ImportIssues/Program.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/actions/sequester/ImportIssues/Program.cs b/actions/sequester/ImportIssues/Program.cs index 3ceb3593..b3c522f9 100644 --- a/actions/sequester/ImportIssues/Program.cs +++ b/actions/sequester/ImportIssues/Program.cs @@ -111,6 +111,8 @@ private static async Task CreateService(ImportOptions option ?? options.ApiKeys.QuestKey; bool useBearerToken = options.ApiKeys.QuestAccessToken is not null; + Console.WriteLine($"Using Azure DevOps token: {token}"); + if (string.IsNullOrWhiteSpace(token)) { throw new InvalidOperationException("Azure DevOps token is missing."); From 2edd53dd2b0bbb455142636d7c668413cc23cfb3 Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Tue, 23 Jul 2024 10:20:57 -0400 Subject: [PATCH 13/13] more testing --- actions/sequester/ImportIssues/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions/sequester/ImportIssues/Program.cs b/actions/sequester/ImportIssues/Program.cs index b3c522f9..4c286f93 100644 --- a/actions/sequester/ImportIssues/Program.cs +++ b/actions/sequester/ImportIssues/Program.cs @@ -111,7 +111,7 @@ private static async Task CreateService(ImportOptions option ?? options.ApiKeys.QuestKey; bool useBearerToken = options.ApiKeys.QuestAccessToken is not null; - Console.WriteLine($"Using Azure DevOps token: {token}"); + Console.WriteLine($"Using Azure DevOps token: {token.Length}, {token.Substring(0,6)}"); if (string.IsNullOrWhiteSpace(token)) {