/
workload_identity_client.ts
214 lines (188 loc) · 6.75 KB
/
workload_identity_client.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
'use strict';
import { URL } from 'url';
import { writeSecureFile } from '@google-github-actions/actions-utils';
import { AuthClient } from './auth_client';
import { BaseClient } from '../base';
/**
* Available options to create the WorkloadIdentityClient.
*
* @param projectID User-supplied value for project ID. If not provided, the
* project ID is extracted from the service account email.
* @param providerID Full path (including project, location, etc) to the Google
* Cloud Workload Identity Provider.
* @param serviceAccount Email address or unique identifier of the service
* account to impersonate
* @param token GitHub OIDC token to use for exchanging with Workload Identity
* Federation.
* @param audience The value for the audience parameter in the generated GitHub
* Actions OIDC token, defaults to the value of workload_identity_provider
*/
interface WorkloadIdentityClientOptions {
projectID?: string;
providerID: string;
serviceAccount: string;
token: string;
audience: string;
oidcTokenRequestURL: string;
oidcTokenRequestToken: string;
}
/**
* WorkloadIdentityClient is a client that uses the GitHub Actions runtime to
* authentication via Workload Identity.
*/
export class WorkloadIdentityClient implements AuthClient {
readonly #projectID: string;
readonly #providerID: string;
readonly #serviceAccount: string;
readonly #token: string;
readonly #audience: string;
readonly #oidcTokenRequestURL: string;
readonly #oidcTokenRequestToken: string;
constructor(opts: WorkloadIdentityClientOptions) {
this.#providerID = opts.providerID;
this.#serviceAccount = opts.serviceAccount;
this.#token = opts.token;
this.#audience = opts.audience;
this.#oidcTokenRequestURL = opts.oidcTokenRequestURL;
this.#oidcTokenRequestToken = opts.oidcTokenRequestToken;
this.#projectID =
opts.projectID || this.extractProjectIDFromServiceAccountEmail(this.#serviceAccount);
}
/**
* extractProjectIDFromServiceAccountEmail extracts the project ID from the
* service account email address.
*/
extractProjectIDFromServiceAccountEmail(str: string): string {
if (!str) {
return '';
}
const [, dn] = str.split('@', 2);
if (!str.endsWith('.iam.gserviceaccount.com')) {
throw new Error(
`Service account email ${str} is not of the form ` +
`"[name]@[project].iam.gserviceaccount.com. You must manually ` +
`specify the "project_id" parameter in your GitHub Actions workflow.`,
);
}
const [project] = dn.split('.', 2);
return project;
}
/**
* getAuthToken generates a Google Cloud federated token using the provided
* OIDC token and Workload Identity Provider.
*/
async getAuthToken(): Promise<string> {
const stsURL = new URL('https://sts.googleapis.com/v1/token');
const data = {
audience: '//iam.googleapis.com/' + this.#providerID,
grantType: 'urn:ietf:params:oauth:grant-type:token-exchange',
requestedTokenType: 'urn:ietf:params:oauth:token-type:access_token',
scope: 'https://www.googleapis.com/auth/cloud-platform',
subjectTokenType: 'urn:ietf:params:oauth:token-type:jwt',
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',
},
};
try {
const resp = await BaseClient.request(opts, JSON.stringify(data));
const parsed = JSON.parse(resp);
return parsed['access_token'];
} catch (err) {
throw new Error(
`Failed to generate Google Cloud federated token for ${this.#providerID}: ${err}`,
);
}
}
/**
* signJWT signs the given JWT using the IAM credentials endpoint.
*
* @param unsignedJWT The JWT to sign.
* @param delegates List of service account email address to use for
* impersonation in the delegation chain to sign the JWT.
*/
async signJWT(unsignedJWT: string, delegates?: Array<string>): Promise<string> {
const serviceAccount = await this.getServiceAccount();
const federatedToken = await this.getAuthToken();
const signJWTURL = new URL(
`https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${serviceAccount}:signJwt`,
);
const data: Record<string, string | Array<string>> = {
payload: unsignedJWT,
};
if (delegates && delegates.length > 0) {
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',
},
};
try {
const resp = await BaseClient.request(opts, JSON.stringify(data));
const parsed = JSON.parse(resp);
return parsed['signedJwt'];
} catch (err) {
throw new Error(`Failed to sign JWT using ${serviceAccount}: ${err}`);
}
}
/**
* getProjectID returns the project ID. If an override was given, the override
* is returned. Otherwise, this will be the project ID that was extracted from
* the service account key JSON.
*/
async getProjectID(): Promise<string> {
return this.#projectID;
}
/**
* getServiceAccount returns the service account email for the authentication,
* extracted from the input parameter.
*/
async getServiceAccount(): Promise<string> {
return this.#serviceAccount;
}
/**
* createCredentialsFile creates a Google Cloud credentials file that can be
* set as GOOGLE_APPLICATION_CREDENTIALS for gcloud and client libraries.
*/
async createCredentialsFile(outputPath: string): Promise<string> {
const requestURL = new URL(this.#oidcTokenRequestURL);
// Append the audience value to the request.
const params = requestURL.searchParams;
params.set('audience', this.#audience);
requestURL.search = params.toString();
const data = {
type: 'external_account',
audience: `//iam.googleapis.com/${this.#providerID}`,
subject_token_type: 'urn:ietf:params:oauth:token-type:jwt',
token_url: 'https://sts.googleapis.com/v1/token',
service_account_impersonation_url: `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${
this.#serviceAccount
}:generateAccessToken`,
credential_source: {
url: requestURL,
headers: {
Authorization: `Bearer ${this.#oidcTokenRequestToken}`,
},
format: {
type: 'json',
subject_token_field_name: 'value',
},
},
};
return await writeSecureFile(outputPath, JSON.stringify(data));
}
}