Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 11 additions & 2 deletions .github/workflows/quest-bulk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,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:
Comment on lines 51 to 54
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
160 changes: 158 additions & 2 deletions .github/workflows/quest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,19 @@ 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"
test-work-item-id:
description: "Azure DevOps work item ID used by the auth-only probe"
required: false
default: "562440"

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') ||
Expand Down Expand Up @@ -73,4 +81,152 @@ 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_TEST }}
tenant-id: ${{ secrets.TENANT_ID_TEST }}
oidc-audience: api://AzureADTokenExchange
audience: ${{ secrets.QUEST_AUDIENCE_TEST }}

- 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: 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" \
--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" \
-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 write probe failed."
exit 1
fi

60 changes: 60 additions & 0 deletions .github/workflows/test-quest-oidc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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: |
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
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 on lines +159 to 164
}
Expand Down
Loading
Loading