Add PKCE support to AuthorizationCodeGrant (#69)
* Add PKCE support
* Run dartfmt
diff --git a/lib/src/authorization_code_grant.dart b/lib/src/authorization_code_grant.dart
index 526ab9f..1e930e1 100644
--- a/lib/src/authorization_code_grant.dart
+++ b/lib/src/authorization_code_grant.dart
@@ -3,7 +3,10 @@
// BSD-style license that can be found in the LICENSE file.
import 'dart:async';
+import 'dart:convert';
+import 'dart:math';
+import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import 'package:http_parser/http_parser.dart';
@@ -100,6 +103,13 @@
/// The current state of the grant object.
_State _state = _State.initial;
+ /// Allowed characters for generating the _codeVerifier
+ static const String _charset =
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
+
+ /// The generated PKCE code verifier
+ String _codeVerifier;
+
/// Creates a new grant.
///
/// If [basicAuth] is `true` (the default), the client credentials are sent to
@@ -175,13 +185,20 @@
scopes = scopes.toList();
}
+ _codeVerifier = _createCodeVerifier();
+ var codeChallenge = base64Url
+ .encode(sha256.convert(ascii.encode(_codeVerifier)).bytes)
+ .replaceAll("=", "");
+
this._redirectEndpoint = redirect;
this._scopes = scopes;
this._stateString = state;
var parameters = {
"response_type": "code",
"client_id": this.identifier,
- "redirect_uri": redirect.toString()
+ "redirect_uri": redirect.toString(),
+ "code_challenge": codeChallenge,
+ "code_challenge_method": "S256"
};
if (state != null) parameters['state'] = state;
@@ -278,7 +295,8 @@
var body = {
"grant_type": "authorization_code",
"code": authorizationCode,
- "redirect_uri": this._redirectEndpoint.toString()
+ "redirect_uri": this._redirectEndpoint.toString(),
+ "code_verifier": _codeVerifier
};
if (_basicAuth && secret != null) {
@@ -304,6 +322,12 @@
onCredentialsRefreshed: _onCredentialsRefreshed);
}
+ /// Randomly generate a 128 character string to be used as the PKCE code verifier
+ static String _createCodeVerifier() {
+ return List.generate(
+ 128, (i) => _charset[Random.secure().nextInt(_charset.length)]).join();
+ }
+
/// Closes the grant and frees its resources.
///
/// This will close the underlying HTTP client, which is shared by the
diff --git a/pubspec.yaml b/pubspec.yaml
index 30cd3f8..0b3d3f2 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -13,6 +13,7 @@
dependencies:
http: '>=0.11.0 <0.13.0'
http_parser: '>=1.0.0 <4.0.0'
+ crypto: '^2.1.3'
dev_dependencies:
pedantic: ^1.2.0
diff --git a/test/authorization_code_grant_test.dart b/test/authorization_code_grant_test.dart
index babdb78..2fcf36b 100644
--- a/test/authorization_code_grant_test.dart
+++ b/test/authorization_code_grant_test.dart
@@ -30,10 +30,13 @@
test('builds the correct URL', () {
expect(
grant.getAuthorizationUrl(redirectUrl).toString(),
- equals('https://example.com/authorization'
- '?response_type=code'
- '&client_id=identifier'
- '&redirect_uri=http%3A%2F%2Fexample.com%2Fredirect'));
+ allOf([
+ startsWith('https://example.com/authorization?response_type=code'),
+ contains('&client_id=identifier'),
+ contains('&redirect_uri=http%3A%2F%2Fexample.com%2Fredirect'),
+ matches(r'&code_challenge=[A-Za-z0-9\+\/\-\_]{43}'),
+ contains('&code_challenge_method=S256')
+ ]));
});
test('builds the correct URL with scopes', () {
@@ -41,11 +44,14 @@
.getAuthorizationUrl(redirectUrl, scopes: ['scope', 'other/scope']);
expect(
authorizationUrl.toString(),
- equals('https://example.com/authorization'
- '?response_type=code'
- '&client_id=identifier'
- '&redirect_uri=http%3A%2F%2Fexample.com%2Fredirect'
- '&scope=scope+other%2Fscope'));
+ allOf([
+ startsWith('https://example.com/authorization?response_type=code'),
+ contains('&client_id=identifier'),
+ contains('&redirect_uri=http%3A%2F%2Fexample.com%2Fredirect'),
+ matches(r'&code_challenge=[A-Za-z0-9\+\/\-\_]{43}'),
+ contains('&code_challenge_method=S256'),
+ contains('&scope=scope+other%2Fscope')
+ ]));
});
test('separates scopes with the correct delimiter', () {
@@ -60,11 +66,14 @@
.getAuthorizationUrl(redirectUrl, scopes: ['scope', 'other/scope']);
expect(
authorizationUrl.toString(),
- equals('https://example.com/authorization'
- '?response_type=code'
- '&client_id=identifier'
- '&redirect_uri=http%3A%2F%2Fexample.com%2Fredirect'
- '&scope=scope_other%2Fscope'));
+ allOf([
+ startsWith('https://example.com/authorization?response_type=code'),
+ contains('&client_id=identifier'),
+ contains('&redirect_uri=http%3A%2F%2Fexample.com%2Fredirect'),
+ matches(r'&code_challenge=[A-Za-z0-9\+\/\-\_]{43}'),
+ contains('&code_challenge_method=S256'),
+ contains('&scope=scope_other%2Fscope')
+ ]));
});
test('builds the correct URL with state', () {
@@ -72,11 +81,14 @@
grant.getAuthorizationUrl(redirectUrl, state: 'state');
expect(
authorizationUrl.toString(),
- equals('https://example.com/authorization'
- '?response_type=code'
- '&client_id=identifier'
- '&redirect_uri=http%3A%2F%2Fexample.com%2Fredirect'
- '&state=state'));
+ allOf([
+ startsWith('https://example.com/authorization?response_type=code'),
+ contains('&client_id=identifier'),
+ contains('&redirect_uri=http%3A%2F%2Fexample.com%2Fredirect'),
+ matches(r'&code_challenge=[A-Za-z0-9\+\/\-\_]{43}'),
+ contains('&code_challenge_method=S256'),
+ contains('&state=state')
+ ]));
});
test('merges with existing query parameters', () {
@@ -90,11 +102,14 @@
var authorizationUrl = grant.getAuthorizationUrl(redirectUrl);
expect(
authorizationUrl.toString(),
- equals('https://example.com/authorization'
- '?query=value'
- '&response_type=code'
- '&client_id=identifier'
- '&redirect_uri=http%3A%2F%2Fexample.com%2Fredirect'));
+ allOf([
+ startsWith('https://example.com/authorization?query=value'),
+ contains('&response_type=code'),
+ contains('&client_id=identifier'),
+ contains('&redirect_uri=http%3A%2F%2Fexample.com%2Fredirect'),
+ matches(r'&code_challenge=[A-Za-z0-9\+\/\-\_]{43}'),
+ contains('&code_challenge_method=S256'),
+ ]));
});
test("can't be called twice", () {
@@ -148,11 +163,13 @@
expect(request.url.toString(), equals(grant.tokenEndpoint.toString()));
expect(
request.bodyFields,
- equals({
- 'grant_type': 'authorization_code',
- 'code': 'auth code',
- 'redirect_uri': redirectUrl.toString()
- }));
+ allOf([
+ containsPair('grant_type', 'authorization_code'),
+ containsPair('code', 'auth code'),
+ containsPair('redirect_uri', redirectUrl.toString()),
+ containsPair(
+ 'code_verifier', matches(r'[A-Za-z0-9\-\.\_\~]{128}'))
+ ]));
expect(request.headers,
containsPair("Authorization", "Basic aWRlbnRpZmllcjpzZWNyZXQ="));
@@ -190,11 +207,13 @@
expect(request.url.toString(), equals(grant.tokenEndpoint.toString()));
expect(
request.bodyFields,
- equals({
- 'grant_type': 'authorization_code',
- 'code': 'auth code',
- 'redirect_uri': redirectUrl.toString()
- }));
+ allOf([
+ containsPair('grant_type', 'authorization_code'),
+ containsPair('code', 'auth code'),
+ containsPair('redirect_uri', redirectUrl.toString()),
+ containsPair(
+ 'code_verifier', matches(r'[A-Za-z0-9\-\.\_\~]{128}'))
+ ]));
expect(request.headers,
containsPair("Authorization", "Basic aWRlbnRpZmllcjpzZWNyZXQ="));
@@ -235,13 +254,15 @@
expect(request.url.toString(), equals(grant.tokenEndpoint.toString()));
expect(
request.bodyFields,
- equals({
- 'grant_type': 'authorization_code',
- 'code': 'auth code',
- 'redirect_uri': redirectUrl.toString(),
- 'client_id': 'identifier',
- 'client_secret': 'secret'
- }));
+ allOf([
+ containsPair('grant_type', 'authorization_code'),
+ containsPair('code', 'auth code'),
+ containsPair('redirect_uri', redirectUrl.toString()),
+ containsPair(
+ 'code_verifier', matches(r'[A-Za-z0-9\-\.\_\~]{128}')),
+ containsPair('client_id', 'identifier'),
+ containsPair('client_secret', 'secret')
+ ]));
return new Future.value(new http.Response(
jsonEncode({
@@ -265,13 +286,15 @@
expect(request.url.toString(), equals(grant.tokenEndpoint.toString()));
expect(
request.bodyFields,
- equals({
- 'grant_type': 'authorization_code',
- 'code': 'auth code',
- 'redirect_uri': redirectUrl.toString(),
- 'client_id': 'identifier',
- 'client_secret': 'secret'
- }));
+ allOf([
+ containsPair('grant_type', 'authorization_code'),
+ containsPair('code', 'auth code'),
+ containsPair('redirect_uri', redirectUrl.toString()),
+ containsPair(
+ 'code_verifier', matches(r'[A-Za-z0-9\-\.\_\~]{128}')),
+ containsPair('client_id', 'identifier'),
+ containsPair('client_secret', 'secret')
+ ]));
return new Future.value(new http.Response(
jsonEncode({