Skip to content

Commit 2ef0114

Browse files
authored
feat(dart): add get access token by refresh token method (#20)
1 parent d0cf6e5 commit 2ef0114

File tree

5 files changed

+119
-33
lines changed

5 files changed

+119
-33
lines changed

lib/logto_client.dart

Lines changed: 100 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,19 @@ class LogtoClient {
2323

2424
static late TokenStorage _tokenStorage;
2525

26+
/// Logto automatically enables refresh token's rotation
27+
///
28+
/// Simultaneous access token request may be problematic
29+
/// Use a request cache map to avoid the race condition
30+
static final Map<String, Future<AccessToken?>> _accessTokenRequestCache = {};
31+
2632
/// Custom [http.Client].
2733
///
2834
/// Note that you will have to call `close()` yourself when passing a [http.Client] instance.
2935
late final http.Client? _httpClient;
3036

37+
bool _loading = false;
38+
3139
bool get loading => _loading;
3240

3341
OidcProviderConfig? _oidcConfig;
@@ -66,15 +74,101 @@ class LogtoClient {
6674
return _oidcConfig!;
6775
}
6876

69-
Future<AccessToken?> getAccessToken(
70-
{List<String>? scopes, String? resource}) async {
71-
return await _tokenStorage.getAccessToken(resource, scopes);
77+
Future<AccessToken?> getAccessToken({String? resource}) async {
78+
final accessToken = await _tokenStorage.getAccessToken(resource);
79+
80+
if (accessToken != null) {
81+
return accessToken;
82+
}
83+
84+
// If no valid access token is found in storage, use refresh token to claim a new one
85+
final cacheKey = TokenStorage.buildAccessTokenKey(resource);
86+
87+
// Reuse the cached request if is exist
88+
if (_accessTokenRequestCache[cacheKey] != null) {
89+
return _accessTokenRequestCache[cacheKey];
90+
}
91+
92+
// Create new token request and add it to cache
93+
final newTokenRequest = _getAccessTokenByRefreshToken(resource);
94+
_accessTokenRequestCache[cacheKey] = newTokenRequest;
95+
96+
final token = await newTokenRequest;
97+
// Clear the cache after response
98+
_accessTokenRequestCache.remove(cacheKey);
99+
100+
return token;
72101
}
73102

74-
bool _loading = false;
103+
// RBAC are not supported currently, no resource specific scopes are needed
104+
Future<AccessToken?> _getAccessTokenByRefreshToken(String? resource) async {
105+
final refreshToken = await _tokenStorage.refreshToken;
106+
107+
if (refreshToken == null) {
108+
throw LogtoAuthException(
109+
LogtoAuthExceptions.authenticationError, 'not_authenticated');
110+
}
111+
112+
final httpClient = _httpClient ?? http.Client();
113+
114+
try {
115+
final oidcConfig = await _getOidcConfig(httpClient);
116+
117+
final response = await logto_core.fetchTokenByRefreshToken(
118+
httpClient: httpClient,
119+
tokenEndPoint: oidcConfig.tokenEndpoint,
120+
clientId: config.appId,
121+
refreshToken: refreshToken,
122+
resource: resource,
123+
// RBAC are not supported currently, no resource specific scopes are needed
124+
scopes: resource != null ? ['offline_access'] : null);
125+
126+
final scopes = response.scope.split(' ');
127+
128+
await _tokenStorage.setAccessToken(response.accessToken,
129+
expiresIn: response.expiresIn, resource: resource, scopes: scopes);
130+
131+
// renew refresh token
132+
await _tokenStorage.setRefreshToken(response.refreshToken);
133+
134+
// verify and store id_token if not null
135+
if (response.idToken != null) {
136+
final idToken = IdToken.unverified(response.idToken!);
137+
await _verifyIdToken(idToken, oidcConfig);
138+
await _tokenStorage.setIdToken(idToken);
139+
}
140+
141+
return await _tokenStorage.getAccessToken(resource, scopes);
142+
} finally {
143+
if (_httpClient == null) httpClient.close();
144+
}
145+
}
146+
147+
Future<void> _verifyIdToken(
148+
IdToken idToken, OidcProviderConfig oidcConfig) async {
149+
final keyStore = JsonWebKeyStore()
150+
..addKeySetUrl(Uri.parse(oidcConfig.jwksUri));
151+
152+
if (!await idToken.verify(keyStore)) {
153+
throw LogtoAuthException(
154+
LogtoAuthExceptions.idTokenValidationError, 'invalid jws signature');
155+
}
156+
157+
final violations = idToken.claims
158+
.validate(issuer: Uri.parse(oidcConfig.issuer), clientId: config.appId);
159+
160+
if (violations.isNotEmpty) {
161+
throw LogtoAuthException(
162+
LogtoAuthExceptions.idTokenValidationError, '$violations');
163+
}
164+
}
75165

76166
Future<void> signIn(String redirectUri) async {
77-
if (_loading) throw Exception('Already signing in...');
167+
if (_loading) {
168+
throw LogtoAuthException(
169+
LogtoAuthExceptions.isLoadingError, 'Already signing in...');
170+
}
171+
78172
final httpClient = _httpClient ?? http.Client();
79173

80174
try {
@@ -130,21 +224,7 @@ class LogtoClient {
130224

131225
final idToken = IdToken.unverified(tokenResponse.idToken);
132226

133-
final keyStore = JsonWebKeyStore()
134-
..addKeySetUrl(Uri.parse(oidcConfig.jwksUri));
135-
136-
if (!await idToken.verify(keyStore)) {
137-
throw LogtoAuthException(
138-
LogtoAuthExceptions.idTokenValidationError, 'invalid jws signature');
139-
}
140-
141-
final violations = idToken.claims
142-
.validate(issuer: Uri.parse(oidcConfig.issuer), clientId: config.appId);
143-
144-
if (violations.isNotEmpty) {
145-
throw LogtoAuthException(
146-
LogtoAuthExceptions.idTokenValidationError, '$violations');
147-
}
227+
await _verifyIdToken(idToken, oidcConfig);
148228

149229
await _tokenStorage.save(
150230
idToken: idToken,

lib/logto_core.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ Uri generateSignInUri(
124124
};
125125

126126
if (resources != null && resources.isNotEmpty) {
127-
queryParameters.addAll({'resources': resources});
127+
queryParameters.addAll({'resource': resources});
128128
}
129129

130130
return addQueryParameters(signInUri, queryParameters);

lib/src/exceptions/logto_auth_exceptions.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
enum LogtoAuthExceptions {
22
callbackUriValidationError,
33
idTokenValidationError,
4-
authenticationError
4+
authenticationError,
5+
isLoadingError
56
}
67

78
class LogtoAuthException implements Exception {

lib/src/modules/token_storage.dart

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -53,16 +53,15 @@ class TokenStorage {
5353
}
5454

5555
Future<void> setIdToken(IdToken? idToken) async {
56-
_idToken = idToken;
57-
5856
await _storage.write(
5957
key: _TokenStorageKeys.idTokenKey,
6058
value: _encodeIdToken(idToken),
6159
);
60+
61+
_idToken = idToken;
6262
}
6363

64-
static String _buildAccessTokenKey(String? resource,
65-
[List<String>? scopes]) =>
64+
static String buildAccessTokenKey(String? resource, [List<String>? scopes]) =>
6665
"${_encodeScopes(scopes)}@${resource ?? ''}";
6766

6867
Future<Map<String, AccessToken>?> _getAccessTokenMapFromStorage() async {
@@ -85,7 +84,7 @@ class TokenStorage {
8584

8685
Future<AccessToken?> getAccessToken(
8786
[String? resource, List<String>? scopes]) async {
88-
final key = _buildAccessTokenKey(resource, scopes);
87+
final key = buildAccessTokenKey(resource, scopes);
8988

9089
_accessTokenMap ??= await _getAccessTokenMapFromStorage();
9190

@@ -130,17 +129,20 @@ class TokenStorage {
130129

131130
Future<void> setAccessToken(String accessToken,
132131
{String? resource, List<String>? scopes, required int expiresIn}) async {
133-
final key = _buildAccessTokenKey(resource, scopes);
132+
final key = buildAccessTokenKey(resource, scopes);
133+
134+
// load current accessTokenMap
135+
final currentAccessTokenMap =
136+
_accessTokenMap ?? await _getAccessTokenMapFromStorage() ?? {};
134137

135138
final Map<String, AccessToken> newAccessTokenMap =
136-
Map.from(_accessTokenMap ?? {});
139+
Map.from(currentAccessTokenMap);
137140

138141
newAccessTokenMap.addAll({
139142
key: AccessToken(
140143
token: accessToken,
141144
scope: _encodeScopes(scopes),
142-
143-
/// convert the expireAt to standard utc time
145+
// convert the expireAt to standard utc time
144146
expiresAt: DateTime.now().add(Duration(seconds: expiresIn)).toUtc())
145147
});
146148

@@ -160,12 +162,12 @@ class TokenStorage {
160162
}
161163

162164
Future<void> setRefreshToken(String? refreshToken) async {
163-
_refreshToken = refreshToken;
164-
165165
await _storage.write(
166166
key: _TokenStorageKeys.refreshTokenKey,
167167
value: refreshToken,
168168
);
169+
170+
_refreshToken = refreshToken;
169171
}
170172

171173
/// Initial token response saving

test/logto_core_test.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,16 @@ void main() {
3030
clientId: clientId,
3131
redirectUri: redirectUri,
3232
codeChallenge: codeChallenge,
33+
resources: ['http://foo.api'],
3334
state: state);
3435

3536
expect(signInUri.scheme, 'http');
3637
expect(signInUri.host, 'foo.com');
3738
expect(signInUri.queryParameters, containsPair('client_id', clientId));
3839
expect(signInUri.queryParameters,
3940
containsPair('redirect_uri', 'http://foo.app.io'));
41+
expect(
42+
signInUri.queryParameters, containsPair('resource', 'http://foo.api'));
4043
expect(signInUri.queryParameters,
4144
containsPair('code_challenge', codeChallenge));
4245
expect(signInUri.queryParameters,

0 commit comments

Comments
 (0)