Make auth universe-aware (#352)

This adds support for making the action "universe" aware, so it will be
usable for TPC and GDCH.
This commit is contained in:
Seth Vargo 2023-11-28 21:59:39 -05:00 committed by GitHub
parent 097d292c04
commit 7c4e01fd00
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 300 additions and 232 deletions

View File

@ -273,6 +273,18 @@ regardless of the authentication mechanism.
identities to use for impersonation in the chain. By default there are no identities to use for impersonation in the chain. By default there are no
delegates. delegates.
- `universe`: (Optional) The Google Cloud universe to use for constructing API
endpoints. The default universe is "googleapis.com", which corresponds to
https://cloud.google.com. Trusted Partner Cloud and Google Distributed
Hosted Cloud should set this to their universe address.
You can also override individual API endpoints by setting the environment variable `GHA_ENDPOINT_OVERRIDE_<endpoint>` where endpoint is the API endpoint to override. This only applies to the `auth` action and does not persist to other steps. For example:
```yaml
env:
GHA_ENDPOINT_OVERRIDE_oauth2: 'https://oauth2.myapi.endpoint/v1'
```
- `cleanup_credentials`: (Optional) If true, the action will remove any - `cleanup_credentials`: (Optional) If true, the action will remove any
created credentials from the filesystem upon completion. This only applies created credentials from the filesystem upon completion. This only applies
if "create_credentials_file" is true. The default is true. if "create_credentials_file" is true. The default is true.

View File

@ -94,6 +94,14 @@ inputs:
impersonation in the chain. impersonation in the chain.
default: '' default: ''
required: false required: false
universe:
description: |-
The Google Cloud universe to use for constructing API endpoints. The
default universe is "googleapis.com", which corresponds to
https://cloud.google.com. Trusted Partner Cloud and Google Distributed
Hosted Cloud should set this to their universe address.
required: false
default: 'googleapis.com'
cleanup_credentials: cleanup_credentials:
description: |- description: |-
If true, the action will remove any created credentials from the If true, the action will remove any created credentials from the

6
dist/main/index.js vendored

File diff suppressed because one or more lines are too long

View File

@ -1,36 +0,0 @@
// 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.
/**
* Client is the default HTTP client for interacting with the IAM credentials
* API.
*/
export interface AuthClient {
/**
* getToken() gets or generates the best token for the auth client.
*/
getToken(): Promise<string>;
/**
* createCredentialsFile creates a credential file (for use with gcloud and
* other Google Cloud tools) that instructs the tool how to perform identity
* federation.
*/
createCredentialsFile(outputPath: string): Promise<string>;
/**
* signJWT signs a JWT using the auth provider.
*/
signJWT(claims: any): Promise<string>;
}

106
src/client/client.ts Normal file
View File

@ -0,0 +1,106 @@
// 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 { HttpClient } from '@actions/http-client';
import { Logger } from '../logger';
import { userAgent } from '../utils';
/**
* AuthClient is the default HTTP client for interacting with the IAM credentials
* API.
*/
export interface AuthClient {
/**
* getToken() gets or generates the best token for the auth client.
*/
getToken(): Promise<string>;
/**
* createCredentialsFile creates a credential file (for use with gcloud and
* other Google Cloud tools) that instructs the tool how to perform identity
* federation.
*/
createCredentialsFile(outputPath: string): Promise<string>;
/**
* signJWT signs a JWT using the auth provider.
*/
signJWT(claims: any): Promise<string>;
}
export interface ClientParameters {
logger: Logger;
universe: string;
child: string;
}
export class Client {
protected readonly _logger: Logger;
protected readonly _httpClient: HttpClient;
protected readonly _endpoints = {
iam: 'https://iam.{universe}/v1',
iamcredentials: 'https://iamcredentials.{universe}/v1',
oauth2: 'https://oauth2.{universe}',
sts: 'https://sts.{universe}/v1',
www: 'https://www.{universe}',
};
protected readonly _universe;
constructor(opts: ClientParameters) {
this._logger = opts.logger.withNamespace(opts.child);
// Create the http client with our user agent.
this._httpClient = new HttpClient(userAgent, undefined, {
allowRedirects: true,
allowRetries: true,
keepAlive: true,
maxRedirects: 5,
maxRetries: 3,
});
// Expand universe to support TPC and custom endpoints.
this._universe = opts.universe;
for (const key of Object.keys(this._endpoints) as Array<keyof typeof this._endpoints>) {
this._endpoints[key] = this.expandEndpoint(key);
}
this._logger.debug(`Computed endpoints for universe ${this._universe}`, this._endpoints);
}
expandEndpoint(key: keyof typeof this._endpoints): string {
const envOverrideKey = `GHA_ENDPOINT_OVERRIDE_${key}`;
const envOverrideValue = process.env[envOverrideKey];
if (envOverrideValue && envOverrideValue !== '') {
this._logger.debug(
`Overriding API endpoint for ${key} because ${envOverrideKey} is set`,
envOverrideValue,
);
return envOverrideValue.replace(/\/+$/, '');
}
return (this._endpoints[key] || '').replace(/{universe}/g, this._universe).replace(/\/+$/, '');
}
}
export { IAMCredentialsClient, IAMCredentialsClientParameters } from './iamcredentials';
export {
ServiceAccountKeyClient,
ServiceAccountKeyClientParameters,
} from './service_account_key_json';
export {
WorkloadIdentityFederationClient,
WorkloadIdentityFederationClientParameters,
} from './workload_identity_federation';

View File

@ -14,10 +14,10 @@
import { URLSearchParams } from 'url'; import { URLSearchParams } from 'url';
import { HttpClient } from '@actions/http-client'; import { errorMessage } from '@google-github-actions/actions-utils';
import { Logger } from './logger'; import { Client } from './client';
import { expandEndpoint, userAgent } from './utils'; import { Logger } from '../logger';
/** /**
* GenerateAccessTokenParameters are the inputs to the generateAccessToken call. * GenerateAccessTokenParameters are the inputs to the generateAccessToken call.
@ -43,6 +43,9 @@ export interface GenerateIDTokenParameters {
* IAMCredentialsClientParameters are the inputs to the IAM client. * IAMCredentialsClientParameters are the inputs to the IAM client.
*/ */
export interface IAMCredentialsClientParameters { export interface IAMCredentialsClientParameters {
readonly logger: Logger;
readonly universe: string;
readonly authToken: string; readonly authToken: string;
} }
@ -50,28 +53,17 @@ export interface IAMCredentialsClientParameters {
* IAMCredentialsClient is a thin HTTP client around the Google Cloud IAM * IAMCredentialsClient is a thin HTTP client around the Google Cloud IAM
* Credentials API. * Credentials API.
*/ */
export class IAMCredentialsClient { export class IAMCredentialsClient extends Client {
readonly #logger: Logger;
readonly #httpClient: HttpClient;
readonly #authToken: string; readonly #authToken: string;
readonly #universe: string = 'googleapis.com'; constructor(opts: IAMCredentialsClientParameters) {
readonly #endpoints = { super({
iamcredentials: 'https://iamcredentials.{universe}/v1', logger: opts.logger,
oauth2: 'https://oauth2.{universe}', universe: opts.universe,
}; child: `IAMCredentialsClient`,
});
constructor(logger: Logger, opts: IAMCredentialsClientParameters) {
this.#logger = logger.withNamespace(this.constructor.name);
this.#httpClient = new HttpClient(userAgent);
this.#authToken = opts.authToken; 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);
} }
/** /**
@ -84,7 +76,9 @@ export class IAMCredentialsClient {
scopes, scopes,
lifetime, lifetime,
}: GenerateAccessTokenParameters): Promise<string> { }: GenerateAccessTokenParameters): Promise<string> {
const pth = `${this.#endpoints.iamcredentials}/projects/-/serviceAccounts/${serviceAccount}:generateAccessToken`; const logger = this._logger.withNamespace('generateAccessToken');
const pth = `${this._endpoints.iamcredentials}/projects/-/serviceAccounts/${serviceAccount}:generateAccessToken`;
const headers = { Authorization: `Bearer ${this.#authToken}` }; const headers = { Authorization: `Bearer ${this.#authToken}` };
@ -100,7 +94,7 @@ export class IAMCredentialsClient {
body.lifetime = `${lifetime}s`; body.lifetime = `${lifetime}s`;
} }
this.#logger.withNamespace('generateAccessToken').debug({ logger.debug(`Built request`, {
method: `POST`, method: `POST`,
path: pth, path: pth,
headers: headers, headers: headers,
@ -108,7 +102,7 @@ export class IAMCredentialsClient {
}); });
try { try {
const resp = await this.#httpClient.postJson<{ accessToken: string }>(pth, body, headers); const resp = await this._httpClient.postJson<{ accessToken: string }>(pth, body, headers);
const statusCode = resp.statusCode || 500; const statusCode = resp.statusCode || 500;
if (statusCode < 200 || statusCode > 299) { if (statusCode < 200 || statusCode > 299) {
throw new Error(`Failed to call ${pth}: HTTP ${statusCode}: ${resp.result || '[no body]'}`); throw new Error(`Failed to call ${pth}: HTTP ${statusCode}: ${resp.result || '[no body]'}`);
@ -120,14 +114,17 @@ export class IAMCredentialsClient {
} }
return result.accessToken; return result.accessToken;
} catch (err) { } catch (err) {
const msg = errorMessage(err);
throw new Error( throw new Error(
`Failed to generate Google Cloud OAuth 2.0 Access Token for ${serviceAccount}: ${err}`, `Failed to generate Google Cloud OAuth 2.0 Access Token for ${serviceAccount}: ${msg}`,
); );
} }
} }
async generateDomainWideDelegationAccessToken(assertion: string): Promise<string> { async generateDomainWideDelegationAccessToken(assertion: string): Promise<string> {
const pth = `${this.#endpoints.oauth2}/token`; const logger = this._logger.withNamespace('generateDomainWideDelegationAccessToken');
const pth = `${this._endpoints.oauth2}/token`;
const headers = { const headers = {
'Accept': 'application/json', 'Accept': 'application/json',
@ -138,7 +135,7 @@ export class IAMCredentialsClient {
body.append('grant_type', 'urn:ietf:params:oauth:grant-type:jwt-bearer'); body.append('grant_type', 'urn:ietf:params:oauth:grant-type:jwt-bearer');
body.append('assertion', assertion); body.append('assertion', assertion);
this.#logger.withNamespace('generateDomainWideDelegationAccessToken').debug({ logger.debug(`Built request`, {
method: `POST`, method: `POST`,
path: pth, path: pth,
headers: headers, headers: headers,
@ -146,7 +143,7 @@ export class IAMCredentialsClient {
}); });
try { try {
const resp = await this.#httpClient.post(pth, body.toString(), headers); const resp = await this._httpClient.post(pth, body.toString(), headers);
const respBody = await resp.readBody(); const respBody = await resp.readBody();
const statusCode = resp.message.statusCode || 500; const statusCode = resp.message.statusCode || 500;
if (statusCode < 200 || statusCode > 299) { if (statusCode < 200 || statusCode > 299) {
@ -155,8 +152,9 @@ export class IAMCredentialsClient {
const parsed = JSON.parse(respBody) as { accessToken: string }; const parsed = JSON.parse(respBody) as { accessToken: string };
return parsed.accessToken; return parsed.accessToken;
} catch (err) { } catch (err) {
const msg = errorMessage(err);
throw new Error( throw new Error(
`Failed to generate Google Cloud Domain Wide Delegation OAuth 2.0 Access Token: ${err}`, `Failed to generate Google Cloud Domain Wide Delegation OAuth 2.0 Access Token: ${msg}`,
); );
} }
} }
@ -171,7 +169,9 @@ export class IAMCredentialsClient {
delegates, delegates,
includeEmail, includeEmail,
}: GenerateIDTokenParameters): Promise<string> { }: GenerateIDTokenParameters): Promise<string> {
const pth = `${this.#endpoints.iamcredentials}/projects/-/serviceAccounts/${serviceAccount}:generateIdToken`; const logger = this._logger.withNamespace('generateIDToken');
const pth = `${this._endpoints.iamcredentials}/projects/-/serviceAccounts/${serviceAccount}:generateIdToken`;
const headers = { Authorization: `Bearer ${this.#authToken}` }; const headers = { Authorization: `Bearer ${this.#authToken}` };
@ -183,7 +183,7 @@ export class IAMCredentialsClient {
body.delegates = delegates; body.delegates = delegates;
} }
this.#logger.withNamespace('generateIDToken').debug({ logger.debug(`Built request`, {
method: `POST`, method: `POST`,
path: pth, path: pth,
headers: headers, headers: headers,
@ -191,7 +191,7 @@ export class IAMCredentialsClient {
}); });
try { try {
const resp = await this.#httpClient.postJson<{ token: string }>(pth, body, headers); const resp = await this._httpClient.postJson<{ token: string }>(pth, body, headers);
const statusCode = resp.statusCode || 500; const statusCode = resp.statusCode || 500;
if (statusCode < 200 || statusCode > 299) { if (statusCode < 200 || statusCode > 299) {
throw new Error(`Failed to call ${pth}: HTTP ${statusCode}: ${resp.result || '[no body]'}`); throw new Error(`Failed to call ${pth}: HTTP ${statusCode}: ${resp.result || '[no body]'}`);
@ -203,19 +203,10 @@ export class IAMCredentialsClient {
} }
return result.token; return result.token;
} catch (err) { } catch (err) {
const msg = errorMessage(err);
throw new Error( throw new Error(
`Failed to generate Google Cloud OpenID Connect ID token for ${serviceAccount}: ${err}`, `Failed to generate Google Cloud OpenID Connect ID token for ${serviceAccount}: ${msg}`,
); );
} }
} }
} }
export { AuthClient } from './client/auth_client';
export {
ServiceAccountKeyClientParameters,
ServiceAccountKeyClient,
} from './client/credentials_json_client';
export {
WorkloadIdentityFederationClientParameters,
WorkloadIdentityFederationClient,
} from './client/workload_identity_client';

View File

@ -15,6 +15,7 @@
import { createSign } from 'crypto'; import { createSign } from 'crypto';
import { import {
errorMessage,
isServiceAccountKey, isServiceAccountKey,
parseCredential, parseCredential,
ServiceAccountKey, ServiceAccountKey,
@ -22,8 +23,7 @@ import {
writeSecureFile, writeSecureFile,
} from '@google-github-actions/actions-utils'; } from '@google-github-actions/actions-utils';
import { AuthClient } from './auth_client'; import { AuthClient, Client } from './client';
import { expandEndpoint } from '../utils';
import { Logger } from '../logger'; import { Logger } from '../logger';
/** /**
@ -31,6 +31,9 @@ import { Logger } from '../logger';
* ServiceAccountKeyClient. * ServiceAccountKeyClient.
*/ */
export interface ServiceAccountKeyClientParameters { export interface ServiceAccountKeyClientParameters {
readonly logger: Logger;
readonly universe: string;
readonly serviceAccountKey: string; readonly serviceAccountKey: string;
} }
@ -38,33 +41,26 @@ export interface ServiceAccountKeyClientParameters {
* ServiceAccountKeyClient is an authentication client that expects a Service * ServiceAccountKeyClient is an authentication client that expects a Service
* Account Key JSON file. * Account Key JSON file.
*/ */
export class ServiceAccountKeyClient implements AuthClient { export class ServiceAccountKeyClient extends Client implements AuthClient {
readonly #logger: Logger;
readonly #serviceAccountKey: ServiceAccountKey; readonly #serviceAccountKey: ServiceAccountKey;
readonly #universe: string = 'googleapis.com';
readonly #endpoints = {
iamcredentials: 'https://iamcredentials.{universe}/v1',
};
readonly #audience: string; readonly #audience: string;
constructor(logger: Logger, opts: ServiceAccountKeyClientParameters) { constructor(opts: ServiceAccountKeyClientParameters) {
this.#logger = logger.withNamespace(this.constructor.name); super({
logger: opts.logger,
universe: opts.universe,
child: `ServiceAccountKeyClient`,
});
const serviceAccountKey = parseCredential(opts.serviceAccountKey); const serviceAccountKey = parseCredential(opts.serviceAccountKey);
if (!isServiceAccountKey(serviceAccountKey)) { if (!isServiceAccountKey(serviceAccountKey)) {
throw new Error(`Provided credential is not a valid Google Service Account Key JSON`); throw new Error(`Provided credential is not a valid Google Service Account Key JSON`);
} }
this.#serviceAccountKey = serviceAccountKey; this.#serviceAccountKey = serviceAccountKey;
this._logger.debug(`Parsed service account key`, serviceAccountKey.client_email);
const endpoints = this.#endpoints; this.#audience = new URL(this._endpoints.iamcredentials).origin + `/`;
for (const key of Object.keys(this.#endpoints) as Array<keyof typeof endpoints>) { this._logger.debug(`Computed audience`, this.#audience);
this.#endpoints[key] = expandEndpoint(this.#endpoints[key], this.#universe);
}
this.#logger.debug(`Computed endpoints`, this.#endpoints);
this.#audience = new URL(this.#endpoints.iamcredentials).origin + `/`;
this.#logger.debug(`Computed audience`, this.#audience);
} }
/** /**
@ -74,25 +70,28 @@ export class ServiceAccountKeyClient implements AuthClient {
* use the JWT to call other endpoints without calling iamcredentials. * use the JWT to call other endpoints without calling iamcredentials.
*/ */
async getToken(): Promise<string> { async getToken(): Promise<string> {
const logger = this._logger.withNamespace('getToken');
const now = Math.floor(new Date().getTime() / 1000);
const claims = {
iss: this.#serviceAccountKey.client_email,
sub: this.#serviceAccountKey.client_email,
aud: this.#audience,
iat: now,
exp: now + 3599,
};
logger.debug(`Built jwt`, {
claims: claims,
});
try { try {
const now = Math.floor(new Date().getTime() / 1000);
const claims = {
iss: this.#serviceAccountKey.client_email,
sub: this.#serviceAccountKey.client_email,
aud: this.#audience,
iat: now,
exp: now + 3599,
};
this.#logger.withNamespace('getToken').debug({
claims: claims,
});
return await this.signJWT(claims); return await this.signJWT(claims);
} catch (err) { } catch (err) {
const msg = errorMessage(err);
throw new Error( throw new Error(
`Failed to sign auth token using ${this.#serviceAccountKey.client_email}: ${err}`, `Failed to sign auth token using ${this.#serviceAccountKey.client_email}: ${msg}`,
); );
} }
} }
@ -101,6 +100,8 @@ export class ServiceAccountKeyClient implements AuthClient {
* signJWT signs a JWT using the Service Account's private key. * signJWT signs a JWT using the Service Account's private key.
*/ */
async signJWT(claims: any): Promise<string> { async signJWT(claims: any): Promise<string> {
const logger = this._logger.withNamespace('signJWT');
const header = { const header = {
alg: `RS256`, alg: `RS256`,
typ: `JWT`, typ: `JWT`,
@ -109,18 +110,25 @@ export class ServiceAccountKeyClient implements AuthClient {
const message = toBase64(JSON.stringify(header)) + `.` + toBase64(JSON.stringify(claims)); const message = toBase64(JSON.stringify(header)) + `.` + toBase64(JSON.stringify(claims));
this.#logger.withNamespace('signJWT').debug({ logger.debug(`Built jwt`, {
header: header, header: header,
claims: claims, claims: claims,
message: message, message: message,
}); });
const signer = createSign(`RSA-SHA256`); try {
signer.write(message); const signer = createSign(`RSA-SHA256`);
signer.end(); signer.write(message);
signer.end();
const signature = signer.sign(this.#serviceAccountKey.private_key); const signature = signer.sign(this.#serviceAccountKey.private_key);
return message + '.' + toBase64(signature); return message + '.' + toBase64(signature);
} catch (err) {
const msg = errorMessage(err);
throw new Error(
`Failed to sign jwt using private key for ${this.#serviceAccountKey.client_email}: ${msg}`,
);
}
} }
/** /**
@ -128,7 +136,12 @@ export class ServiceAccountKeyClient implements AuthClient {
* the specified outputPath. * the specified outputPath.
*/ */
async createCredentialsFile(outputPath: string): Promise<string> { async createCredentialsFile(outputPath: string): Promise<string> {
this.#logger.withNamespace('createCredentialsFile').debug({ outputPath: outputPath }); const logger = this._logger.withNamespace('createCredentialsFile');
logger.debug(`Creating credentials`, {
outputPath: outputPath,
});
return await writeSecureFile(outputPath, JSON.stringify(this.#serviceAccountKey)); return await writeSecureFile(outputPath, JSON.stringify(this.#serviceAccountKey));
} }
} }

View File

@ -12,12 +12,9 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import { HttpClient } from '@actions/http-client'; import { errorMessage, writeSecureFile } from '@google-github-actions/actions-utils';
import { writeSecureFile } from '@google-github-actions/actions-utils'; import { AuthClient, Client } from './client';
import { AuthClient } from './auth_client';
import { expandEndpoint, userAgent } from '../utils';
import { Logger } from '../logger'; import { Logger } from '../logger';
/** /**
@ -25,6 +22,9 @@ import { Logger } from '../logger';
* WorkloadIdentityFederationClient. * WorkloadIdentityFederationClient.
*/ */
export interface WorkloadIdentityFederationClientParameters { export interface WorkloadIdentityFederationClientParameters {
readonly logger: Logger;
readonly universe: string;
readonly githubOIDCToken: string; readonly githubOIDCToken: string;
readonly githubOIDCTokenRequestURL: string; readonly githubOIDCTokenRequestURL: string;
readonly githubOIDCTokenRequestToken: string; readonly githubOIDCTokenRequestToken: string;
@ -38,32 +38,24 @@ export interface WorkloadIdentityFederationClientParameters {
* WorkloadIdentityFederationClient is an authentication client that configures * WorkloadIdentityFederationClient is an authentication client that configures
* a Workload Identity authentication scheme. * a Workload Identity authentication scheme.
*/ */
export class WorkloadIdentityFederationClient implements AuthClient { export class WorkloadIdentityFederationClient extends Client implements AuthClient {
readonly #logger: Logger;
readonly #httpClient: HttpClient;
readonly #githubOIDCToken: string; readonly #githubOIDCToken: string;
readonly #githubOIDCTokenRequestURL: string; readonly #githubOIDCTokenRequestURL: string;
readonly #githubOIDCTokenRequestToken: string; readonly #githubOIDCTokenRequestToken: string;
readonly #githubOIDCTokenAudience: string; readonly #githubOIDCTokenAudience: string;
readonly #workloadIdentityProviderName: string; readonly #workloadIdentityProviderName: string;
readonly #serviceAccount?: string; readonly #serviceAccount?: string;
readonly #audience: string;
#cachedToken?: string; #cachedToken?: string;
#cachedAt?: number; #cachedAt?: number;
readonly #universe: string = 'googleapis.com'; constructor(opts: WorkloadIdentityFederationClientParameters) {
readonly #endpoints = { super({
iam: 'https://iam.{universe}/v1', logger: opts.logger,
iamcredentials: 'https://iamcredentials.{universe}/v1', universe: opts.universe,
sts: 'https://sts.{universe}/v1', child: `WorkloadIdentityFederationClient`,
www: 'https://www.{universe}', });
};
readonly #audience: string;
constructor(logger: Logger, opts: WorkloadIdentityFederationClientParameters) {
this.#logger = logger.withNamespace(this.constructor.name);
this.#httpClient = new HttpClient(userAgent);
this.#githubOIDCToken = opts.githubOIDCToken; this.#githubOIDCToken = opts.githubOIDCToken;
this.#githubOIDCTokenRequestURL = opts.githubOIDCTokenRequestURL; this.#githubOIDCTokenRequestURL = opts.githubOIDCTokenRequestURL;
@ -72,15 +64,9 @@ export class WorkloadIdentityFederationClient implements AuthClient {
this.#workloadIdentityProviderName = opts.workloadIdentityProviderName; this.#workloadIdentityProviderName = opts.workloadIdentityProviderName;
this.#serviceAccount = opts.serviceAccount; this.#serviceAccount = opts.serviceAccount;
const endpoints = this.#endpoints; const iamHost = new URL(this._endpoints.iam).host;
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);
const iamHost = new URL(this.#endpoints.iam).host;
this.#audience = `//${iamHost}/${this.#workloadIdentityProviderName}`; this.#audience = `//${iamHost}/${this.#workloadIdentityProviderName}`;
this.#logger.debug(`Computed audience`, this.#audience); this._logger.debug(`Computed audience`, this.#audience);
} }
/** /**
@ -91,31 +77,36 @@ export class WorkloadIdentityFederationClient implements AuthClient {
* impersonation. * impersonation.
*/ */
async getToken(): Promise<string> { async getToken(): Promise<string> {
const logger = this._logger.withNamespace(`getToken`);
const now = new Date().getTime(); const now = new Date().getTime();
if (this.#cachedToken && this.#cachedAt && now - this.#cachedAt > 60_000) { if (this.#cachedToken && this.#cachedAt && now - this.#cachedAt > 60_000) {
this.#logger.debug(`Using cached token`); logger.debug(`Using cached token`, {
now: now,
cachedAt: this.#cachedAt,
});
return this.#cachedToken; return this.#cachedToken;
} }
const pth = `${this.#endpoints.sts}/token`; const pth = `${this._endpoints.sts}/token`;
const body = { const body = {
audience: this.#audience, audience: this.#audience,
grantType: `urn:ietf:params:oauth:grant-type:token-exchange`, grantType: `urn:ietf:params:oauth:grant-type:token-exchange`,
requestedTokenType: `urn:ietf:params:oauth:token-type:access_token`, requestedTokenType: `urn:ietf:params:oauth:token-type:access_token`,
scope: `${this.#endpoints.www}/auth/cloud-platform`, scope: `${this._endpoints.www}/auth/cloud-platform`,
subjectTokenType: `urn:ietf:params:oauth:token-type:jwt`, subjectTokenType: `urn:ietf:params:oauth:token-type:jwt`,
subjectToken: this.#githubOIDCToken, subjectToken: this.#githubOIDCToken,
}; };
this.#logger.withNamespace('getToken').debug({ logger.debug(`Built request`, {
method: `POST`, method: `POST`,
path: pth, path: pth,
body: body, body: body,
}); });
try { try {
const resp = await this.#httpClient.postJson<{ access_token: string }>(pth, body); const resp = await this._httpClient.postJson<{ access_token: string }>(pth, body);
const statusCode = resp.statusCode || 500; const statusCode = resp.statusCode || 500;
if (statusCode < 200 || statusCode > 299) { if (statusCode < 200 || statusCode > 299) {
throw new Error(`Failed to call ${pth}: HTTP ${statusCode}: ${resp.result || '[no body]'}`); throw new Error(`Failed to call ${pth}: HTTP ${statusCode}: ${resp.result || '[no body]'}`);
@ -130,8 +121,9 @@ export class WorkloadIdentityFederationClient implements AuthClient {
this.#cachedAt = now; this.#cachedAt = now;
return result.access_token; return result.access_token;
} catch (err) { } catch (err) {
const msg = errorMessage(err);
throw new Error( throw new Error(
`Failed to generate Google Cloud federated token for ${this.#audience}: ${err}`, `Failed to generate Google Cloud federated token for ${this.#audience}: ${msg}`,
); );
} }
} }
@ -140,11 +132,13 @@ export class WorkloadIdentityFederationClient implements AuthClient {
* signJWT signs a JWT using the Service Account's private key. * signJWT signs a JWT using the Service Account's private key.
*/ */
async signJWT(claims: any): Promise<string> { async signJWT(claims: any): Promise<string> {
const logger = this._logger.withNamespace(`signJWT`);
if (!this.#serviceAccount) { if (!this.#serviceAccount) {
throw new Error(`Cannot sign JWTs without specifying a service account`); throw new Error(`Cannot sign JWTs without specifying a service account`);
} }
const pth = `${this.#endpoints.iamcredentials}/projects/-/serviceAccounts/${this.#serviceAccount}:signJwt`; const pth = `${this._endpoints.iamcredentials}/projects/-/serviceAccounts/${this.#serviceAccount}:signJwt`;
const headers = { const headers = {
Authorization: `Bearer ${this.getToken()}`, Authorization: `Bearer ${this.getToken()}`,
@ -154,7 +148,7 @@ export class WorkloadIdentityFederationClient implements AuthClient {
payload: claims, payload: claims,
}; };
this.#logger.withNamespace('signJWT').debug({ logger.debug(`Built request`, {
method: `POST`, method: `POST`,
path: pth, path: pth,
headers: headers, headers: headers,
@ -162,7 +156,7 @@ export class WorkloadIdentityFederationClient implements AuthClient {
}); });
try { try {
const resp = await this.#httpClient.postJson<{ signedJwt: string }>(pth, body, headers); const resp = await this._httpClient.postJson<{ signedJwt: string }>(pth, body, headers);
const statusCode = resp.statusCode || 500; const statusCode = resp.statusCode || 500;
if (statusCode < 200 || statusCode > 299) { if (statusCode < 200 || statusCode > 299) {
throw new Error(`Failed to call ${pth}: HTTP ${statusCode}: ${resp.result || '[no body]'}`); throw new Error(`Failed to call ${pth}: HTTP ${statusCode}: ${resp.result || '[no body]'}`);
@ -174,7 +168,8 @@ export class WorkloadIdentityFederationClient implements AuthClient {
} }
return result.signedJwt; return result.signedJwt;
} catch (err) { } catch (err) {
throw new Error(`Failed to sign JWT using ${this.#serviceAccount}: ${err}`); const msg = errorMessage(err);
throw new Error(`Failed to sign JWT using ${this.#serviceAccount}: ${msg}`);
} }
} }
@ -183,6 +178,8 @@ export class WorkloadIdentityFederationClient implements AuthClient {
* to disk at the specific outputPath. * to disk at the specific outputPath.
*/ */
async createCredentialsFile(outputPath: string): Promise<string> { async createCredentialsFile(outputPath: string): Promise<string> {
const logger = this._logger.withNamespace(`createCredentialsFile`);
const requestURL = new URL(this.#githubOIDCTokenRequestURL); const requestURL = new URL(this.#githubOIDCTokenRequestURL);
// Append the audience value to the request. // Append the audience value to the request.
@ -194,7 +191,7 @@ export class WorkloadIdentityFederationClient implements AuthClient {
type: `external_account`, type: `external_account`,
audience: this.#audience, audience: this.#audience,
subject_token_type: `urn:ietf:params:oauth:token-type:jwt`, subject_token_type: `urn:ietf:params:oauth:token-type:jwt`,
token_url: `${this.#endpoints.sts}/token`, token_url: `${this._endpoints.sts}/token`,
credential_source: { credential_source: {
url: requestURL, url: requestURL,
headers: { headers: {
@ -210,10 +207,15 @@ export class WorkloadIdentityFederationClient implements AuthClient {
// Only request impersonation if a service account was given, otherwise use // Only request impersonation if a service account was given, otherwise use
// the WIF identity directly. // the WIF identity directly.
if (this.#serviceAccount) { if (this.#serviceAccount) {
data.service_account_impersonation_url = `${this.#endpoints.iamcredentials}/projects/-/serviceAccounts/${this.#serviceAccount}:generateAccessToken`; const impersonationURL = `${this._endpoints.iamcredentials}/projects/-/serviceAccounts/${this.#serviceAccount}:generateAccessToken`;
logger.debug(`Enabling service account impersonation via ${impersonationURL}`);
data.service_account_impersonation_url = impersonationURL;
} }
this.#logger.withNamespace('createCredentialsFile').debug({ outputPath: outputPath }); logger.debug(`Creating credentials`, {
outputPath: outputPath,
});
return await writeSecureFile(outputPath, JSON.stringify(data)); return await writeSecureFile(outputPath, JSON.stringify(data));
} }
} }

View File

@ -39,7 +39,7 @@ import {
IAMCredentialsClient, IAMCredentialsClient,
ServiceAccountKeyClient, ServiceAccountKeyClient,
WorkloadIdentityFederationClient, WorkloadIdentityFederationClient,
} from './base'; } from './client/client';
import { Logger } from './logger'; import { Logger } from './logger';
import { import {
buildDomainWideDelegationJWT, buildDomainWideDelegationJWT,
@ -112,6 +112,7 @@ async function main(logger: Logger) {
const exportEnvironmentVariables = getBooleanInput(`export_environment_variables`); const exportEnvironmentVariables = getBooleanInput(`export_environment_variables`);
const tokenFormat = getInput(`token_format`); const tokenFormat = getInput(`token_format`);
const delegates = parseCSV(getInput(`delegates`)); const delegates = parseCSV(getInput(`delegates`));
const universe = getInput(`universe`);
// Ensure exactly one of workload_identity_provider and credentials_json was // Ensure exactly one of workload_identity_provider and credentials_json was
// provided. // provided.
@ -138,7 +139,10 @@ async function main(logger: Logger) {
} }
const oidcToken = await getIDToken(oidcTokenAudience); const oidcToken = await getIDToken(oidcTokenAudience);
client = new WorkloadIdentityFederationClient(logger, { client = new WorkloadIdentityFederationClient({
logger: logger,
universe: universe,
githubOIDCToken: oidcToken, githubOIDCToken: oidcToken,
githubOIDCTokenRequestURL: oidcTokenRequestURL, githubOIDCTokenRequestURL: oidcTokenRequestURL,
githubOIDCTokenRequestToken: oidcTokenRequestToken, githubOIDCTokenRequestToken: oidcTokenRequestToken,
@ -148,7 +152,10 @@ async function main(logger: Logger) {
}); });
} else { } else {
logger.debug(`Using credentials JSON`); logger.debug(`Using credentials JSON`);
client = new ServiceAccountKeyClient(logger, { client = new ServiceAccountKeyClient({
logger: logger,
universe: universe,
serviceAccountKey: credentialsJSON, serviceAccountKey: credentialsJSON,
}); });
} }
@ -245,7 +252,10 @@ async function main(logger: Logger) {
setOutput('auth_token', authToken); setOutput('auth_token', authToken);
// Create the credential client, we might not use it, but it's basically free. // Create the credential client, we might not use it, but it's basically free.
const iamCredentialsClient = new IAMCredentialsClient(logger, { const iamCredentialsClient = new IAMCredentialsClient({
logger: logger,
universe: universe,
authToken: authToken, authToken: authToken,
}); });

View File

@ -134,13 +134,6 @@ export function projectIDFromServiceAccountEmail(serviceAccount?: string): strin
return addressParts[0]; return addressParts[0];
} }
/**
* expandEndpoint expands the input url relative to the universe.
*/
export function expandEndpoint(input: string, universe: string): string {
return (input || '').replace(/{universe}/g, universe).replace(/\/+$/, '');
}
/** /**
* generateCredentialsFilename creates a predictable filename under which * generateCredentialsFilename creates a predictable filename under which
* credentials are written. This string is the filename, not the filepath. It must match the format: * credentials are written. This string is the filename, not the filepath. It must match the format:

View File

@ -22,7 +22,7 @@ import { tmpdir } from 'os';
import { randomFilename } from '@google-github-actions/actions-utils'; import { randomFilename } from '@google-github-actions/actions-utils';
import { NullLogger } from '../../src/logger'; import { NullLogger } from '../../src/logger';
import { ServiceAccountKeyClient } from '../../src/client/credentials_json_client'; import { ServiceAccountKeyClient } from '../../src/client/service_account_key_json';
// Yes, this is a real private key. No, it's not valid for authenticating // Yes, this is a real private key. No, it's not valid for authenticating
// Google Cloud. // Google Cloud.
@ -45,7 +45,9 @@ describe('ServiceAccountKeyClient', () => {
describe('#parseServiceAccountKeyJSON', () => { describe('#parseServiceAccountKeyJSON', () => {
it('throws exception on invalid json', async () => { it('throws exception on invalid json', async () => {
assert.rejects(async () => { assert.rejects(async () => {
new ServiceAccountKeyClient(new NullLogger(), { new ServiceAccountKeyClient({
logger: new NullLogger(),
universe: 'googleapis.com',
serviceAccountKey: 'invalid json', serviceAccountKey: 'invalid json',
}); });
}, SyntaxError); }, SyntaxError);
@ -53,7 +55,9 @@ describe('ServiceAccountKeyClient', () => {
it('handles base64', async () => { it('handles base64', async () => {
assert.rejects(async () => { assert.rejects(async () => {
new ServiceAccountKeyClient(new NullLogger(), { new ServiceAccountKeyClient({
logger: new NullLogger(),
universe: 'googleapis.com',
serviceAccountKey: 'base64', serviceAccountKey: 'base64',
}); });
}, SyntaxError); }, SyntaxError);
@ -62,7 +66,9 @@ describe('ServiceAccountKeyClient', () => {
describe('#getToken', () => { describe('#getToken', () => {
it('gets a token', async () => { it('gets a token', async () => {
const client = new ServiceAccountKeyClient(new NullLogger(), { const client = new ServiceAccountKeyClient({
logger: new NullLogger(),
universe: 'googleapis.com',
serviceAccountKey: credentialsJSON, serviceAccountKey: credentialsJSON,
}); });
@ -73,7 +79,9 @@ describe('ServiceAccountKeyClient', () => {
describe('#signJWT', () => { describe('#signJWT', () => {
it('signs a jwt', async () => { it('signs a jwt', async () => {
const client = new ServiceAccountKeyClient(new NullLogger(), { const client = new ServiceAccountKeyClient({
logger: new NullLogger(),
universe: 'googleapis.com',
serviceAccountKey: credentialsJSON, serviceAccountKey: credentialsJSON,
}); });
@ -85,7 +93,9 @@ describe('ServiceAccountKeyClient', () => {
describe('#createCredentialsFile', () => { describe('#createCredentialsFile', () => {
it('writes the file', async () => { it('writes the file', async () => {
const outputFile = pathjoin(tmpdir(), randomFilename()); const outputFile = pathjoin(tmpdir(), randomFilename());
const client = new ServiceAccountKeyClient(new NullLogger(), { const client = new ServiceAccountKeyClient({
logger: new NullLogger(),
universe: 'googleapis.com',
serviceAccountKey: credentialsJSON, serviceAccountKey: credentialsJSON,
}); });

View File

@ -22,13 +22,16 @@ import { readFileSync } from 'fs';
import { randomFilename } from '@google-github-actions/actions-utils'; import { randomFilename } from '@google-github-actions/actions-utils';
import { NullLogger } from '../../src/logger'; import { NullLogger } from '../../src/logger';
import { WorkloadIdentityFederationClient } from '../../src/client/workload_identity_client'; import { WorkloadIdentityFederationClient } from '../../src/client/workload_identity_federation';
describe('WorkloadIdentityFederationClient', () => { describe('WorkloadIdentityFederationClient', () => {
describe('#createCredentialsFile', () => { describe('#createCredentialsFile', () => {
it('writes the file', async () => { it('writes the file', async () => {
const outputFile = pathjoin(tmpdir(), randomFilename()); const outputFile = pathjoin(tmpdir(), randomFilename());
const client = new WorkloadIdentityFederationClient(new NullLogger(), { const client = new WorkloadIdentityFederationClient({
logger: new NullLogger(),
universe: 'googleapis.com',
githubOIDCToken: 'my-token', githubOIDCToken: 'my-token',
githubOIDCTokenRequestURL: 'https://example.com/', githubOIDCTokenRequestURL: 'https://example.com/',
githubOIDCTokenRequestToken: 'token', githubOIDCTokenRequestToken: 'token',
@ -62,7 +65,10 @@ describe('WorkloadIdentityFederationClient', () => {
it('writes the file with impersonation', async () => { it('writes the file with impersonation', async () => {
const outputFile = pathjoin(tmpdir(), randomFilename()); const outputFile = pathjoin(tmpdir(), randomFilename());
const client = new WorkloadIdentityFederationClient(new NullLogger(), { const client = new WorkloadIdentityFederationClient({
logger: new NullLogger(),
universe: 'googleapis.com',
githubOIDCToken: 'my-token', githubOIDCToken: 'my-token',
githubOIDCTokenRequestURL: 'https://example.com/', githubOIDCTokenRequestURL: 'https://example.com/',
githubOIDCTokenRequestToken: 'token', githubOIDCTokenRequestToken: 'token',

View File

@ -19,7 +19,6 @@ import {
buildDomainWideDelegationJWT, buildDomainWideDelegationJWT,
computeProjectID, computeProjectID,
computeServiceAccountEmail, computeServiceAccountEmail,
expandEndpoint,
generateCredentialsFilename, generateCredentialsFilename,
projectIDFromServiceAccountEmail, projectIDFromServiceAccountEmail,
} from '../src/utils'; } from '../src/utils';
@ -163,52 +162,6 @@ describe('Utils', async () => {
}); });
}); });
describe('#expandEndpoint', async () => {
const cases = [
{
name: 'empty',
endpoint: '',
universe: '',
exp: '',
},
{
name: 'no match',
endpoint: 'https://www.googleapis.com',
universe: 'foobar',
exp: 'https://www.googleapis.com',
},
{
name: 'removes trailing slash',
endpoint: 'https://www.googleapis.com/',
exp: 'https://www.googleapis.com',
},
{
name: 'removes trailing slashes',
endpoint: 'https://www.googleapis.com/////',
exp: 'https://www.googleapis.com',
},
{
name: 'replaces {universe}',
endpoint: 'https://www.{universe}',
universe: 'foo.bar',
exp: 'https://www.foo.bar',
},
{
name: 'replaces multiple {universe}',
endpoint: 'https://www.{universe}.{universe}',
universe: 'foo.bar',
exp: 'https://www.foo.bar.foo.bar',
},
];
cases.forEach(async (tc) => {
it(tc.name, async () => {
const result = expandEndpoint(tc.endpoint, tc.universe || '');
assert.deepStrictEqual(result, tc.exp);
});
});
});
describe('#generateCredentialsFilename', async () => { describe('#generateCredentialsFilename', async () => {
it('returns a string matching the regex', () => { it('returns a string matching the regex', () => {
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {