Skip to content

Commit 3d46e0f

Browse files
authored
feat: Add Multi-Resource Refresh Token (MRRT) support for Flutter Web (#864)
1 parent 156edd5 commit 3d46e0f

10 files changed

Lines changed: 275 additions & 3 deletions

auth0_flutter/lib/auth0_flutter_web.dart

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import 'package:auth0_flutter_platform_interface/auth0_flutter_platform_interfac
22
import 'src/version.dart';
33

44
export 'package:auth0_flutter_platform_interface/auth0_flutter_platform_interface.dart'
5-
show WebException, CacheLocation;
5+
show WebException, CacheLocation, ApiCredentials;
66

77
/// Primary interface for interacting with Auth0 on web platforms.
88
class Auth0Web {
@@ -66,6 +66,12 @@ class Auth0Web {
6666
/// to learn more.
6767
/// * [scopes] defaults to `openid profile email`. You can override these
6868
/// scopes, but `openid` is always requested regardless of this setting.
69+
/// * [useMrrt] enables Multi-Resource Refresh Tokens, allowing a single
70+
/// refresh token to be reused to obtain access tokens for multiple APIs
71+
/// (audiences). Once enabled, request per-audience tokens via
72+
/// [getApiCredentials]. Enabling this forces [useRefreshTokens] on (even if
73+
/// it was explicitly set to `false`), and requires MRRT to be enabled on your
74+
/// Auth0 tenant.
6975
Future<Credentials?> onLoad(
7076
{final int? authorizeTimeoutInSeconds,
7177
final CacheLocation? cacheLocation,
@@ -79,6 +85,7 @@ class Auth0Web {
7985
final bool? useFormData,
8086
final bool? useRefreshTokens,
8187
final bool? useRefreshTokensFallback,
88+
final bool? useMrrt,
8289
final String? audience,
8390
final Set<String>? scopes,
8491
final Map<String, String> parameters = const {}}) async {
@@ -98,6 +105,7 @@ class Auth0Web {
98105
useFormData: useFormData,
99106
useRefreshTokens: useRefreshTokens,
100107
useRefreshTokensFallback: useRefreshTokensFallback,
108+
useMrrt: useMrrt,
101109
audience: audience,
102110
scopes: scopes,
103111
parameters: {
@@ -369,6 +377,53 @@ class Auth0Web {
369377
),
370378
);
371379

380+
/// Retrieves a set of [ApiCredentials] scoped to a specific API ([audience])
381+
/// by reusing the stored refresh token via Multi-Resource Refresh Tokens
382+
/// (MRRT).
383+
///
384+
/// Enable MRRT by passing `useMrrt: true` to [onLoad]. This exchanges the
385+
/// single refresh token obtained at login for an access token valid for the
386+
/// requested [audience], without requiring the user to log in again.
387+
///
388+
/// Additional notes:
389+
/// * [scopes] are the scopes to request for the new access token. If empty,
390+
/// the default scopes configured for the API are used.
391+
/// * Arbitrary [parameters] can be specified and then picked up in a custom
392+
/// Auth0 [Action](https://auth0.com/docs/customize/actions).
393+
///
394+
/// **Prerequisites:**
395+
/// * MRRT must be enabled on your Auth0 tenant.
396+
/// * The user must have logged in with the `offline_access` scope so that a
397+
/// refresh token is available for the exchange.
398+
///
399+
/// **Throws** a [WebException] if the exchange fails.
400+
Future<ApiCredentials> getApiCredentials({
401+
required final String audience,
402+
final Set<String> scopes = const {},
403+
final Map<String, String> parameters = const {},
404+
}) =>
405+
Auth0FlutterWebPlatform.instance.getApiCredentials(
406+
GetApiCredentialsOptions(
407+
audience: audience,
408+
scopes: scopes,
409+
parameters: parameters,
410+
),
411+
);
412+
413+
/// No-op on the web, kept for cross-platform API parity.
414+
///
415+
/// `auth0-spa-js` manages API token caching internally and exposes no
416+
/// single-audience eviction API, so this completes without removing anything
417+
/// and logs a warning to the browser console. The [audience] and [scope]
418+
/// arguments are accepted only for consistency with the mobile API.
419+
Future<void> clearApiCredentials({
420+
required final String audience,
421+
final String? scope,
422+
}) =>
423+
Auth0FlutterWebPlatform.instance.clearApiCredentials(
424+
ClearApiCredentialsOptions(audience: audience, scope: scope),
425+
);
426+
372427
/// Indicates whether a user is currently authenticated.
373428
Future<bool> hasValidCredentials() =>
374429
Auth0FlutterWebPlatform.instance.hasValidCredentials();

auth0_flutter/lib/src/web/auth0_flutter_plugin_real.dart

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import 'package:flutter_web_plugins/flutter_web_plugins.dart';
66
import 'package:web/web.dart';
77

88
import 'auth0_flutter_web_platform_proxy.dart';
9+
import 'extensions/api_credentials_extension.dart';
910
import 'extensions/client_options_extensions.dart';
1011
import 'extensions/credentials_extension.dart';
1112
import 'extensions/credentials_options_extension.dart';
@@ -173,6 +174,37 @@ class Auth0FlutterPlugin extends Auth0FlutterWebPlatform {
173174
}
174175
}
175176

177+
@override
178+
Future<ApiCredentials> getApiCredentials(
179+
final GetApiCredentialsOptions options) async {
180+
final clientProxy = _ensureClient();
181+
final tokenOptions = interop.GetTokenSilentlyOptions(
182+
authorizationParams: JsInteropUtils.stripNulls(
183+
JsInteropUtils.addCustomParams(
184+
interop.GetTokenSilentlyAuthParams(
185+
audience: options.audience,
186+
scope: options.scopes.isNotEmpty
187+
? options.scopes.join(' ')
188+
: null),
189+
options.parameters)),
190+
detailedResponse: true);
191+
try {
192+
final result = await clientProxy.getTokenSilently(tokenOptions);
193+
return ApiCredentialsExtension.fromWeb(result);
194+
} catch (e) {
195+
throw WebExceptionExtension.fromJsObject(JSObject.fromInteropObject(e));
196+
}
197+
}
198+
199+
@override
200+
Future<void> clearApiCredentials(
201+
final ClearApiCredentialsOptions options) async {
202+
console.warn(
203+
"'clearApiCredentials' is not supported on the web. auth0-spa-js "
204+
'handles credential storage automatically.'
205+
.toJS);
206+
}
207+
176208
@override
177209
Future<bool> hasValidCredentials() => clientProxy!.isAuthenticated();
178210

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import 'dart:js_interop';
2+
3+
import 'package:auth0_flutter_platform_interface/auth0_flutter_platform_interface.dart';
4+
5+
import '../js_interop.dart';
6+
import 'string_extension.dart';
7+
8+
extension ApiCredentialsExtension on ApiCredentials {
9+
static ApiCredentials fromWeb(final WebCredentials webCredentials) {
10+
final expiresIn = webCredentials.expires_in.toDartInt;
11+
final expiresAt = DateTime.now().add(Duration(seconds: expiresIn));
12+
return ApiCredentials(
13+
accessToken: webCredentials.access_token,
14+
tokenType: webCredentials.token_type ?? 'Bearer',
15+
expiresAt: expiresAt,
16+
scopes: {...webCredentials.scope?.splitBySingleSpace() ?? []});
17+
}
18+
}

auth0_flutter/lib/src/web/extensions/client_options_extensions.dart

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,14 @@ extension ClientOptionsExtension on ClientOptions {
1919
sessionCheckExpiryDays: sessionCheckExpiryInDays,
2020
useCookiesForTransactions: useCookiesForTransactions,
2121
useFormData: useFormData,
22-
useRefreshTokens: useRefreshTokens,
22+
// MRRT requires refresh tokens, so enabling it forces them on even
23+
// if `useRefreshTokens` was explicitly set to false. `== true`
24+
// null-guards the nullable flag (a bare `useMrrt ?` is not a valid
25+
// bool condition).
26+
useRefreshTokens: useMrrt == true ? true : useRefreshTokens,
2327
useRefreshTokensFallback: useRefreshTokensFallback,
2428
useDpop: useDPoP,
29+
useMrrt: useMrrt,
2530
authorizationParams: JsInteropUtils.stripNulls(
2631
JsInteropUtils.addCustomParams(
2732
AuthorizationParams(

auth0_flutter/lib/src/web/js_interop.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ extension type Auth0ClientOptions._(JSObject _) implements JSObject {
107107
final bool? useRefreshTokens,
108108
final bool? useRefreshTokensFallback,
109109
final bool? useDpop,
110+
final bool? useMrrt,
110111
final AuthorizationParams? authorizationParams});
111112
}
112113

auth0_flutter/test/web/auth0_flutter_web_test.dart

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,49 @@ void main() {
316316
e.message == 'test exception')));
317317
});
318318

319+
test('getApiCredentials exchanges the refresh token for an audience',
320+
() async {
321+
when(mockClientProxy.getTokenSilently(any))
322+
.thenAnswer((final _) => Future.value(webCredentials));
323+
324+
final result = await auth0.getApiCredentials(
325+
audience: 'http://my.api',
326+
scopes: {'read:messages', 'write:messages'},
327+
parameters: {'prompt': 'none'});
328+
329+
final options =
330+
verify(mockClientProxy.getTokenSilently(captureAny)).captured.first;
331+
332+
expect(options.authorizationParams.audience, 'http://my.api');
333+
expect(options.authorizationParams.scope, 'read:messages write:messages');
334+
expect(options.authorizationParams.prompt, 'none');
335+
expect(options.detailedResponse, true);
336+
337+
expect(result, isA<ApiCredentials>());
338+
expect(result.accessToken, jwt);
339+
expect(result.scopes, {'openid', 'read_messages'});
340+
});
341+
342+
test('getApiCredentials is called and throws', () async {
343+
when(mockClientProxy.getTokenSilently(any))
344+
.thenThrow(createJsException('test', 'test exception'));
345+
346+
expect(
347+
() async => auth0.getApiCredentials(audience: 'http://my.api'),
348+
throwsA(predicate((final e) =>
349+
e is WebException &&
350+
e.code == 'test' &&
351+
e.message == 'test exception')));
352+
});
353+
354+
test('clearApiCredentials is a no-op and completes without throwing',
355+
() async {
356+
await expectLater(
357+
auth0.clearApiCredentials(audience: 'http://my.api'), completes);
358+
359+
verifyNever(mockClientProxy.getTokenSilently(any));
360+
});
361+
319362
test('logout is called and succeeds', () async {
320363
when(mockClientProxy.logout(any)).thenAnswer((final _) => Future.value());
321364
await auth0.logout(federated: true, returnToUrl: 'http://returnto.url');
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
@Tags(['browser'])
2+
3+
import 'dart:js_interop';
4+
import 'package:auth0_flutter/src/web/extensions/api_credentials_extension.dart';
5+
import 'package:auth0_flutter/src/web/extensions/string_extension.dart';
6+
import 'package:auth0_flutter/src/web/js_interop.dart';
7+
import 'package:flutter_test/flutter_test.dart';
8+
9+
void main() {
10+
group('ApiCredentialsExtension', () {
11+
test('creates ApiCredentials from WebCredentials with required values', () {
12+
const accessToken = 'foo';
13+
const expiresIn = 8400;
14+
const expectedTokenType = 'Bearer';
15+
final webCredentials = WebCredentials(
16+
access_token: accessToken,
17+
id_token: 'id',
18+
expires_in: expiresIn.toJS,
19+
);
20+
21+
final result = ApiCredentialsExtension.fromWeb(webCredentials);
22+
23+
expect(result.accessToken, accessToken);
24+
expect(result.tokenType, expectedTokenType);
25+
expect(
26+
result.expiresAt.difference(
27+
DateTime.now().add(const Duration(seconds: expiresIn)),
28+
),
29+
lessThan(const Duration(seconds: 1)));
30+
expect(result.scopes, isEmpty);
31+
});
32+
33+
test('creates ApiCredentials from WebCredentials with optional values', () {
34+
const accessToken = 'token';
35+
const expiresIn = 1;
36+
const scope = 'openid profile read:messages';
37+
const tokenType = 'DPoP';
38+
final webCredentials = WebCredentials(
39+
access_token: accessToken,
40+
id_token: 'id',
41+
expires_in: expiresIn.toJS,
42+
scope: scope,
43+
token_type: tokenType);
44+
45+
final result = ApiCredentialsExtension.fromWeb(webCredentials);
46+
47+
expect(result.tokenType, tokenType);
48+
expect(result.scopes, {...scope.splitBySingleSpace()});
49+
});
50+
});
51+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
@Tags(['browser'])
2+
3+
import 'dart:js_interop';
4+
import 'dart:js_interop_unsafe';
5+
6+
import 'package:auth0_flutter/src/web/extensions/client_options_extensions.dart';
7+
import 'package:auth0_flutter_platform_interface/auth0_flutter_platform_interface.dart';
8+
import 'package:flutter_test/flutter_test.dart';
9+
10+
bool? _readBool(final JSObject object, final String key) =>
11+
(object.getProperty(key.toJS) as JSBoolean?)?.toDart;
12+
13+
void main() {
14+
final userAgent = UserAgent(name: 'auth0-flutter', version: '1.0.0');
15+
const account = Account('test-domain', 'test-client-id');
16+
17+
group('ClientOptionsExtension MRRT', () {
18+
test('useMrrt is forwarded and auto-enables refresh tokens', () {
19+
final options = ClientOptions(account: account, useMrrt: true);
20+
21+
final result = options.toAuth0ClientOptions(userAgent) as JSObject;
22+
23+
expect(_readBool(result, 'useMrrt'), true);
24+
// MRRT requires refresh tokens, so they are implicitly enabled.
25+
expect(_readBool(result, 'useRefreshTokens'), true);
26+
});
27+
28+
test('useMrrt forces refresh tokens on even when set to false', () {
29+
final options = ClientOptions(
30+
account: account, useMrrt: true, useRefreshTokens: false);
31+
32+
final result = options.toAuth0ClientOptions(userAgent) as JSObject;
33+
34+
expect(_readBool(result, 'useMrrt'), true);
35+
expect(_readBool(result, 'useRefreshTokens'), true);
36+
});
37+
38+
test('useMrrt is not enabled by default', () {
39+
final options = ClientOptions(account: account);
40+
41+
final result = options.toAuth0ClientOptions(userAgent) as JSObject;
42+
43+
expect(_readBool(result, 'useMrrt'), isNull);
44+
});
45+
});
46+
}

auth0_flutter_platform_interface/lib/src/auth0_flutter_web_platform.dart

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,16 @@ abstract class Auth0FlutterWebPlatform extends PlatformInterface {
4646
);
4747
}
4848

49+
Future<ApiCredentials> getApiCredentials(
50+
final GetApiCredentialsOptions options) {
51+
throw UnimplementedError('web.getApiCredentials has not been implemented');
52+
}
53+
54+
Future<void> clearApiCredentials(final ClearApiCredentialsOptions options) {
55+
throw UnimplementedError(
56+
'web.clearApiCredentials has not been implemented');
57+
}
58+
4959
Future<bool> hasValidCredentials() {
5060
throw UnimplementedError(
5161
'web.hasValidCredentials has not been implemented',

auth0_flutter_platform_interface/lib/src/web/client_options.dart

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,16 @@ class ClientOptions {
111111
/// Enables DPoP for token security. Defaults to `false`.
112112
final bool useDPoP;
113113

114+
/// Whether Multi-Resource Refresh Tokens (MRRT) are enabled, allowing a
115+
/// single refresh token to be reused to obtain access tokens for multiple
116+
/// APIs (audiences).
117+
///
118+
/// MRRT requires refresh tokens, so enabling this forces [useRefreshTokens]
119+
/// on, overriding it even if it was explicitly set to `false`.
120+
///
121+
/// Defaults to `false`. Requires MRRT to be enabled on the Auth0 tenant.
122+
final bool? useMrrt;
123+
114124
ClientOptions(
115125
{required this.account,
116126
this.authorizeTimeoutInSeconds,
@@ -127,5 +137,6 @@ class ClientOptions {
127137
this.audience,
128138
this.scopes,
129139
this.parameters = const {},
130-
this.useDPoP = false});
140+
this.useDPoP = false,
141+
this.useMrrt});
131142
}

0 commit comments

Comments
 (0)