diff --git a/.github/workflows/quest-bulk.yml b/.github/workflows/quest-bulk.yml index cf8eb2b4..3cb01605 100644 --- a/.github/workflows/quest-bulk.yml +++ b/.github/workflows/quest-bulk.yml @@ -37,14 +37,23 @@ 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 + uses: dotnet/docs-tools/actions/sequester@going-secretless env: 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: diff --git a/actions/sequester/ImportIssues/Program.cs b/actions/sequester/ImportIssues/Program.cs index a3f983fb..4c286f93 100644 --- a/actions/sequester/ImportIssues/Program.cs +++ b/actions/sequester/ImportIssues/Program.cs @@ -107,11 +107,30 @@ private static async Task CreateService(ImportOptions option { Console.WriteLine("Warning: Imported work items won't be assigned based on GitHub assignee."); } + string? token = options.ApiKeys.QuestAccessToken + ?? options.ApiKeys.QuestKey; + bool useBearerToken = options.ApiKeys.QuestAccessToken is not null; + Console.WriteLine($"Using Azure DevOps token: {token.Length}, {token.Substring(0,6)}"); + + 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, - 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..ca4cde76 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}"))); @@ -153,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; } 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..bf65c78a 100644 --- a/actions/sequester/Quest2GitHub/Options/EnvironmentVariableReader.cs +++ b/actions/sequester/Quest2GitHub/Options/EnvironmentVariableReader.cs @@ -5,7 +5,9 @@ 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. + // 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. // If missing, the action runs using repo-only rights. @@ -14,11 +16,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/";