Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add proxy support #218

Merged
merged 1 commit into from Aug 31, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion dist/main/index.js

Large diffs are not rendered by default.

283 changes: 148 additions & 135 deletions package-lock.json

Large diffs are not rendered by default.

11 changes: 6 additions & 5 deletions package.json
Expand Up @@ -24,22 +24,23 @@
"license": "Apache-2.0",
"dependencies": {
"@actions/core": "^1.9.1",
"@actions/http-client": "^2.0.1",
"@google-github-actions/actions-utils": "^0.4.2"
},
"devDependencies": {
"@types/chai": "^4.3.3",
"@types/mocha": "^9.1.1",
"@types/node": "^18.7.13",
"@typescript-eslint/eslint-plugin": "^5.34.0",
"@typescript-eslint/parser": "^5.34.0",
"@types/node": "^18.7.14",
"@typescript-eslint/eslint-plugin": "^5.36.1",
"@typescript-eslint/parser": "^5.36.1",
"@vercel/ncc": "^0.34.0",
"chai": "^4.3.6",
"eslint": "^8.22.0",
"eslint": "^8.23.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^4.2.1",
"mocha": "^10.0.0",
"prettier": "^2.7.1",
"ts-node": "^10.9.1",
"typescript": "^4.7.4"
"typescript": "^4.8.2"
}
}
141 changes: 52 additions & 89 deletions src/base.ts
@@ -1,7 +1,7 @@
'use strict';

import https, { RequestOptions } from 'https';
import { URL, URLSearchParams } from 'url';
import { HttpClient } from '@actions/http-client';
import { URLSearchParams } from 'url';
import {
GoogleAccessTokenParameters,
GoogleAccessTokenResponse,
Expand All @@ -13,85 +13,53 @@ import {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { version: appVersion } = require('../package.json');

// userAgent is the default user agent.
const userAgent = `google-github-actions:auth/${appVersion}`;

/**
* BaseClient is the default HTTP client for interacting with the IAM
* credentials API.
*/
export class BaseClient {
/**
* request is a high-level helper that returns a promise from the executed
* request.
* client is the HTTP client.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
static request(opts: RequestOptions, data?: any): Promise<string> {
if (!opts.headers) {
opts.headers = {};
}

if (!opts.headers['User-Agent']) {
opts.headers['User-Agent'] = `google-github-actions:auth/${appVersion}`;
}

return new Promise((resolve, reject) => {
const req = https.request(opts, (res) => {
res.setEncoding('utf8');

let body = '';
res.on('data', (data) => {
body += data;
});

res.on('end', () => {
if (res.statusCode && res.statusCode >= 400) {
reject(body);
} else {
resolve(body);
}
});
});

req.on('error', (err) => {
reject(err);
});

if (data != null) {
req.write(data);
}
protected readonly client: HttpClient;

req.end();
});
constructor() {
this.client = new HttpClient(userAgent);
}

/**
* googleIDToken generates a Google Cloud ID token for the provided
* service account email or unique id.
*/
static async googleIDToken(
async googleIDToken(
token: string,
{ serviceAccount, audience, delegates, includeEmail }: GoogleIDTokenParameters,
): Promise<GoogleIDTokenResponse> {
const serviceAccountID = `projects/-/serviceAccounts/${serviceAccount}`;
const tokenURL = new URL(
`https://iamcredentials.googleapis.com/v1/${serviceAccountID}:generateIdToken`,
);
const pth = `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${serviceAccount}:generateIdToken`;

const data = {
delegates: delegates,
audience: audience,
includeEmail: includeEmail,
};

const opts = {
hostname: tokenURL.hostname,
port: tokenURL.port,
path: tokenURL.pathname + tokenURL.search,
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'application/json',
'Content-Type': 'application/json',
},
const headers = {
'Authorization': `Bearer ${token}`,
'Accept': 'application/json',
'Content-Type': 'application/json',
};

try {
const resp = await BaseClient.request(opts, JSON.stringify(data));
const parsed = JSON.parse(resp);
const resp = await this.client.request('POST', pth, JSON.stringify(data), headers);
const body = await resp.readBody();
const statusCode = resp.message.statusCode || 500;
if (statusCode >= 400) {
throw new Error(`(${statusCode}) ${body}`);
}
const parsed = JSON.parse(body);
return {
token: parsed['token'],
};
Expand All @@ -104,14 +72,11 @@ export class BaseClient {
* googleAccessToken generates a Google Cloud access token for the provided
* service account email or unique id.
*/
static async googleAccessToken(
async googleAccessToken(
token: string,
{ serviceAccount, delegates, scopes, lifetime }: GoogleAccessTokenParameters,
): Promise<GoogleAccessTokenResponse> {
const serviceAccountID = `projects/-/serviceAccounts/${serviceAccount}`;
const tokenURL = new URL(
`https://iamcredentials.googleapis.com/v1/${serviceAccountID}:generateAccessToken`,
);
const pth = `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${serviceAccount}:generateAccessToken`;

const data: Record<string, string | Array<string>> = {};
if (delegates && delegates.length > 0) {
Expand All @@ -125,21 +90,20 @@ export class BaseClient {
data.lifetime = `${lifetime}s`;
}

const opts = {
hostname: tokenURL.hostname,
port: tokenURL.port,
path: tokenURL.pathname + tokenURL.search,
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'application/json',
'Content-Type': 'application/json',
},
const headers = {
'Authorization': `Bearer ${token}`,
'Accept': 'application/json',
'Content-Type': 'application/json',
};

try {
const resp = await BaseClient.request(opts, JSON.stringify(data));
const parsed = JSON.parse(resp);
const resp = await this.client.request('POST', pth, JSON.stringify(data), headers);
const body = await resp.readBody();
const statusCode = resp.message.statusCode || 500;
if (statusCode >= 400) {
throw new Error(`(${statusCode}) ${body}`);
}
const parsed = JSON.parse(body);
return {
accessToken: parsed['accessToken'],
expiration: parsed['expireTime'],
Expand All @@ -155,27 +119,26 @@ export class BaseClient {
*
* @param assertion A signed JWT.
*/
static async googleOAuthToken(assertion: string): Promise<GoogleAccessTokenResponse> {
const tokenURL = new URL('https://oauth2.googleapis.com/token');

const opts = {
hostname: tokenURL.hostname,
port: tokenURL.port,
path: tokenURL.pathname + tokenURL.search,
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
async googleOAuthToken(assertion: string): Promise<GoogleAccessTokenResponse> {
const pth = `https://oauth2.googleapis.com/token`;

const headers = {
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
};

const data = new URLSearchParams();
data.append('grant_type', 'urn:ietf:params:oauth:grant-type:jwt-bearer');
data.append('assertion', assertion);

try {
const resp = await BaseClient.request(opts, data.toString());
const parsed = JSON.parse(resp);
const resp = await this.client.request('POST', pth, data.toString(), headers);
const body = await resp.readBody();
const statusCode = resp.message.statusCode || 500;
if (statusCode >= 400) {
throw new Error(`(${statusCode}) ${body}`);
}
const parsed = JSON.parse(body);

// Normalize the expiration to be a timestamp like the iamcredentials API.
// This API returns the number of seconds until expiration, so convert
Expand Down
10 changes: 10 additions & 0 deletions src/client/auth_client.ts
Expand Up @@ -9,6 +9,16 @@ export interface AuthClient {
getProjectID(): Promise<string>;
getServiceAccount(): Promise<string>;
createCredentialsFile(outputDir: string): Promise<string>;

/**
* Provided by BaseClient.
*/
googleIDToken(token: string, params: GoogleIDTokenParameters): Promise<GoogleIDTokenResponse>;
googleAccessToken(
token: string,
params: GoogleAccessTokenParameters,
): Promise<GoogleAccessTokenResponse>;
googleOAuthToken(assertion: string): Promise<GoogleAccessTokenResponse>;
}

/**
Expand Down
5 changes: 4 additions & 1 deletion src/client/credentials_json_client.ts
Expand Up @@ -10,6 +10,7 @@ import {
} from '@google-github-actions/actions-utils';

import { AuthClient } from './auth_client';
import { BaseClient } from '../base';

/**
* Available options to create the CredentialsJSONClient.
Expand All @@ -27,11 +28,13 @@ interface CredentialsJSONClientOptions {
* CredentialsJSONClient is a client that accepts a service account key JSON
* credential.
*/
export class CredentialsJSONClient implements AuthClient {
export class CredentialsJSONClient extends BaseClient implements AuthClient {
readonly #projectID: string;
readonly #credentials: ServiceAccountKey;

constructor(opts: CredentialsJSONClientOptions) {
super();

const credentials = parseCredential(opts.credentialsJSON);
if (!isServiceAccountKey(credentials)) {
throw new Error(`Provided credential is not a valid service account key JSON`);
Expand Down
54 changes: 26 additions & 28 deletions src/client/workload_identity_client.ts
Expand Up @@ -35,7 +35,7 @@ interface WorkloadIdentityClientOptions {
* WorkloadIdentityClient is a client that uses the GitHub Actions runtime to
* authentication via Workload Identity.
*/
export class WorkloadIdentityClient implements AuthClient {
export class WorkloadIdentityClient extends BaseClient implements AuthClient {
readonly #projectID: string;
readonly #providerID: string;
readonly #serviceAccount: string;
Expand All @@ -46,6 +46,8 @@ export class WorkloadIdentityClient implements AuthClient {
readonly #oidcTokenRequestToken: string;

constructor(opts: WorkloadIdentityClientOptions) {
super();

this.#providerID = opts.providerID;
this.#serviceAccount = opts.serviceAccount;
this.#token = opts.token;
Expand Down Expand Up @@ -85,7 +87,7 @@ export class WorkloadIdentityClient implements AuthClient {
* OIDC token and Workload Identity Provider.
*/
async getAuthToken(): Promise<string> {
const stsURL = new URL('https://sts.googleapis.com/v1/token');
const pth = `https://sts.googleapis.com/v1/token`;

const data = {
audience: '//iam.googleapis.com/' + this.#providerID,
Expand All @@ -96,20 +98,19 @@ export class WorkloadIdentityClient implements AuthClient {
subjectToken: this.#token,
};

const opts = {
hostname: stsURL.hostname,
port: stsURL.port,
path: stsURL.pathname + stsURL.search,
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
const headers = {
'Accept': 'application/json',
'Content-Type': 'application/json',
};

try {
const resp = await BaseClient.request(opts, JSON.stringify(data));
const parsed = JSON.parse(resp);
const resp = await this.client.request('POST', pth, JSON.stringify(data), headers);
const body = await resp.readBody();
const statusCode = resp.message.statusCode || 500;
if (statusCode >= 400) {
throw new Error(`(${statusCode}) ${body}`);
}
const parsed = JSON.parse(body);
return parsed['access_token'];
} catch (err) {
throw new Error(
Expand All @@ -129,9 +130,7 @@ export class WorkloadIdentityClient implements AuthClient {
const serviceAccount = await this.getServiceAccount();
const federatedToken = await this.getAuthToken();

const signJWTURL = new URL(
`https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${serviceAccount}:signJwt`,
);
const pth = `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${serviceAccount}:signJwt`;

const data: Record<string, string | Array<string>> = {
payload: unsignedJWT,
Expand All @@ -140,21 +139,20 @@ export class WorkloadIdentityClient implements AuthClient {
data.delegates = delegates;
}

const opts = {
hostname: signJWTURL.hostname,
port: signJWTURL.port,
path: signJWTURL.pathname + signJWTURL.search,
method: 'POST',
headers: {
'Accept': 'application/json',
'Authorization': `Bearer ${federatedToken}`,
'Content-Type': 'application/json',
},
const headers = {
'Accept': 'application/json',
'Authorization': `Bearer ${federatedToken}`,
'Content-Type': 'application/json',
};

try {
const resp = await BaseClient.request(opts, JSON.stringify(data));
const parsed = JSON.parse(resp);
const resp = await this.client.request('POST', pth, JSON.stringify(data), headers);
const body = await resp.readBody();
const statusCode = resp.message.statusCode || 500;
if (statusCode >= 400) {
throw new Error(`(${statusCode}) ${body}`);
}
const parsed = JSON.parse(body);
return parsed['signedJwt'];
} catch (err) {
throw new Error(`Failed to sign JWT using ${serviceAccount}: ${err}`);
Expand Down