Add support for Domain-Wide Delegation (#70)

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

View File

@ -32,11 +32,6 @@ and permissions on Google Cloud.
configure a Google Cloud Workload Identity Provider. See [setup](#setup)
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

View File

@ -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:

177
dist/main/index.js vendored
View File

@ -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();
// 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 { accessToken, expiration } = yield base_1.BaseClient.googleAccessToken(authToken, {
({ 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
View File

@ -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;
/***/ }),

View File

@ -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}`);
}
}
}

View File

@ -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;
}
/**

View File

@ -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}`);
}
}

View File

@ -108,6 +108,49 @@ export class WorkloadIdentityClient implements AuthClient {
}
}
/**
* signJWT signs the given JWT using the IAM credentials endpoint.
*
* @param unsignedJWT The JWT to sign.
* @param delegates List of service account email address to use for
* impersonation in the delegation chain to sign the JWT.
*/
async signJWT(unsignedJWT: string, delegates?: Array<string>): Promise<string> {
const serviceAccount = await this.getServiceAccount();
const federatedToken = await this.getAuthToken();
const signJWTURL = new URL(
`https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${serviceAccount}:signJwt`,
);
const data: Record<string, string | Array<string>> = {
payload: unsignedJWT,
};
if (delegates && delegates.length > 0) {
data.delegates = delegates;
}
const opts = {
hostname: signJWTURL.hostname,
port: signJWTURL.port,
path: signJWTURL.pathname + signJWTURL.search,
method: 'POST',
headers: {
'Accept': 'application/json',
'Authorization': `Bearer ${federatedToken}`,
'Content-Type': 'application/json',
},
};
try {
const resp = await BaseClient.request(opts, JSON.stringify(data));
const parsed = JSON.parse(resp);
return parsed['signedJwt'];
} catch (err) {
throw new Error(`Failed to sign JWT using ${serviceAccount}: ${err}`);
}
}
/**
* getProjectID returns the project ID. If an override was given, the override
* is returned. Otherwise, this will be the project ID that was extracted from

View File

@ -13,7 +13,7 @@ import { WorkloadIdentityClient } from './client/workload_identity_client';
import { CredentialsJSONClient } from './client/credentials_json_client';
import { 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();
// 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 { accessToken, expiration } = await BaseClient.googleAccessToken(authToken, {
({ accessToken, expiration } = await BaseClient.googleAccessToken(authToken, {
serviceAccount,
delegates,
scopes: accessTokenScopes,
lifetime: accessTokenLifetime,
});
}));
}
setSecret(accessToken);
setOutput('access_token', accessToken);

View File

@ -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);
}

View File

@ -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;
});
});

View File

@ -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;
}
});
});
});
});