diff --git a/README.md b/README.md index fb1404d..289ee89 100644 --- a/README.md +++ b/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 diff --git a/action.yml b/action.yml index 0446237..92b0f5f 100644 --- a/action.yml +++ b/action.yml @@ -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: diff --git a/dist/main/index.js b/dist/main/index.js index 0f16b0b..198255a 100644 --- a/dist/main/index.js +++ b/dist/main/index.js @@ -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 diff --git a/dist/post/index.js b/dist/post/index.js index eeba608..c3a000e 100644 --- a/dist/post/index.js +++ b/dist/post/index.js @@ -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; /***/ }), diff --git a/src/base.ts b/src/base.ts index 8acaa26..2e3181b 100644 --- a/src/base.ts +++ b/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> = {}; + 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 { + 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}`); + } + } } diff --git a/src/client/auth_client.ts b/src/client/auth_client.ts index a38ea6a..9bb0029 100644 --- a/src/client/auth_client.ts +++ b/src/client/auth_client.ts @@ -5,6 +5,7 @@ */ export interface AuthClient { getAuthToken(): Promise; + signJWT(unsignedJWT: string, delegates?: Array): Promise; getProjectID(): Promise; getServiceAccount(): Promise; createCredentialsFile(outputDir: string): Promise; @@ -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; scopes?: Array; - lifetime?: string; + lifetime?: number; } /** diff --git a/src/client/credentials_json_client.ts b/src/client/credentials_json_client.ts index 945cc19..fde9f83 100644 --- a/src/client/credentials_json_client.ts +++ b/src/client/credentials_json_client.ts @@ -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 { + 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}`); } } diff --git a/src/client/workload_identity_client.ts b/src/client/workload_identity_client.ts index 6bfeced..8187551 100644 --- a/src/client/workload_identity_client.ts +++ b/src/client/workload_identity_client.ts @@ -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): Promise { + 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> = { + 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 diff --git a/src/main.ts b/src/main.ts index e617bfd..9f616d8 100644 --- a/src/main.ts +++ b/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 { 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); diff --git a/src/utils.ts b/src/utils.ts index da450eb..164bcff 100644 --- a/src/utils.ts +++ b/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 | undefined | null, + lifetime: number, +): string { + const now = Math.floor(new Date().getTime() / 1000); + + const body: Record = { + 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); +} diff --git a/tests/client/credentials_json_client.test.ts b/tests/client/credentials_json_client.test.ts index 63f8587..85cfe97 100644 --- a/tests/client/credentials_json_client.test.ts +++ b/tests/client/credentials_json_client.test.ts @@ -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; }); }); diff --git a/tests/utils.test.ts b/tests/utils.test.ts index 47f1a8a..4b40d3d 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -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; + } + }); + }); + }); });