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