Skip to content
This repository was archived by the owner on Aug 26, 2024. It is now read-only.

Support text/plain content type in handleAccessTokenResponse #20

Merged
merged 42 commits into from
Mar 26, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
f993e07
Fixed Facebook-related issues
thosakwe Jan 12, 2017
b67c9be
Revert "Fixed Facebook-related issues"
thosakwe Mar 11, 2017
c5c82b9
Applied changes WITHOUT formatting changes
thosakwe Mar 11, 2017
30e3552
Tweak delimiter documentation.
nex3 May 15, 2017
e36e4fd
Moved `delimiter` to two constructors
thosakwe Jun 1, 2017
316dd33
Moved `delimiter` to two constructors
thosakwe Jun 1, 2017
2686c65
Updated auth
thosakwe Jun 1, 2017
a5ae947
Updated auth
thosakwe Jun 1, 2017
9dbf6f7
Merge remote-tracking branch 'origin/master'
thosakwe Jun 1, 2017
22d98d7
Simplified data model, documented constructor params
thosakwe Jun 2, 2017
ba12cc8
Removed redundant null-checks
thosakwe Jun 2, 2017
c6eb6dc
Merge remote-tracking branch 'origin/master'
thosakwe Jun 2, 2017
dead0aa
Removed redundant null-checks
thosakwe Jun 2, 2017
8ce0a94
AuthCodeGrant test - ensure correct delimiter is used
thosakwe Jun 2, 2017
0f27fe7
Added delimiter tests in multiple files
thosakwe Jun 2, 2017
435f056
Merge remote-tracking branch 'origin/master'
thosakwe Jun 2, 2017
e28aeb4
Style changes.
nex3 Jun 2, 2017
04c1d4b
Pubspec and changelog.
nex3 Jun 2, 2017
feffaf1
Sync fork
thosakwe Jun 3, 2017
fcfb1e7
Fixed Windows CRLF
thosakwe Jun 3, 2017
c413943
Support text/plain
thosakwe Jun 3, 2017
3090e6f
Support application/x-www-form-urlencoded
thosakwe Jun 3, 2017
3d6e1d9
Introduce end-of-line normalization for Windows
thosakwe Jun 5, 2017
7caf769
Revert "Introduce end-of-line normalization for Windows"
thosakwe Jun 5, 2017
c2caa88
Line endings fixed???
thosakwe Jun 5, 2017
c31b0eb
Remove WebStorm meta
thosakwe Jun 5, 2017
d93ec1e
Added optional "getParameter"
thosakwe Jul 8, 2017
57df89d
fixes as per review
thosakwe Oct 16, 2017
5753ccf
fix reviews + merge conflict
thosakwe Oct 16, 2017
38ca4bf
More fixes
thosakwe Oct 17, 2017
bd8c50b
`getParameters` now accepts a MediaType, instead of a String
thosakwe Mar 19, 2018
e79a0ef
Removed `validate` functions, replaced with `if` statements
thosakwe Mar 19, 2018
a6f3972
Adjust tests to show for the fact that instead of passing around cont…
thosakwe Mar 19, 2018
b0fd615
Update Authcodegrant to take a MediaType
thosakwe Mar 19, 2018
e5ee6eb
merge in from upstream
thosakwe Mar 20, 2018
4608a7b
merge in from upstream
thosakwe Mar 20, 2018
8a3fc26
Remove IDEA (oops)
thosakwe Mar 21, 2018
932be82
Run formatter
thosakwe Mar 21, 2018
f8e7935
Remove *.orig artifacts left over from merge
thosakwe Mar 21, 2018
9256040
Explicitly throw a FormatException on null or missing content-type
thosakwe Mar 24, 2018
0c0097a
Style changes
nex3 Mar 26, 2018
1144eb3
Pubspec and changelog
nex3 Mar 26, 2018
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
24 changes: 21 additions & 3 deletions lib/src/authorization_code_grant.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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][].
Expand All @@ -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
Expand Down Expand Up @@ -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<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.
Expand Down Expand Up @@ -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,
Expand Down
24 changes: 21 additions & 3 deletions lib/src/credentials.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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<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.
///
Expand Down Expand Up @@ -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.
Expand Down
166 changes: 79 additions & 87 deletions lib/src/handle_access_token_response.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand All @@ -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<String> 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<String> scopes, String delimiter,
{Map<String, dynamic> getParameters(MediaType contentType, String body)}) {
getParameters ??= parseJsonParameters;

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');
}

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.
Expand All @@ -113,38 +117,26 @@ 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'];
var uriString = parameters['error_uri'];
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}');
}
34 changes: 34 additions & 0 deletions lib/src/parameters.dart
Original file line number Diff line number Diff line change
@@ -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);
}
Loading