Skip to content

Commit 68c8bbc

Browse files
Add GitHubActionsCredential for GitHub Actions OIDC federation
Implements a first-class GitHubActionsCredential in @azure/identity that enables authentication to Microsoft Entra ID using GitHub Actions OIDC federated identity credentials. - Parameterless constructor reads AZURE_TENANT_ID, AZURE_CLIENT_ID, ACTIONS_ID_TOKEN_REQUEST_URL, and ACTIONS_ID_TOKEN_REQUEST_TOKEN from env - Delegates to ClientAssertionCredential with OIDC token as assertion - Derives audience from authorityHost for sovereign cloud support - Fails eagerly with CredentialUnavailableError when env vars are missing - Includes browser stub, unit tests, and public API tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4dd7bfd commit 68c8bbc

7 files changed

Lines changed: 447 additions & 0 deletions

File tree

sdk/identity/identity/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## 4.14.0-beta.3 (2026-04-08)
44

5+
### Features Added
6+
7+
- 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`.
8+
59
### Other Changes
610

711
- Reduced bundle size by optimizing imports from `@azure/msal-node`, e.g. achieving a ~61kb reduction (from 851kb to 790kb) when importing `ClientCertificateCredential`. [#36942](https://github.com/Azure/azure-sdk-for-js/pull/36942)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
import type { AccessToken, GetTokenOptions, TokenCredential } from "@azure/core-auth";
5+
import type { GitHubActionsCredentialOptions } from "./gitHubActionsCredentialOptions.js";
6+
import { credentialLogger, formatError } from "../util/logging.js";
7+
8+
const BrowserNotSupportedError = new Error(
9+
"GitHubActionsCredential is not supported in the browser.",
10+
);
11+
const logger = credentialLogger("GitHubActionsCredential");
12+
13+
/**
14+
* Enables authentication to Microsoft Entra ID using GitHub Actions
15+
* OIDC federated identity credentials.
16+
*/
17+
export class GitHubActionsCredential implements TokenCredential {
18+
/**
19+
* Only available in Node.js
20+
*/
21+
constructor(_options?: GitHubActionsCredentialOptions) {
22+
logger.info(formatError("", BrowserNotSupportedError));
23+
throw BrowserNotSupportedError;
24+
}
25+
26+
public getToken(
27+
_scopes: string | string[],
28+
_options?: GetTokenOptions,
29+
): Promise<AccessToken | null> {
30+
logger.getToken.info(formatError("", BrowserNotSupportedError));
31+
throw BrowserNotSupportedError;
32+
}
33+
}
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
import type { AccessToken, GetTokenOptions, TokenCredential } from "@azure/core-auth";
5+
import { AuthenticationError, CredentialUnavailableError } from "../errors.js";
6+
import { createHttpHeaders, createPipelineRequest } from "@azure/core-rest-pipeline";
7+
8+
import type { GitHubActionsCredentialOptions } from "./gitHubActionsCredentialOptions.js";
9+
import { ClientAssertionCredential } from "./clientAssertionCredential.js";
10+
import { IdentityClient } from "../client/identityClient.js";
11+
import type { PipelineResponse } from "@azure/core-rest-pipeline";
12+
import { checkTenantId } from "../util/tenantIdUtils.js";
13+
import { credentialLogger } from "../util/logging.js";
14+
import { AzureAuthorityHosts } from "../constants.js";
15+
16+
const credentialName = "GitHubActionsCredential";
17+
const logger = credentialLogger(credentialName);
18+
19+
/**
20+
* Derives the OIDC audience from the authority host for sovereign cloud support.
21+
* @internal
22+
*/
23+
export function deriveAudience(authorityHost: string): string {
24+
let hostname: string;
25+
try {
26+
hostname = new URL(authorityHost).hostname.toLowerCase();
27+
} catch {
28+
// If it's not a valid URL, fall back to public cloud default
29+
return "api://AzureADTokenExchange";
30+
}
31+
32+
switch (hostname) {
33+
case "login.microsoftonline.us":
34+
return "api://AzureADTokenExchangeUSGov";
35+
case "login.chinacloudapi.cn":
36+
return "api://AzureADTokenExchangeChina";
37+
case "login.microsoftonline.com":
38+
default:
39+
return "api://AzureADTokenExchange";
40+
}
41+
}
42+
43+
/**
44+
* Enables authentication to Microsoft Entra ID using GitHub Actions
45+
* OIDC federated identity credentials.
46+
*
47+
* This credential is designed for use in GitHub Actions workflows that have
48+
* `permissions: id-token: write` configured. It reads the following environment
49+
* variables:
50+
* - `AZURE_TENANT_ID` — The Entra ID tenant.
51+
* - `AZURE_CLIENT_ID` — The client ID of the app registration with a federated identity credential.
52+
* - `ACTIONS_ID_TOKEN_REQUEST_URL` — Set automatically by GitHub Actions runner.
53+
* - `ACTIONS_ID_TOKEN_REQUEST_TOKEN` — Set automatically by GitHub Actions runner.
54+
*/
55+
export class GitHubActionsCredential implements TokenCredential {
56+
private clientAssertionCredential: ClientAssertionCredential | undefined;
57+
private identityClient: IdentityClient;
58+
59+
/**
60+
* Creates a new instance of GitHubActionsCredential.
61+
*
62+
* Reads `AZURE_TENANT_ID` and `AZURE_CLIENT_ID` from environment variables,
63+
* as well as GitHub Actions OIDC variables `ACTIONS_ID_TOKEN_REQUEST_URL` and
64+
* `ACTIONS_ID_TOKEN_REQUEST_TOKEN`.
65+
*
66+
* @param options - Options to configure the credential.
67+
*/
68+
constructor(options: GitHubActionsCredentialOptions = {}) {
69+
const tenantId = process.env.AZURE_TENANT_ID;
70+
const clientId = process.env.AZURE_CLIENT_ID;
71+
72+
if (!tenantId) {
73+
throw new CredentialUnavailableError(
74+
`${credentialName}: is unavailable. Set the AZURE_TENANT_ID environment variable to use this credential.`,
75+
);
76+
}
77+
if (!clientId) {
78+
throw new CredentialUnavailableError(
79+
`${credentialName}: is unavailable. Set the AZURE_CLIENT_ID environment variable to use this credential.`,
80+
);
81+
}
82+
83+
this.identityClient = new IdentityClient(options);
84+
checkTenantId(logger, tenantId);
85+
86+
const oidcRequestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
87+
const oidcRequestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
88+
89+
if (!oidcRequestUrl || !oidcRequestToken) {
90+
const missing = [
91+
!oidcRequestUrl ? "ACTIONS_ID_TOKEN_REQUEST_URL" : "",
92+
!oidcRequestToken ? "ACTIONS_ID_TOKEN_REQUEST_TOKEN" : "",
93+
]
94+
.filter(Boolean)
95+
.join(", ");
96+
throw new CredentialUnavailableError(
97+
`${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`,
98+
);
99+
}
100+
101+
const authorityHost = options.authorityHost ?? AzureAuthorityHosts.AzurePublicCloud;
102+
const audience = deriveAudience(authorityHost);
103+
104+
logger.info(
105+
`Invoking GitHubActionsCredential with tenant ID: ${tenantId}, client ID: ${clientId}, audience: ${audience}`,
106+
);
107+
108+
this.clientAssertionCredential = new ClientAssertionCredential(
109+
tenantId,
110+
clientId,
111+
this.requestOidcToken.bind(this, oidcRequestUrl, oidcRequestToken, audience),
112+
options,
113+
);
114+
}
115+
116+
/**
117+
* Authenticates with Microsoft Entra ID and returns an access token if successful.
118+
* If authentication fails, a {@link CredentialUnavailableError} or
119+
* {@link AuthenticationError} will be thrown with the details of the failure.
120+
*
121+
* @param scopes - The list of scopes for which the token will have access.
122+
* @param options - The options used to configure any requests this
123+
* TokenCredential implementation might make.
124+
*/
125+
public async getToken(
126+
scopes: string | string[],
127+
options?: GetTokenOptions,
128+
): Promise<AccessToken> {
129+
if (!this.clientAssertionCredential) {
130+
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`;
131+
logger.error(errorMessage);
132+
throw new CredentialUnavailableError(errorMessage);
133+
}
134+
logger.info("Invoking getToken() of Client Assertion Credential");
135+
return this.clientAssertionCredential.getToken(scopes, options);
136+
}
137+
138+
/**
139+
* Requests an OIDC token from the GitHub Actions OIDC provider.
140+
* @internal
141+
*/
142+
private async requestOidcToken(
143+
oidcRequestUrl: string,
144+
oidcRequestToken: string,
145+
audience: string,
146+
): Promise<string> {
147+
logger.info("Requesting OIDC token from GitHub Actions...");
148+
149+
// GitHub OIDC endpoint uses GET (not POST like Azure Pipelines).
150+
// Audience is appended as query param; omit if empty.
151+
let url = oidcRequestUrl;
152+
if (audience) {
153+
url = `${oidcRequestUrl}&audience=${encodeURIComponent(audience)}`;
154+
}
155+
156+
const request = createPipelineRequest({
157+
url,
158+
method: "GET",
159+
headers: createHttpHeaders({
160+
Authorization: `Bearer ${oidcRequestToken}`,
161+
}),
162+
});
163+
164+
const response = await this.identityClient.sendRequest(request);
165+
return handleOidcResponse(response);
166+
}
167+
}
168+
169+
/**
170+
* Parses the OIDC token response from GitHub's OIDC provider.
171+
* @internal
172+
*/
173+
export function handleOidcResponse(response: PipelineResponse): string {
174+
const text = response.bodyAsText;
175+
if (!text) {
176+
logger.error(
177+
`${credentialName}: Authentication Failed. Received null token from OIDC request. Status code: ${response.status}.`,
178+
);
179+
throw new AuthenticationError(response.status, {
180+
error: `${credentialName}: Authentication Failed. Received null token from OIDC request.`,
181+
error_description: `Status code: ${response.status}. See the troubleshooting guide for more information: https://aka.ms/azsdk/js/identity/githubactionscredential/troubleshoot`,
182+
});
183+
}
184+
185+
try {
186+
const result = JSON.parse(text);
187+
if (result?.value) {
188+
return result.value;
189+
} else {
190+
const errorMessage = `${credentialName}: Authentication Failed. "value" field not detected in the response.`;
191+
let errorDescription = "";
192+
if (response.status !== 200) {
193+
errorDescription = `Response body = ${text}. Status code: ${response.status}. See the troubleshooting guide for more information: https://aka.ms/azsdk/js/identity/githubactionscredential/troubleshoot`;
194+
}
195+
logger.error(errorMessage);
196+
logger.error(errorDescription);
197+
throw new AuthenticationError(response.status, {
198+
error: errorMessage,
199+
error_description: errorDescription,
200+
});
201+
}
202+
} catch (e: any) {
203+
if (e instanceof AuthenticationError) throw e;
204+
const errorDetails = `${credentialName}: Authentication Failed. Failed to parse OIDC response. Response = ${text}. Error: ${e.message}`;
205+
logger.error(errorDetails);
206+
throw new AuthenticationError(response.status, {
207+
error: errorDetails,
208+
error_description: `See the troubleshooting guide for more information: https://aka.ms/azsdk/js/identity/githubactionscredential/troubleshoot`,
209+
});
210+
}
211+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
import type { AuthorityValidationOptions } from "./authorityValidationOptions.js";
5+
import type { CredentialPersistenceOptions } from "./credentialPersistenceOptions.js";
6+
import type { MultiTenantTokenCredentialOptions } from "./multiTenantTokenCredentialOptions.js";
7+
8+
/**
9+
* Optional parameters for the {@link GitHubActionsCredential} class.
10+
*/
11+
export interface GitHubActionsCredentialOptions
12+
extends
13+
MultiTenantTokenCredentialOptions,
14+
CredentialPersistenceOptions,
15+
AuthorityValidationOptions {}

sdk/identity/identity/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ export type {
8686
export type { DeviceCodeCredentialOptions } from "./credentials/deviceCodeCredentialOptions.js";
8787
export { AzurePipelinesCredential as AzurePipelinesCredential } from "./credentials/azurePipelinesCredential.js";
8888
export type { AzurePipelinesCredentialOptions as AzurePipelinesCredentialOptions } from "./credentials/azurePipelinesCredentialOptions.js";
89+
export { GitHubActionsCredential } from "./credentials/gitHubActionsCredential.js";
90+
export type { GitHubActionsCredentialOptions } from "./credentials/gitHubActionsCredentialOptions.js";
8991
export { AuthorizationCodeCredential } from "./credentials/authorizationCodeCredential.js";
9092
export type { AuthorizationCodeCredentialOptions } from "./credentials/authorizationCodeCredentialOptions.js";
9193
export { AzurePowerShellCredential } from "./credentials/azurePowerShellCredential.js";
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
import type { PipelineResponse } from "@azure/core-rest-pipeline";
5+
import { createHttpHeaders, createPipelineRequest } from "@azure/core-rest-pipeline";
6+
import {
7+
handleOidcResponse,
8+
deriveAudience,
9+
} from "$internal/credentials/gitHubActionsCredential.js";
10+
import { describe, it, assert } from "vitest";
11+
12+
describe("GitHubActionsCredential (internal)", function () {
13+
describe("handleOidcResponse", function () {
14+
function createResponse(status: number, bodyAsText?: string): PipelineResponse {
15+
return {
16+
request: createPipelineRequest({
17+
url: "https://token.actions.githubusercontent.com/.well-known/openid-configuration",
18+
method: "GET",
19+
headers: createHttpHeaders({
20+
Authorization: "Bearer REDACTED",
21+
}),
22+
}),
23+
status,
24+
headers: createHttpHeaders(),
25+
bodyAsText,
26+
};
27+
}
28+
29+
it("returns the token value on a successful response", function () {
30+
const response = createResponse(200, JSON.stringify({ value: "test-jwt-token" }));
31+
const result = handleOidcResponse(response);
32+
assert.strictEqual(result, "test-jwt-token");
33+
});
34+
35+
it("throws Authentication Error when body is null", function () {
36+
const response = createResponse(400);
37+
assert.throws(
38+
() => handleOidcResponse(response),
39+
/GitHubActionsCredential: Authentication Failed. Received null token from OIDC request/,
40+
);
41+
});
42+
43+
it("throws Authentication Error when 'value' field is missing", function () {
44+
const response = createResponse(400, JSON.stringify({ error: "Bad Request" }));
45+
assert.throws(
46+
() => handleOidcResponse(response),
47+
/GitHubActionsCredential: Authentication Failed. "value" field not detected in the response/,
48+
);
49+
});
50+
51+
it("throws Authentication Error when response is not valid JSON", function () {
52+
const response = createResponse(500, "Internal Server Error");
53+
assert.throws(
54+
() => handleOidcResponse(response),
55+
/GitHubActionsCredential: Authentication Failed. Failed to parse OIDC response/,
56+
);
57+
});
58+
59+
it("includes status code in error for null body", function () {
60+
const response = createResponse(401);
61+
assert.throws(() => handleOidcResponse(response), /Status code: 401/);
62+
});
63+
});
64+
65+
describe("deriveAudience", function () {
66+
it("returns public cloud audience for login.microsoftonline.com", function () {
67+
assert.strictEqual(
68+
deriveAudience("https://login.microsoftonline.com"),
69+
"api://AzureADTokenExchange",
70+
);
71+
});
72+
73+
it("returns US Gov audience for login.microsoftonline.us", function () {
74+
assert.strictEqual(
75+
deriveAudience("https://login.microsoftonline.us"),
76+
"api://AzureADTokenExchangeUSGov",
77+
);
78+
});
79+
80+
it("returns China audience for login.chinacloudapi.cn", function () {
81+
assert.strictEqual(
82+
deriveAudience("https://login.chinacloudapi.cn"),
83+
"api://AzureADTokenExchangeChina",
84+
);
85+
});
86+
87+
it("returns public cloud audience for unknown hosts", function () {
88+
assert.strictEqual(
89+
deriveAudience("https://custom.authority.example.com"),
90+
"api://AzureADTokenExchange",
91+
);
92+
});
93+
94+
it("returns public cloud audience for invalid URLs", function () {
95+
assert.strictEqual(deriveAudience("not-a-url"), "api://AzureADTokenExchange");
96+
});
97+
});
98+
});

0 commit comments

Comments
 (0)