diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a72fc4..4a26b86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# 1.2.0 + +* Add a `getParameter()` parameter to `new AuthorizationCodeGrant()`, `new + Credentials()`, and `resourceOwnerPasswordGrant()`. This controls how the + authorization server's response is parsed for servers that don't provide the + standard JSON response. + # 1.1.1 * `resourceOwnerPasswordGrant()` now properly uses its HTTP client for requests diff --git a/lib/src/authorization_code_grant.dart b/lib/src/authorization_code_grant.dart index faec8c9..bacb680 100644 --- a/lib/src/authorization_code_grant.dart +++ b/lib/src/authorization_code_grant.dart @@ -5,10 +5,12 @@ import 'dart:async'; import 'package:http/http.dart' as http; +import 'package:http_parser/http_parser.dart'; import 'client.dart'; import 'authorization_exception.dart'; import 'handle_access_token_response.dart'; +import 'parameters.dart'; import 'utils.dart'; /// A class for obtaining credentials via an [authorization code grant][]. @@ -26,6 +28,9 @@ import 'utils.dart'; /// /// [authorization code grant]: http://tools.ietf.org/html/draft-ietf-oauth-v2-31#section-4.1 class AuthorizationCodeGrant { + /// The function used to parse parameters from a host's response. + final GetParameters _getParameters; + /// The client identifier for this client. /// /// The authorization server will issue each client a separate client @@ -105,15 +110,27 @@ class AuthorizationCodeGrant { /// The scope strings will be separated by the provided [delimiter]. This /// defaults to `" "`, the OAuth2 standard, but some APIs (such as Facebook's) /// use non-standard delimiters. + /// + /// By default, this follows the OAuth2 spec and requires the server's + /// responses to be in JSON format. However, some servers return non-standard + /// response formats, which can be parsed using the [getParameters] function. + /// + /// This function is passed the `Content-Type` header of the response as well + /// as its body as a UTF-8-decoded string. It should return a map in the same + /// format as the [standard JSON response][]. + /// + /// [standard JSON response]: https://tools.ietf.org/html/rfc6749#section-5.1 AuthorizationCodeGrant( this.identifier, this.authorizationEndpoint, this.tokenEndpoint, {this.secret, String delimiter, bool basicAuth: true, - http.Client httpClient}) + http.Client httpClient, + Map getParameters(MediaType contentType, String body)}) : _basicAuth = basicAuth, _httpClient = httpClient == null ? new http.Client() : httpClient, - _delimiter = delimiter ?? ' '; + _delimiter = delimiter ?? ' ', + _getParameters = getParameters ?? parseJsonParameters; /// Returns the URL to which the resource owner should be redirected to /// authorize this client. @@ -266,7 +283,8 @@ class AuthorizationCodeGrant { headers: headers, body: body); var credentials = handleAccessTokenResponse( - response, tokenEndpoint, startTime, _scopes, _delimiter); + response, tokenEndpoint, startTime, _scopes, _delimiter, + getParameters: _getParameters); return new Client(credentials, identifier: this.identifier, secret: this.secret, diff --git a/lib/src/credentials.dart b/lib/src/credentials.dart index 06a89d0..4bd22a1 100644 --- a/lib/src/credentials.dart +++ b/lib/src/credentials.dart @@ -7,8 +7,10 @@ import 'dart:collection'; import 'dart:convert'; import 'package:http/http.dart' as http; +import 'package:http_parser/http_parser.dart'; import 'handle_access_token_response.dart'; +import 'parameters.dart'; import 'utils.dart'; /// Credentials that prove that a client is allowed to access a resource on the @@ -57,6 +59,9 @@ class Credentials { /// expiration date. final DateTime expiration; + /// The function used to parse parameters from a host's response. + final GetParameters _getParameters; + /// Whether or not these credentials have expired. /// /// Note that it's possible the credentials will expire shortly after this is @@ -78,17 +83,29 @@ class Credentials { /// The scope strings will be separated by the provided [delimiter]. This /// defaults to `" "`, the OAuth2 standard, but some APIs (such as Facebook's) /// use non-standard delimiters. + /// + /// By default, this follows the OAuth2 spec and requires the server's + /// responses to be in JSON format. However, some servers return non-standard + /// response formats, which can be parsed using the [getParameters] function. + /// + /// This function is passed the `Content-Type` header of the response as well + /// as its body as a UTF-8-decoded string. It should return a map in the same + /// format as the [standard JSON response][]. + /// + /// [standard JSON response]: https://tools.ietf.org/html/rfc6749#section-5.1 Credentials(this.accessToken, {this.refreshToken, this.tokenEndpoint, Iterable scopes, this.expiration, - String delimiter}) + String delimiter, + Map getParameters(MediaType mediaType, String body)}) : scopes = new UnmodifiableListView( // Explicitly type-annotate the list literal to work around // sdk#24202. scopes == null ? [] : scopes.toList()), - _delimiter = delimiter ?? ' '; + _delimiter = delimiter ?? ' ', + _getParameters = getParameters ?? parseJsonParameters; /// Loads a set of credentials from a JSON-serialized form. /// @@ -208,7 +225,8 @@ class Credentials { var response = await httpClient.post(tokenEndpoint, headers: headers, body: body); var credentials = await handleAccessTokenResponse( - response, tokenEndpoint, startTime, scopes, _delimiter); + response, tokenEndpoint, startTime, scopes, _delimiter, + getParameters: _getParameters); // The authorization server may issue a new refresh token. If it doesn't, // we should re-use the one we already have. diff --git a/lib/src/handle_access_token_response.dart b/lib/src/handle_access_token_response.dart index 7f03f1f..84fac67 100644 --- a/lib/src/handle_access_token_response.dart +++ b/lib/src/handle_access_token_response.dart @@ -2,14 +2,12 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. -import 'dart:convert'; - -import 'package:collection/collection.dart'; import 'package:http/http.dart' as http; import 'package:http_parser/http_parser.dart'; import 'credentials.dart'; import 'authorization_exception.dart'; +import 'parameters.dart'; /// The amount of time to add as a "grace period" for credential expiration. /// @@ -23,80 +21,86 @@ const _expirationGrace = const Duration(seconds: 10); /// This response format is common across several different components of the /// OAuth2 flow. /// -/// The scope strings will be separated by the provided [delimiter]. +/// By default, this follows the OAuth2 spec and requires the server's responses +/// to be in JSON format. However, some servers return non-standard response +/// formats, which can be parsed using the [getParameters] function. +/// +/// This function is passed the `Content-Type` header of the response as well as +/// its body as a UTF-8-decoded string. It should return a map in the same +/// format as the [standard JSON response][]. +/// +/// [standard JSON response]: https://tools.ietf.org/html/rfc6749#section-5.1 Credentials handleAccessTokenResponse(http.Response response, Uri tokenEndpoint, - DateTime startTime, List scopes, String delimiter) { - if (response.statusCode != 200) _handleErrorResponse(response, tokenEndpoint); - - validate(condition, message) => - _validate(response, tokenEndpoint, condition, message); - - var contentTypeString = response.headers['content-type']; - var contentType = - contentTypeString == null ? null : new MediaType.parse(contentTypeString); - - // The spec requires a content-type of application/json, but some endpoints - // (e.g. Dropbox) serve it as text/javascript instead. - validate( - contentType != null && - (contentType.mimeType == "application/json" || - contentType.mimeType == "text/javascript"), - 'content-type was "$contentType", expected "application/json"'); + DateTime startTime, List scopes, String delimiter, + {Map getParameters(MediaType contentType, String body)}) { + getParameters ??= parseJsonParameters; - Map parameters; try { - var untypedParameters = JSON.decode(response.body); - validate(untypedParameters is Map, - 'parameters must be a map, was "$parameters"'); - parameters = DelegatingMap.typed(untypedParameters); - } on FormatException { - validate(false, 'invalid JSON'); - } - - for (var requiredParameter in ['access_token', 'token_type']) { - validate(parameters.containsKey(requiredParameter), - 'did not contain required parameter "$requiredParameter"'); - validate( - parameters[requiredParameter] is String, - 'required parameter "$requiredParameter" was not a string, was ' - '"${parameters[requiredParameter]}"'); - } + if (response.statusCode != 200) { + _handleErrorResponse(response, tokenEndpoint, getParameters); + } - // TODO(nweiz): support the "mac" token type - // (http://tools.ietf.org/html/draft-ietf-oauth-v2-http-mac-01) - validate(parameters['token_type'].toLowerCase() == 'bearer', - '"$tokenEndpoint": unknown token type "${parameters['token_type']}"'); + var contentTypeString = response.headers['content-type']; + if (contentTypeString == null) { + throw new FormatException('Missing Content-Type string.'); + } - var expiresIn = parameters['expires_in']; - validate(expiresIn == null || expiresIn is int, - 'parameter "expires_in" was not an int, was "$expiresIn"'); + var parameters = + getParameters(new MediaType.parse(contentTypeString), response.body); + + for (var requiredParameter in ['access_token', 'token_type']) { + if (!parameters.containsKey(requiredParameter)) { + throw new FormatException( + 'did not contain required parameter "$requiredParameter"'); + } else if (parameters[requiredParameter] is! String) { + throw new FormatException( + 'required parameter "$requiredParameter" was not a string, was ' + '"${parameters[requiredParameter]}"'); + } + } - for (var name in ['refresh_token', 'scope']) { - var value = parameters[name]; - validate(value == null || value is String, - 'parameter "$name" was not a string, was "$value"'); - } + // TODO(nweiz): support the "mac" token type + // (http://tools.ietf.org/html/draft-ietf-oauth-v2-http-mac-01) + if (parameters['token_type'].toLowerCase() != 'bearer') { + throw new FormatException( + '"$tokenEndpoint": unknown token type "${parameters['token_type']}"'); + } - var scope = parameters['scope'] as String; - if (scope != null) scopes = scope.split(delimiter); + var expiresIn = parameters['expires_in']; + if (expiresIn != null && expiresIn is! int) { + throw new FormatException( + 'parameter "expires_in" was not an int, was "$expiresIn"'); + } - var expiration = expiresIn == null - ? null - : startTime.add(new Duration(seconds: expiresIn) - _expirationGrace); + for (var name in ['refresh_token', 'scope']) { + var value = parameters[name]; + if (value != null && value is! String) + throw new FormatException( + 'parameter "$name" was not a string, was "$value"'); + } - return new Credentials(parameters['access_token'], - refreshToken: parameters['refresh_token'], - tokenEndpoint: tokenEndpoint, - scopes: scopes, - expiration: expiration); + var scope = parameters['scope'] as String; + if (scope != null) scopes = scope.split(delimiter); + + var expiration = expiresIn == null + ? null + : startTime.add(new Duration(seconds: expiresIn) - _expirationGrace); + + return new Credentials(parameters['access_token'], + refreshToken: parameters['refresh_token'], + tokenEndpoint: tokenEndpoint, + scopes: scopes, + expiration: expiration); + } on FormatException catch (e) { + throw new FormatException('Invalid OAuth response for "$tokenEndpoint": ' + '${e.message}.\n\n${response.body}'); + } } /// Throws the appropriate exception for an error response from the /// authorization server. -void _handleErrorResponse(http.Response response, Uri tokenEndpoint) { - validate(condition, message) => - _validate(response, tokenEndpoint, condition, message); - +void _handleErrorResponse( + http.Response response, Uri tokenEndpoint, GetParameters getParameters) { // OAuth2 mandates a 400 or 401 response code for access token error // responses. If it's not a 400 reponse, the server is either broken or // off-spec. @@ -113,27 +117,22 @@ void _handleErrorResponse(http.Response response, Uri tokenEndpoint) { var contentType = contentTypeString == null ? null : new MediaType.parse(contentTypeString); - validate(contentType != null && contentType.mimeType == "application/json", - 'content-type was "$contentType", expected "application/json"'); + var parameters = getParameters(contentType, response.body); - var parameters; - try { - parameters = JSON.decode(response.body); - } on FormatException { - validate(false, 'invalid JSON'); + if (!parameters.containsKey('error')) { + throw new FormatException('did not contain required parameter "error"'); + } else if (parameters['error'] is! String) { + throw new FormatException( + 'required parameter "error" was not a string, was ' + '"${parameters["error"]}"'); } - validate(parameters.containsKey('error'), - 'did not contain required parameter "error"'); - validate( - parameters["error"] is String, - 'required parameter "error" was not a string, was ' - '"${parameters["error"]}"'); - for (var name in ['error_description', 'error_uri']) { var value = parameters[name]; - validate(value == null || value is String, - 'parameter "$name" was not a string, was "$value"'); + + if (value != null && value is! String) + throw new FormatException( + 'parameter "$name" was not a string, was "$value"'); } var description = parameters['error_description']; @@ -141,10 +140,3 @@ void _handleErrorResponse(http.Response response, Uri tokenEndpoint) { var uri = uriString == null ? null : Uri.parse(uriString); throw new AuthorizationException(parameters['error'], description, uri); } - -void _validate( - http.Response response, Uri tokenEndpoint, bool condition, String message) { - if (condition) return; - throw new FormatException('Invalid OAuth response for "$tokenEndpoint": ' - '$message.\n\n${response.body}'); -} diff --git a/lib/src/parameters.dart b/lib/src/parameters.dart new file mode 100644 index 0000000..ccbf23e --- /dev/null +++ b/lib/src/parameters.dart @@ -0,0 +1,34 @@ +// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:http_parser/http_parser.dart'; + +/// The type of a callback that parses parameters from an HTTP response. +typedef Map GetParameters(MediaType contentType, String body); + +/// Parses parameters from a response with a JSON body, as per the [OAuth2 +/// spec][]. +/// +/// [OAuth2 spec]: https://tools.ietf.org/html/rfc6749#section-5.1 +Map parseJsonParameters(MediaType contentType, String body) { + // The spec requires a content-type of application/json, but some endpoints + // (e.g. Dropbox) serve it as text/javascript instead. + if (contentType == null || + (contentType.mimeType != "application/json" && + contentType.mimeType != "text/javascript")) { + throw new FormatException( + 'Content-Type was "$contentType", expected "application/json"'); + } + + var untypedParameters = JSON.decode(body); + if (untypedParameters is! Map) { + throw new FormatException( + 'Parameters must be a map, was "$untypedParameters"'); + } + + return DelegatingMap.typed(untypedParameters); +} diff --git a/lib/src/resource_owner_password_grant.dart b/lib/src/resource_owner_password_grant.dart index 90c7f6c..ebd1d12 100644 --- a/lib/src/resource_owner_password_grant.dart +++ b/lib/src/resource_owner_password_grant.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'package:http/http.dart' as http; +import 'package:http_parser/http_parser.dart'; import 'client.dart'; import 'handle_access_token_response.dart'; @@ -32,6 +33,16 @@ import 'utils.dart'; /// The scope strings will be separated by the provided [delimiter]. This /// defaults to `" "`, the OAuth2 standard, but some APIs (such as Facebook's) /// use non-standard delimiters. +/// +/// By default, this follows the OAuth2 spec and requires the server's responses +/// to be in JSON format. However, some servers return non-standard response +/// formats, which can be parsed using the [getParameters] function. +/// +/// This function is passed the `Content-Type` header of the response as well as +/// its body as a UTF-8-decoded string. It should return a map in the same +/// format as the [standard JSON response][]. +/// +/// [standard JSON response]: https://tools.ietf.org/html/rfc6749#section-5.1 Future resourceOwnerPasswordGrant( Uri authorizationEndpoint, String username, String password, {String identifier, @@ -39,7 +50,9 @@ Future resourceOwnerPasswordGrant( Iterable scopes, bool basicAuth: true, http.Client httpClient, - String delimiter}) async { + String delimiter, + Map getParameters( + MediaType contentType, String body)}) async { delimiter ??= ' '; var startTime = new DateTime.now(); @@ -67,7 +80,8 @@ Future resourceOwnerPasswordGrant( headers: headers, body: body); var credentials = await handleAccessTokenResponse( - response, authorizationEndpoint, startTime, scopes, delimiter); + response, authorizationEndpoint, startTime, scopes, delimiter, + getParameters: getParameters); return new Client(credentials, identifier: identifier, secret: secret, httpClient: httpClient); } diff --git a/pubspec.yaml b/pubspec.yaml index 44e41fa..8c49603 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: oauth2 -version: 1.1.1-dev +version: 1.2.0 author: Dart Team homepage: https://github.com/dart-lang/oauth2 description: > diff --git a/test/handle_access_token_response_test.dart b/test/handle_access_token_response_test.dart index 6edfbcb..e32e13d 100644 --- a/test/handle_access_token_response_test.dart +++ b/test/handle_access_token_response_test.dart @@ -5,9 +5,12 @@ import 'dart:convert'; import 'package:http/http.dart' as http; +import 'package:http_parser/http_parser.dart'; +import 'package:test/test.dart'; + import 'package:oauth2/oauth2.dart' as oauth2; import 'package:oauth2/src/handle_access_token_response.dart'; -import 'package:test/test.dart'; +import 'package:oauth2/src/parameters.dart'; import 'utils.dart'; @@ -15,8 +18,11 @@ final Uri tokenEndpoint = Uri.parse("https://example.com/token"); final DateTime startTime = new DateTime.now(); -oauth2.Credentials handle(http.Response response) => handleAccessTokenResponse( - response, tokenEndpoint, startTime, ["scope"], ' '); +oauth2.Credentials handle(http.Response response, + {GetParameters getParameters}) => + handleAccessTokenResponse( + response, tokenEndpoint, startTime, ["scope"], ' ', + getParameters: getParameters); void main() { group('an error response', () { @@ -49,6 +55,12 @@ void main() { throwsFormatException); }); + test('with a non-JSON, non-plain content-type causes a FormatException', + () { + expect(() => handleError(headers: {'content-type': 'image/png'}), + throwsFormatException); + }); + test( 'with a JSON content-type and charset causes an ' 'AuthorizationException', () { @@ -151,6 +163,37 @@ void main() { expect(credentials.accessToken, equals('access token')); }); + test('with custom getParameters() returns the correct credentials', () { + var body = '_' + + JSON.encode({'token_type': 'bearer', 'access_token': 'access token'}); + var credentials = handle( + new http.Response(body, 200, headers: {'content-type': 'text/plain'}), + getParameters: (contentType, body) => JSON.decode(body.substring(1))); + expect(credentials.accessToken, equals('access token')); + expect(credentials.tokenEndpoint.toString(), + equals(tokenEndpoint.toString())); + }); + + test('throws a FormatException if custom getParameters rejects response', + () { + var response = new http.Response( + JSON.encode({ + 'access_token': 'access token', + 'token_type': 'bearer', + 'expires_in': 24, + 'refresh_token': 'refresh token', + 'scope': 'scope', + }), + 200, + headers: {'content-type': 'foo/bar'}); + + expect( + () => handle(response, + getParameters: (contentType, body) => throw new FormatException( + 'unsupported content-type: $contentType')), + throwsFormatException); + }); + test('with a null access token throws a FormatException', () { expect(() => handleSuccess(accessToken: null), throwsFormatException); }); diff --git a/test/utils.dart b/test/utils.dart index d6b1d62..92835ea 100644 --- a/test/utils.dart +++ b/test/utils.dart @@ -46,8 +46,7 @@ class ExpectClient extends MockClient { const isAuthorizationException = const _AuthorizationException(); /// A matcher for functions that throw AuthorizationException. -const Matcher throwsAuthorizationException = - const Throws(isAuthorizationException); +final Matcher throwsAuthorizationException = throwsA(isAuthorizationException); class _AuthorizationException extends TypeMatcher { const _AuthorizationException() : super("AuthorizationException"); @@ -58,7 +57,7 @@ class _AuthorizationException extends TypeMatcher { const isExpirationException = const _ExpirationException(); /// A matcher for functions that throw ExpirationException. -const Matcher throwsExpirationException = const Throws(isExpirationException); +final Matcher throwsExpirationException = throwsA(isExpirationException); class _ExpirationException extends TypeMatcher { const _ExpirationException() : super("ExpirationException");