diff --git a/src/auth/baseexternalclient.ts b/src/auth/baseexternalclient.ts index ea33f4e9..af93a74d 100644 --- a/src/auth/baseexternalclient.ts +++ b/src/auth/baseexternalclient.ts @@ -215,7 +215,7 @@ export abstract class BaseExternalAccountClient extends AuthClient { /** * The main authentication interface. It takes an optional url which when - * present is the endpoint> being accessed, and returns a Promise which + * present is the endpoint being accessed, and returns a Promise which * resolves with authorization header fields. * * The result has the form: diff --git a/src/auth/downscopedclient.ts b/src/auth/downscopedclient.ts index b7768cc8..bbd3d147 100644 --- a/src/auth/downscopedclient.ts +++ b/src/auth/downscopedclient.ts @@ -34,7 +34,13 @@ const STS_REQUEST_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token'; */ const STS_SUBJECT_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token'; /** The STS access token exchange end point. */ -const STS_ACCESS_TOKEN_URL = 'https://sts.googleapis.com/v1beta/token'; +const STS_ACCESS_TOKEN_URL = 'https://sts.googleapis.com/v1/token'; + +/** + * The maximum number of access boundary rules a Credential Access Boundary + * can contain. + */ +export const MAX_ACCESS_BOUNDARY_RULES_COUNT = 10; /** * Offset to take into account network delays and server clock skews. @@ -48,10 +54,18 @@ interface CredentialsWithResponse extends Credentials { res?: GaxiosResponse | null; } +/** + * Internal interface for tracking and returning the Downscoped access token + * expiration time in epoch time (seconds). + */ +interface DownscopedAccessTokenResponse extends GetAccessTokenResponse { + expirationTime?: number | null; +} + /** * Defines an upper bound of permissions available for a GCP credential. */ -interface CredentialAccessBoundary { +export interface CredentialAccessBoundary { accessBoundary: { accessBoundaryRules: AccessBoundaryRule[]; }; @@ -74,35 +88,67 @@ interface AvailabilityCondition { description?: string; } +/** + * Defines a set of Google credentials that are downscoped from an existing set + * of Google OAuth2 credentials. This is useful to restrict the Identity and + * Access Management (IAM) permissions that a short-lived credential can use. + * The common pattern of usage is to have a token broker with elevated access + * generate these downscoped credentials from higher access source credentials + * and pass the downscoped short-lived access tokens to a token consumer via + * some secure authenticated channel for limited access to Google Cloud Storage + * resources. + */ export class DownscopedClient extends AuthClient { - /** - * OAuth scopes for the GCP access token to use. When not provided, - * the default https://www.googleapis.com/auth/cloud-platform is - * used. - */ private cachedDownscopedAccessToken: CredentialsWithResponse | null; private readonly stsCredential: sts.StsCredentials; - public readonly authClient: AuthClient; - public readonly credentialAccessBoundary: CredentialAccessBoundary; public readonly eagerRefreshThresholdMillis: number; public readonly forceRefreshOnFailure: boolean; + /** + * Instantiates a downscoped client object using the provided source + * AuthClient and credential access boundary rules. + * To downscope permissions of a source AuthClient, a Credential Access + * Boundary that specifies which resources the new credential can access, as + * well as an upper bound on the permissions that are available on each + * resource, has to be defined. A downscoped client can then be instantiated + * using the source AuthClient and the Credential Access Boundary. + * @param authClient The source AuthClient to be downscoped based on the + * provided Credential Access Boundary rules. + * @param credentialAccessBoundary The Credential Access Boundary which + * contains a list of access boundary rules. Each rule contains information + * on the resource that the rule applies to, the upper bound of the + * permissions that are available on that resource and an optional + * condition to further restrict permissions. + * @param additionalOptions Optional additional behavior customization + * options. These currently customize expiration threshold time and + * whether to retry on 401/403 API request errors. + */ constructor( - private client: AuthClient, - private cab: CredentialAccessBoundary, + private readonly authClient: AuthClient, + private readonly credentialAccessBoundary: CredentialAccessBoundary, additionalOptions?: RefreshOptions ) { super(); - - // Check a number of 1-10 access boundary rules are defined within credential access boundary. - if (cab.accessBoundary.accessBoundaryRules.length === 0) { + // Check 1-10 Access Boundary Rules are defined within Credential Access + // Boundary. + if ( + credentialAccessBoundary.accessBoundary.accessBoundaryRules.length === 0 + ) { throw new Error('At least one access boundary rule needs to be defined.'); - } else if (cab.accessBoundary.accessBoundaryRules.length > 10) { - throw new Error('Access boundary rule exceeds limit, max 10 allowed.'); + } else if ( + credentialAccessBoundary.accessBoundary.accessBoundaryRules.length > + MAX_ACCESS_BOUNDARY_RULES_COUNT + ) { + throw new Error( + 'The provided access boundary has more than ' + + `${MAX_ACCESS_BOUNDARY_RULES_COUNT} access boundary rules.` + ); } - // Check at least one permission should be defined in each access boundary rule. - for (const rule of cab.accessBoundary.accessBoundaryRules) { + // Check at least one permission should be defined in each Access Boundary + // Rule. + for (const rule of credentialAccessBoundary.accessBoundary + .accessBoundaryRules) { if (rule.availablePermissions.length === 0) { throw new Error( 'At least one permission should be defined in access boundary rules.' @@ -111,10 +157,7 @@ export class DownscopedClient extends AuthClient { } this.stsCredential = new sts.StsCredentials(STS_ACCESS_TOKEN_URL); - // Default OAuth scope. This could be overridden via public property. this.cachedDownscopedAccessToken = null; - this.credentialAccessBoundary = cab; - this.authClient = client; // As threshold could be zero, // eagerRefreshThresholdMillis || EXPIRATION_TIME_OFFSET will override the // zero value. @@ -129,39 +172,42 @@ export class DownscopedClient extends AuthClient { /** * Provides a mechanism to inject Downscoped access tokens directly. - * When the provided credential expires, a new credential, using the - * external account options are retrieved. - * Notice DownscopedClient is the broker class mainly used for generate - * downscoped access tokens, it is unlikely we call this function in real - * use case. - * We implement to make this a helper function for testing all cases in getAccessToken(). + * The expiry_date field is required to facilitate determination of the token + * expiration which would make it easier for the token consumer to handle. * @param credentials The Credentials object to set on the current client. */ setCredentials(credentials: Credentials) { + if (!credentials.expiry_date) { + throw new Error( + 'The access token expiry_date field is missing in the provided ' + + 'credentials.' + ); + } super.setCredentials(credentials); this.cachedDownscopedAccessToken = credentials; } - async getAccessToken(): Promise { + async getAccessToken(): Promise { // If the cached access token is unavailable or expired, force refresh. - // The Downscoped access token will be returned in GetAccessTokenResponse format. - // If cached access token is unavailable or expired, force refresh. + // The Downscoped access token will be returned in + // DownscopedAccessTokenResponse format. if ( !this.cachedDownscopedAccessToken || this.isExpired(this.cachedDownscopedAccessToken) ) { await this.refreshAccessTokenAsync(); } - // Return Downscoped access token in GetAccessTokenResponse format. + // Return Downscoped access token in DownscopedAccessTokenResponse format. return { token: this.cachedDownscopedAccessToken!.access_token, + expirationTime: this.cachedDownscopedAccessToken!.expiry_date, res: this.cachedDownscopedAccessToken!.res, }; } /** * The main authentication interface. It takes an optional url which when - * present is the endpoint> being accessed, and returns a Promise which + * present is the endpoint being accessed, and returns a Promise which * resolves with authorization header fields. * * The result has the form: @@ -178,7 +224,7 @@ export class DownscopedClient extends AuthClient { * @param opts Request options. * @param callback callback. * @return A promise that resolves with the HTTP response when no callback - * is provided. + * is provided. */ request(opts: GaxiosOptions): GaxiosPromise; request(opts: GaxiosOptions, callback: BodyResponseCallback): void; @@ -192,13 +238,14 @@ export class DownscopedClient extends AuthClient { /** * Forces token refresh, even if unexpired tokens are currently cached. * GCP access tokens are retrieved from authclient object/source credential. - * Thenm GCP access tokens are exchanged for downscoped access tokens via the + * Then GCP access tokens are exchanged for downscoped access tokens via the * token exchange endpoint. * @return A promise that resolves with the fresh downscoped access token. */ protected async refreshAccessTokenAsync(): Promise { // Retrieve GCP access token from source credential. - const subjectToken = await (await this.authClient.getAccessToken()).token; + const subjectToken = (await this.authClient.getAccessToken()).token; + // Construct the STS credentials options. const stsCredentialsOptions: sts.StsCredentialsOptions = { grantType: STS_GRANT_TYPE, @@ -207,7 +254,8 @@ export class DownscopedClient extends AuthClient { subjectTokenType: STS_SUBJECT_TOKEN_TYPE, }; - // Exchange the source access token for a Downscoped access token. + // Exchange the source AuthClient access token for a Downscoped access + // token. const stsResponse = await this.stsCredential.exchangeToken( stsCredentialsOptions, undefined, diff --git a/src/auth/stscredentials.ts b/src/auth/stscredentials.ts index 65b22d0c..3fe2b2ee 100644 --- a/src/auth/stscredentials.ts +++ b/src/auth/stscredentials.ts @@ -122,7 +122,7 @@ export interface StsSuccessfulResponse { token_type: string; expires_in: number; refresh_token?: string; - scope: string; + scope?: string; res?: GaxiosResponse | null; } diff --git a/src/index.ts b/src/index.ts index a0fa19ce..35cf959c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,7 +22,6 @@ export { } from './auth/credentials'; export {GCPEnv} from './auth/envDetect'; export {GoogleAuthOptions, ProjectIdCallback} from './auth/googleauth'; -export {DownscopedClient} from './auth/downscopedclient'; export {IAMAuth, RequestMetadata} from './auth/iam'; export {IdTokenClient, IdTokenProvider} from './auth/idtokenclient'; export {Claims, JWTAccess} from './auth/jwtaccess'; diff --git a/test/externalclienthelper.ts b/test/externalclienthelper.ts index 71aec662..391616e2 100644 --- a/test/externalclienthelper.ts +++ b/test/externalclienthelper.ts @@ -51,7 +51,6 @@ const poolId = 'POOL_ID'; const providerId = 'PROVIDER_ID'; const baseUrl = 'https://sts.googleapis.com'; const path = '/v1/token'; -const betaPath = '/v1beta/token'; const saEmail = 'service-1234@service-name.iam.gserviceaccount.com'; const saBaseUrl = 'https://iamcredentials.googleapis.com'; const saPath = `/v1/projects/-/serviceAccounts/${saEmail}:generateAccessToken`; @@ -76,26 +75,6 @@ export function mockStsTokenExchange( return scope; } -export function mockStsBetaTokenExchange( - nockParams: NockMockStsToken[] -): nock.Scope { - const scope = nock(baseUrl); - nockParams.forEach(nockMockStsToken => { - const headers = Object.assign( - { - 'content-type': 'application/x-www-form-urlencoded', - }, - nockMockStsToken.additionalHeaders || {} - ); - scope - .post(betaPath, qs.stringify(nockMockStsToken.request), { - reqheaders: headers, - }) - .reply(nockMockStsToken.statusCode, nockMockStsToken.response); - }); - return scope; -} - export function mockGenerateAccessToken( nockParams: NockMockGenerateAccessToken[] ): nock.Scope { diff --git a/test/test.downscopedclient.ts b/test/test.downscopedclient.ts index 06d445b0..b857aac8 100644 --- a/test/test.downscopedclient.ts +++ b/test/test.downscopedclient.ts @@ -13,18 +13,51 @@ // limitations under the License. import * as assert from 'assert'; -import {describe, it, before, after, beforeEach, afterEach} from 'mocha'; +import {describe, it, beforeEach, afterEach} from 'mocha'; import * as nock from 'nock'; import * as sinon from 'sinon'; +import {GaxiosOptions, GaxiosPromise} from 'gaxios'; import {StsSuccessfulResponse} from '../src/auth/stscredentials'; -import {DownscopedClient} from '../src/auth/downscopedclient'; +import { + DownscopedClient, + CredentialAccessBoundary, + MAX_ACCESS_BOUNDARY_RULES_COUNT, +} from '../src/auth/downscopedclient'; import {AuthClient} from '../src/auth/authclient'; -import {mockStsBetaTokenExchange} from './externalclienthelper'; -import {GoogleAuth} from '../src'; +import {mockStsTokenExchange} from './externalclienthelper'; +import { + OAuthErrorResponse, + getErrorFromOAuthErrorResponse, +} from '../src/auth/oauth2common'; +import {GetAccessTokenResponse, Headers} from '../src/auth/oauth2client'; nock.disableNetConnect(); +/** A dummy class used as source credential for testing. */ +class TestAuthClient extends AuthClient { + public throwError = false; + private counter = 0; + + async getAccessToken(): Promise { + if (!this.throwError) { + // Increment subject_token counter each time this is called. + return { + token: `subject_token_${this.counter++}`, + }; + } + throw new Error('Cannot get subject token.'); + } + + async getRequestHeaders(url?: string): Promise { + throw new Error('Not implemented.'); + } + + request(opts: GaxiosOptions): GaxiosPromise { + throw new Error('Not implemented.'); + } +} + interface SampleResponse { foo: string; bar: number; @@ -32,12 +65,7 @@ interface SampleResponse { describe('DownscopedClient', () => { let clock: sinon.SinonFakeTimers; - - const auth = new GoogleAuth({ - keyFilename: './test/fixtures/private.json', - scopes: 'https://www.googleapis.com/auth/cloud-platform', - }); - let client: AuthClient; + let client: TestAuthClient; const ONE_HOUR_IN_SECS = 3600; const testAvailableResource = @@ -67,7 +95,6 @@ describe('DownscopedClient', () => { issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', token_type: 'Bearer', expires_in: ONE_HOUR_IN_SECS, - scope: 'scope1 scope2', }; /** * Offset to take into account network delays and server clock skews. @@ -75,7 +102,7 @@ describe('DownscopedClient', () => { const EXPIRATION_TIME_OFFSET = 5 * 60 * 1000; beforeEach(async () => { - client = await auth.getClient(); + client = new TestAuthClient(); }); afterEach(() => { @@ -100,13 +127,14 @@ describe('DownscopedClient', () => { }, expectedError); }); - it('should throw on exceed number of access boundary rules', () => { + it('should throw when number of access boundary rules is exceeded', () => { const expectedError = new Error( - 'Access boundary rule exceeds limit, max 10 allowed.' + 'The provided access boundary has more than ' + + `${MAX_ACCESS_BOUNDARY_RULES_COUNT} access boundary rules.` ); - const cabWithExceedingAccessBoundaryRules = { + const cabWithExceedingAccessBoundaryRules: CredentialAccessBoundary = { accessBoundary: { - accessBoundaryRules: [] as any, + accessBoundaryRules: [], }, }; const testAccessBoundaryRule = { @@ -116,7 +144,7 @@ describe('DownscopedClient', () => { expression: testAvailabilityConditionExpression, }, }; - for (let num = 0; num <= 10; num++) { + for (let num = 0; num <= MAX_ACCESS_BOUNDARY_RULES_COUNT; num++) { cabWithExceedingAccessBoundaryRules.accessBoundary.accessBoundaryRules.push( testAccessBoundaryRule ); @@ -269,24 +297,50 @@ describe('DownscopedClient', () => { }); }); - describe('getAccessToken()', () => { - let sandbox: sinon.SinonSandbox; - before(() => { - const expectedAccessTokenResponse = { - token: 'subject_token', + describe('setCredential()', () => { + it('should throw error if no expire time is set in credential', async () => { + const credentials = { + access_token: 'DOWNSCOPED_CLIENT_ACCESS_TOKEN', }; - sandbox = sinon.createSandbox(); - sandbox - .stub(client, 'getAccessToken') - .resolves(expectedAccessTokenResponse); + const expectedError = new Error( + 'The access token expiry_date field is missing in the provided ' + + 'credentials.' + ); + const downscopedClient = new DownscopedClient( + client, + testClientAccessBoundary + ); + assert.throws(() => { + downscopedClient.setCredentials(credentials); + }, expectedError); }); - after(() => { - sandbox.restore(); + it('should not throw error if expire time is set in credential', async () => { + const now = new Date().getTime(); + const credentials = { + access_token: 'DOWNSCOPED_CLIENT_ACCESS_TOKEN', + expiry_date: now + ONE_HOUR_IN_SECS * 1000, + }; + const downscopedClient = new DownscopedClient( + client, + testClientAccessBoundary + ); + assert.doesNotThrow(() => { + downscopedClient.setCredentials(credentials); + }); + const tokenResponse = await downscopedClient.getAccessToken(); + assert.deepStrictEqual(tokenResponse.token, credentials.access_token); + assert.deepStrictEqual( + tokenResponse.expirationTime, + credentials.expiry_date + ); }); + }); + describe('getAccessToken()', () => { it('should return current unexpired cached DownscopedClient access token', async () => { const now = new Date().getTime(); + clock = sinon.useFakeTimers(now); const credentials = { access_token: 'DOWNSCOPED_CLIENT_ACCESS_TOKEN', expiry_date: now + ONE_HOUR_IN_SECS * 1000, @@ -298,6 +352,29 @@ describe('DownscopedClient', () => { downscopedClient.setCredentials(credentials); const tokenResponse = await downscopedClient.getAccessToken(); assert.deepStrictEqual(tokenResponse.token, credentials.access_token); + assert.deepStrictEqual( + tokenResponse.expirationTime, + credentials.expiry_date + ); + assert.deepStrictEqual( + tokenResponse.token, + downscopedClient.credentials.access_token + ); + assert.deepStrictEqual( + tokenResponse.expirationTime, + downscopedClient.credentials.expiry_date + ); + + clock.tick(ONE_HOUR_IN_SECS * 1000 - EXPIRATION_TIME_OFFSET - 1); + const cachedTokenResponse = await downscopedClient.getAccessToken(); + assert.deepStrictEqual( + cachedTokenResponse.token, + credentials.access_token + ); + assert.deepStrictEqual( + cachedTokenResponse.expirationTime, + credentials.expiry_date + ); }); it('should refresh a new DownscopedClient access when cached one gets expired', async () => { @@ -307,7 +384,7 @@ describe('DownscopedClient', () => { access_token: 'DOWNSCOPED_CLIENT_ACCESS_TOKEN', expiry_date: now + ONE_HOUR_IN_SECS * 1000, }; - const scope = mockStsBetaTokenExchange([ + const scope = mockStsTokenExchange([ { statusCode: 200, response: stsSuccessfulResponse, @@ -315,11 +392,9 @@ describe('DownscopedClient', () => { grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', requested_token_type: 'urn:ietf:params:oauth:token-type:access_token', - subject_token: 'subject_token', + subject_token: 'subject_token_0', subject_token_type: 'urn:ietf:params:oauth:token-type:access_token', - options: - testClientAccessBoundary && - JSON.stringify(testClientAccessBoundary), + options: JSON.stringify(testClientAccessBoundary), }, }, ]); @@ -336,16 +411,31 @@ describe('DownscopedClient', () => { clock.tick(1); const refreshedTokenResponse = await downscopedClient.getAccessToken(); - + const expectedExpirationTime = + credentials.expiry_date + + stsSuccessfulResponse.expires_in * 1000 - + EXPIRATION_TIME_OFFSET; assert.deepStrictEqual( refreshedTokenResponse.token, stsSuccessfulResponse.access_token ); + assert.deepStrictEqual( + refreshedTokenResponse.expirationTime, + expectedExpirationTime + ); + assert.deepStrictEqual( + refreshedTokenResponse.token, + downscopedClient.credentials.access_token + ); + assert.deepStrictEqual( + refreshedTokenResponse.expirationTime, + downscopedClient.credentials.expiry_date + ); scope.done(); }); - it('should return new DownscopedClient access token when there is no cached downscoped access token', async () => { - const scope = mockStsBetaTokenExchange([ + it('should return new access token when no cached token is available', async () => { + const scope = mockStsTokenExchange([ { statusCode: 200, response: stsSuccessfulResponse, @@ -353,27 +443,103 @@ describe('DownscopedClient', () => { grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', requested_token_type: 'urn:ietf:params:oauth:token-type:access_token', - subject_token: 'subject_token', + subject_token: 'subject_token_0', subject_token_type: 'urn:ietf:params:oauth:token-type:access_token', - options: - testClientAccessBoundary && - JSON.stringify(testClientAccessBoundary), + options: JSON.stringify(testClientAccessBoundary), }, }, ]); - const downscopedClient = new DownscopedClient( client, testClientAccessBoundary ); + assert.deepStrictEqual(downscopedClient.credentials, {}); const tokenResponse = await downscopedClient.getAccessToken(); - assert.deepStrictEqual( tokenResponse.token, stsSuccessfulResponse.access_token ); + assert.deepStrictEqual( + tokenResponse.token, + downscopedClient.credentials.access_token + ); + assert.deepStrictEqual( + tokenResponse.expirationTime, + downscopedClient.credentials.expiry_date + ); + scope.done(); + }); + + it('should handle underlying token exchange errors', async () => { + const errorResponse: OAuthErrorResponse = { + error: 'invalid_request', + error_description: 'Invalid subject token', + error_uri: 'https://tools.ietf.org/html/rfc6749#section-5.2', + }; + const scope = mockStsTokenExchange([ + { + statusCode: 400, + response: errorResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + subject_token: 'subject_token_0', + subject_token_type: 'urn:ietf:params:oauth:token-type:access_token', + options: JSON.stringify(testClientAccessBoundary), + }, + }, + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + subject_token: 'subject_token_1', + subject_token_type: 'urn:ietf:params:oauth:token-type:access_token', + options: JSON.stringify(testClientAccessBoundary), + }, + }, + ]); + + const downscopedClient = new DownscopedClient( + client, + testClientAccessBoundary + ); + assert.deepStrictEqual(downscopedClient.credentials, {}); + await assert.rejects( + downscopedClient.getAccessToken(), + getErrorFromOAuthErrorResponse(errorResponse) + ); + assert.deepStrictEqual(downscopedClient.credentials, {}); + // Next try should succeed. + const actualResponse = await downscopedClient.getAccessToken(); + delete actualResponse.res; + assert.deepStrictEqual( + actualResponse.token, + stsSuccessfulResponse.access_token + ); + assert.deepStrictEqual( + actualResponse.token, + downscopedClient.credentials.access_token + ); + assert.deepStrictEqual( + actualResponse.expirationTime, + downscopedClient.credentials.expiry_date + ); scope.done(); }); + + it('should throw when the source AuthClient rejects on token request', async () => { + const expectedError = new Error('Cannot get subject token.'); + client.throwError = true; + const downscopedClient = new DownscopedClient( + client, + testClientAccessBoundary + ); + await assert.rejects(downscopedClient.getAccessToken(), expectedError); + }); }); describe('getRequestHeader()', () => { diff --git a/test/test.index.ts b/test/test.index.ts index 822eda1c..3d9b0457 100644 --- a/test/test.index.ts +++ b/test/test.index.ts @@ -42,7 +42,6 @@ describe('index', () => { assert(gal.IdentityPoolClient); assert(gal.AwsClient); assert(gal.BaseExternalAccountClient); - assert(gal.DownscopedClient); assert(gal.Impersonated); }); });