Make the oauth2 lib handle OAuth2 401 errors.

BUG=6813

Review URL: https://codereview.chromium.org//11316325

git-svn-id: https://dart.googlecode.com/svn/branches/bleeding_edge/dart@15713 260f80e4-7a28-3924-810f-c04153c831b5
diff --git a/lib/src/client.dart b/lib/src/client.dart
index 7322afa..eb7b062 100644
--- a/lib/src/client.dart
+++ b/lib/src/client.dart
@@ -8,6 +8,7 @@
 
 import '../../../http/lib/http.dart' as http;
 
+import 'authorization_exception.dart';
 import 'credentials.dart';
 import 'expiration_exception.dart';
 import 'utils.dart';
@@ -24,9 +25,9 @@
 /// authorization server provides ill-formatted responses, or an
 /// [ExpirationException] if the credentials are expired and can't be refreshed.
 ///
-/// Currently this client doesn't attempt to identify errors from the resource
-/// server that are caused by authentication failure. However, it may throw
-/// [AuthorizationException]s for such errors in the future.
+/// The client will also throw an [AuthorizationException] if the resource
+/// server returns a 401 response with a WWW-Authenticate header indicating that
+/// the current credentials are invalid.
 ///
 /// If you already have a set of [Credentials], you can construct a [Client]
 /// directly. However, in order to first obtain the credentials, you must
@@ -87,8 +88,28 @@
     }).chain((_) {
       request.headers['authorization'] = "Bearer ${credentials.accessToken}";
       return _httpClient.send(request);
+    }).transform((response) {
+      if (response.statusCode != 401 ||
+          !response.headers.containsKey('www-authenticate')) {
+        return response;
+      }
+
+      var authenticate;
+      try {
+        authenticate = parseAuthenticateHeader(
+            response.headers['www-authenticate']);
+      } on FormatException catch (e) {
+        return response;
+      }
+
+      if (authenticate.first != 'bearer') return response;
+
+      var params = authenticate.last;
+      if (!params.containsKey('error')) return response;
+
+      throw new AuthorizationException(
+          params['error'], params['error_description'], params['error_uri']);
     });
-    // TODO(nweiz): parse 401 errors that are caused by OAuth errors here.
   }
 
   /// Explicitly refreshes this client's credentials. Returns this client.
diff --git a/lib/src/utils.dart b/lib/src/utils.dart
index 583a5bc..59428b3 100644
--- a/lib/src/utils.dart
+++ b/lib/src/utils.dart
@@ -65,6 +65,52 @@
       toSplit.substring(index + pattern.length)];
 }
 
+/// A WWW-Authenticate header value, parsed as per [RFC 2617][].
+///
+/// [RFC 2617]: http://tools.ietf.org/html/rfc2617
+class AuthenticateHeader {
+  final String scheme;
+  final Map<String, String> parameters;
+
+  AuthenticateHeader(this.scheme, this.parameters);
+
+  /// Parses a header string. Throws a [FormatException] if the header is
+  /// invalid.
+  factory AuthenticateHeader.parse(String header) {
+    var split = split1(header, ' ');
+    if (split.length == 0) {
+      throw new FormatException('Invalid WWW-Authenticate header: "$header"');
+    } else if (split.length == 1 || split[1].trim().isEmpty) {
+      return new AuthenticateHeader(split[0].toLowerCase(), {});
+    }
+    var scheme = split[0].toLowerCase();
+    var paramString = split[1];
+
+    // From http://www.w3.org/Protocols/rfc2616/rfc2616-sec2.html.
+    var tokenChar = r'[^\0-\x1F()<>@,;:\\"/\[\]?={} \t\x7F]';
+    var quotedStringChar = r'(?:[^\0-\x1F\x7F"]|\\.)';
+    var regexp = new RegExp('^ *($tokenChar+)="($quotedStringChar*)" *(, *)?');
+
+    var parameters = {};
+    var match;
+    do {
+      match = regexp.firstMatch(paramString);
+      if (match == null) {
+        throw new FormatException('Invalid WWW-Authenticate header: "$header"');
+      }
+
+      paramString = paramString.substring(match.end);
+      parameters[match.group(1).toLowerCase()] = match.group(2);
+    } while (match.group(3) != null);
+
+    if (!paramString.trim().isEmpty) {
+      throw new FormatException('Invalid WWW-Authenticate header: "$header"');
+    }
+
+    return new AuthenticateHeader(scheme, parameters);
+  }
+}
+
 /// Returns a [Future] that asynchronously completes to `null`.
 Future get async {
   var completer = new Completer();
diff --git a/test/client_test.dart b/test/client_test.dart
index 94b7920..fba14a6 100644
--- a/test/client_test.dart
+++ b/test/client_test.dart
@@ -117,4 +117,109 @@
       expect(client.refreshCredentials(), throwsStateError);
     });
   });
+
+  group('with invalid credentials', () {
+    setUp(createHttpClient);
+
+    test('throws an AuthorizationException for a 401 response', () {
+      var credentials = new oauth2.Credentials('access token');
+      var client = new oauth2.Client('identifier', 'secret', credentials,
+          httpClient: httpClient);
+
+      httpClient.expectRequest((request) {
+        expect(request.method, equals('GET'));
+        expect(request.url.toString(), equals(requestUri.toString()));
+        expect(request.headers['authorization'],
+            equals('Bearer access token'));
+
+        var authenticate = 'Bearer error="invalid_token", error_description='
+            '"Something is terribly wrong."';
+        return new Future.immediate(new http.Response('bad job', 401,
+                headers: {'www-authenticate': authenticate}));
+      });
+
+      expect(client.read(requestUri), throwsAuthorizationException);
+    });
+
+    test('passes through a 401 response without www-authenticate', () {
+      var credentials = new oauth2.Credentials('access token');
+      var client = new oauth2.Client('identifier', 'secret', credentials,
+          httpClient: httpClient);
+
+      httpClient.expectRequest((request) {
+        expect(request.method, equals('GET'));
+        expect(request.url.toString(), equals(requestUri.toString()));
+        expect(request.headers['authorization'],
+            equals('Bearer access token'));
+
+        return new Future.immediate(new http.Response('bad job', 401));
+      });
+
+      expect(
+          client.get(requestUri).transform((response) => response.statusCode),
+          completion(equals(401)));
+    });
+
+    test('passes through a 401 response with invalid www-authenticate', () {
+      var credentials = new oauth2.Credentials('access token');
+      var client = new oauth2.Client('identifier', 'secret', credentials,
+          httpClient: httpClient);
+
+      httpClient.expectRequest((request) {
+        expect(request.method, equals('GET'));
+        expect(request.url.toString(), equals(requestUri.toString()));
+        expect(request.headers['authorization'],
+            equals('Bearer access token'));
+
+        var authenticate = 'Bearer error="invalid_token", error_description='
+          '"Something is terribly wrong.", ';
+        return new Future.immediate(new http.Response('bad job', 401,
+                headers: {'www-authenticate': authenticate}));
+      });
+
+      expect(
+          client.get(requestUri).transform((response) => response.statusCode),
+          completion(equals(401)));
+    });
+
+    test('passes through a 401 response with non-bearer www-authenticate', () {
+      var credentials = new oauth2.Credentials('access token');
+      var client = new oauth2.Client('identifier', 'secret', credentials,
+          httpClient: httpClient);
+
+      httpClient.expectRequest((request) {
+        expect(request.method, equals('GET'));
+        expect(request.url.toString(), equals(requestUri.toString()));
+        expect(request.headers['authorization'],
+            equals('Bearer access token'));
+
+        return new Future.immediate(new http.Response('bad job', 401,
+                headers: {'www-authenticate': 'Digest'}));
+      });
+
+      expect(
+          client.get(requestUri).transform((response) => response.statusCode),
+          completion(equals(401)));
+    });
+
+    test('passes through a 401 response with non-OAuth2 www-authenticate', () {
+      var credentials = new oauth2.Credentials('access token');
+      var client = new oauth2.Client('identifier', 'secret', credentials,
+          httpClient: httpClient);
+
+      httpClient.expectRequest((request) {
+        expect(request.method, equals('GET'));
+        expect(request.url.toString(), equals(requestUri.toString()));
+        expect(request.headers['authorization'],
+            equals('Bearer access token'));
+
+        return new Future.immediate(new http.Response('bad job', 401,
+                headers: {'www-authenticate': 'Bearer'}));
+      });
+
+      expect(
+          client.get(requestUri).transform((response) => response.statusCode),
+          completion(equals(401)));
+    });
+  });
 }
diff --git a/test/utils_test.dart b/test/utils_test.dart
new file mode 100644
index 0000000..d76e2b0
--- /dev/null
+++ b/test/utils_test.dart
@@ -0,0 +1,91 @@
+// Copyright (c) 2012, 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.
+
+library utils_test;
+
+import '../../unittest/lib/unittest.dart';
+import '../lib/src/utils.dart';
+
+
+void main() {
+  group('AuthenticateHeader', () {
+    test("parses a scheme", () {
+      var header = new AuthenticateHeader.parse('bearer');
+      expect(header.scheme, equals('bearer'));
+      expect(header.parameters, equals({}));
+    });
+
+    test("lower-cases the scheme", () {
+      var header = new AuthenticateHeader.parse('BeaRer');
+      expect(header.scheme, equals('bearer'));
+      expect(header.parameters, equals({}));
+    });
+
+    test("parses a scheme with trailing whitespace", () {
+      var header = new AuthenticateHeader.parse('bearer   ');
+      expect(header.scheme, equals('bearer'));
+      expect(header.parameters, equals({}));
+    });
+
+    test("parses a scheme with one param", () {
+      var header = new AuthenticateHeader.parse('bearer  foo="bar"');
+      expect(header.scheme, equals('bearer'));
+      expect(header.parameters, equals({'foo': 'bar'}));
+    });
+
+    test("parses a scheme with several params", () {
+      var header = new AuthenticateHeader.parse(
+          'bearer foo="bar", bar="baz"  ,baz="qux"');
+      expect(header.scheme, equals('bearer'));
+      expect(header.parameters, equals({
+        'foo': 'bar',
+        'bar': 'baz',
+        'baz': 'qux'
+      }));
+    });
+
+    test("lower-cases parameter names but not values", () {
+      var header = new AuthenticateHeader.parse('bearer FoO="bAr"');
+      expect(header.scheme, equals('bearer'));
+      expect(header.parameters, equals({'foo': 'bAr'}));
+    });
+
+    test("allows empty values", () {
+      var header = new AuthenticateHeader.parse('bearer foo=""');
+      expect(header.scheme, equals('bearer'));
+      expect(header.parameters, equals({'foo': ''}));
+    });
+
+    test("won't parse an empty string", () {
+      expect(() => new AuthenticateHeader.parse(''),
+          throwsFormatException);
+    });
+
+    test("won't parse a token without a value", () {
+      expect(() => new AuthenticateHeader.parse('bearer foo'),
+          throwsFormatException);
+
+      expect(() => new AuthenticateHeader.parse('bearer foo='),
+          throwsFormatException);
+    });
+
+    test("won't parse a token without a value", () {
+      expect(() => new AuthenticateHeader.parse('bearer foo'),
+          throwsFormatException);
+
+      expect(() => new AuthenticateHeader.parse('bearer foo='),
+          throwsFormatException);
+    });
+
+    test("won't parse a trailing comma", () {
+      expect(() => new AuthenticateHeader.parse('bearer foo="bar",'),
+          throwsFormatException);
+    });
+
+    test("won't parse a multiple params without a comma", () {
+      expect(() => new AuthenticateHeader.parse('bearer foo="bar" bar="baz"'),
+          throwsFormatException);
+    });
+  });
+}