mirror of
https://github.com/block/buzz.git
synced 2026-11-04 08:21:48 +00:00
On main, "or sign in with email" on iOS fails immediately with "We could not finish connecting. You can try again." No Builderlab page opens. [#8700](https://github.com/block/buzz/pull/8700) changed the iOS browser bridge so the public account hosts (`app.builderlab.xyz`, `app.builderlabstaging.xyz`) may only open `/auth/native`. The Dart side still opens `/api/goose/v1/auth/login` for them, so the bridge rejects every public sign-in before presenting a browser. Every iOS build since #8700 is affected; organization sign-in on tenant hosts is not. The matching Dart change was Wes's [#8712](https://github.com/block/buzz/pull/8712). It was merged into the #8700 branch, but main's squash of #8700 kept only the bridge half. This PR restores #8712 on top of main: - The public account opens `/auth/native` (state, S256 challenge, callback and retained invite unchanged, without the CLI-only `type`/`product` parameters). Organization tenants keep `/api/goose/v1/auth/login`. - A saved session whose account still needs web setup goes through the same browser handoff, keeping its subject and destination. Android has no path allowlist in its browser plugin, so it needs no change. ### Validation - Reproduced on an iPhone 17 Pro (iOS 27) simulator with a main-based debug build: "or sign in with email" fails instantly. With this change applied, the same build shows the system "Buzz wants to use app.builderlab.xyz to sign in" prompt, then the login.builderlab.xyz "Welcome back to Buzz" page, which confirms production serves `/auth/native`. - Restored #8712's tests on top of main's current tests: the browser test checks the public login URL and its exact parameters, and the hosted invite journey runs for both a fresh account and a saved account that still needs setup, asserting `/auth/native` and that the invite is retained. Without the Dart fix, these fail. - Mobile format and analyze pass; the hosted onboarding journeys and `shared/hosted` tests pass with the CI auth-domain configuration. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
186 lines
6.1 KiB
Dart
186 lines
6.1 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:buzz/shared/hosted/browser_auth.dart';
|
|
import 'package:buzz/shared/hosted/hosted_environment.dart';
|
|
import 'package:crypto/crypto.dart';
|
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
setUp(() => FlutterSecureStorage.setMockInitialValues({}));
|
|
|
|
test('cold-start callback restores PKCE and intent, consumes once', () async {
|
|
late Uri login;
|
|
final first = BrowserAuth(
|
|
openBrowser: (uri) async {
|
|
login = uri;
|
|
return null;
|
|
},
|
|
);
|
|
await first.start({
|
|
'relay': 'wss://example.communities.buzz.xyz',
|
|
'code': 'invite',
|
|
});
|
|
await first.dispose();
|
|
final restored = BrowserAuth();
|
|
final codes = <BrowserAuthCode>[];
|
|
final failures = <BrowserAuthFailure>[];
|
|
restored.codes.listen(codes.add);
|
|
restored.failures.listen(failures.add);
|
|
final callback = Uri.parse(BrowserAuth.callback).replace(
|
|
queryParameters: {
|
|
'state': login.queryParameters['state']!,
|
|
'code': 'exchange-code',
|
|
},
|
|
);
|
|
// A temporary backend failure must surface retry without losing the invite.
|
|
await restored.receive(
|
|
callback.replace(
|
|
queryParameters: {
|
|
'state': login.queryParameters['state']!,
|
|
'error': 'temporarily_unavailable',
|
|
},
|
|
),
|
|
);
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(codes, isEmpty);
|
|
expect(failures, [BrowserAuthFailure.restartLogin]);
|
|
expect(await restored.retainedIntent(), containsPair('code', 'invite'));
|
|
failures.clear();
|
|
await restored.receive(callback);
|
|
await restored.receive(callback);
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(codes, hasLength(1));
|
|
expect(failures, isEmpty);
|
|
expect(codes.single.intent['code'], 'invite');
|
|
expect(
|
|
login.queryParameters['code_challenge'],
|
|
base64UrlEncode(
|
|
sha256.convert(ascii.encode(codes.single.verifier)).bytes,
|
|
).replaceAll('=', ''),
|
|
);
|
|
expect(login.queryParameters['code_challenge_method'], 'S256');
|
|
expect(login.origin, HostedEnvironment.origin.origin);
|
|
expect(login.path, '/auth/native');
|
|
expect(
|
|
login.queryParametersAll.keys,
|
|
unorderedEquals([
|
|
'returnTo',
|
|
'state',
|
|
'code_challenge',
|
|
'code_challenge_method',
|
|
]),
|
|
);
|
|
expect(login.queryParametersAll.values, everyElement(hasLength(1)));
|
|
final stored = await const FlutterSecureStorage().readAll();
|
|
expect(stored.keys, everyElement(contains(HostedEnvironment.origin.host)));
|
|
expect(login.queryParameters['returnTo'], BrowserAuth.callback);
|
|
expect(await restored.retainedIntent(), codes.single.intent);
|
|
await restored.dispose();
|
|
});
|
|
|
|
test(
|
|
'invalid state and destination never consume the valid pending login',
|
|
() async {
|
|
late Uri login;
|
|
final auth = BrowserAuth(
|
|
openBrowser: (uri) async {
|
|
login = uri;
|
|
return null;
|
|
},
|
|
);
|
|
final codes = <BrowserAuthCode>[];
|
|
final failures = <BrowserAuthFailure>[];
|
|
auth.codes.listen(codes.add);
|
|
auth.failures.listen(failures.add);
|
|
await auth.start({'relay': 'wss://example.communities.buzz.xyz'});
|
|
final state = login.queryParameters['state']!;
|
|
for (final invalid in [
|
|
'${BrowserAuth.callback}?code=x',
|
|
'${BrowserAuth.callback}?state=wrong&code=x',
|
|
'${BrowserAuth.callback}?state=$state&state=$state&code=x',
|
|
'xyz.block.buzz.mobile://auth/callback?state=$state&code=x',
|
|
'${BrowserAuth.callback}/?state=$state&code=x',
|
|
'${BrowserAuth.callback}?state=$state&code=x#fragment',
|
|
]) {
|
|
await auth.receive(Uri.parse(invalid));
|
|
}
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(codes, isEmpty);
|
|
expect(failures, everyElement(BrowserAuthFailure.restartLogin));
|
|
expect(failures, isEmpty);
|
|
expect(await auth.retainedIntent(), isNotNull);
|
|
await auth.receive(
|
|
Uri.parse('${BrowserAuth.callback}?state=$state&code=good'),
|
|
);
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(codes.single.code, 'good');
|
|
await auth.dispose();
|
|
},
|
|
);
|
|
|
|
test('expiry and cancel preserve destination but prevent exchange', () async {
|
|
var now = DateTime.utc(2026);
|
|
late Uri login;
|
|
final auth = BrowserAuth(
|
|
now: () => now,
|
|
openBrowser: (uri) async {
|
|
login = uri;
|
|
return null;
|
|
},
|
|
);
|
|
final codes = <BrowserAuthCode>[];
|
|
auth.codes.listen(codes.add);
|
|
await auth.start({'relay': 'wss://example.communities.buzz.xyz'});
|
|
now = now.add(const Duration(minutes: 11));
|
|
await auth.receive(
|
|
Uri.parse(
|
|
'${BrowserAuth.callback}?state=${login.queryParameters['state']}&code=x',
|
|
),
|
|
);
|
|
await auth.cancel();
|
|
now = now.subtract(const Duration(minutes: 11));
|
|
await auth.receive(
|
|
Uri.parse(
|
|
'${BrowserAuth.callback}?state=${login.queryParameters['state']}&code=x',
|
|
),
|
|
);
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(codes, isEmpty);
|
|
expect(await auth.retainedIntent(), isNotNull);
|
|
await auth.dispose();
|
|
});
|
|
test('old callback cannot fail a newer pending login', () async {
|
|
final logins = <Uri>[];
|
|
final auth = BrowserAuth(
|
|
openBrowser: (uri) async {
|
|
logins.add(uri);
|
|
return null;
|
|
},
|
|
);
|
|
final failures = <BrowserAuthFailure>[];
|
|
final codes = <BrowserAuthCode>[];
|
|
auth.failures.listen(failures.add);
|
|
auth.codes.listen(codes.add);
|
|
await auth.start({'target': 'old'});
|
|
await auth.start({'target': 'new'});
|
|
await auth.receive(
|
|
Uri.parse(
|
|
'${BrowserAuth.callback}?state=${logins.first.queryParameters['state']}&code=old',
|
|
),
|
|
);
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(failures, isEmpty);
|
|
expect(codes, isEmpty);
|
|
await auth.receive(
|
|
Uri.parse(
|
|
'${BrowserAuth.callback}?state=${logins.last.queryParameters['state']}&code=new',
|
|
),
|
|
);
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(codes.single.intent['target'], 'new');
|
|
await auth.dispose();
|
|
});
|
|
}
|