Skip to content

Commit

Permalink
Make auth universe-aware
Browse files Browse the repository at this point in the history
  • Loading branch information
sethvargo committed Nov 29, 2023
1 parent 097d292 commit 50c41b1
Show file tree
Hide file tree
Showing 13 changed files with 298 additions and 230 deletions.
12 changes: 12 additions & 0 deletions README.md
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions action.yml
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions dist/main/index.js

Large diffs are not rendered by default.

36 changes: 0 additions & 36 deletions src/client/auth_client.ts

This file was deleted.

106 changes: 106 additions & 0 deletions src/client/client.ts
@@ -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';
77 changes: 34 additions & 43 deletions src/base.ts → src/client/iamcredentials.ts
Expand Up @@ -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.
Expand All @@ -43,35 +43,27 @@ export interface GenerateIDTokenParameters {
* IAMCredentialsClientParameters are the inputs to the IAM client.
*/
export interface IAMCredentialsClientParameters {
readonly logger: Logger;
readonly universe: string;

readonly authToken: string;
}

/**
* IAMCredentialsClient is a thin HTTP client around the Google Cloud IAM
* Credentials API.
*/
export class IAMCredentialsClient {
readonly #logger: Logger;
readonly #httpClient: HttpClient;
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);
}

/**
Expand All @@ -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}` };

Expand All @@ -100,15 +94,15 @@ export class IAMCredentialsClient {
body.lifetime = `${lifetime}s`;
}

this.#logger.withNamespace('generateAccessToken').debug({
logger.debug({
method: `POST`,
path: pth,
headers: headers,
body: body,
});

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]'}`);
Expand All @@ -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',
Expand All @@ -138,15 +135,15 @@ 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({
method: `POST`,
path: pth,
headers: headers,
body: body,
});

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) {
Expand All @@ -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}`,
);
}
}
Expand All @@ -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}` };

Expand All @@ -183,15 +183,15 @@ export class IAMCredentialsClient {
body.delegates = delegates;
}

this.#logger.withNamespace('generateIDToken').debug({
logger.debug({
method: `POST`,
path: pth,
headers: headers,
body: body,
});

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]'}`);
Expand All @@ -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';

0 comments on commit 50c41b1

Please sign in to comment.