Skip to content

Commit

Permalink
fix(downscoped-client): bug fixes for downscoped client implementatio…
Browse files Browse the repository at this point in the history
…n. (#1219)
  • Loading branch information
xil222 committed Jul 29, 2021
1 parent 1578f23 commit 4fbe67e
Show file tree
Hide file tree
Showing 7 changed files with 294 additions and 103 deletions.
2 changes: 1 addition & 1 deletion src/auth/baseexternalclient.ts
Expand Up @@ -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:
Expand Down
120 changes: 84 additions & 36 deletions src/auth/downscopedclient.ts
Expand Up @@ -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.
Expand All @@ -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[];
};
Expand All @@ -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.'
Expand All @@ -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.
Expand All @@ -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<GetAccessTokenResponse> {
async getAccessToken(): Promise<DownscopedAccessTokenResponse> {
// 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:
Expand All @@ -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<T>(opts: GaxiosOptions): GaxiosPromise<T>;
request<T>(opts: GaxiosOptions, callback: BodyResponseCallback<T>): void;
Expand All @@ -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<CredentialsWithResponse> {
// 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,
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/auth/stscredentials.ts
Expand Up @@ -122,7 +122,7 @@ export interface StsSuccessfulResponse {
token_type: string;
expires_in: number;
refresh_token?: string;
scope: string;
scope?: string;
res?: GaxiosResponse | null;
}

Expand Down
1 change: 0 additions & 1 deletion src/index.ts
Expand Up @@ -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';
Expand Down
21 changes: 0 additions & 21 deletions test/externalclienthelper.ts
Expand Up @@ -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`;
Expand All @@ -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 {
Expand Down

0 comments on commit 4fbe67e

Please sign in to comment.