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)
|
||||
for instructions.
|
||||
|
||||
## Limitations
|
||||
|
||||
- This Action does not support authenticating through service accounts via
|
||||
Domain-Wide Delegation.
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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.
|
||||
@ -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
|
||||
|
@ -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:
|
||||
|
187
dist/main/index.js
vendored
187
dist/main/index.js
vendored
@ -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);
|
||||
@ -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));
|
||||
@ -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;
|
||||
@ -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;
|
||||
|
||||
|
||||
/***/ }),
|
||||
@ -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}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -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,
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
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 };
|
||||
};
|
||||
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));
|
||||
@ -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;
|
||||
@ -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;
|
||||
|
||||
|
||||
/***/ }),
|
||||
|
60
src/base.ts
60
src/base.ts
@ -1,7 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
import https, { RequestOptions } from 'https';
|
||||
import { URL } from 'url';
|
||||
import { URL, URLSearchParams } from 'url';
|
||||
import {
|
||||
GoogleAccessTokenParameters,
|
||||
GoogleAccessTokenResponse,
|
||||
@ -113,11 +113,17 @@ export class BaseClient {
|
||||
`https://iamcredentials.googleapis.com/v1/${serviceAccountID}:generateAccessToken`,
|
||||
);
|
||||
|
||||
const data = {
|
||||
delegates: delegates,
|
||||
lifetime: lifetime,
|
||||
scope: scopes,
|
||||
};
|
||||
const data: Record<string, string | Array<string>> = {};
|
||||
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,
|
||||
@ -142,4 +148,46 @@ export class BaseClient {
|
||||
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 {
|
||||
getAuthToken(): Promise<string>;
|
||||
signJWT(unsignedJWT: string, delegates?: Array<string>): Promise<string>;
|
||||
getProjectID(): Promise<string>;
|
||||
getServiceAccount(): 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
|
||||
*
|
||||
* @param serviceAccount Optional email address or unique identifier of the service
|
||||
* account.
|
||||
* @param serviceAccount Optional email address or unique identifier of the
|
||||
* service account.
|
||||
* @param delegates Optional sequence of service accounts in the delegation
|
||||
* 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 {
|
||||
serviceAccount?: string;
|
||||
delegates?: 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']);
|
||||
return message + '.' + 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 ${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
|
||||
* is returned. Otherwise, this will be the project ID that was extracted from
|
||||
|
34
src/main.ts
34
src/main.ts
@ -13,7 +13,7 @@ import { WorkloadIdentityClient } from './client/workload_identity_client';
|
||||
import { CredentialsJSONClient } from './client/credentials_json_client';
|
||||
import { AuthClient } from './client/auth_client';
|
||||
import { BaseClient } from './base';
|
||||
import { explodeStrings } from './utils';
|
||||
import { buildDomainWideDelegationJWT, explodeStrings, parseDuration } from './utils';
|
||||
|
||||
const secretsWarning =
|
||||
'If you are specifying input values via GitHub secrets, ensure the secret ' +
|
||||
@ -125,17 +125,33 @@ async function run(): Promise<void> {
|
||||
break;
|
||||
}
|
||||
case 'access_token': {
|
||||
const accessTokenLifetime = getInput('access_token_lifetime');
|
||||
const accessTokenLifetime = parseDuration(getInput('access_token_lifetime'));
|
||||
const accessTokenScopes = explodeStrings(getInput('access_token_scopes'));
|
||||
const accessTokenSubject = getInput('access_token_subject');
|
||||
const serviceAccount = await client.getServiceAccount();
|
||||
|
||||
const authToken = await client.getAuthToken();
|
||||
const { accessToken, expiration } = await 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 = buildDomainWideDelegationJWT(
|
||||
serviceAccount,
|
||||
accessTokenSubject,
|
||||
accessTokenScopes,
|
||||
accessTokenLifetime,
|
||||
);
|
||||
const signedJWT = await client.signJWT(unsignedJWT, delegates);
|
||||
({ accessToken, expiration } = await BaseClient.googleOAuthToken(signedJWT));
|
||||
} else {
|
||||
const authToken = await client.getAuthToken();
|
||||
({ accessToken, expiration } = await BaseClient.googleAccessToken(authToken, {
|
||||
serviceAccount,
|
||||
delegates,
|
||||
scopes: accessTokenScopes,
|
||||
lifetime: accessTokenLifetime,
|
||||
}));
|
||||
}
|
||||
|
||||
setSecret(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.
|
||||
*/
|
||||
export function fromBase64(s: string): string {
|
||||
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');
|
||||
}
|
||||
|
||||
@ -193,3 +193,39 @@ export function parseDuration(str: string): number {
|
||||
|
||||
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 { expect } from 'chai';
|
||||
|
||||
import { tmpdir } from 'os';
|
||||
import { readFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
import { CredentialsJSONClient } from '../../src/client/credentials_json_client';
|
||||
|
||||
// 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();
|
||||
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 {
|
||||
buildDomainWideDelegationJWT,
|
||||
explodeStrings,
|
||||
fromBase64,
|
||||
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