add example
This commit is contained in:
parent
32da3a16b5
commit
ac2c7691ea
7 changed files with 458 additions and 5 deletions
|
@ -1,6 +1,7 @@
|
||||||
# 2.1.0
|
# 2.1.0
|
||||||
* Updates
|
* Updates
|
||||||
* Support `device_code` grants.
|
* Support `device_code` grants.
|
||||||
|
* Add support for [PKCE](https://tools.ietf.org/html/rfc7636).
|
||||||
|
|
||||||
# 2.0.0
|
# 2.0.0
|
||||||
* Angel 2 support.
|
* Angel 2 support.
|
||||||
|
|
71
README.md
71
README.md
|
@ -4,13 +4,19 @@
|
||||||
|
|
||||||
A class containing handlers that can be used within
|
A class containing handlers that can be used within
|
||||||
[Angel](https://angel-dart.github.io/) to build a spec-compliant
|
[Angel](https://angel-dart.github.io/) to build a spec-compliant
|
||||||
OAuth 2.0 server.
|
OAuth 2.0 server, including PKCE support.
|
||||||
|
|
||||||
|
* [Installation](#installation)
|
||||||
|
* [Usage](#usage)
|
||||||
|
* [Other Grants](#other-grants)
|
||||||
|
* [PKCE](#pkce)
|
||||||
|
|
||||||
# Installation
|
# Installation
|
||||||
In your `pubspec.yaml`:
|
In your `pubspec.yaml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
dependencies:
|
dependencies:
|
||||||
|
angel_framework: ^2.0.0-alpha
|
||||||
angel_oauth2: ^2.0.0
|
angel_oauth2: ^2.0.0
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -102,4 +108,65 @@ The following are available, not including authorization code grant support (men
|
||||||
* `deviceCodeGrant`
|
* `deviceCodeGrant`
|
||||||
|
|
||||||
Read the [OAuth2 specification](https://tools.ietf.org/html/rfc6749)
|
Read the [OAuth2 specification](https://tools.ietf.org/html/rfc6749)
|
||||||
for in-depth information on each grant type.
|
for in-depth information on each grant type.
|
||||||
|
|
||||||
|
## PKCE
|
||||||
|
In some cases, you will be using OAuth2 on a mobile device, or on some other
|
||||||
|
public client, where the client cannot have a client
|
||||||
|
secret.
|
||||||
|
|
||||||
|
In such a case, you may consider using
|
||||||
|
[PKCE](https://tools.ietf.org/html/rfc7636).
|
||||||
|
|
||||||
|
Both the `authorizationEndpoint` and `tokenEndpoint`
|
||||||
|
inject a `Pkce` factory into the request, so it
|
||||||
|
can be used as follows:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@override
|
||||||
|
Future requestAuthorizationCode(
|
||||||
|
PseudoApplication client,
|
||||||
|
String redirectUri,
|
||||||
|
Iterable<String> scopes,
|
||||||
|
String state,
|
||||||
|
RequestContext req,
|
||||||
|
ResponseContext res) async {
|
||||||
|
// Automatically throws an error if the request doesn't contain the
|
||||||
|
// necessary information.
|
||||||
|
var pkce = req.container.make<Pkce>();
|
||||||
|
|
||||||
|
// At this point, store `pkce.codeChallenge` and `pkce.codeChallengeMethod`,
|
||||||
|
// so that when it's time to exchange the auth code for a token, we can
|
||||||
|
// create a new [Pkce] object, and verify the client.
|
||||||
|
return await getAuthCodeSomehow(client, pkce.codeChallenge, pkce.codeChallengeMethod);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AuthorizationTokenResponse> exchangeAuthorizationCodeForToken(
|
||||||
|
String authCode,
|
||||||
|
String redirectUri,
|
||||||
|
RequestContext req,
|
||||||
|
ResponseContext res) async {
|
||||||
|
// When exchanging the authorization code for a token, we'll need
|
||||||
|
// a `code_verifier` from the client, so that we can ensure
|
||||||
|
// that the correct client is trying to use the auth code.
|
||||||
|
//
|
||||||
|
// If none is present, an OAuth2 exception is thrown.
|
||||||
|
var codeVerifier = await getPkceCodeVerifier(req);
|
||||||
|
|
||||||
|
// Next, we'll need to retrieve the code challenge and code challenge method
|
||||||
|
// from earlier.
|
||||||
|
var codeChallenge = await getTheChallenge();
|
||||||
|
var codeChallengeMethod = await getTheChallengeMethod();
|
||||||
|
|
||||||
|
// Make a new [Pkce] object.
|
||||||
|
var pkce = new Pkce(codeChallengeMethod, codeChallenge);
|
||||||
|
|
||||||
|
// Call `validate`. If the client is invalid, it throws an OAuth2 exception.
|
||||||
|
pkce.validate(codeVerifier);
|
||||||
|
|
||||||
|
// If we reach here, we know that the `code_verifier` was valid,
|
||||||
|
// so we can return our authorization token as per usual.
|
||||||
|
return new AuthorizationTokenResponse('...');
|
||||||
|
}
|
||||||
|
```
|
74
example/main.dart
Normal file
74
example/main.dart
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
// ignore_for_file: todo
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:angel_framework/angel_framework.dart';
|
||||||
|
import 'package:angel_oauth2/angel_oauth2.dart';
|
||||||
|
|
||||||
|
main() async {
|
||||||
|
var app = new Angel();
|
||||||
|
var oauth2 = new _ExampleAuthorizationServer();
|
||||||
|
var _rgxBearer = new RegExp(r'^[Bb]earer ([^\n\s]+)$');
|
||||||
|
|
||||||
|
app.group('/auth', (router) {
|
||||||
|
router
|
||||||
|
..get('/authorize', oauth2.authorizationEndpoint)
|
||||||
|
..post('/token', oauth2.tokenEndpoint);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assume that all other requests must be authenticated...
|
||||||
|
app.fallback((req, res) {
|
||||||
|
var authToken =
|
||||||
|
req.headers.value('authorization')?.replaceAll(_rgxBearer, '')?.trim();
|
||||||
|
|
||||||
|
if (authToken == null) {
|
||||||
|
throw AngelHttpException.forbidden();
|
||||||
|
} else {
|
||||||
|
// TODO: The user has a token, now verify it.
|
||||||
|
// It is up to you how to store and retrieve auth tokens within your application.
|
||||||
|
// The purpose of `package:angel_oauth2` is to provide the transport
|
||||||
|
// across which you distribute these tokens in the first place.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class ThirdPartyApp {}
|
||||||
|
|
||||||
|
class User {}
|
||||||
|
|
||||||
|
/// A [ThirdPartyApp] can act on behalf of a [User].
|
||||||
|
class _ExampleAuthorizationServer
|
||||||
|
extends AuthorizationServer<ThirdPartyApp, User> {
|
||||||
|
@override
|
||||||
|
FutureOr<ThirdPartyApp> findClient(String clientId) {
|
||||||
|
// TODO: Add your code to find the app associated with a client ID.
|
||||||
|
throw new UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<bool> verifyClient(ThirdPartyApp client, String clientSecret) {
|
||||||
|
// TODO: Add your code to verify a client secret, if given one.
|
||||||
|
throw new UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr requestAuthorizationCode(
|
||||||
|
ThirdPartyApp client,
|
||||||
|
String redirectUri,
|
||||||
|
Iterable<String> scopes,
|
||||||
|
String state,
|
||||||
|
RequestContext req,
|
||||||
|
ResponseContext res) {
|
||||||
|
// TODO: In many cases, here you will render a view displaying to the user which scopes are being requested.
|
||||||
|
throw new UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<AuthorizationTokenResponse> exchangeAuthorizationCodeForToken(
|
||||||
|
String authCode,
|
||||||
|
String redirectUri,
|
||||||
|
RequestContext req,
|
||||||
|
ResponseContext res) {
|
||||||
|
// TODO: Here, you'll convert the auth code into a full-fledged token.
|
||||||
|
// You might have the auth code stored in a database somewhere.
|
||||||
|
throw new UnimplementedError();
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,4 +2,4 @@ export 'src/exception.dart';
|
||||||
export 'src/pkce.dart';
|
export 'src/pkce.dart';
|
||||||
export 'src/response.dart';
|
export 'src/response.dart';
|
||||||
export 'src/server.dart';
|
export 'src/server.dart';
|
||||||
export 'src/token_type.dart';
|
export 'src/token_type.dart';
|
||||||
|
|
|
@ -51,9 +51,9 @@ class Pkce {
|
||||||
|
|
||||||
if (isS256) {
|
if (isS256) {
|
||||||
foreignChallenge =
|
foreignChallenge =
|
||||||
base64Url.encode(sha256.convert(ascii.encode(codeChallenge)).bytes);
|
base64Url.encode(sha256.convert(ascii.encode(codeVerifier)).bytes);
|
||||||
} else {
|
} else {
|
||||||
foreignChallenge = codeChallenge;
|
foreignChallenge = codeVerifier;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (foreignChallenge != codeChallenge) {
|
if (foreignChallenge != codeChallenge) {
|
||||||
|
|
|
@ -65,6 +65,31 @@ abstract class AuthorizationServer<Client, User> {
|
||||||
/// Verify that a [client] is the one identified by the [clientSecret].
|
/// Verify that a [client] is the one identified by the [clientSecret].
|
||||||
FutureOr<bool> verifyClient(Client client, String clientSecret);
|
FutureOr<bool> verifyClient(Client client, String clientSecret);
|
||||||
|
|
||||||
|
/// Retrieves the PKCE `code_verifier` parameter from a [RequestContext], or throws.
|
||||||
|
Future<String> getPkceCodeVerifier(RequestContext req,
|
||||||
|
{bool body: true, String state, Uri uri}) async {
|
||||||
|
var data = body
|
||||||
|
? await req.parseBody().then((_) => req.bodyAsMap)
|
||||||
|
: req.queryParameters;
|
||||||
|
var codeVerifier = data['code_verifier'];
|
||||||
|
|
||||||
|
if (codeVerifier == null) {
|
||||||
|
throw new AuthorizationException(new ErrorResponse(
|
||||||
|
ErrorResponse.invalidRequest,
|
||||||
|
"Missing `code_verifier` parameter.",
|
||||||
|
state,
|
||||||
|
uri: uri));
|
||||||
|
} else if (codeVerifier is! String) {
|
||||||
|
throw new AuthorizationException(new ErrorResponse(
|
||||||
|
ErrorResponse.invalidRequest,
|
||||||
|
"The `code_verifier` parameter must be a string.",
|
||||||
|
state,
|
||||||
|
uri: uri));
|
||||||
|
} else {
|
||||||
|
return codeVerifier as String;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Prompt the currently logged-in user to grant or deny access to the [client].
|
/// Prompt the currently logged-in user to grant or deny access to the [client].
|
||||||
///
|
///
|
||||||
/// In many applications, this will entail showing a dialog to the user in question.
|
/// In many applications, this will entail showing a dialog to the user in question.
|
||||||
|
|
286
test/pkce_test.dart
Normal file
286
test/pkce_test.dart
Normal file
|
@ -0,0 +1,286 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:collection';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:angel_framework/angel_framework.dart';
|
||||||
|
import 'package:angel_framework/http.dart';
|
||||||
|
import 'package:angel_oauth2/angel_oauth2.dart';
|
||||||
|
import 'package:angel_test/angel_test.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:oauth2/oauth2.dart' as oauth2;
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
import 'common.dart';
|
||||||
|
|
||||||
|
main() {
|
||||||
|
Angel app;
|
||||||
|
Uri authorizationEndpoint, tokenEndpoint, redirectUri;
|
||||||
|
TestClient testClient;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
app = new Angel();
|
||||||
|
app.container.registerSingleton(new AuthCodes());
|
||||||
|
|
||||||
|
var server = new _Server();
|
||||||
|
|
||||||
|
app.group('/oauth2', (router) {
|
||||||
|
router
|
||||||
|
..get('/authorize', server.authorizationEndpoint)
|
||||||
|
..post('/token', server.tokenEndpoint);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.logger = new Logger('angel')
|
||||||
|
..onRecord.listen((rec) {
|
||||||
|
print(rec);
|
||||||
|
if (rec.error != null) print(rec.error);
|
||||||
|
if (rec.stackTrace != null) print(rec.stackTrace);
|
||||||
|
});
|
||||||
|
|
||||||
|
var http = new AngelHttp(app);
|
||||||
|
var s = await http.startServer();
|
||||||
|
var url = 'http://${s.address.address}:${s.port}';
|
||||||
|
authorizationEndpoint = Uri.parse('$url/oauth2/authorize');
|
||||||
|
tokenEndpoint = Uri.parse('$url/oauth2/token');
|
||||||
|
redirectUri = Uri.parse('http://foo.bar/baz');
|
||||||
|
|
||||||
|
testClient = await connectTo(app);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
await testClient.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
group('get auth code', () {
|
||||||
|
test('with challenge + implied plain', () async {
|
||||||
|
var url = authorizationEndpoint.replace(queryParameters: {
|
||||||
|
'response_type': 'code',
|
||||||
|
'client_id': 'freddie mercury',
|
||||||
|
'redirect_uri': 'https://freddie.mercu.ry',
|
||||||
|
'code_challenge': 'foo',
|
||||||
|
});
|
||||||
|
var response = await testClient
|
||||||
|
.get(url.toString(), headers: {'accept': 'application/json'});
|
||||||
|
print(response.body);
|
||||||
|
expect(
|
||||||
|
response,
|
||||||
|
allOf(
|
||||||
|
hasStatus(200),
|
||||||
|
isJson({"code": "ok"}),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('with challenge + plain', () async {
|
||||||
|
var url = authorizationEndpoint.replace(queryParameters: {
|
||||||
|
'response_type': 'code',
|
||||||
|
'client_id': 'freddie mercury',
|
||||||
|
'redirect_uri': 'https://freddie.mercu.ry',
|
||||||
|
'code_challenge': 'foo',
|
||||||
|
'code_challenge_method': 'plain',
|
||||||
|
});
|
||||||
|
var response = await testClient
|
||||||
|
.get(url.toString(), headers: {'accept': 'application/json'});
|
||||||
|
print(response.body);
|
||||||
|
expect(
|
||||||
|
response,
|
||||||
|
allOf(
|
||||||
|
hasStatus(200),
|
||||||
|
isJson({"code": "ok"}),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('with challenge + s256', () async {
|
||||||
|
var url = authorizationEndpoint.replace(queryParameters: {
|
||||||
|
'response_type': 'code',
|
||||||
|
'client_id': 'freddie mercury',
|
||||||
|
'redirect_uri': 'https://freddie.mercu.ry',
|
||||||
|
'code_challenge': 'foo',
|
||||||
|
'code_challenge_method': 's256',
|
||||||
|
});
|
||||||
|
var response = await testClient
|
||||||
|
.get(url.toString(), headers: {'accept': 'application/json'});
|
||||||
|
print(response.body);
|
||||||
|
expect(
|
||||||
|
response,
|
||||||
|
allOf(
|
||||||
|
hasStatus(200),
|
||||||
|
isJson({"code": "ok"}),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('with challenge + wrong method', () async {
|
||||||
|
var url = authorizationEndpoint.replace(queryParameters: {
|
||||||
|
'response_type': 'code',
|
||||||
|
'client_id': 'freddie mercury',
|
||||||
|
'redirect_uri': 'https://freddie.mercu.ry',
|
||||||
|
'code_challenge': 'foo',
|
||||||
|
'code_challenge_method': 'bar',
|
||||||
|
});
|
||||||
|
var response = await testClient
|
||||||
|
.get(url.toString(), headers: {'accept': 'application/json'});
|
||||||
|
print(response.body);
|
||||||
|
expect(
|
||||||
|
response,
|
||||||
|
allOf(
|
||||||
|
hasStatus(400),
|
||||||
|
isJson({
|
||||||
|
"error": "invalid_request",
|
||||||
|
"error_description":
|
||||||
|
"The `code_challenge_method` parameter must be either 'plain' or 's256'."
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('with no challenge', () async {
|
||||||
|
var url = authorizationEndpoint.replace(queryParameters: {
|
||||||
|
'response_type': 'code',
|
||||||
|
'client_id': 'freddie mercury',
|
||||||
|
'redirect_uri': 'https://freddie.mercu.ry'
|
||||||
|
});
|
||||||
|
var response = await testClient
|
||||||
|
.get(url.toString(), headers: {'accept': 'application/json'});
|
||||||
|
print(response.body);
|
||||||
|
expect(
|
||||||
|
response,
|
||||||
|
allOf(
|
||||||
|
hasStatus(400),
|
||||||
|
isJson({
|
||||||
|
"error": "invalid_request",
|
||||||
|
"error_description": "Missing `code_challenge` parameter."
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('get token', () {
|
||||||
|
test('with correct verifier', () async {
|
||||||
|
var url = tokenEndpoint.replace(
|
||||||
|
userInfo: '${pseudoApplication.id}:${pseudoApplication.secret}');
|
||||||
|
var response = await testClient.post(url.toString(), headers: {
|
||||||
|
'accept': 'application/json',
|
||||||
|
'authorization': 'Basic ' + base64Url.encode(ascii.encode(url.userInfo))
|
||||||
|
}, body: {
|
||||||
|
'grant_type': 'authorization_code',
|
||||||
|
'client_id': 'freddie mercury',
|
||||||
|
'redirect_uri': 'https://freddie.mercu.ry',
|
||||||
|
'code': 'ok',
|
||||||
|
'code_verifier': 'hello',
|
||||||
|
});
|
||||||
|
|
||||||
|
print(response.body);
|
||||||
|
expect(
|
||||||
|
response,
|
||||||
|
allOf(
|
||||||
|
hasStatus(200),
|
||||||
|
isJson({"token_type": "bearer", "access_token": "yes"}),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
test('with incorrect verifier', () async {
|
||||||
|
var url = tokenEndpoint.replace(
|
||||||
|
userInfo: '${pseudoApplication.id}:${pseudoApplication.secret}');
|
||||||
|
var response = await testClient.post(url.toString(), headers: {
|
||||||
|
'accept': 'application/json',
|
||||||
|
'authorization': 'Basic ' + base64Url.encode(ascii.encode(url.userInfo))
|
||||||
|
}, body: {
|
||||||
|
'grant_type': 'authorization_code',
|
||||||
|
'client_id': 'freddie mercury',
|
||||||
|
'redirect_uri': 'https://freddie.mercu.ry',
|
||||||
|
'code': 'ok',
|
||||||
|
'code_verifier': 'foo',
|
||||||
|
});
|
||||||
|
|
||||||
|
print(response.body);
|
||||||
|
expect(
|
||||||
|
response,
|
||||||
|
allOf(
|
||||||
|
hasStatus(400),
|
||||||
|
isJson({
|
||||||
|
"error": "invalid_grant",
|
||||||
|
"error_description":
|
||||||
|
"The given `code_verifier` parameter is invalid."
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('with missing verifier', () async {
|
||||||
|
var url = tokenEndpoint.replace(
|
||||||
|
userInfo: '${pseudoApplication.id}:${pseudoApplication.secret}');
|
||||||
|
var response = await testClient.post(url.toString(), headers: {
|
||||||
|
'accept': 'application/json',
|
||||||
|
'authorization': 'Basic ' + base64Url.encode(ascii.encode(url.userInfo))
|
||||||
|
}, body: {
|
||||||
|
'grant_type': 'authorization_code',
|
||||||
|
'client_id': 'freddie mercury',
|
||||||
|
'redirect_uri': 'https://freddie.mercu.ry',
|
||||||
|
'code': 'ok'
|
||||||
|
});
|
||||||
|
|
||||||
|
print(response.body);
|
||||||
|
expect(
|
||||||
|
response,
|
||||||
|
allOf(
|
||||||
|
hasStatus(400),
|
||||||
|
isJson({
|
||||||
|
"error": "invalid_request",
|
||||||
|
"error_description": "Missing `code_verifier` parameter."
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Server extends AuthorizationServer<PseudoApplication, Map> {
|
||||||
|
final Uuid _uuid = new Uuid();
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<PseudoApplication> findClient(String clientId) {
|
||||||
|
return pseudoApplication;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> verifyClient(
|
||||||
|
PseudoApplication client, String clientSecret) async {
|
||||||
|
return client.secret == clientSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future requestAuthorizationCode(
|
||||||
|
PseudoApplication client,
|
||||||
|
String redirectUri,
|
||||||
|
Iterable<String> scopes,
|
||||||
|
String state,
|
||||||
|
RequestContext req,
|
||||||
|
ResponseContext res) async {
|
||||||
|
req.container.make<Pkce>();
|
||||||
|
return {'code': 'ok'};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AuthorizationTokenResponse> exchangeAuthorizationCodeForToken(
|
||||||
|
String authCode,
|
||||||
|
String redirectUri,
|
||||||
|
RequestContext req,
|
||||||
|
ResponseContext res) async {
|
||||||
|
var codeVerifier = await getPkceCodeVerifier(req);
|
||||||
|
var pkce = new Pkce('plain', 'hello');
|
||||||
|
pkce.validate(codeVerifier);
|
||||||
|
return new AuthorizationTokenResponse('yes');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthCodes extends MapBase<String, String> with MapMixin<String, String> {
|
||||||
|
var inner = <String, String>{};
|
||||||
|
|
||||||
|
@override
|
||||||
|
String operator [](Object key) => inner[key];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void operator []=(String key, String value) => inner[key] = value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void clear() => inner.clear();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Iterable<String> get keys => inner.keys;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String remove(Object key) => inner.remove(key);
|
||||||
|
}
|
Loading…
Reference in a new issue