auth/src/base.ts
Seth Vargo fe9207673e
Add support for Direct Workload Identity auth (#348)
This adds a new authentication mode, Direct Workload Identity
Federation. This new mode permits authenticating to Google Cloud
directly using the GitHub Actions OIDC token instead of proxying through
a Google Cloud Service Account.
2023-11-28 10:41:10 -05:00

222 lines
6.8 KiB
TypeScript

// Copyright 2023 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { URLSearchParams } from 'url';
import { HttpClient } from '@actions/http-client';
import { Logger } from './logger';
import { expandEndpoint, userAgent } from './utils';
/**
* GenerateAccessTokenParameters are the inputs to the generateAccessToken call.
*/
export interface GenerateAccessTokenParameters {
readonly serviceAccount: string;
readonly delegates?: string[];
readonly scopes?: string[];
readonly lifetime?: number;
}
/**
* GenerateIDTokenParameters are the inputs to the generateIDToken call.
*/
export interface GenerateIDTokenParameters {
readonly serviceAccount: string;
readonly audience: string;
readonly delegates?: string[];
readonly includeEmail?: boolean;
}
/**
* IAMCredentialsClientParameters are the inputs to the IAM client.
*/
export interface IAMCredentialsClientParameters {
readonly authToken: string;
}
/**
* IAMCredentialsClient is a thin HTTP client around the Google Cloud IAM
* Credentials API.
*/
export class IAMCredentialsClient {
readonly #logger: Logger;
readonly #httpClient: HttpClient;
readonly #authToken: string;
readonly #universe: string = 'googleapis.com';
readonly #endpoints = {
iamcredentials: 'https://iamcredentials.{universe}/v1',
oauth2: 'https://oauth2.{universe}',
};
constructor(logger: Logger, opts: IAMCredentialsClientParameters) {
this.#logger = logger.withNamespace(this.constructor.name);
this.#httpClient = new HttpClient(userAgent);
this.#authToken = opts.authToken;
const endpoints = this.#endpoints;
for (const key of Object.keys(this.#endpoints) as Array<keyof typeof endpoints>) {
this.#endpoints[key] = expandEndpoint(this.#endpoints[key], this.#universe);
}
this.#logger.debug(`Computed endpoints`, this.#endpoints);
}
/**
* generateAccessToken generates a new OAuth 2.0 Access Token for a service
* account.
*/
async generateAccessToken({
serviceAccount,
delegates,
scopes,
lifetime,
}: GenerateAccessTokenParameters): Promise<string> {
const pth = `${this.#endpoints.iamcredentials}/projects/-/serviceAccounts/${serviceAccount}:generateAccessToken`;
const headers = { Authorization: `Bearer ${this.#authToken}` };
const body: Record<string, string | Array<string>> = {};
if (delegates && delegates.length > 0) {
body.delegates = delegates;
}
if (scopes && scopes.length > 0) {
// Not a typo, the API expects the field to be "scope" (singular).
body.scope = scopes;
}
if (lifetime && lifetime > 0) {
body.lifetime = `${lifetime}s`;
}
this.#logger.withNamespace('generateAccessToken').debug({
method: `POST`,
path: pth,
headers: headers,
body: body,
});
try {
const resp = await this.#httpClient.postJson<{ accessToken: string }>(pth, body, headers);
const statusCode = resp.statusCode || 500;
if (statusCode < 200 || statusCode > 299) {
throw new Error(`Failed to call ${pth}: HTTP ${statusCode}: ${resp.result || '[no body]'}`);
}
const result = resp.result;
if (!result) {
throw new Error(`Successfully called ${pth}, but the result was empty`);
}
return result.accessToken;
} catch (err) {
throw new Error(
`Failed to generate Google Cloud OAuth 2.0 Access Token for ${serviceAccount}: ${err}`,
);
}
}
async generateDomainWideDelegationAccessToken(assertion: string): Promise<string> {
const pth = `${this.#endpoints.oauth2}/token`;
const headers = {
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
};
const body = new URLSearchParams();
body.append('grant_type', 'urn:ietf:params:oauth:grant-type:jwt-bearer');
body.append('assertion', assertion);
this.#logger.withNamespace('generateDomainWideDelegationAccessToken').debug({
method: `POST`,
path: pth,
headers: headers,
body: body,
});
try {
const resp = await this.#httpClient.post(pth, body.toString(), headers);
const respBody = await resp.readBody();
const statusCode = resp.message.statusCode || 500;
if (statusCode < 200 || statusCode > 299) {
throw new Error(`Failed to call ${pth}: HTTP ${statusCode}: ${respBody || '[no body]'}`);
}
const parsed = JSON.parse(respBody) as { accessToken: string };
return parsed.accessToken;
} catch (err) {
throw new Error(
`Failed to generate Google Cloud Domain Wide Delegation OAuth 2.0 Access Token: ${err}`,
);
}
}
/**
* generateIDToken generates a new OpenID Connect ID token for a service
* account.
*/
async generateIDToken({
serviceAccount,
audience,
delegates,
includeEmail,
}: GenerateIDTokenParameters): Promise<string> {
const pth = `${this.#endpoints.iamcredentials}/projects/-/serviceAccounts/${serviceAccount}:generateIdToken`;
const headers = { Authorization: `Bearer ${this.#authToken}` };
const body: Record<string, string | string[] | boolean> = {
audience: audience,
includeEmail: includeEmail ? true : false,
};
if (delegates && delegates.length > 0) {
body.delegates = delegates;
}
this.#logger.withNamespace('generateIDToken').debug({
method: `POST`,
path: pth,
headers: headers,
body: body,
});
try {
const resp = await this.#httpClient.postJson<{ token: string }>(pth, body, headers);
const statusCode = resp.statusCode || 500;
if (statusCode < 200 || statusCode > 299) {
throw new Error(`Failed to call ${pth}: HTTP ${statusCode}: ${resp.result || '[no body]'}`);
}
const result = resp.result;
if (!result) {
throw new Error(`Successfully called ${pth}, but the result was empty`);
}
return result.token;
} catch (err) {
throw new Error(
`Failed to generate Google Cloud OpenID Connect ID token for ${serviceAccount}: ${err}`,
);
}
}
}
export { AuthClient } from './client/auth_client';
export {
ServiceAccountKeyClientParameters,
ServiceAccountKeyClient,
} from './client/credentials_json_client';
export {
WorkloadIdentityFederationClientParameters,
WorkloadIdentityFederationClient,
} from './client/workload_identity_client';