Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implements the OAuth token exchange spec based on rfc8693 #1026

Merged
merged 5 commits into from
Aug 11, 2020
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions src/auth/stscredentials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
// Copyright 2020 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, GaxiosResponse} from 'gaxios';
import * as querystring from 'querystring';

import {DefaultTransporter} from '../transporters';
import {Headers} from './oauth2client';
import {
ClientAuthentication,
OAuthClientAuthHandler,
OAuthErrorResponse,
getErrorFromOAuthErrorResponse,
} from './oauth2common';

/**
* Defines the interface needed to initialize an StsCredentials instance.
* The interface does not directly map to the spec and instead is converted
* to be compliant with the JavaScript style guide. This is because this is
* instantiated internally.
* StsCredentials implement the OAuth 2.0 token exchange based on
* https://tools.ietf.org/html/rfc8693.
* Request options are defined in
* https://tools.ietf.org/html/rfc8693#section-2.1
*/
export interface StsCredentialsOptions {
grantType: string;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if the spec lays these out clearly, but I like to pull whatever text they have (if of appropriate value and length) to include in comments above the interface properties. Like:

/**
 * REQUIRED.  The value "urn:ietf:params:oauth:grant-type:token-exchange" indicates that a token exchange is being performed.
*/
grantType: string;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

resource?: string;
audience?: string;
scope?: string[];
requestedTokenType?: string;
subjectToken: string;
subjectTokenType: string;
actingParty?: {
actorToken: string;
actorTokenType: string;
};
}

/**
* Defines the standard request options as defined by the OAuth token
* exchange spec: https://tools.ietf.org/html/rfc8693#section-2.1
*/
interface StsRequestOptions {
grant_type: string;
resource?: string;
audience?: string;
scope?: string;
requested_token_type?: string;
subject_token: string;
subject_token_type: string;
actor_token?: string;
actor_token_type?: string;
client_id?: string;
client_secret?: string;
[key: string]: string | undefined;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is less than ideal :/ It effectively means any string key is value here with a string value. Is this really an open property bag?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok. Currently there is only one non-standard field here. I manually added it.

}

/**
* Defines the OAuth 2.0 token exchange successful response based on
* https://tools.ietf.org/html/rfc8693#section-2.2.1
*/
export interface StsSuccessfulResponse {
access_token: string;
issued_token_type: string;
token_type: string;
expires_in: number;
refresh_token?: string;
scope: string;
res?: GaxiosResponse | null;
}

/**
* Implements the OAuth 2.0 token exchange based on
* https://tools.ietf.org/html/rfc8693
*/
export class StsCredentials extends OAuthClientAuthHandler {
private transporter: DefaultTransporter;

/**
* Initializes an STS credentials instance.
* @param tokenExchangeEndpoint The token exchange endpoint.
* @param clientAuthentication The client authentication credentials if
* available.
*/
constructor(
private readonly tokenExchangeEndpoint: string,
clientAuthentication?: ClientAuthentication
) {
super(clientAuthentication);
this.transporter = new DefaultTransporter();
}

/**
* Exchanges the provided token for another type of token based on the
* rfc8693 spec.
* @param stsCredentialsOptions The token exchange options used to populate
* the token exchange request.
* @param additionalHeaders Optional additional headers to pass along the
* request.
* @param options Optional additional GCP-specific non-spec defined options
* to send with the request.
* Example: `&options=${encodeUriComponent(JSON.stringified(options))}`
* @return A promise that resolves with the token exchange response containing
* the requested token and its expiration time.
*/
async exchangeToken(
stsCredentialsOptions: StsCredentialsOptions,
additionalHeaders?: Headers,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
options?: {[key: string]: any}
): Promise<StsSuccessfulResponse> {
const values: StsRequestOptions = {
grant_type: stsCredentialsOptions.grantType,
resource: stsCredentialsOptions.resource,
audience: stsCredentialsOptions.audience,
scope: stsCredentialsOptions.scope?.join(' '),
requested_token_type: stsCredentialsOptions.requestedTokenType,
subject_token: stsCredentialsOptions.subjectToken,
subject_token_type: stsCredentialsOptions.subjectTokenType,
actor_token: stsCredentialsOptions.actingParty?.actorToken,
actor_token_type: stsCredentialsOptions.actingParty?.actorTokenType,
// Non-standard GCP-specific options.
options: options && JSON.stringify(options),
};
// Remove undefined fields.
Object.keys(values).forEach(key => {
if (typeof values[key] === 'undefined') {
delete values[key];
}
});

const headers = {
'Content-Type': 'application/x-www-form-urlencoded',
};
// Inject additional STS headers if available.
Object.assign(headers, additionalHeaders || {});

const opts: GaxiosOptions = {
url: this.tokenExchangeEndpoint,
method: 'POST',
headers,
data: querystring.stringify(values),
responseType: 'json',
};
// Apply OAuth client authentication.
this.applyClientAuthenticationOptions(opts);

try {
const response = await this.transporter.request<StsSuccessfulResponse>(
opts
);
// Successful response.
const stsSuccessfulResponse = response.data;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious - why return the response object along with the data?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly following the existing pattern established here and elsewhere in that file.

stsSuccessfulResponse.res = response;
return stsSuccessfulResponse;
} catch (error) {
// Translate error to OAuthError.
if (error.response) {
throw getErrorFromOAuthErrorResponse(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to double check - does this method create a new Error, or modify the message on the existing? I want to be about not creating a new error, because we want to preserve the original stack trace.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was creating a new error and not preserving the original error data. I have extended this to preserve the original error data (including stack) but to modify the error message.

error.response.data as OAuthErrorResponse
);
}
// Request could fail before the server responds.
throw error;
}
}
}
Loading