Fix ACP bearer auth normalization
This commit is contained in:
parent
5b347ea239
commit
b5abc8bf09
@ -684,7 +684,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
))?.trim();
|
||||
final normalizedToken = bridgeToken?.trim() ?? '';
|
||||
if (normalizedToken.isNotEmpty) {
|
||||
return 'Bearer $normalizedToken';
|
||||
return normalizedToken;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
@ -637,11 +637,33 @@ class GatewayAcpClient {
|
||||
Uri endpoint, {
|
||||
String authorizationOverride = '',
|
||||
}) async {
|
||||
final override = authorizationOverride.trim();
|
||||
final override = _normalizeAuthorizationHeader(authorizationOverride);
|
||||
if (override.isNotEmpty) {
|
||||
return override;
|
||||
}
|
||||
return (await authorizationResolver?.call(endpoint))?.trim() ?? '';
|
||||
return _normalizeAuthorizationHeader(
|
||||
(await authorizationResolver?.call(endpoint))?.trim() ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
String _normalizeAuthorizationHeader(String raw) {
|
||||
final trimmed = raw.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return '';
|
||||
}
|
||||
if (_looksLikeAuthorizationHeader(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
return 'Bearer $trimmed';
|
||||
}
|
||||
|
||||
bool _looksLikeAuthorizationHeader(String raw) {
|
||||
final separatorIndex = raw.indexOf(RegExp(r'\s'));
|
||||
if (separatorIndex <= 0 || separatorIndex >= raw.length - 1) {
|
||||
return false;
|
||||
}
|
||||
final scheme = raw.substring(0, separatorIndex);
|
||||
return RegExp(r"^[A-Za-z][A-Za-z0-9!#$%&'*+.^_`|~-]*$").hasMatch(scheme);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _consumeSseRpcResponse({
|
||||
|
||||
@ -8,68 +8,115 @@ import 'package:xworkmate/runtime/secure_config_store.dart';
|
||||
|
||||
void main() {
|
||||
group('Bridge runtime cleanup', () {
|
||||
test('resolves the current synced bridge endpoint before env leftovers', () async {
|
||||
final storeRoot = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-bridge-runtime-cleanup-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await storeRoot.exists()) {
|
||||
await storeRoot.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
test(
|
||||
'resolves the current synced bridge endpoint before env leftovers',
|
||||
() async {
|
||||
final storeRoot = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-bridge-runtime-cleanup-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await storeRoot.exists()) {
|
||||
await storeRoot.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
final store = SecureConfigStore(
|
||||
secretRootPathResolver: () async => '${storeRoot.path}/secrets',
|
||||
appDataRootPathResolver: () async => '${storeRoot.path}/app-data',
|
||||
supportRootPathResolver: () async => '${storeRoot.path}/support',
|
||||
enableSecureStorage: false,
|
||||
);
|
||||
await store.initialize();
|
||||
await store.saveAccountSyncState(
|
||||
AccountSyncState.defaults().copyWith(
|
||||
syncedDefaults: AccountRemoteProfile.defaults().copyWith(
|
||||
bridgeServerUrl: 'https://xworkmate-bridge-alt.svc.plus',
|
||||
final store = SecureConfigStore(
|
||||
secretRootPathResolver: () async => '${storeRoot.path}/secrets',
|
||||
appDataRootPathResolver: () async => '${storeRoot.path}/app-data',
|
||||
supportRootPathResolver: () async => '${storeRoot.path}/support',
|
||||
enableSecureStorage: false,
|
||||
);
|
||||
await store.initialize();
|
||||
await store.saveAccountSyncState(
|
||||
AccountSyncState.defaults().copyWith(
|
||||
syncedDefaults: AccountRemoteProfile.defaults().copyWith(
|
||||
bridgeServerUrl: 'https://xworkmate-bridge-alt.svc.plus',
|
||||
),
|
||||
syncState: 'ready',
|
||||
),
|
||||
syncState: 'ready',
|
||||
),
|
||||
);
|
||||
);
|
||||
|
||||
final controller = AppController(
|
||||
store: store,
|
||||
environmentOverride: const <String, String>{
|
||||
'BRIDGE_SERVER_URL': 'https://stale.example.invalid',
|
||||
},
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
await controller.settingsControllerInternal.initialize();
|
||||
final controller = AppController(
|
||||
store: store,
|
||||
environmentOverride: const <String, String>{
|
||||
'BRIDGE_SERVER_URL': 'https://stale.example.invalid',
|
||||
},
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
await controller.settingsControllerInternal.initialize();
|
||||
|
||||
expect(
|
||||
controller.resolveBridgeAcpEndpointInternal()?.toString(),
|
||||
'https://xworkmate-bridge-alt.svc.plus',
|
||||
);
|
||||
expect(
|
||||
controller
|
||||
.resolveExternalAcpEndpointForTargetInternal(
|
||||
AssistantExecutionTarget.gateway,
|
||||
)
|
||||
?.toString(),
|
||||
'https://xworkmate-bridge-alt.svc.plus',
|
||||
);
|
||||
});
|
||||
expect(
|
||||
controller.resolveBridgeAcpEndpointInternal()?.toString(),
|
||||
'https://xworkmate-bridge-alt.svc.plus',
|
||||
);
|
||||
expect(
|
||||
controller
|
||||
.resolveExternalAcpEndpointForTargetInternal(
|
||||
AssistantExecutionTarget.gateway,
|
||||
)
|
||||
?.toString(),
|
||||
'https://xworkmate-bridge-alt.svc.plus',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('falls back to the managed bridge endpoint without BRIDGE_SERVER_URL', () {
|
||||
final controller = AppController(
|
||||
environmentOverride: const <String, String>{
|
||||
'BRIDGE_SERVER_URL': 'https://stale.example.invalid',
|
||||
},
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
test(
|
||||
'falls back to the managed bridge endpoint without BRIDGE_SERVER_URL',
|
||||
() {
|
||||
final controller = AppController(
|
||||
environmentOverride: const <String, String>{
|
||||
'BRIDGE_SERVER_URL': 'https://stale.example.invalid',
|
||||
},
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
expect(
|
||||
controller.resolveBridgeAcpEndpointInternal()?.toString(),
|
||||
kManagedBridgeServerUrl,
|
||||
);
|
||||
});
|
||||
expect(
|
||||
controller.resolveBridgeAcpEndpointInternal()?.toString(),
|
||||
kManagedBridgeServerUrl,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'resolves raw bridge token only for the current managed bridge endpoint',
|
||||
() async {
|
||||
final storeRoot = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-bridge-auth-resolver-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await storeRoot.exists()) {
|
||||
await storeRoot.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
final store = SecureConfigStore(
|
||||
secretRootPathResolver: () async => '${storeRoot.path}/secrets',
|
||||
appDataRootPathResolver: () async => '${storeRoot.path}/app-data',
|
||||
supportRootPathResolver: () async => '${storeRoot.path}/support',
|
||||
enableSecureStorage: false,
|
||||
);
|
||||
await store.initialize();
|
||||
await store.saveAccountManagedSecret(
|
||||
target: kAccountManagedSecretTargetBridgeAuthToken,
|
||||
value: 'bridge-token',
|
||||
);
|
||||
|
||||
final controller = AppController(store: store);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
final bridgeHeader = await controller
|
||||
.resolveGatewayAcpAuthorizationHeaderInternal(
|
||||
Uri.parse('https://xworkmate-bridge.svc.plus/acp/rpc'),
|
||||
);
|
||||
final unrelatedHeader = await controller
|
||||
.resolveGatewayAcpAuthorizationHeaderInternal(
|
||||
Uri.parse('https://unrelated.example.com/acp/rpc'),
|
||||
);
|
||||
|
||||
expect(bridgeHeader, 'bridge-token');
|
||||
expect(unrelatedHeader, isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'runtime coordinator only exposes remote and offline gateway modes',
|
||||
|
||||
145
test/runtime/gateway_acp_client_auth_test.dart
Normal file
145
test/runtime/gateway_acp_client_auth_test.dart
Normal file
@ -0,0 +1,145 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xworkmate/app/app_controller.dart';
|
||||
import 'package:xworkmate/runtime/gateway_acp_client.dart';
|
||||
import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
import 'package:xworkmate/runtime/secure_config_store.dart';
|
||||
|
||||
void main() {
|
||||
group('GatewayAcpClient authorization', () {
|
||||
test('normalizes raw resolver token into bearer header for HTTP', () async {
|
||||
final capture = await _startAcpHttpServer();
|
||||
addTearDown(capture.close);
|
||||
|
||||
final client = GatewayAcpClient(
|
||||
endpointResolver: () => capture.baseEndpoint,
|
||||
authorizationResolver: (_) async => 'bridge-token',
|
||||
);
|
||||
|
||||
final response = await client.request(
|
||||
method: 'acp.capabilities',
|
||||
params: const <String, dynamic>{},
|
||||
);
|
||||
|
||||
expect(capture.authorizationHeader, 'Bearer bridge-token');
|
||||
expect(capture.requestPath, '/acp/rpc');
|
||||
expect((response['result'] as Map)['ok'], true);
|
||||
});
|
||||
|
||||
test(
|
||||
'normalizes raw authorization override into bearer header for HTTP',
|
||||
() async {
|
||||
final capture = await _startAcpHttpServer();
|
||||
addTearDown(capture.close);
|
||||
|
||||
final client = GatewayAcpClient(
|
||||
endpointResolver: () => capture.baseEndpoint,
|
||||
);
|
||||
|
||||
await client.request(
|
||||
method: 'acp.capabilities',
|
||||
params: const <String, dynamic>{},
|
||||
authorizationOverride: 'override-token',
|
||||
);
|
||||
|
||||
expect(capture.authorizationHeader, 'Bearer override-token');
|
||||
},
|
||||
);
|
||||
|
||||
test('preserves prebuilt bearer authorization header', () async {
|
||||
final capture = await _startAcpHttpServer();
|
||||
addTearDown(capture.close);
|
||||
|
||||
final client = GatewayAcpClient(
|
||||
endpointResolver: () => capture.baseEndpoint,
|
||||
);
|
||||
|
||||
await client.request(
|
||||
method: 'acp.capabilities',
|
||||
params: const <String, dynamic>{},
|
||||
authorizationOverride: 'Bearer ready-token',
|
||||
);
|
||||
|
||||
expect(capture.authorizationHeader, 'Bearer ready-token');
|
||||
});
|
||||
|
||||
test('desktop bridge auth resolver skips unrelated endpoints', () async {
|
||||
final storeRoot = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-acp-auth-unrelated-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await storeRoot.exists()) {
|
||||
await storeRoot.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
final store = SecureConfigStore(
|
||||
secretRootPathResolver: () async => '${storeRoot.path}/secrets',
|
||||
appDataRootPathResolver: () async => '${storeRoot.path}/app-data',
|
||||
supportRootPathResolver: () async => '${storeRoot.path}/support',
|
||||
enableSecureStorage: false,
|
||||
);
|
||||
await store.initialize();
|
||||
await store.saveAccountManagedSecret(
|
||||
target: kAccountManagedSecretTargetBridgeAuthToken,
|
||||
value: 'bridge-token',
|
||||
);
|
||||
|
||||
final controller = AppController(store: store);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
final header = await controller
|
||||
.resolveGatewayAcpAuthorizationHeaderInternal(
|
||||
Uri.parse('https://unrelated.example.com/acp/rpc'),
|
||||
);
|
||||
|
||||
expect(header, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<_CapturedAcpHttpServer> _startAcpHttpServer() async {
|
||||
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
final capture = _CapturedAcpHttpServer._(
|
||||
server,
|
||||
Uri.parse('http://127.0.0.1:${server.port}'),
|
||||
);
|
||||
server.listen((request) async {
|
||||
capture.authorizationHeader =
|
||||
request.headers.value(HttpHeaders.authorizationHeader) ?? '';
|
||||
capture.requestPath = request.uri.path;
|
||||
final body = await utf8.decoder.bind(request).join();
|
||||
final id = _decodeRequestId(body);
|
||||
request.response.headers.contentType = ContentType.json;
|
||||
request.response.write(
|
||||
jsonEncode(<String, dynamic>{
|
||||
'jsonrpc': '2.0',
|
||||
'id': id,
|
||||
'result': <String, dynamic>{'ok': true},
|
||||
}),
|
||||
);
|
||||
await request.response.close();
|
||||
});
|
||||
return capture;
|
||||
}
|
||||
|
||||
String _decodeRequestId(String body) {
|
||||
final decoded = jsonDecode(body);
|
||||
if (decoded is Map && decoded['id'] != null) {
|
||||
return decoded['id'].toString();
|
||||
}
|
||||
return 'request-id';
|
||||
}
|
||||
|
||||
class _CapturedAcpHttpServer {
|
||||
_CapturedAcpHttpServer._(this._server, this.baseEndpoint);
|
||||
|
||||
final HttpServer _server;
|
||||
final Uri baseEndpoint;
|
||||
String authorizationHeader = '';
|
||||
String requestPath = '';
|
||||
|
||||
Future<void> close() => _server.close(force: true);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user