Skip to content
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

Going secretless for Azure DevOps access #376

Draft
wants to merge 13 commits into
base: main
Choose a base branch
from
13 changes: 11 additions & 2 deletions .github/workflows/quest-bulk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
21 changes: 20 additions & 1 deletion actions/sequester/ImportIssues/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,30 @@ private static async Task<QuestGitHubService> 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,
BillWagner marked this conversation as resolved.
Show resolved Hide resolved
options.AzureDevOps.Org,
options.AzureDevOps.Project,
options.AzureDevOps.AreaPath,
Expand Down
13 changes: 10 additions & 3 deletions actions/sequester/Quest2GitHub/AzDoClientServices/QuestClient.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Polly;
using System.Net.Http;
using Polly;
using Polly.Contrib.WaitAndRetry;
using Polly.Retry;

Expand Down Expand Up @@ -35,14 +36,16 @@ public sealed class QuestClient : IDisposable
/// <param name="token">The personal access token</param>
/// <param name="org">The Azure DevOps organization</param>
/// <param name="project">The Azure DevOps project</param>
public QuestClient(string token, string org, string project)
/// <param name="useBearerToken">True to use a just in time bearer token, false assumes PAT</param>
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}")));

Expand Down Expand Up @@ -153,6 +156,10 @@ static async Task<JsonElement> 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;
}
Expand Down
12 changes: 12 additions & 0 deletions actions/sequester/Quest2GitHub/Options/ApiKeys.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,18 @@ public sealed record class ApiKeys
/// </remarks>
public string? AzureAccessToken { get; init; }

/// <summary>
/// The client ID for identifying this app with AzureDevOps.
/// </summary>
/// <remarks>
/// Assign this from an environment variable with the following key, <c>ImportOptions__ApiKeys__AzureAccessToken</c>:
/// <code>
/// env:
/// ImportOptions__ApiKeys__QuestAccessToken: ${{ secrets.QUEST_ACCESS_TOKEN }}
/// </code>
/// </remarks>
public string? QuestAccessToken { get; init; }

/// <summary>
/// The Azure DevOps API key.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion actions/sequester/Quest2GitHub/QuestGitHubService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public class QuestGitHubService(
IGitHubClient ghClient,
OspoClient? ospoClient,
string azdoKey,
bool useBearerToken,
string questOrg,
string questProject,
string areaPath,
Expand All @@ -40,7 +41,7 @@ public class QuestGitHubService(
IEnumerable<LabelToTagMap> 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/";

Expand Down
Loading