Files
buzz/mobile/lib/shared/hosted/browser_auth.dart
Tom Brow 12f3d51001 feat(mobile): one Builderlab per build, internal and public distributions (#9224)
The mobile app can now be built as a public build or an internal build,
set explicitly with `BUZZ_DISTRIBUTION=internal|public`. Each build
trusts exactly one Builderlab (`BUZZ_BUILDERLAB_URL`), and sends that
Builderlab's relay assertions only to the relays in
`BUZZ_BUILDERLAB_RELAYS`. The three settings are set together or not at
all; with none set, the app is a Nostr-only development build. The
decision is recorded in
`docs/adr/0001-internal-and-public-mobile-builds.md`, and the terms are
defined in `CONTEXT.md`.

Before this change, one build supported a public Builderlab and an
organization Builderlab at the same time, with two sessions. An
organization's members could reach public communities from the
organization's app.

### Behavior

- **Public build:** QR pairing, email sign-in and multiple communities
are unchanged. It has no knowledge of any organization. An
organization's relay already rejects clients that can't present its
Builderlab's assertion, and the public build relies on that.
- **Internal build:** the first screen has a single **Sign in** button
that goes straight to the organization's single sign-on and then joins
the organization's community. There is no QR pairing, community link or
community creation, and the community switcher is hidden. Only the exact
relay hosts listed in `BUZZ_BUILDERLAB_RELAYS` are reachable.
- **Identity (internal):** a member whose account has no Nostr identity
yet gets a new key on first sign-in; the first relay assertion binds it,
and the app backs it up to the organization automatically. A member
whose account is already bound uses that key from this device, or
restores it from the organization's backup. If neither is possible, Sign
in says it couldn't restore the identity and saves nothing.
- **Sign-out (internal):** a destructive **Sign out** row at the bottom
of Settings, the build's only way out. It removes the community, closes
Settings and returns to Sign in. The Builderlab session is cleared from
storage immediately and can never be reused to sign back in. The relay
push-stop keeps the retired credential in memory only, for up to 10
minutes, and uses it only to get an assertion for a key that is already
bound. It is a best-effort request.
- **Upgrade to an internal build:** a saved public Builderlab session is
cleared. Communities on other relays are hidden, not deleted, and their
keys stay on the device.
- `BUZZ_HOSTED_RELAY_AUTH` is unchanged here.

### Screenshots (internal build)

| Sign in | Community menu | Settings |
| --- | --- | --- |
| ![Internal build landing with a single Sign in
button](https://github.com/user-attachments/assets/05f076a2-1552-4095-8322-626754a8765b)
| ![Community menu with Agents, Appearance and Settings, without Switch
community](https://github.com/user-attachments/assets/0c0cf4e0-3fa9-458e-817f-b4665374505b)
| ![Settings ending with a red Sign out
row](https://github.com/user-attachments/assets/f3d60dc1-8170-45e3-a16d-0a1156fdae1a)
|

Gateway-side push revocation, which needs no Builderlab session, is
planned as a separate PR.

### Validation

Added tests:
- An internal-build journey: one Sign in creates and binds a key, and a
retry after a cancelled join reuses it. The app uploads that key's
backup itself. Sign out from Settings closes it and returns to the
landing, which ends the session, and Sign in then restores the same
identity from the backup. Communities on other relays stay stored but
hidden.
- Internal Sign in for a bound account: the key on this device, a key
restored from backup, and no backup (clear error, nothing saved). Each
guard was shown to fail when removed.
- Organization relay assertions: assertions go only to exact listed
hosts, not subdomains. After sign-out, only a push stop for a bound key
gets an assertion, and nothing can sign back in. Proven to fail without
the fix.
- Builderlab build setting parsing.

CI runs the internal and public journeys in their own lanes with those
settings. The full suite passes in the default configuration. The full
suite isn't meant to run under the internal configuration, because most
fixtures use relays that an internal build hides.

Automated full-app run on an iPhone 17 Pro simulator at the current
head:
- Public build, upgraded in place over a signed-in install: home loads,
a channel opens and a message sends.
- Internal build: a fresh install shows only Sign in. With a saved
organization community, the community menu has no Switch community, and
Settings → Sign out → confirm returns to Sign in.

Real single sign-on can't run in the simulator.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@localhost>
2026-11-06 21:27:14 -08:00

339 lines
12 KiB
Dart

import '../storage/serial_queue.dart';
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:app_links/app_links.dart';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../storage/secure_storage.dart';
import 'hosted_environment.dart';
import 'organization_environment.dart';
/// Safe to show as a generic retry prompt. Never carries a URL or server text.
enum BrowserAuthFailure { restartLogin, cancelled, unavailable, storage }
class BrowserAuthCode {
const BrowserAuthCode(this.code, this.verifier, this.intent);
final String code;
final String verifier;
final Map<String, String> intent;
}
/// One app-scoped owner per account origin. Subscribe to [codes] and
/// [failures] before [initialize]. Callbacks bypass the community deep-link
/// FIFO. A consumed code is never replayed automatically: exchange failure
/// requires a fresh browser login.
///
/// Every origin shares the one registered [callback], so an owner leaves a
/// callback whose state belongs to another live owner's attempt to that owner.
class BrowserAuth {
BrowserAuth({
FlutterSecureStorage? storage,
Future<String?> Function(Uri)? openBrowser,
DateTime Function()? now,
Uri? origin,
}) : _storage = storage ?? accountSecureStorage,
_openBrowser = openBrowser ?? _openPlatformBrowser,
_now = now ?? DateTime.now,
origin = origin ?? HostedEnvironment.origin {
_owners.add(this);
}
static const callback = 'xyz.block.buzz.mobile:/auth/callback';
static final _owners = <BrowserAuth>{};
/// The account origin this owner signs in to.
final Uri origin;
late final _attemptKey = 'buzz_browser_auth_attempt_v1_${origin.host}';
late final _intentKey = 'buzz_browser_auth_intent_v1_${origin.host}';
static const _channel = MethodChannel('buzz/browser_auth');
final FlutterSecureStorage _storage;
final Future<String?> Function(Uri) _openBrowser;
final DateTime Function() _now;
final _codes = StreamController<BrowserAuthCode>.broadcast();
final _failures = StreamController<BrowserAuthFailure>.broadcast();
Stream<BrowserAuthCode> get codes => _codes.stream;
Stream<BrowserAuthFailure> get failures => _failures.stream;
StreamSubscription<Uri>? _links;
final _queue = SerialQueue();
bool _disposed = false;
bool _initialized = false;
int _generation = 0;
String? _activeState;
String? _consumedCallbackHash;
String? _consumedState;
Future<T> _serial<T>(Future<T> Function() task) {
final result = _queue.run(() {
if (_disposed) throw BrowserAuthFailure.cancelled;
return task();
});
return result;
}
Future<void> initialize() async {
if (_initialized || _disposed) return;
_initialized = true;
final links = AppLinks();
_links = links.uriLinkStream.listen((uri) {
if (uri.scheme == 'xyz.block.buzz.mobile') {
unawaited(receive(uri).catchError((Object _) {}));
}
}, onError: (Object _) => _fail(BrowserAuthFailure.unavailable));
try {
final initial = await links.getInitialLink();
if (initial != null && initial.scheme == 'xyz.block.buzz.mobile') {
await receive(initial);
}
} catch (_) {
_fail(BrowserAuthFailure.unavailable);
}
}
/// Intent is opaque to authentication; callers validate its relay/challenge
/// before acting. Retained separately so missing state never loses navigation.
Future<Map<String, String>?> retainedIntent() => _serial(() async {
try {
final raw = await _storage.read(key: _intentKey);
if (raw == null) return null;
return Map<String, String>.from(jsonDecode(raw) as Map);
} catch (_) {
throw BrowserAuthFailure.storage;
}
});
/// Persist setup intent before remote work or acknowledging key transfer.
Future<void> retainIntent(Map<String, String> intent) => _serial(() async {
final raw = jsonEncode(intent);
if (raw.length > 16384) throw BrowserAuthFailure.storage;
await _storage.write(key: _intentKey, value: raw);
if (await _storage.read(key: _intentKey) != raw) {
throw BrowserAuthFailure.storage;
}
});
Future<void> start(Map<String, String> intent) async {
final generation = ++_generation;
final uri = await _serial(() async {
if (jsonEncode(intent).length > 16384) throw BrowserAuthFailure.storage;
final random = Random.secure();
String secret() => base64UrlEncode(
List.generate(32, (_) => random.nextInt(256)),
).replaceAll('=', '');
final verifier = secret();
final state = secret();
_activeState = state;
final record = jsonEncode({
'verifier': verifier,
'state': state,
'created': _now().toUtc().millisecondsSinceEpoch,
'intent': intent,
});
try {
await _storage.write(key: _intentKey, value: jsonEncode(intent));
await _storage.write(key: _attemptKey, value: record);
if (await _storage.read(key: _attemptKey) != record) {
throw BrowserAuthFailure.storage;
}
} catch (_) {
throw BrowserAuthFailure.storage;
}
// Public accounts must finish web setup before native code issuance.
// Organization accounts go straight to their single sign-on.
final publicAccount =
!OrganizationEnvironment.enabled &&
origin == HostedEnvironment.origin;
return origin.replace(
path: publicAccount ? '/auth/native' : '/api/goose/v1/auth/login',
queryParameters: {
if (!publicAccount) ...{'type': 'cli', 'product': 'buzz'},
'returnTo': callback,
'state': state,
'code_challenge': base64UrlEncode(
sha256.convert(ascii.encode(verifier)).bytes,
).replaceAll('=', ''),
'code_challenge_method': 'S256',
},
);
});
if (_disposed || generation != _generation) return;
try {
final result = await _openBrowser(uri);
if (!_disposed && generation == _generation && result != null) {
await receive(Uri.parse(result));
}
} on PlatformException catch (error) {
if (generation == _generation) {
_fail(
error.code == 'cancelled'
? BrowserAuthFailure.cancelled
: BrowserAuthFailure.unavailable,
);
}
} catch (_) {
if (generation == _generation) _fail(BrowserAuthFailure.unavailable);
}
}
Future<void> receive(Uri uri) {
final generation = _generation;
return _serial(() async {
if (generation != _generation) return;
void fail(BrowserAuthFailure failure) {
if (generation == _generation) _fail(failure);
}
// Reject noncanonical destination and duplicate query fields before reading
// secrets. Neither invalid state nor malformed callbacks mutate the session.
final callbackHash = sha256
.convert(utf8.encode(uri.toString()))
.toString();
if (callbackHash == _consumedCallbackHash) return;
final query = uri.queryParametersAll;
if (uri.toString().split('?').first != callback ||
uri.hasFragment ||
query.values.any((v) => v.length != 1) ||
query.keys.any((k) => k != 'state' && k != 'code' && k != 'error')) {
if (_activeState == null) fail(BrowserAuthFailure.restartLogin);
return;
}
try {
final saved = await _storage.read(key: _attemptKey);
if (saved == null) {
if (!await _peerOwns(query['state']?.single)) {
fail(BrowserAuthFailure.restartLogin);
}
return;
}
final record = jsonDecode(saved) as Map<String, dynamic>;
final expectedState = record['state'];
if (expectedState is! String ||
!RegExp(r'^[A-Za-z0-9_-]{43}$').hasMatch(expectedState)) {
throw BrowserAuthFailure.storage;
}
// Another owner's callback never expires this owner's attempt.
if (query['state']?.single != expectedState) {
if (_activeState != expectedState &&
!await _peerOwns(query['state']?.single)) {
fail(BrowserAuthFailure.restartLogin);
}
return;
}
final age =
_now().toUtc().millisecondsSinceEpoch - (record['created'] as int);
if (age < 0 || age > const Duration(minutes: 10).inMilliseconds) {
fail(BrowserAuthFailure.restartLogin);
return;
}
if (generation != _generation) return;
final code = query['code']?.single;
if (query.containsKey('error') ||
code == null ||
code.isEmpty ||
code.length > 4096) {
fail(BrowserAuthFailure.restartLogin);
return;
}
final verifier = record['verifier'] as String;
if (!RegExp(r'^[A-Za-z0-9_-]{43}$').hasMatch(verifier)) {
throw BrowserAuthFailure.storage;
}
final intent = Map<String, String>.from(record['intent'] as Map);
// Claimed before the saved attempt disappears, so a peer checking
// ownership concurrently never sees neither.
_consumedState = expectedState;
await _storage.delete(key: _attemptKey);
if (await _storage.read(key: _attemptKey) != null) {
throw BrowserAuthFailure.storage;
}
_activeState = null;
_consumedCallbackHash = callbackHash;
if (!_disposed && generation == _generation) {
_codes.add(BrowserAuthCode(code, verifier, Map.unmodifiable(intent)));
}
} catch (_) {
fail(BrowserAuthFailure.storage);
}
});
}
Future<void> cancel() async {
++_generation;
_activeState = null;
if (defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.android) {
try {
await _channel.invokeMethod<void>('cancel');
} catch (_) {
/* No session may be active. */
}
}
await _delete(_attemptKey);
}
/// Call only after completing or explicitly abandoning the retained intent.
Future<void> clearIntent() => _delete(_intentKey);
Future<void> _delete(String key) => _serial(() async {
try {
await _storage.delete(key: key);
} catch (_) {
throw BrowserAuthFailure.storage;
}
});
/// Whether another live owner has a saved attempt with this state.
Future<bool> _peerOwns(String? state) async {
if (state == null) return false;
for (final peer in _owners.toList()) {
if (identical(peer, this) || peer._disposed) continue;
if (peer._activeState == state || peer._consumedState == state) {
return true;
}
try {
final saved = await peer._storage.read(key: peer._attemptKey);
if (saved != null &&
(jsonDecode(saved) as Map<String, dynamic>)['state'] == state) {
return true;
}
// The peer may have consumed its attempt during the read.
if (peer._consumedState == state) return true;
} catch (_) {
// An unreadable peer attempt is that owner's failure to report.
}
}
return false;
}
void _fail(BrowserAuthFailure failure) {
if (!_disposed) _failures.add(failure);
}
Future<void> dispose() async {
_disposed = true;
_owners.remove(this);
++_generation;
await _links?.cancel();
await _codes.close();
await _failures.close();
}
static Future<String?> _openPlatformBrowser(Uri uri) async {
if (defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.android) {
return _channel.invokeMethod<String>('start', {
'url': uri.toString(),
if (kDebugMode &&
const bool.fromEnvironment('BUZZ_DEBUG_PRIVATE_LOGIN'))
'ephemeral': 'true',
});
}
throw BrowserAuthFailure.unavailable;
}
}