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:
parent
097d292c04
commit
7c4e01fd00
12
README.md
12
README.md
@ -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.
|
||||
|
@ -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
6
dist/main/index.js
vendored
File diff suppressed because one or more lines are too long
@ -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
106
src/client/client.ts
Normal 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';
|
@ -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';
|
@ -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));
|
||||
}
|
||||
}
|
@ -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));
|
||||
}
|
||||
}
|
18
src/main.ts
18
src/main.ts
@ -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,
|
||||
});
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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,
|
||||
});
|
||||
|
||||
|
@ -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',
|
||||
|
@ -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++) {
|
||||
|
Loading…
Reference in New Issue
Block a user