Skip to content

Commit cfd5448

Browse files
brianquinlanCommit Queue
authored andcommitted
Add credentials for bearer token authentication
Closes #60048 GitOrigin-RevId: bbed1f2 Change-Id: I22c5141f942d2986d0927f13e31beca0ffcc36ba Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/408241 Reviewed-by: Alexander Aprelev <[email protected]> Commit-Queue: Brian Quinlan <[email protected]>
1 parent 56e3164 commit cfd5448

File tree

6 files changed

+421
-54
lines changed

6 files changed

+421
-54
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88

99
- Added `Iterable.withIterator` constructor.
1010

11+
#### `dart:io`
12+
13+
- Added support `HttpClientBearerCredentials`.
14+
1115
#### `dart:html`
1216

1317
- **Breaking change**: Native classes in `dart:html`, like `HtmlElement`, can no

sdk/lib/_http/http.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2054,6 +2054,13 @@ abstract final class HttpClientBasicCredentials
20542054
_HttpClientBasicCredentials(username, password);
20552055
}
20562056

2057+
/// Represents credentials for bearer token authentication.
2058+
abstract final class HttpClientBearerCredentials
2059+
implements HttpClientCredentials {
2060+
factory HttpClientBearerCredentials(String token) =>
2061+
_HttpClientBearerCredentials(token);
2062+
}
2063+
20572064
/// Represents credentials for digest authentication. Digest
20582065
/// authentication is only supported for servers using the MD5
20592066
/// algorithm and quality of protection (qop) of either "none" or

sdk/lib/_http/http_impl.dart

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3900,18 +3900,21 @@ class _AuthenticationScheme {
39003900

39013901
static const UNKNOWN = _AuthenticationScheme(-1);
39023902
static const BASIC = _AuthenticationScheme(0);
3903-
static const DIGEST = _AuthenticationScheme(1);
3903+
static const BEARER = _AuthenticationScheme(1);
3904+
static const DIGEST = _AuthenticationScheme(2);
39043905

39053906
const _AuthenticationScheme(this._scheme);
39063907

39073908
factory _AuthenticationScheme.fromString(String scheme) {
39083909
if (scheme.toLowerCase() == "basic") return BASIC;
3910+
if (scheme.toLowerCase() == "bearer") return BEARER;
39093911
if (scheme.toLowerCase() == "digest") return DIGEST;
39103912
return UNKNOWN;
39113913
}
39123914

39133915
String toString() {
39143916
if (this == BASIC) return "Basic";
3917+
if (this == BEARER) return "Bearer";
39153918
if (this == DIGEST) return "Digest";
39163919
return "Unknown";
39173920
}
@@ -4038,6 +4041,33 @@ final class _HttpClientBasicCredentials extends _HttpClientCredentials
40384041
}
40394042
}
40404043

4044+
final class _HttpClientBearerCredentials extends _HttpClientCredentials
4045+
implements HttpClientBearerCredentials {
4046+
String token;
4047+
4048+
_HttpClientBearerCredentials(this.token) {
4049+
// verify token according to RFC-6750 section 2.1
4050+
// https://www.rfc-editor.org/rfc/rfc6750.html#section-2.1
4051+
if (RegExp(r'[^0-9A-Za-z\-._~+/=]').hasMatch(token)) {
4052+
throw ArgumentError.value(token, "token", "Invalid characters");
4053+
}
4054+
}
4055+
4056+
_AuthenticationScheme get scheme => _AuthenticationScheme.BEARER;
4057+
4058+
String authorization() {
4059+
return "Bearer $token";
4060+
}
4061+
4062+
void authorize(_Credentials _, HttpClientRequest request) {
4063+
request.headers.set(HttpHeaders.authorizationHeader, authorization());
4064+
}
4065+
4066+
void authorizeProxy(_ProxyCredentials _, HttpClientRequest request) {
4067+
request.headers.set(HttpHeaders.proxyAuthorizationHeader, authorization());
4068+
}
4069+
}
4070+
40414071
final class _HttpClientDigestCredentials extends _HttpClientCredentials
40424072
implements HttpClientDigestCredentials {
40434073
String username;

sdk/lib/io/io.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ export 'dart:_http'
228228
HttpClientResponseCompressionState,
229229
HttpClientCredentials,
230230
HttpClientBasicCredentials,
231+
HttpClientBearerCredentials,
231232
HttpClientDigestCredentials,
232233
HttpConnectionInfo,
233234
RedirectInfo,
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'dart:async';
6+
import 'dart:convert';
7+
import 'dart:io';
8+
9+
import "package:expect/async_helper.dart";
10+
import "package:expect/expect.dart";
11+
12+
class Server {
13+
late HttpServer server;
14+
15+
Future<Server> start() async {
16+
server = await HttpServer.bind(InternetAddress.loopbackIPv4.address, 0);
17+
server.listen((request) {
18+
final response = request.response;
19+
20+
// WARNING: this authenticate header is malformed because of missing
21+
// commas between the arguments
22+
if (request.uri.path == "/malformedAuthenticate") {
23+
response.statusCode = HttpStatus.unauthorized;
24+
response.headers.set(
25+
HttpHeaders.wwwAuthenticateHeader,
26+
"Bearer realm=\"realm\" error=\"invalid_token\"",
27+
);
28+
response.close();
29+
return;
30+
}
31+
32+
// NOTE: see RFC6750 section 3 regarding the authenticate response header
33+
// field:
34+
// https://www.rfc-editor.org/rfc/rfc6750.html#section-3
35+
if (request.headers[HttpHeaders.authorizationHeader] != null) {
36+
final token = base64.encode(utf8.encode(request.uri.path.substring(1)));
37+
Expect.equals(
38+
1,
39+
request.headers[HttpHeaders.authorizationHeader]!.length,
40+
);
41+
final authorizationHeaderParts = request
42+
.headers[HttpHeaders.authorizationHeader]![0]
43+
.split(" ");
44+
Expect.equals("Bearer", authorizationHeaderParts[0]);
45+
if (token != authorizationHeaderParts[1]) {
46+
response.statusCode = HttpStatus.unauthorized;
47+
response.headers.set(
48+
HttpHeaders.wwwAuthenticateHeader,
49+
"Bearer realm=\"realm\", error=\"invalid_token\"",
50+
);
51+
}
52+
} else {
53+
response.statusCode = HttpStatus.unauthorized;
54+
response.headers.set(
55+
HttpHeaders.wwwAuthenticateHeader,
56+
"Bearer realm=\"realm\"",
57+
);
58+
}
59+
response.close();
60+
});
61+
return this;
62+
}
63+
64+
void shutdown() {
65+
server.close();
66+
}
67+
68+
String get host => server.address.address;
69+
70+
int get port => server.port;
71+
}
72+
73+
void testCreateValidBearerTokens() {
74+
HttpClientBearerCredentials("977ce44bc56dc5000c9d2c329e682547");
75+
HttpClientBearerCredentials("dGVzdHRlc3R0ZXN0dGVzdA==");
76+
HttpClientBearerCredentials("mF_9.B5f-4.1JqM");
77+
}
78+
79+
void testCreateInvalidBearerTokens() {
80+
Expect.throws(() => HttpClientBearerCredentials("#(&%)"));
81+
Expect.throws(() => HttpClientBearerCredentials("áéîöü"));
82+
Expect.throws(() => HttpClientBearerCredentials("あいうえお"));
83+
Expect.throws(() => HttpClientBearerCredentials(" "));
84+
}
85+
86+
void testBearerWithoutCredentials() async {
87+
final server = await Server().start();
88+
final client = HttpClient();
89+
90+
Future makeRequest(Uri url) async {
91+
final request = await client.getUrl(url);
92+
final response = await request.close();
93+
Expect.equals(HttpStatus.unauthorized, response.statusCode);
94+
return response.drain();
95+
}
96+
97+
await Future.wait([
98+
for (int i = 0; i < 5; i++) ...[
99+
makeRequest(Uri.parse("http://${server.host}:${server.port}/test$i")),
100+
],
101+
]);
102+
103+
server.shutdown();
104+
client.close();
105+
}
106+
107+
void testBearerWithCredentials() async {
108+
final server = await Server().start();
109+
final client = HttpClient();
110+
111+
Future makeRequest(Uri url) async {
112+
final request = await client.getUrl(url);
113+
final response = await request.close();
114+
Expect.equals(HttpStatus.ok, response.statusCode);
115+
return response.drain();
116+
}
117+
118+
for (int i = 0; i < 5; i++) {
119+
final token = base64.encode(utf8.encode("test$i"));
120+
client.addCredentials(
121+
Uri.parse("http://${server.host}:${server.port}/test$i"),
122+
"realm",
123+
HttpClientBearerCredentials(token),
124+
);
125+
}
126+
127+
await Future.wait([
128+
for (int i = 0; i < 5; i++) ...[
129+
makeRequest(Uri.parse("http://${server.host}:${server.port}/test$i")),
130+
],
131+
]);
132+
133+
server.shutdown();
134+
client.close();
135+
}
136+
137+
void testBearerWithAuthenticateCallback() async {
138+
final server = await Server().start();
139+
final client = HttpClient();
140+
141+
final callbacks = <String>{};
142+
143+
client.authenticate = (url, scheme, realm) async {
144+
Expect.equals("Bearer", scheme);
145+
Expect.equals("realm", realm);
146+
callbacks.add(url.path.substring(1));
147+
String token = base64.encode(utf8.encode(url.path.substring(1)));
148+
client.addCredentials(url, realm!, HttpClientBearerCredentials(token));
149+
return true;
150+
};
151+
152+
Future makeRequest(Uri url) async {
153+
final request = await client.getUrl(url);
154+
final response = await request.close();
155+
Expect.equals(HttpStatus.ok, response.statusCode);
156+
return response.drain();
157+
}
158+
159+
await Future.wait([
160+
for (int i = 0; i < 5; i++) ...[
161+
makeRequest(Uri.parse("http://${server.host}:${server.port}/test$i")),
162+
],
163+
]);
164+
165+
// assert that all authenticate callbacks have actually been called
166+
Expect.setEquals({for (int i = 0; i < 5; i++) "test$i"}, callbacks);
167+
168+
server.shutdown();
169+
client.close();
170+
}
171+
172+
void testMalformedAuthenticateHeaderWithoutCredentials() async {
173+
final server = await Server().start();
174+
final client = HttpClient();
175+
final uri = Uri.parse(
176+
"http://${server.host}:${server.port}/malformedAuthenticate",
177+
);
178+
179+
// the request should resolve normally if no authentication is configured
180+
final request = await client.getUrl(uri);
181+
final response = await request.close();
182+
183+
server.shutdown();
184+
client.close();
185+
}
186+
187+
void testMalformedAuthenticateHeaderWithCredentials() async {
188+
final server = await Server().start();
189+
final client = HttpClient();
190+
final uri = Uri.parse(
191+
"http://${server.host}:${server.port}/malformedAuthenticate",
192+
);
193+
final token = base64.encode(utf8.encode("test"));
194+
195+
// the request should throw an exception if credentials have been added
196+
client.addCredentials(uri, "realm", HttpClientBearerCredentials(token));
197+
await asyncExpectThrows<HttpException>(
198+
Future(() async {
199+
final request = await client.getUrl(uri);
200+
final response = await request.close();
201+
}),
202+
);
203+
204+
server.shutdown();
205+
client.close();
206+
}
207+
208+
void testMalformedAuthenticateHeaderWithAuthenticateCallback() async {
209+
final server = await Server().start();
210+
final client = HttpClient();
211+
final uri = Uri.parse(
212+
"http://${server.host}:${server.port}/malformedAuthenticate",
213+
);
214+
215+
// the request should throw an exception if the authenticate handler is set
216+
client.authenticate = (url, scheme, realm) async => false;
217+
await asyncExpectThrows<HttpException>(
218+
Future(() async {
219+
final request = await client.getUrl(uri);
220+
final response = await request.close();
221+
}),
222+
);
223+
224+
server.shutdown();
225+
client.close();
226+
}
227+
228+
void testLocalServerBearer() async {
229+
final client = HttpClient();
230+
231+
client.authenticate = (url, scheme, realm) async {
232+
final token = base64.encode(utf8.encode("test"));
233+
client.addCredentials(
234+
Uri.parse("http://127.0.0.1/bearer"),
235+
"test",
236+
HttpClientBearerCredentials(token),
237+
);
238+
return true;
239+
};
240+
241+
final request = await client.getUrl(
242+
Uri.parse("http://127.0.0.1/bearer/test"),
243+
);
244+
final response = await request.close();
245+
Expect.equals(HttpStatus.ok, response.statusCode);
246+
await response.drain();
247+
248+
client.close();
249+
}
250+
251+
main() {
252+
testCreateValidBearerTokens();
253+
testCreateInvalidBearerTokens();
254+
testBearerWithoutCredentials();
255+
testBearerWithCredentials();
256+
testBearerWithAuthenticateCallback();
257+
testMalformedAuthenticateHeaderWithoutCredentials();
258+
testMalformedAuthenticateHeaderWithCredentials();
259+
testMalformedAuthenticateHeaderWithAuthenticateCallback();
260+
// These tests are not normally run. They can be used for locally
261+
// testing with another web server (e.g. Apache).
262+
// testLocalServerBearer();
263+
}

0 commit comments

Comments
 (0)