From 7bbc9b9fc07d8d002f6456983eb64714cbaaf519 Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 11 Jul 2024 11:32:48 -0400 Subject: [PATCH 01/20] 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 3cea37ce6316abcc37cc5c2f993a12684c72d8e7 Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 11 Jul 2024 11:42:31 -0400 Subject: [PATCH 02/20] 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 7c659a52bff0102dd9dc2f59466a4f3a3f11941b Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 11 Jul 2024 12:48:07 -0400 Subject: [PATCH 03/20] 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 3b18d88062a56c7811a1f0b755acf5c2e722869e Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 11 Jul 2024 12:55:04 -0400 Subject: [PATCH 04/20] 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 4474d46c98c20ff784f333997a2dbf3b7812f02b Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 11 Jul 2024 13:04:08 -0400 Subject: [PATCH 05/20] 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 d9e608ba57b61dbc0432a8d4038f1696cf8dbe6d Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 11 Jul 2024 13:04:33 -0400 Subject: [PATCH 06/20] 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 3a8c6ed54bf6046d4d60cd349b0b83e190ed612c Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 11 Jul 2024 13:10:23 -0400 Subject: [PATCH 07/20] 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 6904e3523d0e5cfb1331f2dcdf1f5fcfd5f333d2 Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 11 Jul 2024 13:15:42 -0400 Subject: [PATCH 08/20] 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 82dd95e8f9d5deb14cba438962adde0cfd08e023 Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 11 Jul 2024 14:17:43 -0400 Subject: [PATCH 09/20] 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 0ced444d7e5681c29bb8f189cf042acde8393eab Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 11 Jul 2024 14:25:28 -0400 Subject: [PATCH 10/20] 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 b31b4900589651dd98a1c5b58c06d5f3e84a0d25 Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Tue, 23 Jul 2024 10:18:40 -0400 Subject: [PATCH 11/20] 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 086857d7d8d53b0e354a5b10b3f3563a926b36eb Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Fri, 6 Mar 2026 16:14:15 -0500 Subject: [PATCH 12/20] 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, From 6b51169b9754667a18b22c93bcdac086ab7741cd Mon Sep 17 00:00:00 2001 From: Rami Bououni Date: Mon, 6 Jul 2026 14:49:25 -0700 Subject: [PATCH 13/20] auth test --- .github/workflows/test-quest-oidc.yml | 51 +++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .github/workflows/test-quest-oidc.yml diff --git a/.github/workflows/test-quest-oidc.yml b/.github/workflows/test-quest-oidc.yml new file mode 100644 index 00000000..0c1d7eb5 --- /dev/null +++ b/.github/workflows/test-quest-oidc.yml @@ -0,0 +1,51 @@ +name: Test Quest OIDC + +on: + workflow_dispatch: + +permissions: + id-token: write + contents: read + +jobs: + test-auth: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run existing Azure DevOps OIDC action + id: quest-auth + uses: ./.github/actions/oidc-auth-flow + with: + client-id: ${{ secrets.QUEST_CLIENT_ID }} + tenant-id: ${{ secrets.TENANT_ID }} + audience: ${{ secrets.QUEST_AUDIENCE }} + + - name: Confirm token was returned + shell: bash + run: | + if [ -z "${{ steps.quest-auth.outputs.access-token }}" ]; then + echo "No access token returned." + exit 1 + fi + echo "Access token was returned." + + - name: Probe Azure DevOps projects API + shell: bash + env: + AZDO_TOKEN: ${{ steps.quest-auth.outputs.access-token }} + run: | + curl -sS -D headers.txt \ + -H "Authorization: Bearer $AZDO_TOKEN" \ + -H "Content-Type: application/json" \ + "https://dev.azure.com/msft-skilling/_apis/projects?api-version=7.1" \ + -o body.json + + echo "HTTP response headers:" + cat headers.txt + + echo "" + echo "Response body:" + cat body.json \ No newline at end of file From 23f8bed2b52edc120add87e9849a60f79db05f90 Mon Sep 17 00:00:00 2001 From: Rami Bououni Date: Wed, 8 Jul 2026 13:57:17 -0700 Subject: [PATCH 14/20] test auth workflow --- .github/workflows/test-quest-oidc.yml | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test-quest-oidc.yml b/.github/workflows/test-quest-oidc.yml index 0c1d7eb5..df3f927e 100644 --- a/.github/workflows/test-quest-oidc.yml +++ b/.github/workflows/test-quest-oidc.yml @@ -37,15 +37,24 @@ jobs: env: AZDO_TOKEN: ${{ steps.quest-auth.outputs.access-token }} run: | - curl -sS -D headers.txt \ + set -euo pipefail + + status_code=$(curl -sS -w '%{http_code}' -D headers.txt \ -H "Authorization: Bearer $AZDO_TOKEN" \ -H "Content-Type: application/json" \ "https://dev.azure.com/msft-skilling/_apis/projects?api-version=7.1" \ - -o body.json + -o body.json) + + echo "HTTP status: $status_code" echo "HTTP response headers:" cat headers.txt echo "" echo "Response body:" - cat body.json \ No newline at end of file + cat body.json + + if [[ ! "$status_code" =~ ^2 ]]; then + echo "Azure DevOps API probe failed." + exit 1 + fi \ No newline at end of file From f6d740db7d4346e65db36b94ab36f69b0eed8c49 Mon Sep 17 00:00:00 2001 From: Rami Bououni Date: Wed, 8 Jul 2026 14:35:20 -0700 Subject: [PATCH 15/20] add auth-only quest path for oidc testing --- .github/workflows/quest.yml | 61 ++++++++++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/.github/workflows/quest.yml b/.github/workflows/quest.yml index 7f33afe5..a435e463 100644 --- a/.github/workflows/quest.yml +++ b/.github/workflows/quest.yml @@ -9,11 +9,15 @@ on: issue: description: "The issue number to manually test" required: true + auth-only: + description: "Run only the Azure DevOps OIDC auth probe" + required: false + default: "false" jobs: import: if: | - github.event_name == 'workflow_dispatch' || + (github.event_name == 'workflow_dispatch' && github.event.inputs.auth-only != 'true') || github.event.label.name == ':world_map: reQUEST' || github.event.label.name == ':pushpin: seQUESTered' || contains(github.event.issue.labels.*.name, ':world_map: reQUEST') || @@ -73,4 +77,59 @@ jobs: org: ${{ github.repository_owner }} repo: ${{ github.repository }} issue: ${{ github.event.issue.number }} + + test-auth: + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.auth-only == 'true' }} + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run existing Azure DevOps OIDC action + id: quest-auth + uses: ./.github/actions/oidc-auth-flow + with: + client-id: ${{ secrets.QUEST_CLIENT_ID }} + tenant-id: ${{ secrets.TENANT_ID }} + audience: ${{ secrets.QUEST_AUDIENCE }} + + - name: Confirm token was returned + shell: bash + run: | + if [ -z "${{ steps.quest-auth.outputs.access-token }}" ]; then + echo "No access token returned." + exit 1 + fi + echo "Access token was returned." + + - name: Probe Azure DevOps projects API + shell: bash + env: + AZDO_TOKEN: ${{ steps.quest-auth.outputs.access-token }} + run: | + set -euo pipefail + + status_code=$(curl -sS -w '%{http_code}' -D headers.txt \ + -H "Authorization: Bearer $AZDO_TOKEN" \ + -H "Content-Type: application/json" \ + "https://dev.azure.com/msft-skilling/_apis/projects?api-version=7.1" \ + -o body.json) + + echo "HTTP status: $status_code" + + echo "HTTP response headers:" + cat headers.txt + + echo "" + echo "Response body:" + cat body.json + + if [[ ! "$status_code" =~ ^2 ]]; then + echo "Azure DevOps API probe failed." + exit 1 + fi From 2546c1fb1eea171c306cca5586cf906fba596862 Mon Sep 17 00:00:00 2001 From: Rami Bououni Date: Wed, 8 Jul 2026 15:12:14 -0700 Subject: [PATCH 16/20] separate audience and resource to test auth flow --- .github/actions/oidc-auth-flow/action.yml | 8 ++++++-- .github/workflows/quest.yml | 1 + 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/actions/oidc-auth-flow/action.yml b/.github/actions/oidc-auth-flow/action.yml index 77f9aab3..e8f7c111 100644 --- a/.github/actions/oidc-auth-flow/action.yml +++ b/.github/actions/oidc-auth-flow/action.yml @@ -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: @@ -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 diff --git a/.github/workflows/quest.yml b/.github/workflows/quest.yml index a435e463..2c96a86f 100644 --- a/.github/workflows/quest.yml +++ b/.github/workflows/quest.yml @@ -95,6 +95,7 @@ jobs: with: client-id: ${{ secrets.QUEST_CLIENT_ID }} tenant-id: ${{ secrets.TENANT_ID }} + oidc-audience: api://AzureADTokenExchange audience: ${{ secrets.QUEST_AUDIENCE }} - name: Confirm token was returned From f52ed66b3bdb753df3c6c10b9cf7bbfedc988585 Mon Sep 17 00:00:00 2001 From: Rami Bououni Date: Thu, 16 Jul 2026 09:58:44 -0700 Subject: [PATCH 17/20] test secrets for auth validation --- .github/workflows/quest.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/quest.yml b/.github/workflows/quest.yml index 2c96a86f..5d16c600 100644 --- a/.github/workflows/quest.yml +++ b/.github/workflows/quest.yml @@ -93,8 +93,8 @@ jobs: id: quest-auth uses: ./.github/actions/oidc-auth-flow with: - client-id: ${{ secrets.QUEST_CLIENT_ID }} - tenant-id: ${{ secrets.TENANT_ID }} + client-id: ${{ secrets.QUEST_CLIENT_ID_TEST }} + tenant-id: ${{ secrets.TENANT_ID_TEST }} oidc-audience: api://AzureADTokenExchange audience: ${{ secrets.QUEST_AUDIENCE }} From 8b51a02ea6ab4243054e1201bb3176ca006113d2 Mon Sep 17 00:00:00 2001 From: Rami Bououni Date: Fri, 17 Jul 2026 09:36:44 -0700 Subject: [PATCH 18/20] rp not found: audience validation --- .github/workflows/quest.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/quest.yml b/.github/workflows/quest.yml index 5d16c600..3f709cd8 100644 --- a/.github/workflows/quest.yml +++ b/.github/workflows/quest.yml @@ -96,7 +96,7 @@ jobs: client-id: ${{ secrets.QUEST_CLIENT_ID_TEST }} tenant-id: ${{ secrets.TENANT_ID_TEST }} oidc-audience: api://AzureADTokenExchange - audience: ${{ secrets.QUEST_AUDIENCE }} + audience: ${{ secrets.QUEST_AUDIENCE_TEST }} - name: Confirm token was returned shell: bash From 3bea03c1876c02f1a10ec531420b203d4dfd322f Mon Sep 17 00:00:00 2001 From: Rami Bououni Date: Thu, 23 Jul 2026 10:44:42 -0700 Subject: [PATCH 19/20] devops auth permission validations --- .github/workflows/quest.yml | 104 ++++++++++++++++++++++++++++++++++-- 1 file changed, 100 insertions(+), 4 deletions(-) diff --git a/.github/workflows/quest.yml b/.github/workflows/quest.yml index 3f709cd8..32b0e3dd 100644 --- a/.github/workflows/quest.yml +++ b/.github/workflows/quest.yml @@ -13,6 +13,10 @@ on: description: "Run only the Azure DevOps OIDC auth probe" required: false default: "false" + test-work-item-id: + description: "Azure DevOps work item ID used by the auth-only probe" + required: false + default: "562440" jobs: import: @@ -107,17 +111,109 @@ jobs: fi echo "Access token was returned." - - name: Probe Azure DevOps projects API + - name: Read Content work item + id: read-work-item + shell: bash + env: + AZDO_TOKEN: ${{ steps.quest-auth.outputs.access-token }} + TEST_WORK_ITEM_ID: ${{ github.event.inputs.test-work-item-id || '562440' }} + run: | + set -euo pipefail + + work_item_url="https://dev.azure.com/msft-skilling/Content/_apis/wit/workitems/$TEST_WORK_ITEM_ID?api-version=7.1" + + status_code=$(curl -sS -w '%{http_code}' -D headers.txt \ + -H "Authorization: Bearer $AZDO_TOKEN" \ + -H "Content-Type: application/json" \ + "$work_item_url" \ + -o body.json) + + echo "HTTP status: $status_code" + + echo "HTTP response headers:" + cat headers.txt + + echo "" + echo "Response body:" + cat body.json + + if [[ ! "$status_code" =~ ^2 ]]; then + echo "Azure DevOps work item read probe failed." + exit 1 + fi + + identity_query=$(jq -r ' + .fields["System.AssignedTo"], + .fields["System.ChangedBy"], + .fields["System.CreatedBy"] + | select(. != null) + | if type == "object" then (.uniqueName // .displayName // .id // empty) else . end + ' body.json | head -n 1) + + if [ -z "$identity_query" ]; then + echo "Unable to derive an Azure DevOps identity from work item $TEST_WORK_ITEM_ID." + exit 1 + fi + + echo "identity-query=$identity_query" >> "$GITHUB_OUTPUT" + + - name: Probe Azure DevOps identities API shell: bash env: AZDO_TOKEN: ${{ steps.quest-auth.outputs.access-token }} + IDENTITY_QUERY: ${{ steps.read-work-item.outputs.identity-query }} run: | set -euo pipefail status_code=$(curl -sS -w '%{http_code}' -D headers.txt \ -H "Authorization: Bearer $AZDO_TOKEN" \ -H "Content-Type: application/json" \ - "https://dev.azure.com/msft-skilling/_apis/projects?api-version=7.1" \ + --get \ + --data-urlencode "searchFilter=General" \ + --data-urlencode "filterValue=$IDENTITY_QUERY" \ + --data-urlencode "queryMembership=None" \ + --data-urlencode "api-version=7.1-preview.1" \ + "https://vssps.dev.azure.com/msft-skilling/_apis/identities" \ + -o body.json) + + echo "HTTP status: $status_code" + + echo "HTTP response headers:" + cat headers.txt + + echo "" + echo "Response body:" + cat body.json + + if [[ ! "$status_code" =~ ^2 ]]; then + echo "Azure DevOps identity read probe failed." + exit 1 + fi + + - name: Add work item history comment + shell: bash + env: + AZDO_TOKEN: ${{ steps.quest-auth.outputs.access-token }} + TEST_WORK_ITEM_ID: ${{ github.event.inputs.test-work-item-id || '562440' }} + run: | + set -euo pipefail + + cat > patch.json <<'EOF' + [ + { + "op": "add", + "path": "/fields/System.History", + "value": "going-secretless auth validation test" + } + ] + EOF + + status_code=$(curl -sS -w '%{http_code}' -D headers.txt \ + -X PATCH \ + -H "Authorization: Bearer $AZDO_TOKEN" \ + -H "Content-Type: application/json-patch+json" \ + --data @patch.json \ + "https://dev.azure.com/msft-skilling/Content/_apis/wit/workitems/$TEST_WORK_ITEM_ID?api-version=7.1&suppressNotifications=true" \ -o body.json) echo "HTTP status: $status_code" @@ -130,7 +226,7 @@ jobs: cat body.json if [[ ! "$status_code" =~ ^2 ]]; then - echo "Azure DevOps API probe failed." + echo "Azure DevOps work item write probe failed." exit 1 fi - + From 6fda1d7a2761ef96fd0b2b560be38789d5bf2308 Mon Sep 17 00:00:00 2001 From: Rami Bououni Date: Thu, 23 Jul 2026 15:40:08 -0700 Subject: [PATCH 20/20] remove suppressNotifications flag --- .github/workflows/quest.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/quest.yml b/.github/workflows/quest.yml index 32b0e3dd..158d296d 100644 --- a/.github/workflows/quest.yml +++ b/.github/workflows/quest.yml @@ -213,7 +213,7 @@ jobs: -H "Authorization: Bearer $AZDO_TOKEN" \ -H "Content-Type: application/json-patch+json" \ --data @patch.json \ - "https://dev.azure.com/msft-skilling/Content/_apis/wit/workitems/$TEST_WORK_ITEM_ID?api-version=7.1&suppressNotifications=true" \ + "https://dev.azure.com/msft-skilling/Content/_apis/wit/workitems/$TEST_WORK_ITEM_ID?api-version=7.1" \ -o body.json) echo "HTTP status: $status_code"