Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Add support for Domain-Wide Delegation #70

Merged
merged 3 commits into from
Dec 2, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 exchangd 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 exchangd 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
Loading