Skip to content

Commit ffe160f

Browse files
author
Peter Bryant
committed
✨ Handle expired user tokens without refresh tokens
1 parent c1b0ac9 commit ffe160f

6 files changed

+211
-23
lines changed

lib/src/token_expired_exception.dart

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// 🌎 Project imports:
2+
import 'package:passputter/src/oauth_token.dart';
3+
4+
/// Thrown when an expired [OAuthToken] is used and cannot be refreshed.
5+
class TokenExpiredException implements Exception {
6+
/// Constructs a [TokenExpiredException]
7+
const TokenExpiredException(
8+
this.expiredToken, [
9+
this.message = 'OAuth token has expired',
10+
]) : super();
11+
12+
/// The [OAuthToken] which has expired.
13+
final OAuthToken expiredToken;
14+
15+
/// The error message
16+
final String message;
17+
18+
@override
19+
bool operator ==(Object other) {
20+
if (identical(this, other)) return true;
21+
22+
return other is TokenExpiredException &&
23+
other.expiredToken == expiredToken &&
24+
other.message == message;
25+
}
26+
27+
@override
28+
int get hashCode => expiredToken.hashCode ^ message.hashCode;
29+
}

lib/src/user_token_interceptor.dart

+23-11
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import 'package:dio/dio.dart';
55
// 🌎 Project imports:
66
import 'package:passputter/passputter.dart';
77
import 'package:passputter/src/oauth_api_interface.dart';
8+
import 'package:passputter/src/token_expired_exception.dart';
89

910
/// Adds a user bearer token to the Authorizaton header of each request
1011
class UserTokenInterceptor extends Interceptor {
@@ -40,20 +41,31 @@ class UserTokenInterceptor extends Interceptor {
4041
final token = tokenStorage.userToken;
4142
if (token != null) {
4243
if (token.expiresAt != null && token.expiresAt!.isBefore(clock.now())) {
43-
try {
44-
final newToken = await oAuthApi.getRefreshedToken(
45-
refreshToken: token.refreshToken,
46-
clientId: clientId,
47-
clientSecret: clientSecret,
48-
);
44+
final refreshToken = token.refreshToken;
45+
if (refreshToken != null) {
46+
try {
47+
final newToken = await oAuthApi.getRefreshedToken(
48+
refreshToken: refreshToken,
49+
clientId: clientId,
50+
clientSecret: clientSecret,
51+
);
4952

50-
await tokenStorage.saveUserToken(newToken);
53+
await tokenStorage.saveUserToken(newToken);
5154

52-
options.headers['Authorization'] = 'Bearer ${newToken.token}';
55+
options.headers['Authorization'] = 'Bearer ${newToken.token}';
5356

54-
return handler.next(options);
55-
} on DioError catch (e) {
56-
return handler.reject(e);
57+
return handler.next(options);
58+
} on DioError catch (e) {
59+
return handler.reject(e);
60+
}
61+
} else {
62+
return handler.reject(
63+
DioError(
64+
requestOptions: options,
65+
type: DioErrorType.other,
66+
error: TokenExpiredException(token),
67+
),
68+
);
5769
}
5870
} else {
5971
options.headers['Authorization'] = 'Bearer ${token.token}';

test/src/oauth_api_impl_test.dart

+6-6
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@ void main() {
4242
requestOptions: RequestOptions(path: endpoint),
4343
statusCode: 200,
4444
data: '''{
45-
"accessToken": "token",
46-
"refreshToken": "refresh"
45+
"access_token": "token",
46+
"refresh_token": "refresh"
4747
}''',
4848
),
4949
);
@@ -105,8 +105,8 @@ void main() {
105105
requestOptions: RequestOptions(path: endpoint),
106106
statusCode: 200,
107107
data: '''{
108-
"accessToken": "token",
109-
"refreshToken": "refresh"
108+
"access_token": "token",
109+
"refresh_token": "refresh"
110110
}''',
111111
),
112112
);
@@ -173,8 +173,8 @@ void main() {
173173
requestOptions: RequestOptions(path: endpoint),
174174
statusCode: 200,
175175
data: '''{
176-
"accessToken": "token",
177-
"refreshToken": "refresh"
176+
"access_token": "token",
177+
"refresh_token": "refresh"
178178
}''',
179179
),
180180
);

test/src/oauth_token_test.dart

+59-6
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ void main() {
1010
group('fromMap', () {
1111
test('without expiry parses successfully', () {
1212
final map = <String, dynamic>{
13-
'accessToken': 'token',
14-
'refreshToken': 'refresh',
13+
'access_token': 'token',
14+
'refresh_token': 'refresh',
1515
};
1616

1717
const expected = OAuthToken(
@@ -23,13 +23,30 @@ void main() {
2323
expect(OAuthToken.fromMap(map), equals(expected));
2424
});
2525

26-
test('with expiry parses successfully', () {
26+
test('without refresh token parses successfully', () {
2727
final clock = Clock.fixed(DateTime(2021, 5, 1));
2828

2929
final map = <String, dynamic>{
30-
'accessToken': 'token',
31-
'refreshToken': 'refresh',
32-
'expiresIn': 1000,
30+
'access_token': 'token',
31+
'expires_in': 1000,
32+
};
33+
34+
final expected = OAuthToken(
35+
token: 'token',
36+
refreshToken: null,
37+
expiresAt: clock.now().add(1000.seconds),
38+
);
39+
40+
expect(OAuthToken.fromMap(map, clock), equals(expected));
41+
});
42+
43+
test('with all arguments parses successfully', () {
44+
final clock = Clock.fixed(DateTime(2021, 5, 1));
45+
46+
final map = <String, dynamic>{
47+
'access_token': 'token',
48+
'refresh_token': 'refresh',
49+
'expires_in': 1000,
3350
};
3451

3552
final expected = OAuthToken(
@@ -41,4 +58,40 @@ void main() {
4158
expect(OAuthToken.fromMap(map, clock), equals(expected));
4259
});
4360
});
61+
62+
group('equality', () {
63+
test('equal tokens are evaluated as equal', () {
64+
const t1 = OAuthToken(
65+
token: 'token',
66+
refreshToken: null,
67+
expiresAt: null,
68+
);
69+
70+
const t2 = OAuthToken(
71+
token: 'token',
72+
refreshToken: null,
73+
expiresAt: null,
74+
);
75+
76+
expect(t1 == t2, isTrue);
77+
expect(t1.hashCode == t2.hashCode, isTrue);
78+
});
79+
80+
test('non-equal tokens are not evaluated as equal', () {
81+
const t1 = OAuthToken(
82+
token: 'token',
83+
refreshToken: null,
84+
expiresAt: null,
85+
);
86+
87+
const t2 = OAuthToken(
88+
token: 'token2',
89+
refreshToken: 'refresh',
90+
expiresAt: null,
91+
);
92+
93+
expect(t1 == t2, isFalse);
94+
expect(t1.hashCode == t2.hashCode, isFalse);
95+
});
96+
});
4497
}
+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// 📦 Package imports:
2+
import 'package:test/test.dart';
3+
4+
// 🌎 Project imports:
5+
import 'package:passputter/src/oauth_token.dart';
6+
import 'package:passputter/src/token_expired_exception.dart';
7+
8+
void main() {
9+
test('equal exceptions are evaluated as equal', () async {
10+
const token = OAuthToken(
11+
token: 'token',
12+
refreshToken: null,
13+
expiresAt: null,
14+
);
15+
const t1 = TokenExpiredException(token);
16+
const t2 = TokenExpiredException(token);
17+
18+
expect(t1 == t2, isTrue);
19+
expect(t1.hashCode == t2.hashCode, isTrue);
20+
});
21+
22+
test('exceptions with different tokens are not evaluated as equal', () async {
23+
const token1 = OAuthToken(
24+
token: 'token',
25+
refreshToken: null,
26+
expiresAt: null,
27+
);
28+
const token2 = OAuthToken(
29+
token: 'token2',
30+
refreshToken: null,
31+
expiresAt: null,
32+
);
33+
const t1 = TokenExpiredException(token1);
34+
const t2 = TokenExpiredException(token2);
35+
36+
expect(t1 == t2, isFalse);
37+
expect(t1.hashCode == t2.hashCode, isFalse);
38+
});
39+
40+
test(
41+
'exceptions with different messages are not evaluated as equal',
42+
() async {
43+
const token = OAuthToken(
44+
token: 'token',
45+
refreshToken: null,
46+
expiresAt: null,
47+
);
48+
const t1 = TokenExpiredException(token);
49+
const t2 = TokenExpiredException(token, 'A different message');
50+
51+
expect(t1 == t2, isFalse);
52+
expect(t1.hashCode == t2.hashCode, isFalse);
53+
},
54+
);
55+
}

test/src/user_token_interceptor_test.dart

+39
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import 'package:time/time.dart';
99
import 'package:passputter/passputter.dart';
1010
import 'package:passputter/src/oauth_api_interface.dart';
1111
import 'package:passputter/src/oauth_token.dart';
12+
import 'package:passputter/src/token_expired_exception.dart';
1213

1314
class MockOAuthApi extends Mock implements OAuthApiInterface {}
1415

@@ -21,6 +22,16 @@ void main() {
2122
late Clock clock;
2223
late UserTokenInterceptor interceptor;
2324

25+
setUpAll(() {
26+
registerFallbackValue<DioError>(
27+
DioError(
28+
requestOptions: RequestOptions(
29+
path: 'path',
30+
),
31+
),
32+
);
33+
});
34+
2435
setUp(() {
2536
tokenStorage = InMemoryTokenStorage();
2637
oAuthApi = MockOAuthApi();
@@ -91,6 +102,34 @@ void main() {
91102
);
92103
});
93104

105+
test(
106+
'throws error if token has expired and there is no refresh token',
107+
() async {
108+
final token = OAuthToken(
109+
token: 'token',
110+
expiresAt: clock.now().subtract(30.days),
111+
refreshToken: null,
112+
);
113+
114+
await tokenStorage.saveUserToken(token);
115+
116+
await interceptor.onRequest(tRequest, handler);
117+
118+
final captured = verify(() => handler.reject(captureAny())).captured;
119+
120+
expect(
121+
captured.last,
122+
isA<DioError>().having(
123+
(e) => e.error,
124+
'error',
125+
isA<TokenExpiredException>(),
126+
),
127+
);
128+
129+
verifyZeroInteractions(oAuthApi);
130+
},
131+
);
132+
94133
test('rejects request if token refresh fails', () async {
95134
final token = OAuthToken(
96135
token: 'token',

0 commit comments

Comments
 (0)