Add support for Domain-Wide Delegation (#70)
This commit is contained in:
parent
057960bb62
commit
8708e498da
29
README.md
29
README.md
@ -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
|
||||||
|
@ -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
177
dist/main/index.js
vendored
@ -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
37
dist/post/index.js
vendored
@ -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;
|
||||||
|
|
||||||
|
|
||||||
/***/ }),
|
/***/ }),
|
||||||
|
60
src/base.ts
60
src/base.ts
@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
24
src/main.ts
24
src/main.ts
@ -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);
|
||||||
|
40
src/utils.ts
40
src/utils.ts
@ -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);
|
||||||
|
}
|
||||||
|
@ -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;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user