Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 47 additions & 42 deletions lib/packages/service/dfx/dfx_auth_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,16 @@ abstract class DFXAuthService {
static const _signMessageTimeout = Duration(minutes: 3);
static const _httpTimeout = Duration(seconds: 20);

final String signMessagePath = '/v1/auth/signMessage';
/// Auth sign-in message, derived locally from the address. Mirrors the
/// server's `Config.auth.signMessageGeneral` template (DFXswiss/api): the
/// backend re-derives this exact string from the address on every verify
/// (stateless, no nonce) and accepts it, so there is no need to first
/// round-trip through `GET /v1/auth/signMessage`. Dropping that call also
/// removes a network-timeout failure mode from the onboarding/pairing flow.
static const _signMessagePrefix =
'By_signing_this_message,_you_confirm_that_you_are_the_sole_owner_'
'of_the_provided_Blockchain_address._Your_ID:_';

final String authPath = '/v1/auth';
final AppStore appStore;
final WalletService walletService;
Expand All @@ -51,7 +60,10 @@ abstract class DFXAuthService {

String get walletAddress => wallet.primaryAddress.address.hexEip55;

Future<String> getSignMessage() => _fetchSignMessage(walletAddress);
String getSignMessage() => buildSignMessage(walletAddress);

/// Builds the deterministic auth sign-in message for [address] (EIP-55).
String buildSignMessage(String address) => '$_signMessagePrefix$address';

/// Create-and-persist the auth signature for [account] without going through
/// `appStore.wallet`. Used during the BitBox pairing flow so the signature is
Expand All @@ -68,27 +80,14 @@ abstract class DFXAuthService {
return;
}

final message = await _fetchSignMessage(address);
final message = buildSignMessage(address);
final signature = await account.signMessage(message).timeout(_signMessageTimeout);
if (signature.isEmpty || signature == '0x') {
throw const SigningCancelledException();
}
await appStore.sessionCache.saveSignature(address, signature);
}

Future<String> _fetchSignMessage(String address) async {
final uri = buildUri(host, signMessagePath, {'address': address});
final response = await appStore.httpClient
.get(uri, headers: {'accept': 'application/json'})
.timeout(_httpTimeout);
if (response.statusCode != 200) {
throw Exception(
'Failed to get sign message. Status: ${response.statusCode} ${response.body}',
);
}
return (jsonDecode(response.body) as Map<String, dynamic>)['message'] as String;
}

// Exceptions this method can throw on the BitBox path:
// * `BitboxNotConnectedException` — `BitboxCredentials.signPersonalMessage`
// aborts up front when the device is disconnected (BLE link dropped).
Expand Down Expand Up @@ -122,7 +121,7 @@ abstract class DFXAuthService {
}

Future<Map<String, dynamic>> getAuthResponse([bool sendWalletName = true]) async {
final signature = await getSignature(await getSignMessage());
final signature = await getSignature(getSignMessage());

final requestBody = jsonEncode(
sendWalletName
Expand Down Expand Up @@ -178,13 +177,15 @@ abstract class DFXAuthService {
Uri uri, {
Map<String, String> headers = const {},
}) {
return _authenticated((token) => appStore.httpClient.get(
uri,
headers: {
...headers,
'Authorization': 'Bearer $token',
},
));
return _authenticated(
(token) => appStore.httpClient.get(
uri,
headers: {
...headers,
'Authorization': 'Bearer $token',
},
),
);
}

Future<http.Response> authenticatedPut(
Expand All @@ -193,15 +194,17 @@ abstract class DFXAuthService {
Object? body,
Encoding? encoding,
}) {
return _authenticated((token) => appStore.httpClient.put(
uri,
headers: {
...headers,
'Authorization': 'Bearer $token',
},
body: body,
encoding: encoding,
));
return _authenticated(
(token) => appStore.httpClient.put(
uri,
headers: {
...headers,
'Authorization': 'Bearer $token',
},
body: body,
encoding: encoding,
),
);
}

Future<http.Response> authenticatedPost(
Expand All @@ -210,15 +213,17 @@ abstract class DFXAuthService {
Object? body,
Encoding? encoding,
}) {
return _authenticated((token) => appStore.httpClient.post(
uri,
headers: {
...headers,
'Authorization': 'Bearer $token',
},
body: body,
encoding: encoding,
));
return _authenticated(
(token) => appStore.httpClient.post(
uri,
headers: {
...headers,
'Authorization': 'Bearer $token',
},
body: body,
encoding: encoding,
),
);
}

/// Runs [request] with a Bearer token and retries once on a 401 with a
Expand Down
4 changes: 2 additions & 2 deletions pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "v0.0.8"
resolved-ref: "7786f6e70b716287f08f4b7082762e6d7d0546bf"
ref: "v0.0.9"
resolved-ref: "6172a2e76ccb5f45a92a37e89f92ff224e0ca4d1"
url: "https://github.com/DFXswiss/bitbox_flutter.git"
source: git
version: "0.0.1"
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ dependencies:
bitbox_flutter:
git:
url: https://github.com/DFXswiss/bitbox_flutter.git
ref: v0.0.8
ref: v0.0.9

dev_dependencies:
flutter_test:
Expand Down
47 changes: 14 additions & 33 deletions test/integration/dfx_auth_sign_ceremony_bitbox_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@ void main() {
late FakeBitboxCredentials credentials;
late BitboxWalletAccount account;

const signMessage = 'Sign me to log in to api.dfx.swiss';
const jwt = 'jwt-fresh-token';

setUp(() {
Expand Down Expand Up @@ -111,20 +110,12 @@ void main() {
}

test(
'happy path: cold cache → signMessage GET → BitBox sign → auth POST 201 → JWT cached',
'happy path: cold cache → local sign message → BitBox sign → auth POST 201 → JWT cached',
() async {
final calls = <String>[];
Map<String, dynamic>? authBody;
final client = MockClient((request) async {
calls.add('${request.method} ${request.url.path}');
if (request.method == 'GET' && request.url.path == '/v1/auth/signMessage') {
// Address query param must be the credentials' EIP-55 address.
expect(
request.url.queryParameters['address'],
credentials.address.hexEip55,
);
return http.Response(jsonEncode({'message': signMessage}), 200);
}
if (request.method == 'POST' && request.url.path == '/v1/auth') {
authBody = jsonDecode(request.body) as Map<String, dynamic>;
return http.Response(jsonEncode({'accessToken': jwt}), 201);
Expand All @@ -137,11 +128,9 @@ void main() {
final token = await service.getAuthToken();

expect(token, jwt);
// The full ceremony happened in order: sign-message GET, then auth POST.
expect(calls, [
'GET /v1/auth/signMessage',
'POST /v1/auth',
]);
// The sign message is built locally now, so the only HTTP call is the
// auth POST — no /v1/auth/signMessage round-trip.
expect(calls, ['POST /v1/auth']);
// BitBox signed exactly once — the cancel/disconnect guards never
// forced a retry on the happy path.
expect(credentials.signCallCount, 1);
Expand Down Expand Up @@ -176,9 +165,6 @@ void main() {
final calls = <String>[];
final client = MockClient((request) async {
calls.add('${request.method} ${request.url.path}');
if (request.method == 'GET') {
return http.Response(jsonEncode({'message': signMessage}), 200);
}
// If the POST ever fires the cancel guard didn't fire — fail loudly.
return http.Response('cancel did not short-circuit', 500);
});
Expand All @@ -190,8 +176,9 @@ void main() {
throwsA(isA<SigningCancelledException>()),
);

// signMessage was fetched but the cancel happened before any POST.
expect(calls, ['GET /v1/auth/signMessage']);
// No HTTP at all: the message is built locally and the cancel happens
// during signing, before any auth POST.
expect(calls, isEmpty);
expect(credentials.signCallCount, 1);
expect(sessionCache.authToken, isNull);
expect(sessionCache.signature, isNull);
Expand All @@ -211,12 +198,9 @@ void main() {
credentials.signDelay = Duration.zero;

fakeAsync((async) {
final client = MockClient((request) async {
if (request.method == 'GET') {
return http.Response(jsonEncode({'message': signMessage}), 200);
}
return http.Response('should never POST on a timed-out sign', 500);
});
final client = MockClient(
(_) async => http.Response('should never POST on a timed-out sign', 500),
);
final service = buildService(client);

Object? caught;
Expand Down Expand Up @@ -248,15 +232,12 @@ void main() {
test(
'403 country-blocked: sign succeeds, auth POST returns 403 with message → Exception propagated to caller',
() async {
final client = MockClient((request) async {
if (request.method == 'GET') {
return http.Response(jsonEncode({'message': signMessage}), 200);
}
return http.Response(
final client = MockClient(
(_) async => http.Response(
jsonEncode({'statusCode': 403, 'message': 'country-blocked: CH'}),
403,
);
});
),
);
final service = buildService(client);

await expectLater(
Expand Down
Loading
Loading