Skip to content

Commit

Permalink
feat: Implement DownscopedClient#getAccessToken() and unit test (#1201)
Browse files Browse the repository at this point in the history
* Use iam client library to setup test

* fix: Update Node.js integration test setup to use iam client library.

* fix: revert package.json changes

* fix lint errors and improve code

* direct use iam

* update package version

* Implement DownscopedClient#getAccessToken() and unit test

Co-authored-by: Justin Beckwith <justin.beckwith@gmail.com>
  • Loading branch information
xil222 and JustinBeckwith authored Jun 30, 2021
1 parent 75e74a9 commit faa6677
Show file tree
Hide file tree
Showing 5 changed files with 683 additions and 0 deletions.
254 changes: 254 additions & 0 deletions src/auth/downscopedclient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
// Copyright 2021 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import {GaxiosOptions, GaxiosPromise, GaxiosResponse} from 'gaxios';

import {BodyResponseCallback} from '../transporters';
import {Credentials} from './credentials';
import {AuthClient} from './authclient';

import {GetAccessTokenResponse, Headers, RefreshOptions} from './oauth2client';
import * as sts from './stscredentials';

/**
* The required token exchange grant_type: rfc8693#section-2.1
*/
const STS_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:token-exchange';
/**
* The requested token exchange requested_token_type: rfc8693#section-2.1
*/
const STS_REQUEST_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token';
/**
* The requested token exchange subject_token_type: rfc8693#section-2.1
*/
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';

/**
* Offset to take into account network delays and server clock skews.
*/
export const EXPIRATION_TIME_OFFSET = 5 * 60 * 1000;

/**
* Internal interface for tracking the access token expiration time.
*/
interface CredentialsWithResponse extends Credentials {
res?: GaxiosResponse | null;
}

/**
* Defines an upper bound of permissions available for a GCP credential.
*/
interface CredentialAccessBoundary {
accessBoundary: {
accessBoundaryRules: AccessBoundaryRule[];
};
}

/** Defines an upper bound of permissions on a particular resource. */
interface AccessBoundaryRule {
availablePermissions: string[];
availableResource: string;
availabilityCondition?: AvailabilityCondition;
}

/**
* An optional condition that can be used as part of a
* CredentialAccessBoundary to further restrict permissions.
*/
interface AvailabilityCondition {
expression: string;
title?: string;
description?: string;
}

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;

constructor(
private client: AuthClient,
private cab: 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) {
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.');
}

// Check at least one permission should be defined in each access boundary rule.
for (const rule of cab.accessBoundary.accessBoundaryRules) {
if (rule.availablePermissions.length === 0) {
throw new Error(
'At least one permission should be defined in access boundary rules.'
);
}
}

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.
if (typeof additionalOptions?.eagerRefreshThresholdMillis !== 'number') {
this.eagerRefreshThresholdMillis = EXPIRATION_TIME_OFFSET;
} else {
this.eagerRefreshThresholdMillis = additionalOptions!
.eagerRefreshThresholdMillis as number;
}
this.forceRefreshOnFailure = !!additionalOptions?.forceRefreshOnFailure;
}

/**
* 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().
* @param credentials The Credentials object to set on the current client.
*/
setCredentials(credentials: Credentials) {
super.setCredentials(credentials);
this.cachedDownscopedAccessToken = credentials;
}

async getAccessToken(): Promise<GetAccessTokenResponse> {
// 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.
if (
!this.cachedDownscopedAccessToken ||
this.isExpired(this.cachedDownscopedAccessToken)
) {
await this.refreshAccessTokenAsync();
}
// Return Downscoped access token in GetAccessTokenResponse format.
return {
token: this.cachedDownscopedAccessToken!.access_token,
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
* resolves with authorization header fields.
*
* The result has the form:
* { Authorization: 'Bearer <access_token_value>' }
*/
async getRequestHeaders(): Promise<Headers> {
throw new Error('Not implemented.');
}

/**
* Provides a request implementation with OAuth 2.0 flow. In cases of
* HTTP 401 and 403 responses, it automatically asks for a new access token
* and replays the unsuccessful request.
* @param opts Request options.
* @param callback callback.
* @return A promise that resolves with the HTTP response when no callback
* is provided.
*/
request<T>(opts: GaxiosOptions): GaxiosPromise<T>;
request<T>(opts: GaxiosOptions, callback: BodyResponseCallback<T>): void;
request<T>(
opts: GaxiosOptions,
callback?: BodyResponseCallback<T>
): GaxiosPromise<T> | void {
throw new Error('Not implemented.');
}

/**
* 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
* token exchange endpoint.
* @return A promise that resolves with the fresh downscoped access token.
*/
protected async refreshAccessTokenAsync(): Promise<CredentialsWithResponse> {
// Retrieve GCP access token from source credential.
const subjectToken = await (await this.authClient.getAccessToken()).token;
// Construct the STS credentials options.
const stsCredentialsOptions: sts.StsCredentialsOptions = {
grantType: STS_GRANT_TYPE,
requestedTokenType: STS_REQUEST_TOKEN_TYPE,
subjectToken: subjectToken as string,
subjectTokenType: STS_SUBJECT_TOKEN_TYPE,
};

// Exchange the source access token for a Downscoped access token.
const stsResponse = await this.stsCredential.exchangeToken(
stsCredentialsOptions,
undefined,
this.credentialAccessBoundary
);

// Save response in cached access token.
this.cachedDownscopedAccessToken = {
access_token: stsResponse.access_token,
expiry_date: new Date().getTime() + stsResponse.expires_in * 1000,
res: stsResponse.res,
};

// Save credentials.
this.credentials = {};
Object.assign(this.credentials, this.cachedDownscopedAccessToken);
delete (this.credentials as CredentialsWithResponse).res;

// Trigger tokens event to notify external listeners.
this.emit('tokens', {
refresh_token: null,
expiry_date: this.cachedDownscopedAccessToken!.expiry_date,
access_token: this.cachedDownscopedAccessToken!.access_token,
token_type: 'Bearer',
id_token: null,
});
// Return the cached access token.
return this.cachedDownscopedAccessToken;
}

/**
* Returns whether the provided credentials are expired or not.
* If there is no expiry time, assumes the token is not expired or expiring.
* @param downscopedAccessToken The credentials to check for expiration.
* @return Whether the credentials are expired or not.
*/
private isExpired(downscopedAccessToken: Credentials): boolean {
const now = new Date().getTime();
return downscopedAccessToken.expiry_date
? now >=
downscopedAccessToken.expiry_date - this.eagerRefreshThresholdMillis
: false;
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ 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';
Expand Down
21 changes: 21 additions & 0 deletions test/externalclienthelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ 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`;
Expand All @@ -75,6 +76,26 @@ 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 {
Expand Down
Loading

0 comments on commit faa6677

Please sign in to comment.