approov_service_flutter_dio_http2 3.5.0
approov_service_flutter_dio_http2: ^3.5.0 copied to clipboard
Approov support for Flutter Dio clients using HTTP/2.
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;
}
}