From a892e8942589528be2e003b8ceeda76f0a6d0c61 Mon Sep 17 00:00:00 2001 From: Florian Weihl Date: Tue, 30 Jan 2024 15:54:30 +0100 Subject: [PATCH 1/5] add refresh_expires_in property to credentials --- lib/src/credentials.dart | 56 ++++++++++++++++++--- lib/src/handle_access_token_response.dart | 27 ++++++++++ test/credentials_test.dart | 42 +++++++++++++--- test/handle_access_token_response_test.dart | 18 +++++++ 4 files changed, 128 insertions(+), 15 deletions(-) diff --git a/lib/src/credentials.dart b/lib/src/credentials.dart index 088b482..e54cf64 100644 --- a/lib/src/credentials.dart +++ b/lib/src/credentials.dart @@ -72,6 +72,12 @@ class Credentials { /// expiration date. final DateTime? expiration; + /// The date at which the refresh token will expire. + /// + /// This is likely to be a few seconds earlier than the server's idea of the + /// expiration date. + final DateTime? refreshToKenExpiration; + /// The function used to parse parameters from a host's response. final GetParameters _getParameters; @@ -85,8 +91,15 @@ class Credentials { return expiration != null && DateTime.now().isAfter(expiration); } + bool get isRefreshTokenExpired { + var refreshToKenExpiration = this.refreshToKenExpiration; + return refreshToKenExpiration != null && + DateTime.now().isAfter(refreshToKenExpiration); + } + /// Whether it's possible to refresh these credentials. - bool get canRefresh => refreshToken != null && tokenEndpoint != null; + bool get canRefresh => + refreshToken != null && tokenEndpoint != null && !isRefreshTokenExpired; /// Creates a new set of credentials. /// @@ -114,6 +127,7 @@ class Credentials { this.tokenEndpoint, Iterable? scopes, this.expiration, + this.refreshToKenExpiration, String? delimiter, Map Function(MediaType? mediaType, String body)? getParameters}) @@ -176,6 +190,26 @@ class Credentials { expirationDateTime = DateTime.fromMillisecondsSinceEpoch(expiration); } + var accessTokenExpiration = parsed['accessTokenExpiration']; + DateTime? accessTokenExpirationDateTime; + if (accessTokenExpiration != null) { + validate(accessTokenExpiration is int, + 'field "expiration" was not an int, was "$accessTokenExpiration"'); + accessTokenExpiration = accessTokenExpiration as int; + accessTokenExpirationDateTime = + DateTime.fromMillisecondsSinceEpoch(accessTokenExpiration); + } + + var refreshTokenExpiration = parsed['refreshTokenExpiration']; + DateTime? refreshTokenExpirationDateTime; + if (refreshTokenExpiration != null) { + validate(refreshTokenExpiration is int, + 'field "expiration" was not an int, was "$refreshTokenExpiration"'); + refreshTokenExpiration = refreshTokenExpiration as int; + refreshTokenExpirationDateTime = + DateTime.fromMillisecondsSinceEpoch(refreshTokenExpiration); + } + return Credentials( parsed['accessToken'] as String, refreshToken: parsed['refreshToken'] as String?, @@ -183,6 +217,7 @@ class Credentials { tokenEndpoint: tokenEndpointUri, scopes: (scopes as List).map((scope) => scope as String), expiration: expirationDateTime, + refreshToKenExpiration: refreshTokenExpirationDateTime, ); } @@ -196,7 +231,9 @@ class Credentials { 'idToken': idToken, 'tokenEndpoint': tokenEndpoint?.toString(), 'scopes': scopes, - 'expiration': expiration?.millisecondsSinceEpoch + 'expiration': expiration?.millisecondsSinceEpoch, + 'refreshTokenExpiration': + refreshToKenExpiration?.millisecondsSinceEpoch, }); /// Returns a new set of refreshed credentials. @@ -257,11 +294,14 @@ class Credentials { // The authorization server may issue a new refresh token. If it doesn't, // we should re-use the one we already have. if (credentials.refreshToken != null) return credentials; - return Credentials(credentials.accessToken, - refreshToken: refreshToken, - idToken: credentials.idToken, - tokenEndpoint: credentials.tokenEndpoint, - scopes: credentials.scopes, - expiration: credentials.expiration); + return Credentials( + credentials.accessToken, + refreshToken: refreshToken, + idToken: credentials.idToken, + tokenEndpoint: credentials.tokenEndpoint, + scopes: credentials.scopes, + expiration: credentials.expiration, + refreshToKenExpiration: credentials.refreshToKenExpiration, + ); } } diff --git a/lib/src/handle_access_token_response.dart b/lib/src/handle_access_token_response.dart index f318e3b..a76a7f3 100644 --- a/lib/src/handle_access_token_response.dart +++ b/lib/src/handle_access_token_response.dart @@ -84,6 +84,23 @@ Credentials handleAccessTokenResponse(http.Response response, Uri tokenEndpoint, } } + var refreshExpiresIn = parameters['refresh_expires_in']; + if (refreshExpiresIn != null) { + if (refreshExpiresIn is String) { + try { + refreshExpiresIn = double.parse(refreshExpiresIn).toInt(); + } on FormatException { + throw FormatException( + 'parameter "refresh_expires_in" could not be parsed as in, was: ' + '"$refreshExpiresIn"', + ); + } + } else if (refreshExpiresIn is! int) { + throw FormatException( + 'parameter "expires_in" was not an int, was: "$refreshExpiresIn"'); + } + } + for (var name in ['refresh_token', 'id_token', 'scope']) { var value = parameters[name]; if (value != null && value is! String) { @@ -99,6 +116,15 @@ Credentials handleAccessTokenResponse(http.Response response, Uri tokenEndpoint, ? null : startTime.add(Duration(seconds: expiresIn as int) - _expirationGrace); + var accessExpiration = expiresIn == null + ? null + : startTime.add(Duration(seconds: expiresIn as int) - _expirationGrace); + + var refreshExpiration = refreshExpiresIn == null + ? null + : startTime + .add(Duration(seconds: refreshExpiresIn as int) - _expirationGrace); + return Credentials( parameters['access_token'] as String, refreshToken: parameters['refresh_token'] as String?, @@ -106,6 +132,7 @@ Credentials handleAccessTokenResponse(http.Response response, Uri tokenEndpoint, tokenEndpoint: tokenEndpoint, scopes: scopes, expiration: expiration, + refreshToKenExpiration: refreshExpiration, ); } on FormatException catch (e) { throw FormatException('Invalid OAuth response for "$tokenEndpoint": ' diff --git a/test/credentials_test.dart b/test/credentials_test.dart index d83bc7e..02471ef 100644 --- a/test/credentials_test.dart +++ b/test/credentials_test.dart @@ -23,13 +23,26 @@ void main() { expect(credentials.isExpired, isFalse); }); - test('is not expired if the expiration is in the future', () { + test('is not expired if no expiration exists', () { + var credentials = + oauth2.Credentials('access token', refreshToken: 'refresh token'); + expect(credentials.isRefreshTokenExpired, isFalse); + }); + + test('is not expired if the accessExpiration is in the future', () { var expiration = DateTime.now().add(const Duration(hours: 1)); var credentials = oauth2.Credentials('access token', expiration: expiration); expect(credentials.isExpired, isFalse); }); + test('is not expired if the refreshExpiration is in the future', () { + var expiration = DateTime.now().add(const Duration(hours: 1)); + var credentials = + oauth2.Credentials('access token', refreshToKenExpiration: expiration); + expect(credentials.isRefreshTokenExpired, isFalse); + }); + test('is expired if the expiration is in the past', () { var expiration = DateTime.now().subtract(const Duration(hours: 1)); var credentials = @@ -37,6 +50,13 @@ void main() { expect(credentials.isExpired, isTrue); }); + test('is expired if the refreshExpiration is in the past', () { + var expiration = DateTime.now().subtract(const Duration(hours: 1)); + var credentials = + oauth2.Credentials('access token', refreshToKenExpiration: expiration); + expect(credentials.isRefreshTokenExpired, isTrue); + }); + test("can't refresh without a refresh token", () { var credentials = oauth2.Credentials('access token', tokenEndpoint: tokenEndpoint); @@ -268,13 +288,19 @@ void main() { var expiration = DateTime.now().subtract(const Duration(hours: 1)); expiration = DateTime.fromMillisecondsSinceEpoch( expiration.millisecondsSinceEpoch); + var refreshExpiration = DateTime.now(); + refreshExpiration = DateTime.fromMillisecondsSinceEpoch( + refreshExpiration.millisecondsSinceEpoch); - var credentials = oauth2.Credentials('access token', - refreshToken: 'refresh token', - idToken: 'id token', - tokenEndpoint: tokenEndpoint, - scopes: ['scope1', 'scope2'], - expiration: expiration); + var credentials = oauth2.Credentials( + 'access token', + refreshToken: 'refresh token', + idToken: 'id token', + tokenEndpoint: tokenEndpoint, + scopes: ['scope1', 'scope2'], + expiration: expiration, + refreshToKenExpiration: refreshExpiration, + ); var reloaded = oauth2.Credentials.fromJson(credentials.toJson()); expect(reloaded.accessToken, equals(credentials.accessToken)); @@ -284,6 +310,8 @@ void main() { equals(credentials.tokenEndpoint.toString())); expect(reloaded.scopes, equals(credentials.scopes)); expect(reloaded.expiration, equals(credentials.expiration)); + expect(reloaded.refreshToKenExpiration, + equals(credentials.refreshToKenExpiration)); }); test('should throw a FormatException for invalid JSON', () { diff --git a/test/handle_access_token_response_test.dart b/test/handle_access_token_response_test.dart index 4d7b519..9a8bc99 100644 --- a/test/handle_access_token_response_test.dart +++ b/test/handle_access_token_response_test.dart @@ -118,6 +118,7 @@ void main() { Object? accessToken = 'access token', Object? tokenType = 'bearer', Object? expiresIn, + Object? refreshExpiresIn, Object? refreshToken, Object? scope}) { return handle(http.Response( @@ -125,6 +126,7 @@ void main() { 'access_token': accessToken, 'token_type': tokenType, 'expires_in': expiresIn, + 'refresh_expires_in': refreshExpiresIn, 'refresh_token': refreshToken, 'scope': scope }), @@ -228,12 +230,26 @@ void main() { startTime.millisecondsSinceEpoch + 90 * 1000); }); + test( + 'with refresh-expires-in sets the expiration to ten seconds earlier than' + ' the server says', () { + var credentials = handleSuccess(refreshExpiresIn: 100); + expect(credentials.refreshToKenExpiration?.millisecondsSinceEpoch, + startTime.millisecondsSinceEpoch + 90 * 1000); + }); + test('with expires-in encoded as string', () { var credentials = handleSuccess(expiresIn: '110'); expect(credentials.expiration?.millisecondsSinceEpoch, startTime.millisecondsSinceEpoch + 100 * 1000); }); + test('with expires-in encoded as string', () { + var credentials = handleSuccess(refreshExpiresIn: '110'); + expect(credentials.refreshToKenExpiration?.millisecondsSinceEpoch, + startTime.millisecondsSinceEpoch + 100 * 1000); + }); + test('with a non-string refresh token throws a FormatException', () { expect(() => handleSuccess(refreshToken: 12), throwsFormatException); }); @@ -275,6 +291,7 @@ void main() { Object? accessToken = 'access token', Object? tokenType = 'bearer', Object? expiresIn, + Object? refreshExpiresIn, Object? idToken = 'decode me', Object? scope}) { return handle(http.Response( @@ -282,6 +299,7 @@ void main() { 'access_token': accessToken, 'token_type': tokenType, 'expires_in': expiresIn, + 'refresh_expires_in': refreshExpiresIn, 'id_token': idToken, 'scope': scope }), From 4c4503d6f3eeb6d4037254fdd3d1e0287990f78e Mon Sep 17 00:00:00 2001 From: Florian Weihl Date: Tue, 30 Jan 2024 16:03:30 +0100 Subject: [PATCH 2/5] add .idea to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index bbe1007..9312b42 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ build/ packages .packages +*idea # Or the files created by dart2js. *.dart.js From 3ffa514e0af52d4ecb66583ebdadf2387ba0b11c Mon Sep 17 00:00:00 2001 From: Florian Weihl Date: Tue, 30 Jan 2024 16:04:07 +0100 Subject: [PATCH 3/5] fix gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 9312b42..5b6c3b3 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,7 @@ build/ packages .packages -*idea +.idea/ # Or the files created by dart2js. *.dart.js From 79de3f295ecd1be3faef2b1474d744129562ef06 Mon Sep 17 00:00:00 2001 From: Florian Weihl Date: Tue, 30 Jan 2024 16:09:11 +0100 Subject: [PATCH 4/5] add changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c5da18..97932a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## 2.0.3-wip * Require `package:http` v1.0.0 +* add property `refreshToKenExpiration` to `Credentials` ## 2.0.2 From 97ac066b19635f6026896acea55564270349f407 Mon Sep 17 00:00:00 2001 From: Cantasura Date: Sat, 17 Feb 2024 19:46:43 +0100 Subject: [PATCH 5/5] remove idea from gitignore --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 5b6c3b3..bbe1007 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,6 @@ build/ packages .packages -.idea/ # Or the files created by dart2js. *.dart.js