Allow response parsing to be customized (#20)
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 @@
///
/// [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 @@
/// 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<String, dynamic> 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 @@
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: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 @@
/// 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 @@
/// 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<String> scopes,
this.expiration,
- String delimiter})
+ String delimiter,
+ Map<String, dynamic> getParameters(MediaType mediaType, String body)})
: scopes = new UnmodifiableListView(
// Explicitly type-annotate the list literal to work around
// sdk#24202.
scopes == null ? <String>[] : scopes.toList()),
- _delimiter = delimiter ?? ' ';
+ _delimiter = delimiter ?? ' ',
+ _getParameters = getParameters ?? parseJsonParameters;
/// Loads a set of credentials from a JSON-serialized form.
///
@@ -208,7 +225,8 @@
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 @@
/// 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<String> scopes, String delimiter) {
- if (response.statusCode != 200) _handleErrorResponse(response, tokenEndpoint);
+ DateTime startTime, List<String> scopes, String delimiter,
+ {Map<String, dynamic> getParameters(MediaType contentType, String body)}) {
+ getParameters ??= parseJsonParameters;
- 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"');
-
- Map<String, dynamic> 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');
+ if (response.statusCode != 200) {
+ _handleErrorResponse(response, tokenEndpoint, getParameters);
+ }
+
+ var contentTypeString = response.headers['content-type'];
+ if (contentTypeString == null) {
+ throw new FormatException('Missing Content-Type string.');
+ }
+
+ 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]}"');
+ }
+ }
+
+ // 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 expiresIn = parameters['expires_in'];
+ if (expiresIn != null && expiresIn is! int) {
+ throw new FormatException(
+ 'parameter "expires_in" was not an int, was "$expiresIn"');
+ }
+
+ 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"');
+ }
+
+ 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}');
}
-
- 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]}"');
- }
-
- // 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 expiresIn = parameters['expires_in'];
- validate(expiresIn == null || expiresIn is int,
- 'parameter "expires_in" was not an int, was "$expiresIn"');
-
- 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"');
- }
-
- 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);
}
/// 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 @@
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 @@
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<String, dynamic> 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<String, dynamic> 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 @@
/// 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<Client> resourceOwnerPasswordGrant(
Uri authorizationEndpoint, String username, String password,
{String identifier,
@@ -39,7 +50,9 @@
Iterable<String> scopes,
bool basicAuth: true,
http.Client httpClient,
- String delimiter}) async {
+ String delimiter,
+ Map<String, dynamic> getParameters(
+ MediaType contentType, String body)}) async {
delimiter ??= ' ';
var startTime = new DateTime.now();
@@ -67,7 +80,8 @@
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 <misc@dartlang.org>
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 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 @@
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 @@
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 @@
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 @@
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");