approov_service_flutter_dio_http2 3.5.0 copy "approov_service_flutter_dio_http2: ^3.5.0" to clipboard
approov_service_flutter_dio_http2: ^3.5.0 copied to clipboard

Approov support for Flutter Dio clients using HTTP/2.

example/lib/main.dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';

import 'package:approov_service_flutter_dio_http2/approov_service_flutter_dio_http2.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';

const _helloUrl = 'https://shapes.approov.io/v1/hello';
const _defaultShapesUrl = 'https://shapes.approov.io/v3/shapes';
const _defaultEchoUrl = 'https://httpbin.org/anything';
const _defaultShapesApiKey = 'yXClypapWNHIifHUWmBIyPFAm';

void main() {
  runApp(const ShapesTestApp());
}

class ShapesTestApp extends StatefulWidget {
  const ShapesTestApp({super.key});

  @override
  State<ShapesTestApp> createState() => _ShapesTestAppState();
}

class _ShapesTestAppState extends State<ShapesTestApp> {
  final _dio = Dio(
    BaseOptions(
      responseType: ResponseType.plain,
      validateStatus: (_) => true,
    ),
  );
  final _results = <_TestResult>[];
  var _running = false;
  var _initialized = false;
  String _status = 'Idle';
  String _shape = '';

  @override
  void initState() {
    super.initState();
    _dio.httpClientAdapter = ApproovDioHttp2Adapter(
      fallbackPolicy: ApproovHttp2FallbackPolicy.protectedHttp1,
      redirectPolicy: ApproovRedirectPolicy.manual,
      bodySigningPolicy: ApproovBodySigningPolicy.bufferUpToLimit,
    );
    unawaited(_runFromLaunchConfig());
  }

  @override
  void dispose() {
    _dio.close(force: true);
    super.dispose();
  }

  Future<void> _runFromLaunchConfig() async {
    final config = _ShapesConfig.fromProcess();
    if (!config.hasApproovConfig) return;
    await _runAll(config);
  }

  Future<void> _runAll([_ShapesConfig? provided]) async {
    final config = provided ?? _ShapesConfig.fromProcess();
    if (!config.hasApproovConfig) {
      setState(() {
        _status = 'Missing configuration';
        _results
          ..clear()
          ..add(
            const _TestResult(
              name: 'Configuration',
              passed: false,
              detail:
                  'Pass APPROOV_CONFIG and optionally APPROOV_DEV_KEY as Dart '
                  'defines, environment variables, or launch arguments.',
            ),
          );
      });
      return;
    }

    setState(() {
      _running = true;
      _status = 'Running Shapes API checks';
      _shape = '';
      _results.clear();
    });

    try {
      await _initializeApproov(config);
      await _runDirectServiceApiCheck(config);
      await _runHelloCheck();
      await _runEchoCheck(config);
      await _runSignedEchoCheck(config);
      await _runCustomHeaderBindingEchoCheck(config);
      await _runExcludedEchoCheck(config);
      await _runTokenCheck(config);
      await _runSignedTokenCheck(config);

      final passed = _results.every((result) => result.passed);
      setState(() => _status = passed ? 'PASS' : 'FAIL');
      // Deliberately avoid logging config, dev key, token, or signature values.
      // ignore: avoid_print
      print('APPROOV_DIO_HTTP2_SHAPES_RESULT: ${passed ? 'PASS' : 'FAIL'}');
    } catch (error) {
      _record('Unexpected failure', false, '$error');
      setState(() => _status = 'FAIL');
      // ignore: avoid_print
      print('APPROOV_DIO_HTTP2_SHAPES_RESULT: FAIL $error');
    } finally {
      _resetApproovRequestConfig();
      setState(() => _running = false);
    }
  }

  Future<void> _initializeApproov(_ShapesConfig config) async {
    if (_initialized) return;
    ApproovService.setLoggingLevel(ApproovLogLevel.TRACE);
    ApproovService.setApproovHeader('Approov-Token', '');
    ApproovService.setApproovTraceIDHeader('Approov-TraceID');
    await ApproovService.initialize(config.approovConfig, 'dio-http2-shapes');
    if (config.devKey.isNotEmpty) {
      await ApproovService.setDevKey(config.devKey);
    }
    _initialized = true;
  }

  void _resetApproovRequestConfig() {
    ApproovService.disableMessageSigning();
    ApproovService.setServiceMutator(null);
    ApproovService.setApproovHeader('Approov-Token', '');
    ApproovService.setApproovTraceIDHeader('Approov-TraceID');
    ApproovService.setBindingHeader('');
  }

  Future<void> _runDirectServiceApiCheck(_ShapesConfig config) async {
    _resetApproovRequestConfig();
    final token = await ApproovService.fetchToken(config.echoUrl);
    final pins = await ApproovService.getPins('public-key-sha256');
    final echoHost = Uri.parse(config.echoUrl).host;
    final shapesHost = Uri.parse(config.shapesUrl).host;
    final echoPins = pins[echoHost];
    final shapesPins = pins[shapesHost];
    final echoPinned = echoPins is List && echoPins.isNotEmpty;
    final shapesPinned = shapesPins is List && shapesPins.isNotEmpty;

    _record(
      'Direct service APIs',
      token.isNotEmpty && echoPinned && shapesPinned,
      'fetchToken=true getPins[$echoHost]=$echoPinned '
          'getPins[$shapesHost]=$shapesPinned',
    );
  }

  Future<void> _runHelloCheck() async {
    final response = await _dio.get<String>(_helloUrl);
    final passed = _is2xx(response);
    _record(
      'Shapes connectivity',
      passed,
      'GET /v1/hello status=${response.statusCode} '
          'httpVersion=${_httpVersion(response)}',
    );
  }

  Future<void> _runEchoCheck(_ShapesConfig config) async {
    _resetApproovRequestConfig();
    final recorder = _HeaderRecordingMutator();
    ApproovService.setServiceMutator(recorder);

    final response = await _dio.post<String>(
      config.echoUrl,
      data: const {'probe': 'dio-http2'},
      options: Options(
        headers: const {'x-approov-dio-http2-test': 'echo-check'},
        contentType: Headers.jsonContentType,
      ),
    );

    var headerEchoed = false;
    var bodyEchoed = false;
    var tokenHeaderEchoed = false;
    if (_is2xx(response) && response.data != null) {
      try {
        final echoed = jsonDecode(response.data!) as Map<String, dynamic>;
        final headers = echoed['headers'] as Map<String, dynamic>?;
        final body = echoed['json'] as Map<String, dynamic>?;
        final tokenHeaderName = recorder.lastMutations?.tokenHeaderKey;
        headerEchoed =
            _echoedHeader(headers, 'x-approov-dio-http2-test') == 'echo-check';
        tokenHeaderEchoed = tokenHeaderName != null &&
            _echoedHeader(headers, tokenHeaderName) != null;
        bodyEchoed = body?['probe'] == 'dio-http2';
      } catch (_) {
        headerEchoed = false;
        bodyEchoed = false;
        tokenHeaderEchoed = false;
      }
    }
    final tokenHeaderName = recorder.lastMutations?.tokenHeaderKey;
    final tokenHeaderPresent = tokenHeaderName != null &&
        recorder.lastHeaderValue(tokenHeaderName) != null;

    _record(
      'HTTP/2 token echo',
      _is2xx(response) &&
          headerEchoed &&
          bodyEchoed &&
          tokenHeaderPresent &&
          tokenHeaderEchoed,
      'POST httpbin status=${response.statusCode} '
          'httpVersion=${_httpVersion(response)} headerEcho=$headerEchoed '
          'bodyEcho=$bodyEchoed tokenHeader=$tokenHeaderPresent '
          'tokenEcho=$tokenHeaderEchoed',
    );
  }

  Future<void> _runSignedEchoCheck(_ShapesConfig config) async {
    _resetApproovRequestConfig();
    final recorder = _HeaderRecordingMutator();
    ApproovService.setServiceMutator(recorder);
    ApproovService.enableMessageSigning(
      defaultFactory: _messageSigningFactory(config.messageSigningMode),
    );

    final response = await _dio.post<String>(
      config.echoUrl,
      data: const {'probe': 'signed-dio-http2'},
      options: Options(
        headers: const {'x-approov-dio-http2-test': 'signed-echo-check'},
        contentType: Headers.jsonContentType,
      ),
    );

    var bodyEchoed = false;
    var tokenHeaderEchoed = false;
    var signatureEchoed = false;
    var signatureInputEchoed = false;
    var debugDigestEchoed = false;
    if (_is2xx(response) && response.data != null) {
      try {
        final echoed = jsonDecode(response.data!) as Map<String, dynamic>;
        final headers = echoed['headers'] as Map<String, dynamic>?;
        final body = echoed['json'] as Map<String, dynamic>?;
        final tokenHeaderName = recorder.lastMutations?.tokenHeaderKey;
        bodyEchoed = body?['probe'] == 'signed-dio-http2';
        tokenHeaderEchoed = tokenHeaderName != null &&
            _echoedHeader(headers, tokenHeaderName) != null;
        signatureEchoed = _echoedHeader(headers, 'signature') != null;
        signatureInputEchoed =
            _echoedHeader(headers, 'signature-input') != null;
        debugDigestEchoed =
            _echoedHeader(headers, 'signature-base-digest') != null;
      } catch (_) {
        bodyEchoed = false;
        tokenHeaderEchoed = false;
        signatureEchoed = false;
        signatureInputEchoed = false;
        debugDigestEchoed = false;
      }
    }

    final tokenHeaderName = recorder.lastMutations?.tokenHeaderKey;
    final tokenHeaderPresent = tokenHeaderName != null &&
        recorder.lastHeaderValue(tokenHeaderName) != null;
    final signaturePresent = recorder.lastHeaderValue('signature') != null;
    final signatureInputPresent =
        recorder.lastHeaderValue('signature-input') != null;
    final debugDigestPresent =
        recorder.lastHeaderValue('signature-base-digest') != null;

    _record(
      'HTTP/2 signed echo',
      _is2xx(response) &&
          bodyEchoed &&
          tokenHeaderPresent &&
          tokenHeaderEchoed &&
          signaturePresent &&
          signatureEchoed &&
          signatureInputPresent &&
          signatureInputEchoed &&
          debugDigestPresent &&
          debugDigestEchoed,
      'POST httpbin status=${response.statusCode} '
          'httpVersion=${_httpVersion(response)} bodyEcho=$bodyEchoed '
          'tokenHeader=$tokenHeaderPresent tokenEcho=$tokenHeaderEchoed '
          'signature=$signaturePresent signatureEcho=$signatureEchoed '
          'signatureInput=$signatureInputPresent '
          'signatureInputEcho=$signatureInputEchoed '
          'baseDigest=$debugDigestPresent baseDigestEcho=$debugDigestEchoed',
    );
  }

  Future<void> _runCustomHeaderBindingEchoCheck(_ShapesConfig config) async {
    _resetApproovRequestConfig();
    const tokenHeader = 'Authorization';
    const traceHeader = 'X-Approov-Trace';
    const bindingHeader = 'X-Approov-Binding';
    const bindingValue = 'binding-demo';
    const customHeader = 'X-Custom-Client-Header';
    const customValue = 'custom-client-value';
    final recorder = _HeaderRecordingMutator();
    ApproovService.setApproovHeader(tokenHeader, 'Bearer ');
    ApproovService.setApproovTraceIDHeader(traceHeader);
    ApproovService.setBindingHeader(bindingHeader);
    ApproovService.setServiceMutator(recorder);

    final response = await _dio.post<String>(
      config.echoUrl,
      data: const {'probe': 'custom-headers-binding'},
      options: Options(
        headers: const {
          bindingHeader: bindingValue,
          customHeader: customValue,
        },
        contentType: Headers.jsonContentType,
      ),
    );

    var bodyEchoed = false;
    var tokenEchoed = false;
    var traceEchoed = false;
    var bindingEchoed = false;
    var customEchoed = false;
    if (_is2xx(response) && response.data != null) {
      try {
        final echoed = jsonDecode(response.data!) as Map<String, dynamic>;
        final headers = echoed['headers'] as Map<String, dynamic>?;
        final body = echoed['json'] as Map<String, dynamic>?;
        bodyEchoed = body?['probe'] == 'custom-headers-binding';
        tokenEchoed = _echoedHeader(headers, tokenHeader)?.startsWith(
              'Bearer ',
            ) ==
            true;
        traceEchoed = _echoedHeader(headers, traceHeader) != null;
        bindingEchoed = _echoedHeader(headers, bindingHeader) == bindingValue;
        customEchoed = _echoedHeader(headers, customHeader) == customValue;
      } catch (_) {
        bodyEchoed = false;
        tokenEchoed = false;
        traceEchoed = false;
        bindingEchoed = false;
        customEchoed = false;
      }
    }

    final tokenHeaderPresent =
        recorder.lastHeaderValue(tokenHeader)?.startsWith('Bearer ') == true;
    final traceHeaderPresent = recorder.lastHeaderValue(traceHeader) != null;

    _record(
      'Custom headers + binding',
      _is2xx(response) &&
          bodyEchoed &&
          tokenHeaderPresent &&
          tokenEchoed &&
          traceHeaderPresent &&
          traceEchoed &&
          bindingEchoed &&
          customEchoed,
      'POST httpbin status=${response.statusCode} '
          'httpVersion=${_httpVersion(response)} bodyEcho=$bodyEchoed '
          'authHeader=$tokenHeaderPresent authEcho=$tokenEchoed '
          'traceHeader=$traceHeaderPresent traceEcho=$traceEchoed '
          'bindingEcho=$bindingEchoed customEcho=$customEchoed',
    );
  }

  Future<void> _runExcludedEchoCheck(_ShapesConfig config) async {
    _resetApproovRequestConfig();
    final exclusionRegex = '^${RegExp.escape(config.echoUrl)}\$';
    ApproovService.addExclusionURLRegex(exclusionRegex);
    try {
      final response = await _dio.post<String>(
        config.echoUrl,
        data: const {'probe': 'excluded'},
        options: Options(
          headers: const {'x-approov-excluded-test': 'excluded'},
          contentType: Headers.jsonContentType,
        ),
      );

      var bodyEchoed = false;
      var excludedHeaderEchoed = false;
      var tokenEchoed = false;
      var traceEchoed = false;
      if (_is2xx(response) && response.data != null) {
        try {
          final echoed = jsonDecode(response.data!) as Map<String, dynamic>;
          final headers = echoed['headers'] as Map<String, dynamic>?;
          final body = echoed['json'] as Map<String, dynamic>?;
          bodyEchoed = body?['probe'] == 'excluded';
          excludedHeaderEchoed =
              _echoedHeader(headers, 'x-approov-excluded-test') == 'excluded';
          tokenEchoed = _echoedHeader(headers, 'Approov-Token') != null ||
              _echoedHeader(headers, 'Authorization') != null;
          traceEchoed = _echoedHeader(headers, 'Approov-TraceID') != null;
        } catch (_) {
          bodyEchoed = false;
          excludedHeaderEchoed = false;
          tokenEchoed = false;
          traceEchoed = false;
        }
      }

      _record(
        'URL exclusion',
        _is2xx(response) &&
            bodyEchoed &&
            excludedHeaderEchoed &&
            !tokenEchoed &&
            !traceEchoed,
        'POST httpbin status=${response.statusCode} '
            'httpVersion=${_httpVersion(response)} bodyEcho=$bodyEchoed '
            'excludedHeaderEcho=$excludedHeaderEchoed '
            'tokenEcho=$tokenEchoed traceEcho=$traceEchoed',
      );
    } finally {
      ApproovService.removeExclusionURLRegex(exclusionRegex);
    }
  }

  Future<void> _runTokenCheck(_ShapesConfig config) async {
    _resetApproovRequestConfig();
    final recorder = _HeaderRecordingMutator();
    ApproovService.setServiceMutator(recorder);

    final response = await _dio.get<String>(
      config.shapesUrl,
      options: Options(headers: {'api-key': config.apiKey}),
    );
    final tokenHeaderName = recorder.lastMutations?.tokenHeaderKey;
    final tokenHeaderPresent = tokenHeaderName != null &&
        recorder.lastHeaderValue(tokenHeaderName) != null;
    final passed = _is2xx(response) && tokenHeaderPresent;

    _updateShape(response);
    _record(
      'Approov token',
      passed,
      'GET /v3/shapes status=${response.statusCode} '
          'httpVersion=${_httpVersion(response)} tokenHeader=$tokenHeaderPresent',
    );
  }

  Future<void> _runSignedTokenCheck(_ShapesConfig config) async {
    _resetApproovRequestConfig();
    final recorder = _HeaderRecordingMutator();
    ApproovService.setServiceMutator(recorder);
    ApproovService.enableMessageSigning(
      defaultFactory: _messageSigningFactory(config.messageSigningMode),
    );

    final response = await _dio.get<String>(
      config.shapesUrl,
      options: Options(headers: {'api-key': config.apiKey}),
    );
    final signaturePresent = recorder.lastHeaderValue('signature') != null;
    final signatureInputPresent =
        recorder.lastHeaderValue('signature-input') != null;
    final debugDigestPresent =
        recorder.lastHeaderValue('signature-base-digest') != null;
    final tokenHeaderName = recorder.lastMutations?.tokenHeaderKey;
    final tokenHeaderPresent = tokenHeaderName != null &&
        recorder.lastHeaderValue(tokenHeaderName) != null;
    final passed = _is2xx(response) &&
        tokenHeaderPresent &&
        signaturePresent &&
        signatureInputPresent &&
        debugDigestPresent;

    _updateShape(response);
    _record(
      'Approov token + message signing',
      passed,
      'GET /v3/shapes status=${response.statusCode} '
          'httpVersion=${_httpVersion(response)} tokenHeader=$tokenHeaderPresent '
          'signature=$signaturePresent signatureInput=$signatureInputPresent '
          'baseDigest=$debugDigestPresent mode=${config.messageSigningMode}',
    );
  }

  SignatureParametersFactory _messageSigningFactory(String mode) {
    final factory = SignatureParametersFactory.generateDefaultFactory()
      ..setDebugMode(true);
    if (mode == 'account') {
      factory.setUseAccountMessageSigning();
    } else {
      factory.setUseInstallMessageSigning();
    }
    return factory;
  }

  bool _is2xx(Response<String> response) {
    final status = response.statusCode;
    return status != null && status >= 200 && status < 300;
  }

  String _httpVersion(Response response) {
    return '${response.extra[HttpClientAdapter.extraKeyHttpVersion] ?? 'unknown'}';
  }

  String? _echoedHeader(Map<String, dynamic>? headers, String name) {
    if (headers == null) return null;
    final lowerName = name.toLowerCase();
    for (final entry in headers.entries) {
      if (entry.key.toLowerCase() == lowerName) {
        return '${entry.value}';
      }
    }
    return null;
  }

  void _updateShape(Response<String> response) {
    if (!_is2xx(response) || response.data == null) return;
    try {
      final json = jsonDecode(response.data!) as Map<String, dynamic>;
      final shape = json['shape'];
      if (shape is String && shape.isNotEmpty) {
        setState(() => _shape = shape);
      }
    } catch (_) {
      // The response was valid HTTP but not a Shapes JSON payload.
    }
  }

  void _record(String name, bool passed, String detail) {
    setState(() {
      _results.add(_TestResult(name: name, passed: passed, detail: detail));
    });
  }

  @override
  Widget build(BuildContext context) {
    final colorScheme = Theme.of(context).colorScheme;
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('Approov Dio HTTP/2 Shapes')),
        body: ListView(
          padding: const EdgeInsets.all(20),
          children: [
            FilledButton.icon(
              onPressed: _running ? null : () => unawaited(_runAll()),
              icon: _running
                  ? const SizedBox.square(
                      dimension: 18,
                      child: CircularProgressIndicator(strokeWidth: 2),
                    )
                  : const Icon(Icons.play_arrow),
              label: const Text('Run Shapes checks'),
            ),
            const SizedBox(height: 20),
            Text(
              _status,
              style: Theme.of(context).textTheme.titleLarge,
            ),
            if (_shape.isNotEmpty) ...[
              const SizedBox(height: 8),
              Text('Last shape: $_shape'),
            ],
            const SizedBox(height: 20),
            for (final result in _results)
              Padding(
                padding: const EdgeInsets.only(bottom: 12),
                child: DecoratedBox(
                  decoration: BoxDecoration(
                    border: Border.all(
                      color: result.passed
                          ? colorScheme.primary
                          : colorScheme.error,
                    ),
                    borderRadius: BorderRadius.circular(8),
                  ),
                  child: Padding(
                    padding: const EdgeInsets.all(12),
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Text(
                          '${result.passed ? 'PASS' : 'FAIL'}  ${result.name}',
                          style: Theme.of(context).textTheme.titleMedium,
                        ),
                        const SizedBox(height: 6),
                        SelectableText(result.detail),
                      ],
                    ),
                  ),
                ),
              ),
          ],
        ),
      ),
    );
  }
}

class _ShapesConfig {
  const _ShapesConfig({
    required this.approovConfig,
    required this.devKey,
    required this.shapesUrl,
    required this.echoUrl,
    required this.apiKey,
    required this.messageSigningMode,
  });

  factory _ShapesConfig.fromProcess() {
    final args = _LaunchArgs(Platform.executableArguments);
    final environment = Platform.environment;
    return _ShapesConfig(
      approovConfig: environment['APPROOV_CONFIG'] ??
          args.value('approov-config') ??
          const String.fromEnvironment('APPROOV_CONFIG'),
      devKey: environment['APPROOV_DEV_KEY'] ??
          args.value('approov-dev-key') ??
          const String.fromEnvironment('APPROOV_DEV_KEY'),
      shapesUrl: environment['APPROOV_SHAPES_URL'] ??
          args.value('approov-shapes-url') ??
          const String.fromEnvironment(
            'APPROOV_SHAPES_URL',
            defaultValue: _defaultShapesUrl,
          ),
      echoUrl: environment['APPROOV_ECHO_URL'] ??
          args.value('approov-echo-url') ??
          const String.fromEnvironment(
            'APPROOV_ECHO_URL',
            defaultValue: _defaultEchoUrl,
          ),
      apiKey: environment['APPROOV_SHAPES_API_KEY'] ??
          args.value('approov-shapes-api-key') ??
          const String.fromEnvironment(
            'APPROOV_SHAPES_API_KEY',
            defaultValue: _defaultShapesApiKey,
          ),
      messageSigningMode: (environment['APPROOV_MESSAGE_SIGNING_MODE'] ??
              args.value('approov-message-signing-mode') ??
              const String.fromEnvironment(
                'APPROOV_MESSAGE_SIGNING_MODE',
                defaultValue: 'install',
              ))
          .toLowerCase(),
    );
  }

  final String approovConfig;
  final String devKey;
  final String shapesUrl;
  final String echoUrl;
  final String apiKey;
  final String messageSigningMode;

  bool get hasApproovConfig => approovConfig.isNotEmpty;
}

class _HeaderRecordingMutator extends ApproovServiceMutator {
  ApproovRequestMutations? lastMutations;
  Map<String, List<String>> lastHeaders = const {};

  @override
  FutureOr<void> handleInterceptorProcessedRequest(
    dynamic request,
    ApproovRequestMutations changes,
  ) {
    final headers = <String, List<String>>{};
    request.headers.forEach((String name, List<String> values) {
      headers[name.toLowerCase()] = List<String>.unmodifiable(values);
    });
    lastHeaders = headers;
    lastMutations = changes;
  }

  String? lastHeaderValue(String name) {
    final values = lastHeaders[name.toLowerCase()];
    if (values == null || values.isEmpty) return null;
    return values.join(', ');
  }
}

class _TestResult {
  const _TestResult({
    required this.name,
    required this.passed,
    required this.detail,
  });

  final String name;
  final bool passed;
  final String detail;
}

class _LaunchArgs {
  _LaunchArgs(this.args);

  final List<String> args;

  String? value(String key) {
    final prefix = '--$key=';
    for (final arg in args) {
      if (arg.startsWith(prefix)) return arg.substring(prefix.length);
    }
    final index = args.indexOf('--$key');
    if (index != -1 && index + 1 < args.length) return args[index + 1];
    return null;
  }
}