Add support for Domain-Wide Delegation (#70)

This commit is contained in:
Seth Vargo 2021-12-02 11:17:06 -05:00 committed by GitHub
parent 057960bb62
commit 8708e498da
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 489 additions and 53 deletions

View File

@ -32,11 +32,6 @@ and permissions on Google Cloud.
configure a Google Cloud Workload Identity Provider. See [setup](#setup) configure a Google Cloud Workload Identity Provider. See [setup](#setup)
for instructions. for instructions.
## Limitations
- This Action does not support authenticating through service accounts via
Domain-Wide Delegation.
## Usage ## Usage
@ -123,7 +118,11 @@ workflow.
- `access_token_lifetime`: (Optional) Desired lifetime duration of the access - `access_token_lifetime`: (Optional) Desired lifetime duration of the access
token, in seconds. This must be specified as the number of seconds with a token, in seconds. This must be specified as the number of seconds with a
trailing "s" (e.g. 30s). The default value is 1 hour (3600s). trailing "s" (e.g. 30s). The default value is 1 hour (3600s). The maximum
value is 1 hour, unless the
[`constraints/iam.allowServiceAccountCredentialLifetimeExtension`
organization policy][orgpolicy-creds-lifetime] is enabled, in which case the
maximum value is 12 hours.
- `access_token_scopes`: (Optional) List of OAuth 2.0 access scopes to be - `access_token_scopes`: (Optional) List of OAuth 2.0 access scopes to be
included in the generated token. This is only valid when "token_format" is included in the generated token. This is only valid when "token_format" is
@ -133,6 +132,20 @@ workflow.
https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/cloud-platform
``` ```
- `access_token_subject`: (Optional) Email address of a user to impersonate
for [Domain-Wide Delegation][dwd]. Access tokens created for Domain-Wide
Delegation cannot have a lifetime beyond 1 hour, even if the
[`constraints/iam.allowServiceAccountCredentialLifetimeExtension`
organization policy][orgpolicy-creds-lifetime] is enabled.
Note: In order to support Domain-Wide Delegation via Workload Identity
Federation, you must grant the external identity ("principalSet")
`roles/iam.serviceAccountTokenCreator` in addition to
`roles/iam.workloadIdentityUser`. The default Workload Identity setup will
only grant the latter role. If you want to use this GitHub Action with
Domain-Wide Delegation, you must manually add the "Service Account Token
Creator" role onto the external identity.
### Generating ID tokens ### Generating ID tokens
The following inputs are for _generating_ ID tokens for authenticating to Google The following inputs are for _generating_ ID tokens for authenticating to Google
@ -286,7 +299,7 @@ Access Token for authenticating to Google Cloud. Most Google Cloud APIs accept
this access token as authentication. this access token as authentication.
The default lifetime is 1 hour, but you can request up to 12 hours if you set The default lifetime is 1 hour, but you can request up to 12 hours if you set
the [`constraints/iam.allowServiceAccountCredentialLifetimeExtension` organization policy](https://cloud.google.com/resource-manager/docs/organization-policy/org-policy-constraints). the [`constraints/iam.allowServiceAccountCredentialLifetimeExtension` organization policy][orgpolicy-creds-lifetime].
Note: If you authenticate via `credentials_json`, the service account must have Note: If you authenticate via `credentials_json`, the service account must have
`roles/iam.serviceAccountTokenCreator` on itself. `roles/iam.serviceAccountTokenCreator` on itself.
@ -513,3 +526,5 @@ mappings, see the [GitHub OIDC token documentation](https://docs.github.com/en/a
[gcloud]: https://cloud.google.com/sdk [gcloud]: https://cloud.google.com/sdk
[map-external]: https://cloud.google.com/iam/docs/access-resources-oidc#impersonate [map-external]: https://cloud.google.com/iam/docs/access-resources-oidc#impersonate
[github-perms]: https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#permissions [github-perms]: https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#permissions
[dwd]: https://developers.google.com/admin-sdk/directory/v1/guides/delegation
[orgpolicy-creds-lifetime]: https://cloud.google.com/resource-manager/docs/organization-policy/org-policy-constraints

View File

@ -92,6 +92,13 @@ inputs:
This is only valid when "token_format" is "access_token". This is only valid when "token_format" is "access_token".
default: 'https://www.googleapis.com/auth/cloud-platform' default: 'https://www.googleapis.com/auth/cloud-platform'
required: false required: false
access_token_subject:
description: |-
Email address of a user to impersonate for Domain-Wide Delegation Access
tokens created for Domain-Wide Delegation cannot have a lifetime beyond 1
hour. This is only valid when "token_format" is "access_token".
default: ''
required: false
# id token params # id token params
id_token_audience: id_token_audience:

177
dist/main/index.js vendored
View File

@ -275,16 +275,28 @@ function run() {
break; break;
} }
case 'access_token': { case 'access_token': {
const accessTokenLifetime = (0, core_1.getInput)('access_token_lifetime'); const accessTokenLifetime = (0, utils_1.parseDuration)((0, core_1.getInput)('access_token_lifetime'));
const accessTokenScopes = (0, utils_1.explodeStrings)((0, core_1.getInput)('access_token_scopes')); const accessTokenScopes = (0, utils_1.explodeStrings)((0, core_1.getInput)('access_token_scopes'));
const accessTokenSubject = (0, core_1.getInput)('access_token_subject');
const serviceAccount = yield client.getServiceAccount(); const serviceAccount = yield client.getServiceAccount();
// If a subject was provided, use the traditional OAuth 2.0 flow to
// perform Domain-Wide Delegation. Otherwise, use the modern IAM
// Credentials endpoints.
let accessToken, expiration;
if (accessTokenSubject) {
const unsignedJWT = (0, utils_1.buildDomainWideDelegationJWT)(serviceAccount, accessTokenSubject, accessTokenScopes, accessTokenLifetime);
const signedJWT = yield client.signJWT(unsignedJWT, delegates);
({ accessToken, expiration } = yield base_1.BaseClient.googleOAuthToken(signedJWT));
}
else {
const authToken = yield client.getAuthToken(); const authToken = yield client.getAuthToken();
const { accessToken, expiration } = yield base_1.BaseClient.googleAccessToken(authToken, { ({ accessToken, expiration } = yield base_1.BaseClient.googleAccessToken(authToken, {
serviceAccount, serviceAccount,
delegates, delegates,
scopes: accessTokenScopes, scopes: accessTokenScopes,
lifetime: accessTokenLifetime, lifetime: accessTokenLifetime,
}); }));
}
(0, core_1.setSecret)(accessToken); (0, core_1.setSecret)(accessToken);
(0, core_1.setOutput)('access_token', accessToken); (0, core_1.setOutput)('access_token', accessToken);
(0, core_1.setOutput)('access_token_expiration', expiration); (0, core_1.setOutput)('access_token_expiration', expiration);
@ -610,7 +622,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod }; return (mod && mod.__esModule) ? mod : { "default": mod };
}; };
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.parseDuration = exports.trimmedString = exports.fromBase64 = exports.toBase64 = exports.explodeStrings = exports.removeExportedCredentials = exports.writeSecureFile = void 0; exports.buildDomainWideDelegationJWT = exports.parseDuration = exports.trimmedString = exports.fromBase64 = exports.toBase64 = exports.explodeStrings = exports.removeExportedCredentials = exports.writeSecureFile = void 0;
const fs_1 = __webpack_require__(747); const fs_1 = __webpack_require__(747);
const crypto_1 = __importDefault(__webpack_require__(417)); const crypto_1 = __importDefault(__webpack_require__(417));
const path_1 = __importDefault(__webpack_require__(622)); const path_1 = __importDefault(__webpack_require__(622));
@ -725,9 +737,9 @@ exports.toBase64 = toBase64;
* encoding with and without padding. * encoding with and without padding.
*/ */
function fromBase64(s) { function fromBase64(s) {
const str = s.replace(/-/g, '+').replace(/_/g, '/'); let str = s.replace(/-/g, '+').replace(/_/g, '/');
while (s.length % 4) while (str.length % 4)
s += '='; str += '=';
return Buffer.from(str, 'base64').toString('utf8'); return Buffer.from(str, 'base64').toString('utf8');
} }
exports.fromBase64 = fromBase64; exports.fromBase64 = fromBase64;
@ -798,6 +810,35 @@ function parseDuration(str) {
return total; return total;
} }
exports.parseDuration = parseDuration; exports.parseDuration = parseDuration;
/**
* buildDomainWideDelegationJWT constructs an _unsigned_ JWT to be used for a
* DWD exchange. The JWT must be signed and then exchanged with the OAuth
* endpoints for a token.
*
* @param serviceAccount Email address of the service account.
* @param subject Email address to use for impersonation.
* @param scopes List of scopes to authorize.
* @param lifetime Number of seconds for which the JWT should be valid.
*/
function buildDomainWideDelegationJWT(serviceAccount, subject, scopes, lifetime) {
const now = Math.floor(new Date().getTime() / 1000);
const body = {
iss: serviceAccount,
aud: 'https://oauth2.googleapis.com/token',
iat: now,
exp: now + lifetime,
};
if (subject && subject.trim().length > 0) {
body.sub = subject;
}
if (scopes && scopes.length > 0) {
// Yes, this is a space delimited list.
// Not a typo, the API expects the field to be "scope" (singular).
body.scope = scopes.join(' ');
}
return JSON.stringify(body);
}
exports.buildDomainWideDelegationJWT = buildDomainWideDelegationJWT;
/***/ }), /***/ }),
@ -1982,7 +2023,33 @@ class CredentialsJSONClient {
return message + '.' + (0, utils_1.toBase64)(signature); return message + '.' + (0, utils_1.toBase64)(signature);
} }
catch (err) { catch (err) {
throw new Error(`Failed to sign auth token using ${this.getServiceAccount()}: ${err}`); throw new Error(`Failed to sign auth token using ${yield this.getServiceAccount()}: ${err}`);
}
});
}
/**
* signJWT signs the given JWT with the private key.
*
* @param unsignedJWT The JWT to sign.
*/
signJWT(unsignedJWT) {
return __awaiter(this, void 0, void 0, function* () {
const header = {
alg: 'RS256',
typ: 'JWT',
kid: __classPrivateFieldGet(this, _CredentialsJSONClient_credentials, "f")['private_key_id'],
};
const message = (0, utils_1.toBase64)(JSON.stringify(header)) + '.' + (0, utils_1.toBase64)(unsignedJWT);
try {
const signer = (0, crypto_1.createSign)('RSA-SHA256');
signer.write(message);
signer.end();
const signature = signer.sign(__classPrivateFieldGet(this, _CredentialsJSONClient_credentials, "f")['private_key']);
const jwt = message + '.' + (0, utils_1.toBase64)(signature);
return jwt;
}
catch (err) {
throw new Error(`Failed to sign JWT using ${yield this.getServiceAccount()}: ${err}`);
} }
}); });
} }
@ -2246,11 +2313,17 @@ class BaseClient {
return __awaiter(this, void 0, void 0, function* () { return __awaiter(this, void 0, void 0, function* () {
const serviceAccountID = `projects/-/serviceAccounts/${serviceAccount}`; const serviceAccountID = `projects/-/serviceAccounts/${serviceAccount}`;
const tokenURL = new url_1.URL(`https://iamcredentials.googleapis.com/v1/${serviceAccountID}:generateAccessToken`); const tokenURL = new url_1.URL(`https://iamcredentials.googleapis.com/v1/${serviceAccountID}:generateAccessToken`);
const data = { const data = {};
delegates: delegates, if (delegates && delegates.length > 0) {
lifetime: lifetime, data.delegates = delegates;
scope: scopes, }
}; if (scopes && scopes.length > 0) {
// Not a typo, the API expects the field to be "scope" (singular).
data.scope = scopes;
}
if (lifetime && lifetime > 0) {
data.lifetime = `${lifetime}s`;
}
const opts = { const opts = {
hostname: tokenURL.hostname, hostname: tokenURL.hostname,
port: tokenURL.port, port: tokenURL.port,
@ -2275,6 +2348,45 @@ class BaseClient {
} }
}); });
} }
/**
* googleOAuthToken generates a Google Cloud OAuth token using the legacy
* OAuth endpoints.
*
* @param assertion A signed JWT.
*/
static googleOAuthToken(assertion) {
return __awaiter(this, void 0, void 0, function* () {
const tokenURL = new url_1.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',
},
};
const data = new url_1.URLSearchParams();
data.append('grant_type', 'urn:ietf:params:oauth:grant-type:jwt-bearer');
data.append('assertion', assertion);
try {
const resp = yield BaseClient.request(opts, data.toString());
const parsed = JSON.parse(resp);
// Normalize the expiration to be a timestamp like the iamcredentials API.
// This API returns the number of seconds until expiration, so convert
// that into a date.
const expiration = new Date(new Date().getTime() + parsed['expires_in'] * 10000);
return {
accessToken: parsed['access_token'],
expiration: expiration.toISOString(),
};
}
catch (err) {
throw new Error(`Failed to generate Google Cloud OAuth token: ${err}`);
}
});
}
} }
exports.BaseClient = BaseClient; exports.BaseClient = BaseClient;
@ -2381,6 +2493,45 @@ class WorkloadIdentityClient {
} }
}); });
} }
/**
* 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.
*/
signJWT(unsignedJWT, delegates) {
return __awaiter(this, void 0, void 0, function* () {
const serviceAccount = yield this.getServiceAccount();
const federatedToken = yield this.getAuthToken();
const signJWTURL = new url_1.URL(`https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${serviceAccount}:signJwt`);
const data = {
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 = yield base_1.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 * 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 * is returned. Otherwise, this will be the project ID that was extracted from

37
dist/post/index.js vendored
View File

@ -449,7 +449,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod }; return (mod && mod.__esModule) ? mod : { "default": mod };
}; };
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.parseDuration = exports.trimmedString = exports.fromBase64 = exports.toBase64 = exports.explodeStrings = exports.removeExportedCredentials = exports.writeSecureFile = void 0; exports.buildDomainWideDelegationJWT = exports.parseDuration = exports.trimmedString = exports.fromBase64 = exports.toBase64 = exports.explodeStrings = exports.removeExportedCredentials = exports.writeSecureFile = void 0;
const fs_1 = __webpack_require__(747); const fs_1 = __webpack_require__(747);
const crypto_1 = __importDefault(__webpack_require__(417)); const crypto_1 = __importDefault(__webpack_require__(417));
const path_1 = __importDefault(__webpack_require__(622)); const path_1 = __importDefault(__webpack_require__(622));
@ -564,9 +564,9 @@ exports.toBase64 = toBase64;
* encoding with and without padding. * encoding with and without padding.
*/ */
function fromBase64(s) { function fromBase64(s) {
const str = s.replace(/-/g, '+').replace(/_/g, '/'); let str = s.replace(/-/g, '+').replace(/_/g, '/');
while (s.length % 4) while (str.length % 4)
s += '='; str += '=';
return Buffer.from(str, 'base64').toString('utf8'); return Buffer.from(str, 'base64').toString('utf8');
} }
exports.fromBase64 = fromBase64; exports.fromBase64 = fromBase64;
@ -637,6 +637,35 @@ function parseDuration(str) {
return total; return total;
} }
exports.parseDuration = parseDuration; exports.parseDuration = parseDuration;
/**
* buildDomainWideDelegationJWT constructs an _unsigned_ JWT to be used for a
* DWD exchange. The JWT must be signed and then exchanged with the OAuth
* endpoints for a token.
*
* @param serviceAccount Email address of the service account.
* @param subject Email address to use for impersonation.
* @param scopes List of scopes to authorize.
* @param lifetime Number of seconds for which the JWT should be valid.
*/
function buildDomainWideDelegationJWT(serviceAccount, subject, scopes, lifetime) {
const now = Math.floor(new Date().getTime() / 1000);
const body = {
iss: serviceAccount,
aud: 'https://oauth2.googleapis.com/token',
iat: now,
exp: now + lifetime,
};
if (subject && subject.trim().length > 0) {
body.sub = subject;
}
if (scopes && scopes.length > 0) {
// Yes, this is a space delimited list.
// Not a typo, the API expects the field to be "scope" (singular).
body.scope = scopes.join(' ');
}
return JSON.stringify(body);
}
exports.buildDomainWideDelegationJWT = buildDomainWideDelegationJWT;
/***/ }), /***/ }),

View File

@ -1,7 +1,7 @@
'use strict'; 'use strict';
import https, { RequestOptions } from 'https'; import https, { RequestOptions } from 'https';
import { URL } from 'url'; import { URL, URLSearchParams } from 'url';
import { import {
GoogleAccessTokenParameters, GoogleAccessTokenParameters,
GoogleAccessTokenResponse, GoogleAccessTokenResponse,
@ -113,11 +113,17 @@ export class BaseClient {
`https://iamcredentials.googleapis.com/v1/${serviceAccountID}:generateAccessToken`, `https://iamcredentials.googleapis.com/v1/${serviceAccountID}:generateAccessToken`,
); );
const data = { const data: Record<string, string | Array<string>> = {};
delegates: delegates, if (delegates && delegates.length > 0) {
lifetime: lifetime, data.delegates = delegates;
scope: scopes, }
}; if (scopes && scopes.length > 0) {
// Not a typo, the API expects the field to be "scope" (singular).
data.scope = scopes;
}
if (lifetime && lifetime > 0) {
data.lifetime = `${lifetime}s`;
}
const opts = { const opts = {
hostname: tokenURL.hostname, hostname: tokenURL.hostname,
@ -142,4 +148,46 @@ export class BaseClient {
throw new Error(`Failed to generate Google Cloud access token for ${serviceAccount}: ${err}`); throw new Error(`Failed to generate Google Cloud access token for ${serviceAccount}: ${err}`);
} }
} }
/**
* googleOAuthToken generates a Google Cloud OAuth token using the legacy
* OAuth endpoints.
*
* @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',
},
};
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);
// Normalize the expiration to be a timestamp like the iamcredentials API.
// This API returns the number of seconds until expiration, so convert
// that into a date.
const expiration = new Date(new Date().getTime() + parsed['expires_in'] * 10000);
return {
accessToken: parsed['access_token'],
expiration: expiration.toISOString(),
};
} catch (err) {
throw new Error(`Failed to generate Google Cloud OAuth token: ${err}`);
}
}
} }

View File

@ -5,6 +5,7 @@
*/ */
export interface AuthClient { export interface AuthClient {
getAuthToken(): Promise<string>; getAuthToken(): Promise<string>;
signJWT(unsignedJWT: string, delegates?: Array<string>): Promise<string>;
getProjectID(): Promise<string>; getProjectID(): Promise<string>;
getServiceAccount(): Promise<string>; getServiceAccount(): Promise<string>;
createCredentialsFile(outputDir: string): Promise<string>; createCredentialsFile(outputDir: string): Promise<string>;
@ -16,17 +17,18 @@ export interface AuthClient {
* *
* https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/generateAccessToken * https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/generateAccessToken
* *
* @param serviceAccount Optional email address or unique identifier of the service * @param serviceAccount Optional email address or unique identifier of the
* account. * service account.
* @param delegates Optional sequence of service accounts in the delegation * @param delegates Optional sequence of service accounts in the delegation
* chain. * chain.
* @param lifetime Optional validity period as a duration. * @param lifetime Optional validity period as a number representing the number
* of seconds.
*/ */
export interface GoogleAccessTokenParameters { export interface GoogleAccessTokenParameters {
serviceAccount?: string; serviceAccount?: string;
delegates?: Array<string>; delegates?: Array<string>;
scopes?: Array<string>; scopes?: Array<string>;
lifetime?: string; lifetime?: number;
} }
/** /**

View File

@ -97,7 +97,34 @@ export class CredentialsJSONClient implements AuthClient {
const signature = signer.sign(this.#credentials['private_key']); const signature = signer.sign(this.#credentials['private_key']);
return message + '.' + toBase64(signature); return message + '.' + toBase64(signature);
} catch (err) { } catch (err) {
throw new Error(`Failed to sign auth token using ${this.getServiceAccount()}: ${err}`); throw new Error(`Failed to sign auth token using ${await this.getServiceAccount()}: ${err}`);
}
}
/**
* signJWT signs the given JWT with the private key.
*
* @param unsignedJWT The JWT to sign.
*/
async signJWT(unsignedJWT: string): Promise<string> {
const header = {
alg: 'RS256',
typ: 'JWT',
kid: this.#credentials['private_key_id'],
};
const message = toBase64(JSON.stringify(header)) + '.' + toBase64(unsignedJWT);
try {
const signer = createSign('RSA-SHA256');
signer.write(message);
signer.end();
const signature = signer.sign(this.#credentials['private_key']);
const jwt = message + '.' + toBase64(signature);
return jwt;
} catch (err) {
throw new Error(`Failed to sign JWT using ${await this.getServiceAccount()}: ${err}`);
} }
} }

View File

@ -108,6 +108,49 @@ export class WorkloadIdentityClient implements AuthClient {
} }
} }
/**
* 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 * 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 * is returned. Otherwise, this will be the project ID that was extracted from

View File

@ -13,7 +13,7 @@ import { WorkloadIdentityClient } from './client/workload_identity_client';
import { CredentialsJSONClient } from './client/credentials_json_client'; import { CredentialsJSONClient } from './client/credentials_json_client';
import { AuthClient } from './client/auth_client'; import { AuthClient } from './client/auth_client';
import { BaseClient } from './base'; import { BaseClient } from './base';
import { explodeStrings } from './utils'; import { buildDomainWideDelegationJWT, explodeStrings, parseDuration } from './utils';
const secretsWarning = const secretsWarning =
'If you are specifying input values via GitHub secrets, ensure the secret ' + 'If you are specifying input values via GitHub secrets, ensure the secret ' +
@ -125,17 +125,33 @@ async function run(): Promise<void> {
break; break;
} }
case 'access_token': { case 'access_token': {
const accessTokenLifetime = getInput('access_token_lifetime'); const accessTokenLifetime = parseDuration(getInput('access_token_lifetime'));
const accessTokenScopes = explodeStrings(getInput('access_token_scopes')); const accessTokenScopes = explodeStrings(getInput('access_token_scopes'));
const accessTokenSubject = getInput('access_token_subject');
const serviceAccount = await client.getServiceAccount(); const serviceAccount = await client.getServiceAccount();
// If a subject was provided, use the traditional OAuth 2.0 flow to
// perform Domain-Wide Delegation. Otherwise, use the modern IAM
// Credentials endpoints.
let accessToken, expiration;
if (accessTokenSubject) {
const unsignedJWT = buildDomainWideDelegationJWT(
serviceAccount,
accessTokenSubject,
accessTokenScopes,
accessTokenLifetime,
);
const signedJWT = await client.signJWT(unsignedJWT, delegates);
({ accessToken, expiration } = await BaseClient.googleOAuthToken(signedJWT));
} else {
const authToken = await client.getAuthToken(); const authToken = await client.getAuthToken();
const { accessToken, expiration } = await BaseClient.googleAccessToken(authToken, { ({ accessToken, expiration } = await BaseClient.googleAccessToken(authToken, {
serviceAccount, serviceAccount,
delegates, delegates,
scopes: accessTokenScopes, scopes: accessTokenScopes,
lifetime: accessTokenLifetime, lifetime: accessTokenLifetime,
}); }));
}
setSecret(accessToken); setSecret(accessToken);
setOutput('access_token', accessToken); setOutput('access_token', accessToken);

View File

@ -118,8 +118,8 @@ export function toBase64(s: string | Buffer): string {
* encoding with and without padding. * encoding with and without padding.
*/ */
export function fromBase64(s: string): string { export function fromBase64(s: string): string {
const str = s.replace(/-/g, '+').replace(/_/g, '/'); let str = s.replace(/-/g, '+').replace(/_/g, '/');
while (s.length % 4) s += '='; while (str.length % 4) str += '=';
return Buffer.from(str, 'base64').toString('utf8'); return Buffer.from(str, 'base64').toString('utf8');
} }
@ -193,3 +193,39 @@ export function parseDuration(str: string): number {
return total; return total;
} }
/**
* buildDomainWideDelegationJWT constructs an _unsigned_ JWT to be used for a
* DWD exchange. The JWT must be signed and then exchanged with the OAuth
* endpoints for a token.
*
* @param serviceAccount Email address of the service account.
* @param subject Email address to use for impersonation.
* @param scopes List of scopes to authorize.
* @param lifetime Number of seconds for which the JWT should be valid.
*/
export function buildDomainWideDelegationJWT(
serviceAccount: string,
subject: string | undefined | null,
scopes: Array<string> | undefined | null,
lifetime: number,
): string {
const now = Math.floor(new Date().getTime() / 1000);
const body: Record<string, string | number> = {
iss: serviceAccount,
aud: 'https://oauth2.googleapis.com/token',
iat: now,
exp: now + lifetime,
};
if (subject && subject.trim().length > 0) {
body.sub = subject;
}
if (scopes && scopes.length > 0) {
// Yes, this is a space delimited list.
// Not a typo, the API expects the field to be "scope" (singular).
body.scope = scopes.join(' ');
}
return JSON.stringify(body);
}

View File

@ -3,8 +3,9 @@
import 'mocha'; import 'mocha';
import { expect } from 'chai'; import { expect } from 'chai';
import { tmpdir } from 'os';
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
import { tmpdir } from 'os';
import { CredentialsJSONClient } from '../../src/client/credentials_json_client'; import { CredentialsJSONClient } from '../../src/client/credentials_json_client';
// Yes, this is a real private key. No, it's not valid for authenticating // Yes, this is a real private key. No, it's not valid for authenticating
@ -54,7 +55,18 @@ describe('CredentialsJSONClient', () => {
}); });
const token = await client.getAuthToken(); const token = await client.getAuthToken();
expect(token).to.not.be.null; expect(token).to.be;
});
});
describe('#signJWT', () => {
it('signs a jwt', async () => {
const client = new CredentialsJSONClient({
credentialsJSON: credentialsJSON,
});
const token = await client.signJWT('thisismy.jwt');
expect(token).to.be;
}); });
}); });

View File

@ -7,6 +7,7 @@ import { tmpdir } from 'os';
import { existsSync, readFileSync } from 'fs'; import { existsSync, readFileSync } from 'fs';
import { import {
buildDomainWideDelegationJWT,
explodeStrings, explodeStrings,
fromBase64, fromBase64,
parseDuration, parseDuration,
@ -248,4 +249,53 @@ describe('Utils', () => {
}); });
}); });
}); });
describe('#buildDomainWideDelegationJWT', () => {
const cases = [
{
name: 'default',
serviceAccount: 'my-service@example.com',
lifetime: 1000,
},
{
name: 'with subject',
serviceAccount: 'my-service@example.com',
subject: 'my-subject',
lifetime: 1000,
},
{
name: 'with scopes',
serviceAccount: 'my-service@example.com',
scopes: ['scope1', 'scope2'],
lifetime: 1000,
},
];
cases.forEach((tc) => {
it(tc.name, async () => {
const val = buildDomainWideDelegationJWT(
tc.serviceAccount,
tc.subject,
tc.scopes,
tc.lifetime,
);
const body = JSON.parse(val);
expect(body.iss).to.eq(tc.serviceAccount);
expect(body.aud).to.eq('https://oauth2.googleapis.com/token');
if (tc.subject) {
expect(body.sub).to.eq(tc.subject);
} else {
expect(body.sub).to.not.be;
}
if (tc.scopes) {
expect(body.scope).to.eq(tc.scopes.join(' '));
} else {
expect(body.scope).to.not.be;
}
});
});
});
}); });