-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Add GitHubActionsCredential for GitHub Actions OIDC federation #38581
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
| } | ||
| } |
| 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.`, | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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`, | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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"); | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Suggested change
|
||||||
| 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 {} |
| 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"); | ||
| }); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
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