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
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
created credentials from the filesystem upon completion. This only applies
if "create_credentials_file" is true. The default is true.

View File

@ -94,6 +94,14 @@ inputs:
impersonation in the chain.
default: ''
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:
description: |-
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 { HttpClient } from '@actions/http-client';
import { errorMessage } from '@google-github-actions/actions-utils';
import { Logger } from './logger';
import { expandEndpoint, userAgent } from './utils';
import { Client } from './client';
import { Logger } from '../logger';
/**
* GenerateAccessTokenParameters are the inputs to the generateAccessToken call.
@ -43,6 +43,9 @@ export interface GenerateIDTokenParameters {
* IAMCredentialsClientParameters are the inputs to the IAM client.
*/
export interface IAMCredentialsClientParameters {
readonly logger: Logger;
readonly universe: string;
readonly authToken: string;
}
@ -50,28 +53,17 @@ export interface IAMCredentialsClientParameters {
* IAMCredentialsClient is a thin HTTP client around the Google Cloud IAM
* Credentials API.
*/
export class IAMCredentialsClient {
readonly #logger: Logger;
readonly #httpClient: HttpClient;
export class IAMCredentialsClient extends Client {
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);
constructor(opts: IAMCredentialsClientParameters) {
super({
logger: opts.logger,
universe: opts.universe,
child: `IAMCredentialsClient`,
});
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,
lifetime,
}: 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}` };
@ -100,7 +94,7 @@ export class IAMCredentialsClient {
body.lifetime = `${lifetime}s`;
}
this.#logger.withNamespace('generateAccessToken').debug({
logger.debug(`Built request`, {
method: `POST`,
path: pth,
headers: headers,
@ -108,7 +102,7 @@ export class IAMCredentialsClient {
});
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;
if (statusCode < 200 || statusCode > 299) {
throw new Error(`Failed to call ${pth}: HTTP ${statusCode}: ${resp.result || '[no body]'}`);
@ -120,14 +114,17 @@ export class IAMCredentialsClient {
}
return result.accessToken;
} catch (err) {
const msg = errorMessage(err);
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> {
const pth = `${this.#endpoints.oauth2}/token`;
const logger = this._logger.withNamespace('generateDomainWideDelegationAccessToken');
const pth = `${this._endpoints.oauth2}/token`;
const headers = {
'Accept': 'application/json',
@ -138,7 +135,7 @@ export class IAMCredentialsClient {
body.append('grant_type', 'urn:ietf:params:oauth:grant-type:jwt-bearer');
body.append('assertion', assertion);
this.#logger.withNamespace('generateDomainWideDelegationAccessToken').debug({
logger.debug(`Built request`, {
method: `POST`,
path: pth,
headers: headers,
@ -146,7 +143,7 @@ export class IAMCredentialsClient {
});
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 statusCode = resp.message.statusCode || 500;
if (statusCode < 200 || statusCode > 299) {
@ -155,8 +152,9 @@ export class IAMCredentialsClient {
const parsed = JSON.parse(respBody) as { accessToken: string };
return parsed.accessToken;
} catch (err) {
const msg = errorMessage(err);
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,
includeEmail,
}: 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}` };
@ -183,7 +183,7 @@ export class IAMCredentialsClient {
body.delegates = delegates;
}
this.#logger.withNamespace('generateIDToken').debug({
logger.debug(`Built request`, {
method: `POST`,
path: pth,
headers: headers,
@ -191,7 +191,7 @@ export class IAMCredentialsClient {
});
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;
if (statusCode < 200 || statusCode > 299) {
throw new Error(`Failed to call ${pth}: HTTP ${statusCode}: ${resp.result || '[no body]'}`);
@ -203,19 +203,10 @@ export class IAMCredentialsClient {
}
return result.token;
} catch (err) {
const msg = errorMessage(err);
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 {
errorMessage,
isServiceAccountKey,
parseCredential,
ServiceAccountKey,
@ -22,8 +23,7 @@ import {
writeSecureFile,
} from '@google-github-actions/actions-utils';
import { AuthClient } from './auth_client';
import { expandEndpoint } from '../utils';
import { AuthClient, Client } from './client';
import { Logger } from '../logger';
/**
@ -31,6 +31,9 @@ import { Logger } from '../logger';
* ServiceAccountKeyClient.
*/
export interface ServiceAccountKeyClientParameters {
readonly logger: Logger;
readonly universe: string;
readonly serviceAccountKey: string;
}
@ -38,33 +41,26 @@ export interface ServiceAccountKeyClientParameters {
* ServiceAccountKeyClient is an authentication client that expects a Service
* Account Key JSON file.
*/
export class ServiceAccountKeyClient implements AuthClient {
readonly #logger: Logger;
export class ServiceAccountKeyClient extends Client implements AuthClient {
readonly #serviceAccountKey: ServiceAccountKey;
readonly #universe: string = 'googleapis.com';
readonly #endpoints = {
iamcredentials: 'https://iamcredentials.{universe}/v1',
};
readonly #audience: string;
constructor(logger: Logger, opts: ServiceAccountKeyClientParameters) {
this.#logger = logger.withNamespace(this.constructor.name);
constructor(opts: ServiceAccountKeyClientParameters) {
super({
logger: opts.logger,
universe: opts.universe,
child: `ServiceAccountKeyClient`,
});
const serviceAccountKey = parseCredential(opts.serviceAccountKey);
if (!isServiceAccountKey(serviceAccountKey)) {
throw new Error(`Provided credential is not a valid Google Service Account Key JSON`);
}
this.#serviceAccountKey = serviceAccountKey;
this._logger.debug(`Parsed service account key`, serviceAccountKey.client_email);
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);
this.#audience = new URL(this.#endpoints.iamcredentials).origin + `/`;
this.#logger.debug(`Computed audience`, this.#audience);
this.#audience = new URL(this._endpoints.iamcredentials).origin + `/`;
this._logger.debug(`Computed audience`, this.#audience);
}
/**
@ -74,7 +70,8 @@ export class ServiceAccountKeyClient implements AuthClient {
* use the JWT to call other endpoints without calling iamcredentials.
*/
async getToken(): Promise<string> {
try {
const logger = this._logger.withNamespace('getToken');
const now = Math.floor(new Date().getTime() / 1000);
const claims = {
@ -85,14 +82,16 @@ export class ServiceAccountKeyClient implements AuthClient {
exp: now + 3599,
};
this.#logger.withNamespace('getToken').debug({
logger.debug(`Built jwt`, {
claims: claims,
});
try {
return await this.signJWT(claims);
} catch (err) {
const msg = errorMessage(err);
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.
*/
async signJWT(claims: any): Promise<string> {
const logger = this._logger.withNamespace('signJWT');
const header = {
alg: `RS256`,
typ: `JWT`,
@ -109,18 +110,25 @@ export class ServiceAccountKeyClient implements AuthClient {
const message = toBase64(JSON.stringify(header)) + `.` + toBase64(JSON.stringify(claims));
this.#logger.withNamespace('signJWT').debug({
logger.debug(`Built jwt`, {
header: header,
claims: claims,
message: message,
});
try {
const signer = createSign(`RSA-SHA256`);
signer.write(message);
signer.end();
const signature = signer.sign(this.#serviceAccountKey.private_key);
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.
*/
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));
}
}

View File

@ -12,12 +12,9 @@
// See the License for the specific language governing permissions and
// 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 } from './auth_client';
import { expandEndpoint, userAgent } from '../utils';
import { AuthClient, Client } from './client';
import { Logger } from '../logger';
/**
@ -25,6 +22,9 @@ import { Logger } from '../logger';
* WorkloadIdentityFederationClient.
*/
export interface WorkloadIdentityFederationClientParameters {
readonly logger: Logger;
readonly universe: string;
readonly githubOIDCToken: string;
readonly githubOIDCTokenRequestURL: string;
readonly githubOIDCTokenRequestToken: string;
@ -38,32 +38,24 @@ export interface WorkloadIdentityFederationClientParameters {
* WorkloadIdentityFederationClient is an authentication client that configures
* a Workload Identity authentication scheme.
*/
export class WorkloadIdentityFederationClient implements AuthClient {
readonly #logger: Logger;
readonly #httpClient: HttpClient;
export class WorkloadIdentityFederationClient extends Client implements AuthClient {
readonly #githubOIDCToken: string;
readonly #githubOIDCTokenRequestURL: string;
readonly #githubOIDCTokenRequestToken: string;
readonly #githubOIDCTokenAudience: string;
readonly #workloadIdentityProviderName: string;
readonly #serviceAccount?: string;
readonly #audience: string;
#cachedToken?: string;
#cachedAt?: number;
readonly #universe: string = 'googleapis.com';
readonly #endpoints = {
iam: 'https://iam.{universe}/v1',
iamcredentials: 'https://iamcredentials.{universe}/v1',
sts: 'https://sts.{universe}/v1',
www: 'https://www.{universe}',
};
readonly #audience: string;
constructor(logger: Logger, opts: WorkloadIdentityFederationClientParameters) {
this.#logger = logger.withNamespace(this.constructor.name);
this.#httpClient = new HttpClient(userAgent);
constructor(opts: WorkloadIdentityFederationClientParameters) {
super({
logger: opts.logger,
universe: opts.universe,
child: `WorkloadIdentityFederationClient`,
});
this.#githubOIDCToken = opts.githubOIDCToken;
this.#githubOIDCTokenRequestURL = opts.githubOIDCTokenRequestURL;
@ -72,15 +64,9 @@ export class WorkloadIdentityFederationClient implements AuthClient {
this.#workloadIdentityProviderName = opts.workloadIdentityProviderName;
this.#serviceAccount = opts.serviceAccount;
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);
const iamHost = new URL(this.#endpoints.iam).host;
const iamHost = new URL(this._endpoints.iam).host;
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.
*/
async getToken(): Promise<string> {
const logger = this._logger.withNamespace(`getToken`);
const now = new Date().getTime();
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;
}
const pth = `${this.#endpoints.sts}/token`;
const pth = `${this._endpoints.sts}/token`;
const body = {
audience: this.#audience,
grantType: `urn:ietf:params:oauth:grant-type:token-exchange`,
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`,
subjectToken: this.#githubOIDCToken,
};
this.#logger.withNamespace('getToken').debug({
logger.debug(`Built request`, {
method: `POST`,
path: pth,
body: body,
});
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;
if (statusCode < 200 || statusCode > 299) {
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;
return result.access_token;
} catch (err) {
const msg = errorMessage(err);
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.
*/
async signJWT(claims: any): Promise<string> {
const logger = this._logger.withNamespace(`signJWT`);
if (!this.#serviceAccount) {
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 = {
Authorization: `Bearer ${this.getToken()}`,
@ -154,7 +148,7 @@ export class WorkloadIdentityFederationClient implements AuthClient {
payload: claims,
};
this.#logger.withNamespace('signJWT').debug({
logger.debug(`Built request`, {
method: `POST`,
path: pth,
headers: headers,
@ -162,7 +156,7 @@ export class WorkloadIdentityFederationClient implements AuthClient {
});
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;
if (statusCode < 200 || statusCode > 299) {
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;
} 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.
*/
async createCredentialsFile(outputPath: string): Promise<string> {
const logger = this._logger.withNamespace(`createCredentialsFile`);
const requestURL = new URL(this.#githubOIDCTokenRequestURL);
// Append the audience value to the request.
@ -194,7 +191,7 @@ export class WorkloadIdentityFederationClient implements AuthClient {
type: `external_account`,
audience: this.#audience,
subject_token_type: `urn:ietf:params:oauth:token-type:jwt`,
token_url: `${this.#endpoints.sts}/token`,
token_url: `${this._endpoints.sts}/token`,
credential_source: {
url: requestURL,
headers: {
@ -210,10 +207,15 @@ export class WorkloadIdentityFederationClient implements AuthClient {
// Only request impersonation if a service account was given, otherwise use
// the WIF identity directly.
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));
}
}

View File

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

View File

@ -134,13 +134,6 @@ export function projectIDFromServiceAccountEmail(serviceAccount?: string): strin
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
* 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 { 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
// Google Cloud.
@ -45,7 +45,9 @@ describe('ServiceAccountKeyClient', () => {
describe('#parseServiceAccountKeyJSON', () => {
it('throws exception on invalid json', async () => {
assert.rejects(async () => {
new ServiceAccountKeyClient(new NullLogger(), {
new ServiceAccountKeyClient({
logger: new NullLogger(),
universe: 'googleapis.com',
serviceAccountKey: 'invalid json',
});
}, SyntaxError);
@ -53,7 +55,9 @@ describe('ServiceAccountKeyClient', () => {
it('handles base64', async () => {
assert.rejects(async () => {
new ServiceAccountKeyClient(new NullLogger(), {
new ServiceAccountKeyClient({
logger: new NullLogger(),
universe: 'googleapis.com',
serviceAccountKey: 'base64',
});
}, SyntaxError);
@ -62,7 +66,9 @@ describe('ServiceAccountKeyClient', () => {
describe('#getToken', () => {
it('gets a token', async () => {
const client = new ServiceAccountKeyClient(new NullLogger(), {
const client = new ServiceAccountKeyClient({
logger: new NullLogger(),
universe: 'googleapis.com',
serviceAccountKey: credentialsJSON,
});
@ -73,7 +79,9 @@ describe('ServiceAccountKeyClient', () => {
describe('#signJWT', () => {
it('signs a jwt', async () => {
const client = new ServiceAccountKeyClient(new NullLogger(), {
const client = new ServiceAccountKeyClient({
logger: new NullLogger(),
universe: 'googleapis.com',
serviceAccountKey: credentialsJSON,
});
@ -85,7 +93,9 @@ describe('ServiceAccountKeyClient', () => {
describe('#createCredentialsFile', () => {
it('writes the file', async () => {
const outputFile = pathjoin(tmpdir(), randomFilename());
const client = new ServiceAccountKeyClient(new NullLogger(), {
const client = new ServiceAccountKeyClient({
logger: new NullLogger(),
universe: 'googleapis.com',
serviceAccountKey: credentialsJSON,
});

View File

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

View File

@ -19,7 +19,6 @@ import {
buildDomainWideDelegationJWT,
computeProjectID,
computeServiceAccountEmail,
expandEndpoint,
generateCredentialsFilename,
projectIDFromServiceAccountEmail,
} 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 () => {
it('returns a string matching the regex', () => {
for (let i = 0; i < 10; i++) {