Add an AuthenticationChallenge class.

A simpler version of this existed already in dart-lang/oauth2; this
makes it widely available and more robust.

R=rnystrom@google.com

Review URL: https://codereview.chromium.org//1307353003 .
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7f50217..3ac45ef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,10 @@
+## 1.0.0
+
+This is 1.0.0 because the API is stable—there are no breaking changes.
+
+* Added an `AuthenticationChallenge` class for parsing and representing the
+  value of `WWW-Authenticate` and related headers.
+
 ## 0.0.2+8
 
 * Bring in the latest `dart:io` WebSocket code.
diff --git a/lib/http_parser.dart b/lib/http_parser.dart
index 0aa9cea..f5921ee 100644
--- a/lib/http_parser.dart
+++ b/lib/http_parser.dart
@@ -4,6 +4,7 @@
 
 library http_parser;
 
+export 'src/authentication_challenge.dart';
 export 'src/http_date.dart';
 export 'src/media_type.dart';
 export 'src/web_socket.dart';
diff --git a/lib/src/authentication_challenge.dart b/lib/src/authentication_challenge.dart
new file mode 100644
index 0000000..3b70fed
--- /dev/null
+++ b/lib/src/authentication_challenge.dart
@@ -0,0 +1,150 @@
+// Copyright (c) 2015, 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 http_paser.authentication_challenge;
+
+import 'dart:collection';
+
+import 'package:string_scanner/string_scanner.dart';
+
+import 'scan.dart';
+import 'utils.dart';
+
+/// A single challenge in a WWW-Authenticate header, parsed as per [RFC 2617][].
+///
+/// [RFC 2617]: http://tools.ietf.org/html/rfc2617
+///
+/// Each WWW-Authenticate header contains one or more challenges, representing
+/// valid ways to authenticate with the server.
+class AuthenticationChallenge {
+  /// The scheme describing the type of authentication that's required, for
+  /// example "basic" or "digest".
+  ///
+  /// This is normalized to always be lower-case.
+  final String scheme;
+
+  /// The parameters describing how to authenticate.
+  ///
+  /// The semantics of these parameters are scheme-specific.
+  final Map<String, String> parameters;
+
+  /// Parses a WWW-Authenticate header, which should contain one or more
+  /// challenges.
+  ///
+  /// Throws a [FormatException] if the header is invalid.
+  static List<AuthenticationChallenge> parseHeader(String header) {
+    return wrapFormatException("authentication header", header, () {
+      var scanner = new StringScanner(header);
+      scanner.scan(whitespace);
+      var challenges = parseList(scanner, () {
+        var scheme = _scanScheme(scanner, whitespaceName: '" " or "="');
+
+        // Manually parse the inner list. We need to do some lookahead to
+        // disambiguate between an auth param and another challenge.
+        var params = {};
+
+        // Consume initial empty values.
+        while (scanner.scan(",")) {
+          scanner.scan(whitespace);
+        }
+
+        _scanAuthParam(scanner, params);
+
+        var beforeComma = scanner.position;
+        while (scanner.scan(",")) {
+          scanner.scan(whitespace);
+
+          // Empty elements are allowed, but excluded from the results.
+          if (scanner.matches(",") || scanner.isDone) continue;
+
+          scanner.expect(token, name: "a token");
+          var name = scanner.lastMatch[0].toLowerCase();
+          scanner.scan(whitespace);
+
+          // If there's no "=", then this is another challenge rather than a
+          // parameter for the current challenge.
+          if (!scanner.scan('=')) {
+            scanner.position = beforeComma;
+            break;
+          }
+
+          scanner.scan(whitespace);
+
+          if (scanner.scan(token)) {
+            params[name] = scanner.lastMatch[0];
+          } else {            
+            params[name] = expectQuotedString(
+                scanner, name: "a token or a quoted string");
+          }
+
+          scanner.scan(whitespace);
+          beforeComma = scanner.position;
+        }
+
+        return new AuthenticationChallenge(scheme, params);
+      });
+
+      scanner.expectDone();
+      return challenges;
+    });
+  }
+
+  /// Parses a single WWW-Authenticate challenge value.
+  ///
+  /// Throws a [FormatException] if the challenge is invalid.
+  factory AuthenticationChallenge.parse(String challenge) {
+    return wrapFormatException("authentication challenge", challenge, () {
+      var scanner = new StringScanner(challenge);
+      scanner.scan(whitespace);
+      var scheme = _scanScheme(scanner);
+
+      var params = {};
+      parseList(scanner, () => _scanAuthParam(scanner, params));
+
+      scanner.expectDone();
+      return new AuthenticationChallenge(scheme, params);
+    });
+  }
+
+  /// Scans a single scheme name and asserts that it's followed by a space.
+  ///
+  /// If [whitespaceName] is passed, it's used as the name for exceptions thrown
+  /// due to invalid trailing whitespace.
+  static String _scanScheme(StringScanner scanner, {String whitespaceName}) {
+    scanner.expect(token, name: "a token");
+    var scheme = scanner.lastMatch[0].toLowerCase();
+
+    scanner.scan(whitespace);
+
+    // The spec specifically requires a space between the scheme and its
+    // params.
+    if (scanner.lastMatch == null || !scanner.lastMatch[0].contains(" ")) {
+      scanner.expect(" ", name: whitespaceName);
+    }
+
+    return scheme;
+  }
+
+  /// Scans a single authentication parameter and stores its result in [params].
+  static void _scanAuthParam(StringScanner scanner, Map params) {
+    scanner.expect(token, name: "a token");
+    var name = scanner.lastMatch[0].toLowerCase();
+    scanner.scan(whitespace);
+    scanner.expect('=');
+    scanner.scan(whitespace);
+
+    if (scanner.scan(token)) {
+      params[name] = scanner.lastMatch[0];
+    } else {            
+      params[name] = expectQuotedString(
+          scanner, name: "a token or a quoted string");
+    }
+
+    scanner.scan(whitespace);
+  }
+
+  /// Creates a new challenge value with [scheme] and [parameters].
+  AuthenticationChallenge(this.scheme, Map<String, String> parameters)
+      : parameters = new UnmodifiableMapView(parameters);
+}
diff --git a/lib/src/http_date.dart b/lib/src/http_date.dart
index 7d46d55..9590536 100644
--- a/lib/src/http_date.dart
+++ b/lib/src/http_date.dart
@@ -6,6 +6,8 @@
 
 import 'package:string_scanner/string_scanner.dart';
 
+import 'utils.dart';
+
 const _WEEKDAYS = const ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
 const _MONTHS = const ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug",
     "Sep", "Oct", "Nov", "Dec"];
@@ -48,7 +50,7 @@
 /// 2616](http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.3). It will
 /// throw a [FormatException] if [date] is invalid.
 DateTime parseHttpDate(String date) {
-  try {
+  return wrapFormatException("HTTP date", date, () {
     var scanner = new StringScanner(date);
 
     if (scanner.scan(_longWeekdayRegExp)) {
@@ -96,9 +98,7 @@
     scanner.expectDone();
 
     return _makeDateTime(year, month, day, time);
-  } on FormatException catch (error) {
-    throw new FormatException('Invalid HTTP date "$date": ${error.message}');
-  }
+  });
 }
 
 /// Parses a short-form month name to a form accepted by [DateTime].
diff --git a/lib/src/media_type.dart b/lib/src/media_type.dart
index fa9350e..5bae0ec 100644
--- a/lib/src/media_type.dart
+++ b/lib/src/media_type.dart
@@ -7,18 +7,8 @@
 import 'package:collection/collection.dart';
 import 'package:string_scanner/string_scanner.dart';
 
-// All of the following regular expressions come from section 2.2 of the HTTP
-// spec: http://www.w3.org/Protocols/rfc2616/rfc2616-sec2.html
-final _lws = new RegExp(r"(?:\r\n)?[ \t]+");
-final _token = new RegExp(r'[^()<>@,;:"\\/[\]?={} \t\x00-\x1F\x7F]+');
-final _quotedString = new RegExp(r'"(?:[^"\x00-\x1F\x7F]|\\.)*"');
-final _quotedPair = new RegExp(r'\\(.)');
-
-/// A regular expression matching any number of [_lws] productions in a row.
-final _whitespace = new RegExp("(?:${_lws.pattern})*");
-
-/// A regular expression matching a character that is not a valid HTTP token.
-final _nonToken = new RegExp(r'[()<>@,;:"\\/\[\]?={} \t\x00-\x1F\x7F]');
+import 'scan.dart';
+import 'utils.dart';
 
 /// A regular expression matching a character that needs to be backslash-escaped
 /// in a quoted string.
@@ -50,44 +40,37 @@
   factory MediaType.parse(String mediaType) {
     // This parsing is based on sections 3.6 and 3.7 of the HTTP spec:
     // http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html.
-    try {
+    return wrapFormatException("media type", mediaType, () {
       var scanner = new StringScanner(mediaType);
-      scanner.scan(_whitespace);
-      scanner.expect(_token);
+      scanner.scan(whitespace);
+      scanner.expect(token);
       var type = scanner.lastMatch[0];
       scanner.expect('/');
-      scanner.expect(_token);
+      scanner.expect(token);
       var subtype = scanner.lastMatch[0];
-      scanner.scan(_whitespace);
+      scanner.scan(whitespace);
 
       var parameters = {};
       while (scanner.scan(';')) {
-        scanner.scan(_whitespace);
-        scanner.expect(_token);
+        scanner.scan(whitespace);
+        scanner.expect(token);
         var attribute = scanner.lastMatch[0];
         scanner.expect('=');
 
         var value;
-        if (scanner.scan(_token)) {
+        if (scanner.scan(token)) {
           value = scanner.lastMatch[0];
         } else {
-          scanner.expect(_quotedString);
-          var quotedString = scanner.lastMatch[0];
-          value = quotedString
-              .substring(1, quotedString.length - 1)
-              .replaceAllMapped(_quotedPair, (match) => match[1]);
+          value = expectQuotedString(scanner);
         }
 
-        scanner.scan(_whitespace);
+        scanner.scan(whitespace);
         parameters[attribute] = value;
       }
 
       scanner.expectDone();
       return new MediaType(type, subtype, parameters);
-    } on FormatException catch (error) {
-      throw new FormatException(
-          'Invalid media type "$mediaType": ${error.message}');
-    }
+    });
   }
 
   MediaType(this.type, this.subtype, [Map<String, String> parameters])
@@ -146,7 +129,7 @@
 
     parameters.forEach((attribute, value) {
       buffer.write("; $attribute=");
-      if (_nonToken.hasMatch(value)) {
+      if (nonToken.hasMatch(value)) {
         buffer
           ..write('"')
           ..write(
diff --git a/lib/src/scan.dart b/lib/src/scan.dart
new file mode 100644
index 0000000..d675b8b
--- /dev/null
+++ b/lib/src/scan.dart
@@ -0,0 +1,77 @@
+// Copyright (c) 2015, 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.
+
+/// A library for broadly-useful functions and regular expressions for scanning
+/// HTTP entities.
+///
+/// Many of the regular expressions come from [section 2.2 of the HTTP
+/// spec][spec].
+///
+/// [spec]: http://www.w3.org/Protocols/rfc2616/rfc2616-sec2.html
+library http_parser.scan;
+
+import 'package:string_scanner/string_scanner.dart';
+
+/// An HTTP token.
+final token = new RegExp(r'[^()<>@,;:"\\/[\]?={} \t\x00-\x1F\x7F]+');
+
+/// Linear whitespace.
+final _lws = new RegExp(r"(?:\r\n)?[ \t]+");
+
+/// A quoted string.
+final _quotedString = new RegExp(r'"(?:[^"\x00-\x1F\x7F]|\\.)*"');
+
+/// A quoted pair.
+final _quotedPair = new RegExp(r'\\(.)');
+
+/// A character that is *not* a valid HTTP token.
+final nonToken = new RegExp(r'[()<>@,;:"\\/\[\]?={} \t\x00-\x1F\x7F]');
+
+/// A regular expression matching any number of [_lws] productions in a row.
+final whitespace = new RegExp("(?:${_lws.pattern})*");
+
+/// Parses a list of elements, as in `1#element` in the HTTP spec.
+///
+/// [scanner] is used to parse the elements, and [parseElement] is used to parse
+/// each one individually. The values returned by [parseElement] are collected
+/// in a list and returned.
+///
+/// Once this is finished, [scanner] will be at the next non-LWS character in
+/// the string, or the end of the string.
+List parseList(StringScanner scanner, parseElement()) {
+  var result = [];
+
+  // Consume initial empty values.
+  while (scanner.scan(",")) {
+    scanner.scan(whitespace);
+  }
+
+  result.add(parseElement());
+  scanner.scan(whitespace);
+
+  while (scanner.scan(",")) {
+    scanner.scan(whitespace);
+
+    // Empty elements are allowed, but excluded from the results.
+    if (scanner.matches(",") || scanner.isDone) continue;
+
+    result.add(parseElement());
+    scanner.scan(whitespace);
+  }
+
+  return result;
+}
+
+/// Parses a single quoted string, and returns its contents.
+///
+/// If [name] is passed, it's used to describe the expected value if it's not
+/// found.
+String expectQuotedString(StringScanner scanner, {String name}) {
+  if (name == null) name = "quoted string";
+  scanner.expect(_quotedString, name: name);
+  var string = scanner.lastMatch[0];
+  return string
+      .substring(1, string.length - 1)
+      .replaceAllMapped(_quotedPair, (match) => match[1]);
+}
diff --git a/lib/src/utils.dart b/lib/src/utils.dart
new file mode 100644
index 0000000..4dcee19
--- /dev/null
+++ b/lib/src/utils.dart
@@ -0,0 +1,23 @@
+// Copyright (c) 2015, 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 'package:source_span/source_span.dart';
+
+/// Runs [body] and wraps any format exceptions it produces.
+///
+/// [name] should describe the type of thing being parsed, and [value] should be
+/// its actual value.
+wrapFormatException(String name, String value, body()) {
+  try {
+    return body();
+  } on SourceSpanFormatException catch (error) {
+    throw new SourceSpanFormatException(
+        'Invalid $name: ${error.message}', error.span, error.source);
+  } on FormatException catch (error) {
+    throw new FormatException(
+        'Invalid $name "$value": ${error.message}',
+        error.source,
+        error.offset);
+  }
+}
diff --git a/pubspec.yaml b/pubspec.yaml
index b4e002c..9af4b45 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: http_parser
-version: 0.0.3-dev
+version: 1.0.0-dev
 author: "Dart Team <misc@dartlang.org>"
 homepage: https://github.com/dart-lang/http_parser
 description: >
@@ -7,6 +7,7 @@
 dependencies:
   crypto: "^0.9.0"
   collection: ">=0.9.1 <2.0.0"
+  source_span: "^1.0.0"
   string_scanner: ">=0.0.0 <0.2.0"
 dev_dependencies:
   test: "^0.12.0"
diff --git a/test/authentication_challenge_test.dart b/test/authentication_challenge_test.dart
new file mode 100644
index 0000000..4be4444
--- /dev/null
+++ b/test/authentication_challenge_test.dart
@@ -0,0 +1,171 @@
+// Copyright (c) 2015, 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 'package:http_parser/http_parser.dart';
+import 'package:test/test.dart';
+
+void main() {
+  group("parse", () {
+    _singleChallengeTests(
+        (challenge) => new AuthenticationChallenge.parse(challenge));
+  });
+
+  group("parseHeader", () {
+    group("with a single challenge", () {
+      _singleChallengeTests((challenge) {
+        var challenges = AuthenticationChallenge.parseHeader(challenge);
+        expect(challenges, hasLength(1));
+        return challenges.single;
+      });
+    });
+
+    test("parses multiple challenges", () {
+      var challenges = AuthenticationChallenge.parseHeader(
+          "scheme1 realm=fblthp, scheme2 realm=asdfg");
+      expect(challenges, hasLength(2));
+      expect(challenges.first.scheme, equals("scheme1"));
+      expect(challenges.first.parameters, equals({"realm": "fblthp"}));
+      expect(challenges.last.scheme, equals("scheme2"));
+      expect(challenges.last.parameters, equals({"realm": "asdfg"}));
+    });
+
+    test("parses multiple challenges with multiple parameters", () {
+      var challenges = AuthenticationChallenge.parseHeader(
+          "scheme1 realm=fblthp, foo=bar, scheme2 realm=asdfg, baz=bang");
+      expect(challenges, hasLength(2));
+
+      expect(challenges.first.scheme, equals("scheme1"));
+      expect(challenges.first.parameters, equals({
+        "realm": "fblthp",
+        "foo": "bar"
+      }));
+
+      expect(challenges.last.scheme, equals("scheme2"));
+      expect(challenges.last.parameters, equals({
+        "realm": "asdfg",
+        "baz": "bang"
+      }));
+    });
+  });
+}
+
+/// Tests to run for parsing a single challenge.
+///
+/// These are run on both [AuthenticationChallenge.parse] and
+/// [AuthenticationChallenge.parseHeader], since they use almost entirely
+/// separate code paths.
+void _singleChallengeTests(
+    AuthenticationChallenge parseChallenge(String challenge)) {
+  test("parses a simple challenge", () {
+    var challenge = parseChallenge("scheme realm=fblthp");
+    expect(challenge.scheme, equals("scheme"));
+    expect(challenge.parameters, equals({"realm": "fblthp"}));
+  });
+
+  test("parses multiple parameters", () {
+    var challenge = parseChallenge("scheme realm=fblthp, foo=bar, baz=qux");
+    expect(challenge.scheme, equals("scheme"));
+    expect(challenge.parameters, equals({
+      "realm": "fblthp",
+      "foo": "bar",
+      "baz": "qux"
+    }));
+  });
+
+  test("parses quoted string parameters", () {
+    var challenge = parseChallenge('scheme realm="fblthp, foo=bar", baz="qux"');
+    expect(challenge.scheme, equals("scheme"));
+    expect(challenge.parameters, equals({
+      "realm": "fblthp, foo=bar",
+      "baz": "qux"
+    }));
+  });
+
+  test("normalizes the case of the scheme", () {
+    var challenge = parseChallenge("ScHeMe realm=fblthp");
+    expect(challenge.scheme, equals("scheme"));
+    expect(challenge.parameters, equals({"realm": "fblthp"}));
+  });
+
+  test("normalizes the case of the parameter name", () {
+    var challenge = parseChallenge("scheme ReAlM=fblthp");
+    expect(challenge.scheme, equals("scheme"));
+    expect(challenge.parameters, containsPair("realm", "fblthp"));
+  });
+
+  test("doesn't normalize the case of the parameter value", () {
+    var challenge = parseChallenge("scheme realm=FbLtHp");
+    expect(challenge.scheme, equals("scheme"));
+    expect(challenge.parameters, containsPair("realm", "FbLtHp"));
+    expect(challenge.parameters, isNot(containsPair("realm", "fblthp")));
+  });
+
+  test("allows extra whitespace", () {
+    var challenge = parseChallenge(
+        "  scheme\t \trealm\t = \tfblthp\t, \tfoo\t\r\n =\tbar\t");
+    expect(challenge.scheme, equals("scheme"));
+    expect(challenge.parameters, equals({
+      "realm": "fblthp",
+      "foo": "bar"
+    }));
+  });
+
+  test("allows an empty parameter", () {
+    var challenge = parseChallenge(
+        "scheme realm=fblthp, , foo=bar");
+    expect(challenge.scheme, equals("scheme"));
+    expect(challenge.parameters, equals({
+      "realm": "fblthp",
+      "foo": "bar"
+    }));
+  });
+
+  test("allows a leading comma", () {
+    var challenge = parseChallenge(
+        "scheme , realm=fblthp, foo=bar,");
+    expect(challenge.scheme, equals("scheme"));
+    expect(challenge.parameters, equals({
+      "realm": "fblthp",
+      "foo": "bar"
+    }));
+  });
+
+  test("allows a trailing comma", () {
+    var challenge = parseChallenge(
+        "scheme realm=fblthp, foo=bar, ,");
+    expect(challenge.scheme, equals("scheme"));
+    expect(challenge.parameters, equals({
+      "realm": "fblthp",
+      "foo": "bar"
+    }));
+  });
+
+  test("disallows only a scheme", () {
+    expect(() => parseChallenge("scheme"),
+        throwsFormatException);
+  });
+
+  test("disallows a valueless parameter", () {
+    expect(() => parseChallenge("scheme realm"),
+        throwsFormatException);
+    expect(() => parseChallenge("scheme realm="),
+        throwsFormatException);
+    expect(() => parseChallenge("scheme realm, foo=bar"),
+        throwsFormatException);
+  });
+
+  test("requires a space after the scheme", () {
+    expect(() => parseChallenge("scheme\trealm"),
+        throwsFormatException);
+    expect(() => parseChallenge("scheme\r\n\trealm="),
+        throwsFormatException);
+  });
+
+  test("disallows junk after the parameters", () {
+    expect(() => parseChallenge("scheme realm=fblthp foo"),
+        throwsFormatException);
+    expect(() => parseChallenge("scheme realm=fblthp, foo=bar baz"),
+        throwsFormatException);
+  });
+}