|
| 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 | +} |
0 commit comments