algolia / algoliasearch-client-dart

⚡️ A fully-featured and blazing-fast Dart/Flutter API client to interact with Algolia
MIT License
12 stars 7 forks source link

Secure API Key Refresh Mechanism #9

Open SdxCoder opened 6 months ago

SdxCoder commented 6 months ago

Feature Request There is an issue where we are receiving validUntil errors from Algolia because of our secure api key expiry.

Currently, we create a HitSearcher instance and it expects application id and api Key, this works perfectly fine but in our case we are using a secure api key which has a specific validity period and needs to be refreshed. So when that api key expires the only way through current implementation is to re-instantiate HitSearcher and this has associated streams as well, and we need to dispose of previous resources which don't really work well in some use cases. If there is a better way, please do share.

I see that there is DioRequester https://github.com/algolia/algoliasearch-client-dart/blob/main/packages/client_core/lib/src/transport/dio/dio_requester.dart which implements Requester https://github.com/algolia/algoliasearch-client-dart/blob/main/packages/client_core/lib/src/transport/requester.dart. This abstract requester can expose final Iterable interceptors as well. Then, this requester can have one implementation which is extendable as well, where the client can be initialized along with other interceptors and this interceptor .e.g (AuthInterceptor, AgentInterceptor, LogInterceptor, ...intecptors allowing developers to implement custom requesters, where they can add their own interceptors as well. In our case, that interceptor can be used to proactively check for api-key validity and fetch a new one if needed. And lastly, this requester can also be exposed from client options https://github.com/algolia/algoliasearch-helper-flutter/blob/e4162d951e367bdf198576db4a738046ab32acd1/helper/lib/src/client_options.dart.

Currently such abstraction doesn't exists so to acheive this we had to use the requester as it is from algolia.SearchClient, as below

class AlgoliaClient {
  final String appId;
  final String apiKey;

  AlgoliaClient({
    required this.appId,
    required this.apiKey,
  });

  SearchClient get instance {
    const connectTimeout = Duration(seconds: 60);
    final agentSegments = [
      const AgentSegment(
        value: 'algolia-helper-flutter',
        version: libVersion,
      ),
    ];
    return SearchClient(
      appId: appId,
      apiKey: apiKey,
      options: ClientOptions(
        connectTimeout: connectTimeout,
        requester: AlgoliaRequester(
          appId: appId,
          apiKey: apiKey,
          connectTimeout: connectTimeout,
          clientSegments: [
            const AgentSegment(value: "Search", version: packageVersion),
            ...agentSegments
          ],
        ),
        agentSegments: agentSegments,
      ),
    );
  }
}

Using AlgoliaRequester to add AlgoliaApiKeyInterceptor

class AlgoliaRequester implements Requester {
  final Dio _client;

  AlgoliaRequester({
    required String appId,
    required String apiKey,
    Map<String, dynamic>? headers,
    Duration? connectTimeout,
    Iterable<AgentSegment>? clientSegments,
    Function(Object?)? logger,
  }) : _client = Dio(
          BaseOptions(
            headers: headers,
            connectTimeout: connectTimeout,
          ),
        )..interceptors.addAll([
            AuthInterceptor(appId: appId, apiKey: apiKey),
            AlgoliaApiKeyInterceptor(),   <---------------------------------- Algolia api key inteceptor
            AgentInterceptor(
              agent: AlgoliaAgent(packageVersion)
                ..addAll(clientSegments ?? const [])
                ..addAll(Platform.agentSegments()),
            ),
            if (logger != null)
              LogInterceptor(
                requestBody: true,
                responseBody: true,
                logPrint: logger,
              ),
          ]);

  ... over-rides go here
}

Inteceptor

class AlgoliaApiKeyInterceptor extends Interceptor {
  @override
  Future<void> onRequest(
    RequestOptions options,
    RequestInterceptorHandler handler,
  ) async {
    final authTokenService = getIt<AuthTokenService>();
    final chopperApiClient = ChopperApiClient.instance;
    // Fetch refresh token from prefs
    final authToken = await authTokenService.getAuthToken();

    ResponseAuthDto? refreshAuthToken;

    if (authToken != null) {
      final isRefreshTokenExpired =
          JwtDecoder.isExpired(authToken.refreshToken);

      if (isRefreshTokenExpired) {
        refreshAuthToken = (await chopperApiClient.handshakePost()).body;
      } else {
        final isAccessTokenExpired =
            JwtDecoder.isExpired(authToken.accessToken);

        if (isAccessTokenExpired) {
          refreshAuthToken = (await chopperApiClient.handshakeRefreshPost(
            body: HandshakeRefreshDto(refreshToken: authToken.refreshToken),
          ))
              .body;
        } else {
          refreshAuthToken = authToken;
        }
      }
    } else {
      refreshAuthToken = (await chopperApiClient.handshakePost()).body;
    }

    // Save new access token to prefs
    unawaited(authTokenService.saveToLocal(refreshAuthToken!));
    options.headers['x-algolia-api-key'] =
        refreshAuthToken.algoliaConfig?.apiKey;

    super.onRequest(options, handler);
  }
}