Reputation: 83
I try to implement authentication in flutter with grpc (with protobuf) and Interceptors. I'm now stuck on handling an expired accessToken, where I would like to refresh the token and refresh the token. After the refresh, the function should get called again, so the actually function wont notice the expired token.
This is my AuthClient
final channel = ClientChannel(
grpcHost,
port: grpcPort,
options: ChannelOptions(
credentials: const ChannelCredentials.insecure(),
codecRegistry:
CodecRegistry(codecs: const [GzipCodec(), IdentityCodec()]),
),
);
_client = AuthServiceClient(channel,
interceptors: [AuthenticationInterceptor()]);
The AuthClient is using this ClientInterceptor
class AuthenticationInterceptor extends ClientInterceptor {
final storage = const FlutterSecureStorage();
FutureOr<void> _provider(Map<String, String> metadata, String uri) async {
final accessToken = await storage.read(key: 'accessToken');
metadata['Authorization'] = accessToken ?? "";
}
@override
ResponseFuture<R> interceptUnary<Q, R>(
ClientMethod<Q, R> method, Q request, CallOptions options, invoker) {
return super.interceptUnary(method, request,
options.mergedWith(CallOptions(providers: [_provider])), invoker)
..catchError((e) async {
if (e is GrpcError) {
if (e.code == StatusCode.invalidArgument &&
e.message == "Invalid auth token") {
await refreshTheToken();
return super.interceptUnary(
method,
request,
options.mergedWith(CallOptions(providers: [_provider])),
invoker);
}
}
throw e;
});
}
}
I call the client like this
loadUser() async {
print("Loading user");
_status = AuthStatus.uninitialized;
try {
StatusRequest statusRequest = StatusRequest();
final status = await _client!.status(statusRequest);
_status = AuthStatus.authenticated;
_currentUser = User(status.user.id, status.user.username);
} catch (e) {
print(e);
_status = AuthStatus.unauthenticated;
} finally {
notifyListeners();
}
}
I expected with the await refreshTheToken();
and the return super.interceptUnary
I could refresh the token and then call the function again, without getting an error in the loadUser
function.
The actual behavior is however that the loadUser
function throws an error, before the catchError
in the interceptUnary
can handle the error and refresh the token.
Upvotes: 4
Views: 539
Reputation: 11
I also ran into this problem. I needed to transfer the user to the authorization page when the token expired. Since Response Future is an interface for Future, we can apply Future methods to the result. And also, as stated in the official documentation:
"If the interceptor returns a GrpcError, the error will be returned as a response and Service Method wouldn't be called."
My solution is both elegant and crutchy at the same time - we hang the callback on the response, but after initializing this callback, we call the ignore() method, which allows us to ignore any unexpected result of Future execution. If we do not ignore the response, then since the interceptUnary method cannot be asynchronous, catch Error and onError will throw the error into the zone, where, in fact, we will not be able to intercept and process it in any way.
In my case, it turned out to be the perfect solution.
@override
ResponseFuture<R> interceptUnary<Q, R>(
ClientMethod<Q, R> method,
Q request,
CallOptions options,
ClientUnaryInvoker<Q, R> invoker,
) {
CallOptions newOptions =
options.mergedWith(_jwtTokenInstance.authOptions).mergedWith(
CallOptions(
metadata: {
'request-uuid': uuid.v1(),
'user-agent': '$appVersion+$appBuildNumber',
},
),
);
final response = invoker(
method,
request,
newOptions,
);
response.catchError(
(error) {
// Go to AuthScreen if JWT-Token is expired (received 'UNAUTHENTICATED' error)
if (error is GrpcError && error.code == StatusCode.unauthenticated) {
navigatorKey.currentContext?.go(
RoutingPathConstants.auth.path,
extra: S.current.sessionIsExpired,
);
}
// Return the value so that the formatter doesn't swear - the result error will be ignored anyway
return error;
},
).ignore();
return response;
}
Upvotes: 0
Reputation: 1
For anyone arriving to this question searching for an answer, here is a code snippet from a GitHub issue on the grpc package that addresses this:
class ResponseFutureImpl<R> extends DelegatingFuture<R>
implements ResponseFuture<R> {
Response? pendingCall;
final Completer<R> _result;
final _headers = Completer<HeadersMap>();
final _trailers = Completer<HeadersMap>();
ResponseFutureImpl._(this._result)
: super(_result.future);
ResponseFutureImpl()
: this._(Completer<R>());
void complete(ResponseFuture<R> other) {
_result.complete(other);
_headers.complete(other.headers);
_trailers.complete(other.trailers);
}
@override
Future<void> cancel() async {
await pendingCall?.cancel();
}
@override
Future<Map<String, String>> get headers => _headers.future;
@override
Future<Map<String, String>> get trailers => _trailers.future;
}
class RetryingInterceptor implements ClientInterceptor {
@override
ResponseFuture<R> interceptUnary<Q, R>(ClientMethod<Q, R> method, Q request,
CallOptions options, ClientUnaryInvoker<Q, R> invoker) {
final result = ResponseFutureImpl<R>();
Future<void> callWithRetry() async {
for (var retryCount = 0;; retryCount++) {
final response = invoker(method, request, options);
result.pendingCall = response;
try {
await response;
// Fall-through. This will forward value to the result.
} catch (error, st) {
if (error is GrpcError &&
error.code == StatusCode.permissionDenied &&
retryCount < 4) {
continue;
}
// Fall-through. This will forward error to the result.
} finally {
result.pendingCall = null;
}
result.complete(response);
return; // Done.
}
}
callWithRetry();
return result;
}
}
Upvotes: 0