Skip to content
Open
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
2 changes: 2 additions & 0 deletions sdk/identity/identity/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

### Features Added

- Added `GitHubActionsCredential` for authenticating to Microsoft Entra ID using GitHub Actions OIDC federated identity credentials. This credential reads `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `ACTIONS_ID_TOKEN_REQUEST_URL`, and `ACTIONS_ID_TOKEN_REQUEST_TOKEN` from the environment and exchanges the GitHub OIDC token for an Azure access token. Sovereign cloud audience is derived automatically from `authorityHost`.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update the Changelog with the PR number following the format of the SDK


### Breaking Changes

### Bugs Fixed
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import type { AccessToken, GetTokenOptions, TokenCredential } from "@azure/core-auth";
import type { GitHubActionsCredentialOptions } from "./gitHubActionsCredentialOptions.js";
import { credentialLogger, formatError } from "../util/logging.js";

const BrowserNotSupportedError = new Error(
"GitHubActionsCredential is not supported in the browser.",
);
const logger = credentialLogger("GitHubActionsCredential");

/**
* Enables authentication to Microsoft Entra ID using GitHub Actions
* OIDC federated identity credentials.
*/
export class GitHubActionsCredential implements TokenCredential {
/**
* Only available in Node.js
*/
constructor(_options?: GitHubActionsCredentialOptions) {
logger.info(formatError("", BrowserNotSupportedError));
throw BrowserNotSupportedError;
}

public getToken(
_scopes: string | string[],
_options?: GetTokenOptions,
): Promise<AccessToken | null> {
logger.getToken.info(formatError("", BrowserNotSupportedError));
throw BrowserNotSupportedError;
}
}
211 changes: 211 additions & 0 deletions sdk/identity/identity/src/credentials/gitHubActionsCredential.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import type { AccessToken, GetTokenOptions, TokenCredential } from "@azure/core-auth";
import { AuthenticationError, CredentialUnavailableError } from "../errors.js";
import { createHttpHeaders, createPipelineRequest } from "@azure/core-rest-pipeline";

import type { GitHubActionsCredentialOptions } from "./gitHubActionsCredentialOptions.js";
import { ClientAssertionCredential } from "./clientAssertionCredential.js";
import { IdentityClient } from "../client/identityClient.js";
import type { PipelineResponse } from "@azure/core-rest-pipeline";
import { checkTenantId } from "../util/tenantIdUtils.js";
import { credentialLogger } from "../util/logging.js";
import { AzureAuthorityHosts } from "../constants.js";

const credentialName = "GitHubActionsCredential";
const logger = credentialLogger(credentialName);

/**
* Derives the OIDC audience from the authority host for sovereign cloud support.
* @internal
*/
export function deriveAudience(authorityHost: string): string {
let hostname: string;
try {
hostname = new URL(authorityHost).hostname.toLowerCase();
} catch {
// If it's not a valid URL, fall back to public cloud default
return "api://AzureADTokenExchange";
}

switch (hostname) {
case "login.microsoftonline.us":
return "api://AzureADTokenExchangeUSGov";
case "login.chinacloudapi.cn":
return "api://AzureADTokenExchangeChina";
case "login.microsoftonline.com":
default:
return "api://AzureADTokenExchange";
}
}

/**
* Enables authentication to Microsoft Entra ID using GitHub Actions
* OIDC federated identity credentials.
*
* This credential is designed for use in GitHub Actions workflows that have
* `permissions: id-token: write` configured. It reads the following environment
* variables:
* - `AZURE_TENANT_ID` — The Entra ID tenant.
* - `AZURE_CLIENT_ID` — The client ID of the app registration with a federated identity credential.
* - `ACTIONS_ID_TOKEN_REQUEST_URL` — Set automatically by GitHub Actions runner.
* - `ACTIONS_ID_TOKEN_REQUEST_TOKEN` — Set automatically by GitHub Actions runner.
*/
export class GitHubActionsCredential implements TokenCredential {
private clientAssertionCredential: ClientAssertionCredential | undefined;
private identityClient: IdentityClient;

/**
* Creates a new instance of GitHubActionsCredential.
*
* Reads `AZURE_TENANT_ID` and `AZURE_CLIENT_ID` from environment variables,
* as well as GitHub Actions OIDC variables `ACTIONS_ID_TOKEN_REQUEST_URL` and
* `ACTIONS_ID_TOKEN_REQUEST_TOKEN`.
*
* @param options - Options to configure the credential.
*/
constructor(options: GitHubActionsCredentialOptions = {}) {
const tenantId = process.env.AZURE_TENANT_ID;
const clientId = process.env.AZURE_CLIENT_ID;

if (!tenantId) {
throw new CredentialUnavailableError(
`${credentialName}: is unavailable. Set the AZURE_TENANT_ID environment variable to use this credential.`,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Let's get all of these error message into a variable so it's easier to manage similar to this https://github.com/Azure/azure-sdk-for-js/blob/main/sdk/identity/identity/src/credentials/workloadIdentityCredential.ts#L37

);
}
if (!clientId) {
throw new CredentialUnavailableError(
`${credentialName}: is unavailable. Set the AZURE_CLIENT_ID environment variable to use this credential.`,
);
}

this.identityClient = new IdentityClient(options);
checkTenantId(logger, tenantId);

const oidcRequestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
const oidcRequestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;

if (!oidcRequestUrl || !oidcRequestToken) {
const missing = [
!oidcRequestUrl ? "ACTIONS_ID_TOKEN_REQUEST_URL" : "",
!oidcRequestToken ? "ACTIONS_ID_TOKEN_REQUEST_TOKEN" : "",
]
.filter(Boolean)
.join(", ");
throw new CredentialUnavailableError(
`${credentialName}: is unavailable. Ensure that you're running this task in a GitHub Actions workflow with 'permissions: id-token: write' so that the following missing system variable(s) can be defined: ${missing}. See the troubleshooting guide for more information: https://aka.ms/azsdk/js/identity/githubactionscredential/troubleshoot`,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make sure this link is valid.

);
}

const authorityHost = options.authorityHost ?? AzureAuthorityHosts.AzurePublicCloud;
const audience = deriveAudience(authorityHost);

logger.info(
`Invoking GitHubActionsCredential with tenant ID: ${tenantId}, client ID: ${clientId}, audience: ${audience}`,
);

this.clientAssertionCredential = new ClientAssertionCredential(
tenantId,
clientId,
this.requestOidcToken.bind(this, oidcRequestUrl, oidcRequestToken, audience),
options,
);
}

/**
* Authenticates with Microsoft Entra ID and returns an access token if successful.
* If authentication fails, a {@link CredentialUnavailableError} or
* {@link AuthenticationError} will be thrown with the details of the failure.
*
* @param scopes - The list of scopes for which the token will have access.
* @param options - The options used to configure any requests this
* TokenCredential implementation might make.
*/
public async getToken(
scopes: string | string[],
options?: GetTokenOptions,
): Promise<AccessToken> {
if (!this.clientAssertionCredential) {
const errorMessage = `${credentialName}: is unavailable. To use GitHub Actions OIDC federation, the following are required: AZURE_TENANT_ID, AZURE_CLIENT_ID, ACTIONS_ID_TOKEN_REQUEST_URL, ACTIONS_ID_TOKEN_REQUEST_TOKEN. See the troubleshooting guide for more information: https://aka.ms/azsdk/js/identity/githubactionscredential/troubleshoot`;
logger.error(errorMessage);
throw new CredentialUnavailableError(errorMessage);
}
logger.info("Invoking getToken() of Client Assertion Credential");
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an old pattern that I think we should change for both azurePipelineCredential and workloadIdentityCredential. No strong preference though.

Suggested change
logger.info("Invoking getToken() of Client Assertion Credential");
logger.getToken.info(`Using the scope ${scope}`);

return this.clientAssertionCredential.getToken(scopes, options);
}

/**
* Requests an OIDC token from the GitHub Actions OIDC provider.
* @internal
*/
private async requestOidcToken(
oidcRequestUrl: string,
oidcRequestToken: string,
audience: string,
): Promise<string> {
logger.info("Requesting OIDC token from GitHub Actions...");

// GitHub OIDC endpoint uses GET (not POST like Azure Pipelines).
// Audience is appended as query param; omit if empty.
let url = oidcRequestUrl;
if (audience) {
url = `${oidcRequestUrl}&audience=${encodeURIComponent(audience)}`;
}

const request = createPipelineRequest({
url,
method: "GET",
headers: createHttpHeaders({
Authorization: `Bearer ${oidcRequestToken}`,
}),
});

const response = await this.identityClient.sendRequest(request);
return handleOidcResponse(response);
}
}

/**
* Parses the OIDC token response from GitHub's OIDC provider.
* @internal
*/
export function handleOidcResponse(response: PipelineResponse): string {
const text = response.bodyAsText;
if (!text) {
logger.error(
`${credentialName}: Authentication Failed. Received null token from OIDC request. Status code: ${response.status}.`,
);
throw new AuthenticationError(response.status, {
error: `${credentialName}: Authentication Failed. Received null token from OIDC request.`,
error_description: `Status code: ${response.status}. See the troubleshooting guide for more information: https://aka.ms/azsdk/js/identity/githubactionscredential/troubleshoot`,
});
}

try {
const result = JSON.parse(text);
if (result?.value) {
return result.value;
} else {
const errorMessage = `${credentialName}: Authentication Failed. "value" field not detected in the response.`;
let errorDescription = "";
if (response.status !== 200) {
errorDescription = `Response body = ${text}. Status code: ${response.status}. See the troubleshooting guide for more information: https://aka.ms/azsdk/js/identity/githubactionscredential/troubleshoot`;
}
logger.error(errorMessage);
logger.error(errorDescription);
throw new AuthenticationError(response.status, {
error: errorMessage,
error_description: errorDescription,
});
}
} catch (e: any) {
if (e instanceof AuthenticationError) throw e;
const errorDetails = `${credentialName}: Authentication Failed. Failed to parse OIDC response. Response = ${text}. Error: ${e.message}`;
logger.error(errorDetails);
throw new AuthenticationError(response.status, {
error: errorDetails,
error_description: `See the troubleshooting guide for more information: https://aka.ms/azsdk/js/identity/githubactionscredential/troubleshoot`,
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import type { AuthorityValidationOptions } from "./authorityValidationOptions.js";
import type { CredentialPersistenceOptions } from "./credentialPersistenceOptions.js";
import type { MultiTenantTokenCredentialOptions } from "./multiTenantTokenCredentialOptions.js";

/**
* Optional parameters for the {@link GitHubActionsCredential} class.
*/
export interface GitHubActionsCredentialOptions
extends
MultiTenantTokenCredentialOptions,
CredentialPersistenceOptions,
AuthorityValidationOptions {}
2 changes: 2 additions & 0 deletions sdk/identity/identity/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ export type { DeviceCodeCredentialOptions } from "./credentials/deviceCodeCreden
export { AzurePipelinesCredential as AzurePipelinesCredential } from "#platform/credentials/azurePipelinesCredential";
export type { AzurePipelinesCredentialOptions as AzurePipelinesCredentialOptions } from "./credentials/azurePipelinesCredentialOptions.js";
export { AuthorizationCodeCredential } from "#platform/credentials/authorizationCodeCredential";
export { GitHubActionsCredential } from "./credentials/gitHubActionsCredential.js";
export type { GitHubActionsCredentialOptions } from "./credentials/gitHubActionsCredentialOptions.js";
export type { AuthorizationCodeCredentialOptions } from "./credentials/authorizationCodeCredentialOptions.js";
export { AzurePowerShellCredential } from "#platform/credentials/azurePowerShellCredential";
export type { AzurePowerShellCredentialOptions } from "./credentials/azurePowerShellCredentialOptions.js";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import type { PipelineResponse } from "@azure/core-rest-pipeline";
import { createHttpHeaders, createPipelineRequest } from "@azure/core-rest-pipeline";
import {
handleOidcResponse,
deriveAudience,
} from "$internal/credentials/gitHubActionsCredential.js";
import { describe, it, assert } from "vitest";

describe("GitHubActionsCredential (internal)", function () {
describe("handleOidcResponse", function () {
function createResponse(status: number, bodyAsText?: string): PipelineResponse {
return {
request: createPipelineRequest({
url: "https://token.actions.githubusercontent.com/.well-known/openid-configuration",
method: "GET",
headers: createHttpHeaders({
Authorization: "Bearer REDACTED",
}),
}),
status,
headers: createHttpHeaders(),
bodyAsText,
};
}

it("returns the token value on a successful response", function () {
const response = createResponse(200, JSON.stringify({ value: "test-jwt-token" }));
const result = handleOidcResponse(response);
assert.strictEqual(result, "test-jwt-token");
});

it("throws Authentication Error when body is null", function () {
const response = createResponse(400);
assert.throws(
() => handleOidcResponse(response),
/GitHubActionsCredential: Authentication Failed. Received null token from OIDC request/,
);
});

it("throws Authentication Error when 'value' field is missing", function () {
const response = createResponse(400, JSON.stringify({ error: "Bad Request" }));
assert.throws(
() => handleOidcResponse(response),
/GitHubActionsCredential: Authentication Failed. "value" field not detected in the response/,
);
});

it("throws Authentication Error when response is not valid JSON", function () {
const response = createResponse(500, "Internal Server Error");
assert.throws(
() => handleOidcResponse(response),
/GitHubActionsCredential: Authentication Failed. Failed to parse OIDC response/,
);
});

it("includes status code in error for null body", function () {
const response = createResponse(401);
assert.throws(() => handleOidcResponse(response), /Status code: 401/);
});
});

describe("deriveAudience", function () {
it("returns public cloud audience for login.microsoftonline.com", function () {
assert.strictEqual(
deriveAudience("https://login.microsoftonline.com"),
"api://AzureADTokenExchange",
);
});

it("returns US Gov audience for login.microsoftonline.us", function () {
assert.strictEqual(
deriveAudience("https://login.microsoftonline.us"),
"api://AzureADTokenExchangeUSGov",
);
});

it("returns China audience for login.chinacloudapi.cn", function () {
assert.strictEqual(
deriveAudience("https://login.chinacloudapi.cn"),
"api://AzureADTokenExchangeChina",
);
});

it("returns public cloud audience for unknown hosts", function () {
assert.strictEqual(
deriveAudience("https://custom.authority.example.com"),
"api://AzureADTokenExchange",
);
});

it("returns public cloud audience for invalid URLs", function () {
assert.strictEqual(deriveAudience("not-a-url"), "api://AzureADTokenExchange");
});
});
});
Loading
Loading