Skip to content

Commit

Permalink
Add support for Domain-Wide Delegation (#70)
Browse files Browse the repository at this point in the history
  • Loading branch information
sethvargo committed Dec 2, 2021
1 parent 057960b commit 8708e49
Show file tree
Hide file tree
Showing 12 changed files with 489 additions and 53 deletions.
29 changes: 22 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,6 @@ and permissions on Google Cloud.
configure a Google Cloud Workload Identity Provider. See [setup](#setup)
for instructions.

## Limitations

- This Action does not support authenticating through service accounts via
Domain-Wide Delegation.


## Usage

Expand Down Expand Up @@ -123,7 +118,11 @@ workflow.

- `access_token_lifetime`: (Optional) Desired lifetime duration of the access
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
included in the generated token. This is only valid when "token_format" is
Expand All @@ -133,6 +132,20 @@ workflow.
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

The following inputs are for _generating_ ID tokens for authenticating to Google
Expand Down Expand Up @@ -286,7 +299,7 @@ Access Token for authenticating to Google Cloud. Most Google Cloud APIs accept
this access token as authentication.

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
`roles/iam.serviceAccountTokenCreator` on itself.
Expand Down Expand Up @@ -513,3 +526,5 @@ mappings, see the [GitHub OIDC token documentation](https://docs.github.com/en/a
[gcloud]: https://cloud.google.com/sdk
[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
[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
7 changes: 7 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ inputs:
This is only valid when "token_format" is "access_token".
default: 'https://www.googleapis.com/auth/cloud-platform'
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_audience:
Expand Down
187 changes: 169 additions & 18 deletions dist/main/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -275,16 +275,28 @@ function run() {
break;
}
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 accessTokenSubject = (0, core_1.getInput)('access_token_subject');
const serviceAccount = yield client.getServiceAccount();
const authToken = yield client.getAuthToken();
const { accessToken, expiration } = yield base_1.BaseClient.googleAccessToken(authToken, {
serviceAccount,
delegates,
scopes: accessTokenScopes,
lifetime: accessTokenLifetime,
});
// 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();
({ accessToken, expiration } = yield base_1.BaseClient.googleAccessToken(authToken, {
serviceAccount,
delegates,
scopes: accessTokenScopes,
lifetime: accessTokenLifetime,
}));
}
(0, core_1.setSecret)(accessToken);
(0, core_1.setOutput)('access_token', accessToken);
(0, core_1.setOutput)('access_token_expiration', expiration);
Expand Down Expand Up @@ -610,7 +622,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
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 crypto_1 = __importDefault(__webpack_require__(417));
const path_1 = __importDefault(__webpack_require__(622));
Expand Down Expand Up @@ -725,9 +737,9 @@ exports.toBase64 = toBase64;
* encoding with and without padding.
*/
function fromBase64(s) {
const str = s.replace(/-/g, '+').replace(/_/g, '/');
while (s.length % 4)
s += '=';
let str = s.replace(/-/g, '+').replace(/_/g, '/');
while (str.length % 4)
str += '=';
return Buffer.from(str, 'base64').toString('utf8');
}
exports.fromBase64 = fromBase64;
Expand Down Expand Up @@ -798,6 +810,35 @@ function parseDuration(str) {
return total;
}
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;


/***/ }),
Expand Down Expand Up @@ -1982,7 +2023,33 @@ class CredentialsJSONClient {
return message + '.' + (0, utils_1.toBase64)(signature);
}
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}`);
}
});
}
Expand Down Expand Up @@ -2246,11 +2313,17 @@ class BaseClient {
return __awaiter(this, void 0, void 0, function* () {
const serviceAccountID = `projects/-/serviceAccounts/${serviceAccount}`;
const tokenURL = new url_1.URL(`https://iamcredentials.googleapis.com/v1/${serviceAccountID}:generateAccessToken`);
const data = {
delegates: delegates,
lifetime: lifetime,
scope: scopes,
};
const data = {};
if (delegates && delegates.length > 0) {
data.delegates = delegates;
}
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 = {
hostname: tokenURL.hostname,
port: tokenURL.port,
Expand All @@ -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;

Expand Down Expand Up @@ -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
* is returned. Otherwise, this will be the project ID that was extracted from
Expand Down
37 changes: 33 additions & 4 deletions dist/post/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
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 crypto_1 = __importDefault(__webpack_require__(417));
const path_1 = __importDefault(__webpack_require__(622));
Expand Down Expand Up @@ -564,9 +564,9 @@ exports.toBase64 = toBase64;
* encoding with and without padding.
*/
function fromBase64(s) {
const str = s.replace(/-/g, '+').replace(/_/g, '/');
while (s.length % 4)
s += '=';
let str = s.replace(/-/g, '+').replace(/_/g, '/');
while (str.length % 4)
str += '=';
return Buffer.from(str, 'base64').toString('utf8');
}
exports.fromBase64 = fromBase64;
Expand Down Expand Up @@ -637,6 +637,35 @@ function parseDuration(str) {
return total;
}
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;


/***/ }),
Expand Down

0 comments on commit 8708e49

Please sign in to comment.