Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
7bbc9b9
Update application to support secretless auth
BillWagner Jul 11, 2024
3cea37c
Doing the YAML thing.
BillWagner Jul 11, 2024
7c659a5
run the code from the new branch. (temp)
BillWagner Jul 11, 2024
3b18d88
Use the right branch.
BillWagner Jul 11, 2024
4474d46
Add logging for which auth in use.
BillWagner Jul 11, 2024
d9e608b
Apply suggestions from code review
BillWagner Jul 11, 2024
3a8c6ed
fix merge issue
BillWagner Jul 11, 2024
6904e35
logging and debugging
BillWagner Jul 11, 2024
82dd95e
encode token
BillWagner Jul 11, 2024
0ced444
revert last change
BillWagner Jul 11, 2024
b31b490
testung
BillWagner Jul 23, 2024
086857d
fix merge issues
BillWagner Mar 6, 2026
6b51169
auth test
Jul 6, 2026
23f8bed
test auth workflow
Jul 8, 2026
f6d740d
add auth-only quest path for oidc testing
Jul 8, 2026
2546c1f
separate audience and resource to test auth flow
Jul 8, 2026
f52ed66
test secrets for auth validation
Jul 16, 2026
8b51a02
rp not found: audience validation
Jul 17, 2026
3bea03c
devops auth permission validations
Jul 23, 2026
6fda1d7
remove suppressNotifications flag
Jul 23, 2026
df0a2ff
sequester workflow controlled test
Jul 28, 2026
832dda6
synchronizer
Jul 28, 2026
1820ca3
resolve oidc path
Jul 28, 2026
021c1cd
bulk quest path
Jul 28, 2026
0aeeb34
audience fix
Jul 28, 2026
20ddf48
review ready: rename test variables
Jul 28, 2026
c604b94
PR feedback
Aug 5, 2026
fff077a
Final cleanup of the PR.
BillWagner Aug 6, 2026
07eb4cc
2nd pass removing test code
BillWagner Aug 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/actions/oidc-auth-flow/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@ inputs:
description: "The Azure AD tenant ID"
required: true
audience:
description: "The audience for the access token"
description: "The target resource audience for the access token"
required: true
oidc-audience:
description: "The GitHub OIDC audience used for Azure login"
required: false
default: "api://AzureADTokenExchange"

outputs:
access-token:
Expand All @@ -25,7 +29,7 @@ runs:
with:
client-id: ${{ inputs.client-id }}
tenant-id: ${{ inputs.tenant-id }}
audience: ${{ inputs.audience }}
audience: ${{ inputs.oidc-audience }}
allow-no-subscriptions: true

- name: OSMP API access
Expand Down
12 changes: 10 additions & 2 deletions .github/workflows/quest-bulk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,22 @@ 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.CLIENT_ID }}
tenant-id: ${{ secrets.TENANT_ID }}
audience: ${{ secrets.QUEST_AUDIENCE }}

- name: bulk-sequester
id: bulk-sequester
uses: dotnet/docs-tools/actions/sequester@main
env:
Comment thread
ramiMSFT marked this conversation as resolved.
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
24 changes: 18 additions & 6 deletions .github/workflows/quest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,12 @@ jobs:
if: ${{ github.event_name == 'workflow_dispatch' }}
run: |
echo "Reason: $REASON"
echo "Target org: $TARGETORG"
echo "Target repo: $TARGETREPO"
echo "Issue number: $ISSUENUMBER"
env:
TARGETORG: ${{ github.event.inputs.target-org || github.repository_owner }}
TARGETREPO: ${{ github.event.inputs.target-repo || github.event.repository.name }}
ISSUENUMBER: ${{ github.event.inputs.issue }}
REASON: ${{ github.event.inputs.reason }}

Expand All @@ -42,6 +46,15 @@ jobs:
tenant-id: ${{ secrets.TENANT_ID }}
audience: ${{ secrets.OSMP_API_AUDIENCE }}

- name: Azure DevOps OpenID Connect for manual test runs
id: azure-devops-oidc-auth
uses: dotnet/docs-tools/.github/actions/oidc-auth-flow@main
with:
client-id: ${{ secrets.CLIENT_ID }}
tenant-id: ${{ secrets.TENANT_ID }}
audience: ${{ secrets.QUEST_AUDIENCE }}


# This step occurs when ran manually, passing the manual issue number input
- name: manual-sequester
if: ${{ github.event_name == 'workflow_dispatch' }}
Expand All @@ -50,27 +63,26 @@ jobs:
env:
ImportOptions__ApiKeys__GitHubToken: ${{ secrets.GITHUB_TOKEN }}
ImportOptions__ApiKeys__AzureAccessToken: ${{ steps.azure-oidc-auth.outputs.access-token }}
ImportOptions__ApiKeys__QuestKey: ${{ secrets.QUEST_KEY }}
ImportOptions__ApiKeys__QuestAccessToken: ${{ steps.azure-devops-oidc-auth.outputs.access-token }}
ImportOptions__ApiKeys__SequesterPrivateKey: ${{ secrets.SEQUESTER_PRIVATEKEY }}
ImportOptions__ApiKeys__SequesterAppID: ${{ secrets.SEQUESTER_APPID }}
with:
org: ${{ github.repository_owner }}
repo: ${{ github.repository }}
org: ${{ github.event.inputs.target-org || github.repository_owner }}
repo: ${{ github.event.inputs.target-repo || github.event.repository.name }}
issue: ${{ github.event.inputs.issue }}

# This step occurs automatically, passing the issue number from the event
- name: auto-sequester
if: ${{ github.event_name != 'workflow_dispatch' }}
id: auto-sequester
uses: dotnet/docs-tools/actions/sequester@main
uses: ./actions/sequester
env:
ImportOptions__ApiKeys__GitHubToken: ${{ secrets.GITHUB_TOKEN }}
ImportOptions__ApiKeys__AzureAccessToken: ${{ steps.azure-oidc-auth.outputs.access-token }}
ImportOptions__ApiKeys__QuestKey: ${{ secrets.QUEST_KEY }}
ImportOptions__ApiKeys__QuestAccessToken: ${{ steps.azure-devops-oidc-auth.outputs.access-token }}
ImportOptions__ApiKeys__SequesterPrivateKey: ${{ secrets.SEQUESTER_PRIVATEKEY }}
ImportOptions__ApiKeys__SequesterAppID: ${{ secrets.SEQUESTER_APPID }}
with:
org: ${{ github.repository_owner }}
repo: ${{ github.repository }}
issue: ${{ github.event.issue.number }}

33 changes: 30 additions & 3 deletions actions/sequester/ImportIssues/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -41,8 +42,25 @@
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.");
}

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);

if (singleIssue)
{
Expand Down Expand Up @@ -70,7 +88,7 @@ await serviceWorker.ProcessIssues(
}
return 0;

static async Task<QuestGitHubService> CreateService(ImportOptions options, bool bulkImport)
static async Task<QuestGitHubService> CreateService(ImportOptions options, bool bulkImport, bool useBearerToken)
{
ArgumentNullException.ThrowIfNull(options.ApiKeys, nameof(options));

Expand All @@ -87,10 +105,19 @@ static async Task<QuestGitHubService> 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;

if (string.IsNullOrWhiteSpace(token))
{
throw new InvalidOperationException("Azure DevOps token is missing.");
}

return new QuestGitHubService(
gitHubClient,
ospoClient,
options);
options,
useBearerToken);
}

static (string org, string repo, int? issue, int? duration, string? questConfigPath, string? branch) ParseArguments(string[] args)
Expand Down
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;
Comment thread
ramiMSFT marked this conversation as resolved.
}
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>
Comment thread
ramiMSFT marked this conversation as resolved.
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
16 changes: 14 additions & 2 deletions actions/sequester/Quest2GitHub/QuestGitHubService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(GetQuestToken(importOptions, useBearerToken), 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/";

Expand Down Expand Up @@ -214,6 +215,17 @@ public void Dispose()
GC.SuppressFinalize(this);
}

private static string GetQuestToken(ImportOptions importOptions, bool useBearerToken)
{
string? token = useBearerToken
? importOptions.ApiKeys.QuestAccessToken
: importOptions.ApiKeys.QuestKey;

return !string.IsNullOrWhiteSpace(token)
? token
: throw new InvalidOperationException("Azure DevOps token is missing.");
}


private Task<QuestIssue?> RetrieveIssueAsync(string org, string repo, int issueNumber)
{
Expand Down