feat(mobile): organization sign-in for NIP-FI relays (#8700)

Block is turning on NIP-FI enforcement on the Block relay. Desktop gets
organization sign-in in block/buzz-app#1424. Mobile can already attach
NIP-FI assertions, but only for Builderlab-hosted communities, through
the build's single Builderlab account. The dogfood build needs that
account for public hosted communities, so it can't simply be pointed at
the Block tenant.

This adds a separate **organization realm**, configured at build time:

- `BUZZ_ORGANIZATION_URL`: the tenant's HTTPS origin (for example
`https://block.builderlab.xyz`).
- `BUZZ_ORGANIZATION_RELAYS`: the exact relay hosts that trust that
tenant.

The two must be set together. The app refuses to start if they're
malformed, overlap a hosted community, or reuse the hosted origin.
Without them, nothing changes.

For a configured relay:

- The app keeps its own organization session and signs in through the
browser at the tenant's existing login (`/api/goose/v1/auth/login`).
Block SSO happens there.
- It requests assertions from the same `/v1/buzz/identity/assertions`
route that hosted communities use, and attaches them to the relay
socket, HTTP, media, huddle and remote-agent requests.
- Unlike the hosted path, it doesn't require an identity backup before
the first binding. As on desktop, the tenant binds the member's existing
key on first issuance. A key already bound to a different one is refused
before any request is sent.
- When signed out, it connects without an assertion and lets the relay
decide. A bottom banner offers **Sign in**. After sign-in, the relay
reconnects with a fresh assertion. Hosted communities keep their
existing banner and flow.

The tenant accepts only the registered
`xyz.block.buzz.mobile:/auth/callback` return URL, so both realms share
it. Each `BrowserAuth` owner leaves callbacks whose `state` belongs to
another owner's attempt to that owner.

### Before merge

- [ ] Kenny Lopez reviews the visible changes.

### Validation

Added tests (`organization_relay_auth_test.dart`):

- Configuration parsing and exact host matching.
- An assertion is issued for the exact relay and key, then cached.
- A different bound key is refused.
- Signed out connects without an assertion.
- Other relays never receive the assertion.
- The shared callback is routed between the hosted and organization
owners.

Full `mobile-check` and `mobile-test` pass. RunnerTests pass locally
except `testNativeNavigationTitleExpandsAgainAtTop`, which this PR
doesn't touch.

Added tests (`organization_sign_in_test.dart`,
`relay_auth_isolation_test.dart`, `builderlab_account_test.dart`), each
failing without its fix:

- Concurrent account restores at cold start share one adopted account.
- An exchange from a cancelled attempt never changes a newer sign-in.
- Storage failures while clearing the intent or cancelling leave the
busy state.
- An unreadable saved organization session is replaced by signing in
again.
- A stalled organization issuer never delays a hosted relay's assertion.
This test needs `BUZZ_AUTH_DOMAINS` and skips without it. CI's
configured-auth-domains step doesn't list it yet, because that workflow
change needs a token with `workflow` scope.

Added tests also cover a restore after an offline start (the banner
clears once a relay retry restores the session) and the iOS bridge
allowlist (`/auth/native` and tenant login on Builderlab subdomains;
other hosts, paths, ports, userinfo and fragments refused).

Simulator (iPhone 17, iOS 27) with the dogfood defines against the
production Block relay `buzz.block.builderlab.xyz`, on a WARP Mac:

- The app starts with the organization configuration and connects to the
relay while signed out, showing the organization **Sign in** banner.
- **Sign in** opens the system sign-in session for
`block.builderlab.xyz`, and the banner shows **Finishing organization
sign-in…**. Cancelling returns to **Sign in** with no error. Before the
bridge fix, no browser opened and the banner reported that sign-in did
not finish.
- The bottom banners (organization sign-in, hosted reconnect, and the
sign-in recovery row) now sit above Home's floating Search/New bar,
which used to cover **Sign in**. A journey test checks the banner clears
the bar, and fails without the fix.

![Home with the organization sign-in banner above the floating
bar](https://github.com/user-attachments/assets/c6ab74f4-3d88-4fe0-ae16-042a548056fe)
- The simulator run stopped at the system consent prompt (SSO does not
work in the simulator). The app calls were driven through the VM service
(the same `signIn()`/`cancel()` the banner buttons call) because the
simulator could not be tapped.

Device (iPhone 12 mini) against bb-block staging in enforce mode, where
every probe without an assertion gets 401:

- Signed out, the organization banner shows and the app stays
responsive. Cancel at the consent prompt returns to **Sign in**, and
retry works.
- Block SSO through Okta completes, and the relay admits the reconnect
with the assertion.
- After relaunch the app connects, loads channel history, and shows no
banner for over 60 seconds.
- Before the restore fix, 2 of 4 cold starts showed "cannot use this
community with this key" although the session was valid. With it, 8 of 8
cold starts connected.

Known limitation: adding an enforcing organization relay by community
link is tracked in #8791.

The dogfood build enables this in squareup/buzz-releases
(`tomb/dogfood-organization-sign-in`). App Store builds stay unchanged.

🤖 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: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Tom Brow
2026-11-01 07:23:12 -08:00
committed by GitHub
parent acf56fff6f
commit db4478e8d2
19 changed files with 1852 additions and 96 deletions

View File

@@ -148,6 +148,7 @@ jobs:
test/features/onboarding/onboarding_entry_routes_test.dart
test/shared/hosted/hosted_environment_test.dart
test/shared/hosted/self_hosted_entry_test.dart
test/shared/hosted/relay_auth_isolation_test.dart
test/features/onboarding/hosted_app_gate_environment_test.dart
test/features/onboarding/hosted_journeys_test.dart
test/features/onboarding/hosted_communities_test.dart

View File

@@ -35,10 +35,7 @@ final class BuzzBrowserAuthBridge: NSObject, ASWebAuthenticationPresentationCont
guard self.session == nil,
let args = call.arguments as? [String: String],
let raw = args["url"], let url = URL(string: raw),
url.scheme == "https",
["app.builderlab.xyz", "app.builderlabstaging.xyz"].contains(url.host ?? ""),
url.path == "/api/goose/v1/auth/login", url.user == nil, url.password == nil,
url.port == nil, url.fragment == nil
Self.allowsLogin(url)
else { result(FlutterError(code: "unavailable", message: nil, details: nil)); return }
self.pending = result
let attemptID = UUID()
@@ -61,6 +58,21 @@ final class BuzzBrowserAuthBridge: NSObject, ASWebAuthenticationPresentationCont
}
}
/// Builderlab login pages only: the public account's `/auth/native`, and a
/// tenant's CLI login on its own host for organization sign-in.
static func allowsLogin(_ url: URL) -> Bool {
guard url.scheme == "https", let host = url.host?.lowercased(),
url.user == nil, url.password == nil, url.port == nil, url.fragment == nil
else { return false }
let publicHosts = ["app.builderlab.xyz", "app.builderlabstaging.xyz"]
if publicHosts.contains(host) { return url.path == "/auth/native" }
let tenant = ["builderlab.xyz", "builderlabstaging.xyz"].contains { domain in
let label = host.dropLast(domain.count + 1)
return host.hasSuffix(".\(domain)") && !label.isEmpty && !label.contains(".")
}
return tenant && url.path == "/api/goose/v1/auth/login"
}
// Safari delivers a deep link before UIKit finishes activating our app.
// ASWebAuthenticationSession.start() rejects presentation during that handoff.
private func startWhenActive(_ id: UUID) {

View File

@@ -12,7 +12,7 @@ final class BrowserAuthBridgeTests: XCTestCase {
let bridge = BuzzBrowserAuthBridge(messenger: messenger, isActive: { active },
startSession: { _ in starts += 1; return true })
defer { withExtendedLifetime(bridge) {} }
let arguments = ["url": "https://app.builderlabstaging.xyz/api/goose/v1/auth/login"]
let arguments = ["url": "https://app.builderlabstaging.xyz/auth/native"]
var results: [String] = []
messenger.invoke("start", arguments: arguments) {
results.append(($0 as? FlutterError)?.code ?? "success")
@@ -42,6 +42,32 @@ final class BrowserAuthBridgeTests: XCTestCase {
XCTAssertEqual(starts, 2, "Ordinary active-app login should start immediately")
messenger.invoke("cancel", arguments: [:]) { _ in }
}
func testAllowsOnlyBuilderlabLoginPages() {
for allowed in [
"https://app.builderlab.xyz/auth/native?state=s",
"https://app.builderlabstaging.xyz/auth/native",
"https://block.builderlab.xyz/api/goose/v1/auth/login?type=cli",
"https://block.builderlabstaging.xyz/api/goose/v1/auth/login",
] {
XCTAssertTrue(BuzzBrowserAuthBridge.allowsLogin(URL(string: allowed)!), allowed)
}
for refused in [
"http://block.builderlab.xyz/api/goose/v1/auth/login",
"https://builderlab.xyz/auth/native",
"https://evilbuilderlab.xyz/api/goose/v1/auth/login",
"https://block.builderlab.xyz.example.com/api/goose/v1/auth/login",
"https://block.builderlab.xyz/api/goose/v1/auth/me",
"https://app.builderlab.xyz/api/goose/v1/auth/login",
"https://block.builderlab.xyz/auth/native",
"https://a.block.builderlab.xyz/api/goose/v1/auth/login",
"https://app.builderlab.xyz:8443/auth/native",
"https://user@app.builderlab.xyz/auth/native",
"https://app.builderlab.xyz/auth/native#fragment",
] {
XCTAssertFalse(BuzzBrowserAuthBridge.allowsLogin(URL(string: refused)!), refused)
}
}
}
private final class BrowserAuthTestMessenger: NSObject, FlutterBinaryMessenger {

View File

@@ -177,10 +177,7 @@ class HomePage extends HookConsumerWidget {
communitySurfaceProgress.value = progress;
},
);
final bottom =
MediaQuery.viewPaddingOf(context).bottom > mobileTabBarBottomGap
? MediaQuery.viewPaddingOf(context).bottom
: mobileTabBarBottomGap;
final bottom = mobileTabBarBottom(context);
return Scaffold(
resizeToAvoidBottomInset: false,
// Register the floating controls with Scaffold so snackbars clear them.

View File

@@ -22,7 +22,9 @@ class HostedReconnectBanner extends HookConsumerWidget {
final auth = ref.watch(hostedRelayAuthProvider);
useListenable(auth);
final config = ref.watch(relayConfigProvider);
// Organization relays have their own sign-in banner.
final active =
!auth.organizationProtects(config) &&
auth.failedRelay == config.baseUrl &&
auth.failedPubkey == pubkeyFromNsec(config.nsec) &&
auth.failure != null;

View File

@@ -0,0 +1,105 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/hosted/builderlab_account.dart';
import '../../shared/hosted/hosted_relay_auth.dart';
import '../../shared/hosted/organization_sign_in.dart';
import '../../shared/relay/relay_provider.dart';
import '../../shared/relay/relay_session.dart';
import '../../shared/theme/app_theme.dart';
import '../../shared/theme/grid.dart';
/// Offers organization sign-in for the active community when it trusts the
/// build's organization. Cached content stays available underneath.
class OrganizationSignInBanner extends HookConsumerWidget {
const OrganizationSignInBanner({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final signIn = ref.watch(organizationSignInProvider);
final auth = ref.watch(hostedRelayAuthProvider);
useListenable(signIn);
useListenable(auth);
final config = ref.watch(relayConfigProvider);
if (signIn == null || !auth.organizationProtects(config)) {
return const SizedBox.shrink();
}
final relayFailure =
auth.failedRelay == config.baseUrl &&
auth.failedPubkey == pubkeyFromNsec(config.nsec)
? auth.failure
: null;
final String message;
final List<Widget> actions;
switch (signIn.status) {
case OrganizationSessionStatus.checking:
return const SizedBox.shrink();
case OrganizationSessionStatus.signingIn:
message = 'Finishing organization sign-in…';
actions = [
TextButton(
onPressed: () => unawaited(signIn.cancel()),
child: const Text('Cancel sign-in'),
),
];
case OrganizationSessionStatus.signedOut:
message = switch (signIn.failure) {
null =>
'Sign in with your organization to stay connected to this community.',
AccountFailure.storage =>
'Your saved organization sign-in could not be read. Sign in again to replace it.',
_ => 'Organization sign-in did not finish. Try again.',
};
actions = [
TextButton(
onPressed: () => unawaited(signIn.signIn()),
child: const Text('Sign in'),
),
];
case OrganizationSessionStatus.signedIn:
// Signed out is handled above; unavailable keeps reconnecting.
if (relayFailure == null ||
relayFailure == AccountFailure.signedOut ||
relayFailure == AccountFailure.unavailable) {
return const SizedBox.shrink();
}
message = relayFailure == AccountFailure.denied
? 'Your organization account cannot use this community with this key.'
: 'Could not reconnect with your organization account.';
actions = [
TextButton(
onPressed: () =>
unawaited(ref.read(relaySessionProvider.notifier).reconnect()),
child: const Text('Retry'),
),
// Retrying the same refused account fails the same way.
if (relayFailure == AccountFailure.denied)
TextButton(
onPressed: () => unawaited(signIn.changeAccount()),
child: const Text('Use a different account'),
),
];
}
return Positioned(
left: Grid.sm,
right: Grid.sm,
bottom: 0,
child: SafeArea(
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(Radii.lg),
child: Padding(
padding: const EdgeInsets.all(Grid.sm),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [Text(message), ...actions],
),
),
),
),
);
}
}

View File

@@ -11,13 +11,16 @@ import 'features/onboarding/self_hosted_entry_sheet.dart';
import 'shared/auth/auth.dart';
import 'shared/theme/grid.dart';
import 'shared/theme/theme_extensions.dart';
import 'shared/widgets/mobile_tab_footer_backdrop.dart';
import 'shared/deeplink/deep_link.dart';
import 'shared/hosted/hosted_community_connection.dart';
import 'shared/hosted/hosted_flow.dart';
import 'shared/hosted/hosted_environment.dart';
import 'features/onboarding/hosted_reconnect_banner.dart';
import 'features/onboarding/organization_sign_in_banner.dart';
import 'shared/hosted/hosted_flow_provider.dart';
import 'shared/hosted/organization_sign_in.dart';
/// Composition seam shared by signed-in and signed-out application states.
class HostedAppGate extends HookConsumerWidget {
@@ -48,6 +51,8 @@ class HostedAppGate extends HookConsumerWidget {
return () => flow.removeListener(onFlowChanged);
}, [flow]);
final inlineEntry = ref.watch(hostedInlineEntryProvider);
// Create the organization's browser owner before any callback is handled.
ref.watch(organizationSignInProvider);
final visible =
flow != null &&
flow.stage != HostedFlowStage.idle &&
@@ -138,38 +143,60 @@ class HostedAppGate extends HookConsumerWidget {
),
),
if (fullScreenSetup) ColoredBox(color: context.colors.surface),
if (flow != null) ...[
if (authenticated && !visible) HostedReconnectBanner(flow: flow),
if (flow.stage == HostedFlowStage.signIn &&
authenticated &&
!inlineEntry)
Positioned(
left: Grid.sm,
right: Grid.sm,
bottom: 0,
child: SafeArea(
child: Material(
color: Colors.transparent,
child: Wrap(
alignment: WrapAlignment.center,
children: [
TextButton(
onPressed: flow.retry,
child: const Text('Start login again'),
),
TextButton(
onPressed: flow.leave,
child: Text(
flow.reconnecting
? 'Cancel sign-in'
: 'Leave setup',
// These overlays only show over Home, so keep them above its
// floating tab bar.
Positioned.fill(
child: MediaQuery(
data: MediaQuery.of(context).copyWith(
padding: MediaQuery.paddingOf(
context,
).copyWith(bottom: mobileTabBarTop(context) + Grid.xxs),
),
child: Stack(
// Banners with their own Stack need the full layer to place
// bottom-anchored content inside hit-testable bounds.
fit: StackFit.expand,
children: [
if (authenticated && !visible)
const OrganizationSignInBanner(),
if (flow != null && authenticated && !visible)
HostedReconnectBanner(flow: flow),
if (flow != null &&
flow.stage == HostedFlowStage.signIn &&
authenticated &&
!inlineEntry)
Positioned(
left: Grid.sm,
right: Grid.sm,
bottom: 0,
child: SafeArea(
child: Material(
color: Colors.transparent,
child: Wrap(
alignment: WrapAlignment.center,
children: [
TextButton(
onPressed: flow.retry,
child: const Text('Start login again'),
),
TextButton(
onPressed: flow.leave,
child: Text(
flow.reconnecting
? 'Cancel sign-in'
: 'Leave setup',
),
),
],
),
),
],
),
),
),
),
],
),
),
),
if (flow != null) ...[
if (fullScreenSetup)
HostedConnectPage(
flow: flow,

View File

@@ -23,23 +23,33 @@ class BrowserAuthCode {
final Map<String, String> intent;
}
/// One app-scoped owner. 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.
/// 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;
_now = now ?? DateTime.now,
origin = origin ?? HostedEnvironment.origin {
_owners.add(this);
}
static const callback = 'xyz.block.buzz.mobile:/auth/callback';
static final _attemptKey =
'buzz_browser_auth_attempt_v1_${HostedEnvironment.origin.host}';
static final _intentKey =
'buzz_browser_auth_intent_v1_${HostedEnvironment.origin.host}';
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;
@@ -55,6 +65,7 @@ class BrowserAuth {
int _generation = 0;
String? _activeState;
String? _consumedCallbackHash;
String? _consumedState;
Future<T> _serial<T>(Future<T> Function() task) {
final result = _queue.run(() {
@@ -131,7 +142,7 @@ class BrowserAuth {
} catch (_) {
throw BrowserAuthFailure.storage;
}
return HostedEnvironment.origin.replace(
return origin.replace(
path: '/api/goose/v1/auth/login',
queryParameters: {
'type': 'cli',
@@ -189,7 +200,9 @@ class BrowserAuth {
try {
final saved = await _storage.read(key: _attemptKey);
if (saved == null) {
fail(BrowserAuthFailure.restartLogin);
if (!await _peerOwns(query['state']?.single)) {
fail(BrowserAuthFailure.restartLogin);
}
return;
}
final record = jsonDecode(saved) as Map<String, dynamic>;
@@ -198,18 +211,20 @@ class BrowserAuth {
!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 (query['state']?.single != expectedState) {
if (_activeState != expectedState) {
fail(BrowserAuthFailure.restartLogin);
}
return;
}
if (generation != _generation) return;
final code = query['code']?.single;
if (query.containsKey('error') ||
@@ -224,6 +239,9 @@ class BrowserAuth {
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;
@@ -250,11 +268,42 @@ class BrowserAuth {
/* No session may be active. */
}
}
await _serial(() => _storage.delete(key: _attemptKey));
await _delete(_attemptKey);
}
/// Call only after completing or explicitly abandoning the retained intent.
Future<void> clearIntent() => _serial(() => _storage.delete(key: _intentKey));
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);
@@ -262,6 +311,7 @@ class BrowserAuth {
Future<void> dispose() async {
_disposed = true;
_owners.remove(this);
++_generation;
await _links?.cancel();
await _codes.close();

View File

@@ -52,17 +52,22 @@ class BuilderlabAccount {
/// subject is refused until the caller clears the session and every fact it
/// holds for the previous subject.
class BuilderlabAccountClient {
/// [accountOrigin] defaults to the hosted account environment. An
/// organization client uses its own trusted origin and session record.
BuilderlabAccountClient({
http.Client? httpClient,
FlutterSecureStorage? storage,
Uri? accountOrigin,
}) : _http = httpClient ?? http.Client(),
_storage = storage ?? defaultStorage;
_storage = storage ?? defaultStorage,
accountOrigin = accountOrigin ?? origin;
/// Device-only: the session never syncs or restores onto another device.
@visibleForTesting
static const defaultStorage = accountSecureStorage;
static final origin = HostedEnvironment.origin;
final Uri accountOrigin;
static const _prefix = '/api/goose';
/// Remote-agent registry routes, shared with desktop.
@@ -79,8 +84,8 @@ class BuilderlabAccountClient {
};
/// Sessions are scoped per origin so staging never reads production state.
static final _storageKey =
'buzz_hosted_session_${origin.host.replaceAll('.', '_')}_v1';
late final _storageKey =
'buzz_hosted_session_${accountOrigin.host.replaceAll('.', '_')}_v1';
final http.Client _http;
final FlutterSecureStorage _storage;
String? _credential;
@@ -104,7 +109,12 @@ class BuilderlabAccountClient {
return result;
}
Future<BuilderlabAccount?> restore() => _serial(() async {
/// Concurrent callers share one restore, so a second one cannot replace the
/// account the first adopted while its callers still hold that session.
Future<BuilderlabAccount?> restore() =>
_restoring ??= _restore().whenComplete(() => _restoring = null);
Future<BuilderlabAccount?>? _restoring;
Future<BuilderlabAccount?> _restore() => _serial(() async {
final record = await _readRecord();
if (record == null) return null;
try {
@@ -124,9 +134,12 @@ class BuilderlabAccountClient {
/// Receives a one-use code only from a validated browser callback. PKCE is
/// verified by Builderlab; state and callback origin are checked by the caller.
/// [isCurrent] is checked just before the session is saved, so a caller can
/// abandon an exchange without it later replacing a newer session.
Future<BuilderlabAccount> exchange({
required String code,
required String verifier,
bool Function()? isCurrent,
}) => _serial(() async {
if (code.isEmpty ||
!RegExp(r'^[A-Za-z0-9._~-]{43,128}$').hasMatch(verifier)) {
@@ -153,9 +166,11 @@ class BuilderlabAccountClient {
if (stored != null && stored.subject != verified.subject) {
throw AccountFailure.differentAccount;
}
if (_disposed) throw AccountFailure.unavailable;
if (_disposed || (isCurrent != null && !isCurrent())) {
throw AccountFailure.unavailable;
}
final record = jsonEncode({
'origin': origin.toString(),
'origin': accountOrigin.toString(),
'credential': credential,
'subject': verified.subject,
'expires_at': verified.expiresAt.toIso8601String(),
@@ -184,7 +199,7 @@ class BuilderlabAccountClient {
try {
final value = jsonDecode(saved);
if (value is Map<String, dynamic> &&
value['origin'] == origin.toString() &&
value['origin'] == accountOrigin.toString() &&
value['credential'] is String &&
value['subject'] is String) {
return (
@@ -201,8 +216,10 @@ class BuilderlabAccountClient {
BuilderlabAccount _adopt(String credential, BuilderlabAccount verified) {
if (_disposed) throw AccountFailure.unavailable;
_credential = credential;
// Assigned before notifying, so subject listeners see the new account.
_account = verified;
_subject.value = verified.subject;
return _account = verified;
return verified;
}
Future<BuilderlabAccount> _verify(String credential) async {
@@ -337,7 +354,7 @@ class BuilderlabAccountClient {
final request =
http.Request(
body == null ? 'GET' : 'POST',
origin.replace(path: '$_prefix$path'),
accountOrigin.replace(path: '$_prefix$path'),
)
..followRedirects = false
..headers['Accept'] = 'application/json';
@@ -346,7 +363,7 @@ class BuilderlabAccountClient {
}
if (body != null) {
request.headers['Content-Type'] = 'application/json';
request.headers['Origin'] = origin.toString();
request.headers['Origin'] = accountOrigin.toString();
request.body = jsonEncode(body);
if (signingKey != null) {
request.headers['Authorization'] = buildNip98AuthHeader(
@@ -360,7 +377,7 @@ class BuilderlabAccountClient {
final response = await boundedHttpResponse(_http, request);
if (kDebugMode && response.statusCode >= 400) {
debugPrint(
'Builderlab ${origin.host} ${request.method} $path: HTTP ${response.statusCode} contentType=${response.headers['content-type']?.split(';').first}',
'Builderlab ${accountOrigin.host} ${request.method} $path: HTTP ${response.statusCode} contentType=${response.headers['content-type']?.split(';').first}',
);
}
if (response.statusCode == 401) throw AccountFailure.signedOut;

View File

@@ -8,6 +8,7 @@ import '../relay/relay_provider.dart';
import 'builderlab_account.dart';
import 'account_identity_store.dart';
import 'hosted_environment.dart';
import 'organization_environment.dart';
final builderlabAccountProvider = Provider<BuilderlabAccountClient>((ref) {
final account = BuilderlabAccountClient();
@@ -15,8 +16,21 @@ final builderlabAccountProvider = Provider<BuilderlabAccountClient>((ref) {
return account;
});
/// The organization's own account session, separate from the hosted account.
/// Null unless this build configures [OrganizationEnvironment].
final organizationAccountProvider = Provider<BuilderlabAccountClient?>((ref) {
final origin = OrganizationEnvironment.origin;
if (origin == null) return null;
final account = BuilderlabAccountClient(accountOrigin: origin);
ref.onDispose(account.dispose);
return account;
});
final hostedRelayAuthProvider = Provider<HostedRelayAuth>((ref) {
final auth = HostedRelayAuth(ref.watch(builderlabAccountProvider));
final auth = HostedRelayAuth(
ref.watch(builderlabAccountProvider),
organization: ref.watch(organizationAccountProvider),
);
ref.onDispose(auth.dispose);
return auth;
});
@@ -28,16 +42,22 @@ final hostedHttpClientFactoryProvider = Provider<http.Client Function()>((ref) {
return () => HostedRelayHttpClient(auth, config);
});
/// One account session, with a short-lived assertion for the current relay/key.
/// Enable alongside backend issuance; legacy and self-hosted builds stay usable.
/// One account session per realm, with a short-lived assertion for the current
/// relay/key. Hosted communities use [account] when [enabled]; the build's
/// organization relays use [organization]. Enable alongside backend issuance;
/// legacy and self-hosted builds stay usable.
class HostedRelayAuth extends ChangeNotifier {
HostedRelayAuth(
this.account, {
this.organization,
this.organizationRelay = OrganizationEnvironment.protects,
this.enabled = const bool.fromEnvironment('BUZZ_HOSTED_RELAY_AUTH'),
DateTime Function()? now,
}) : _now = now ?? DateTime.now;
final BuilderlabAccountClient account;
final BuilderlabAccountClient? organization;
final bool Function(Uri relay) organizationRelay;
final bool enabled;
final DateTime Function() _now;
({
@@ -48,16 +68,35 @@ class HostedRelayAuth extends ChangeNotifier {
BuilderlabAccount account,
})?
_cached;
Future<void>? _pending;
// Per account client, so a stalled issuer never delays an unrelated one.
final _pending = <BuilderlabAccountClient, Future<void>>{};
bool _disposed = false;
// Orders issuance and refusals, so a stale result never replaces a newer
// failure for another relay or key.
int _sequence = 0;
int _failureSequence = 0;
String? failedRelay;
String? failedPubkey;
AccountFailure? failure;
bool protects(RelayConfig config) =>
enabled &&
HostedEnvironment.requiresAuth(Uri.parse(config.baseUrl).host) &&
HostedEnvironment.supportsRelay(Uri.parse(config.baseUrl));
bool protects(RelayConfig config) => _accountFor(config) != null;
/// Whether this relay uses the organization's session, not the hosted one.
bool organizationProtects(RelayConfig config) =>
organization != null && identical(_accountFor(config), organization);
BuilderlabAccountClient? _accountFor(RelayConfig config) {
final relay = Uri.parse(config.baseUrl);
if (organization != null && organizationRelay(relay)) {
return organization;
}
if (enabled &&
HostedEnvironment.requiresAuth(relay.host) &&
HostedEnvironment.supportsRelay(relay)) {
return account;
}
return null;
}
/// Headers for optional reads, without starting account work or changing
/// recovery state. Null means protected metadata must wait for active auth.
@@ -68,7 +107,7 @@ class HostedRelayAuth extends ChangeNotifier {
if (cached == null ||
cached.relay != Uri.parse(config.wsUrl).replace(path: '').toString() ||
cached.pubkey != pubkeyFromNsec(config.nsec) ||
!identical(cached.account, account.account) ||
!identical(cached.account, _accountFor(config)?.account) ||
!cached.expiry.isAfter(_now())) {
return null;
}
@@ -76,7 +115,25 @@ class HostedRelayAuth extends ChangeNotifier {
}
Future<Map<String, String>> headers(RelayConfig config) async {
if (!protects(config)) return const {};
final client = _accountFor(config);
if (client == null) return const {};
try {
return await _headers(config, client);
} on AccountFailure catch (failure) {
// Without an organization session, connect as before and let the relay
// decide: an enforcing relay refuses, and sign-in is offered.
if (failure == AccountFailure.signedOut &&
identical(client, organization)) {
return const {};
}
rethrow;
}
}
Future<Map<String, String>> _headers(
RelayConfig config,
BuilderlabAccountClient client,
) async {
final relay = Uri.parse(config.wsUrl).replace(path: '').toString();
final pubkey = pubkeyFromNsec(config.nsec);
if (pubkey == null) throw AccountFailure.denied;
@@ -85,11 +142,11 @@ class HostedRelayAuth extends ChangeNotifier {
if (cached != null &&
cached.relay == relay &&
cached.pubkey == pubkey &&
identical(cached.account, account.account) &&
identical(cached.account, client.account) &&
cached.expiry.isAfter(_now())) {
return {'Nostr-Federated-Identity': 'Bearer ${cached.token}'};
}
final pending = _pending;
final pending = _pending[client];
if (pending != null) {
// Different communities share account restoration, never assertions.
try {
@@ -99,30 +156,39 @@ class HostedRelayAuth extends ChangeNotifier {
}
continue;
}
final operation = _issue(config, relay, pubkey);
_pending = operation;
final operation = _issue(config, client, relay, pubkey);
_pending[client] = operation;
try {
await operation;
} finally {
_pending = null;
if (identical(_pending[client], operation)) _pending.remove(client);
}
}
throw AccountFailure.unavailable;
}
Future<void> _issue(RelayConfig config, String relay, String pubkey) async {
Future<void> _issue(
RelayConfig config,
BuilderlabAccountClient account,
String relay,
String pubkey,
) async {
final started = ++_sequence;
try {
if (account.account == null && await account.restore() == null) {
throw AccountFailure.signedOut;
}
final session = account.account!;
// Issuance can create a binding. Reconnect must not silently give a
// different, unbound account this community's saved identity.
final current = await account.currentIdentity();
if (!identical(session, account.account)) throw AccountFailure.denied;
final identity = current['identity'];
if (current['error'] != null) throw AccountFailure.invalidResponse;
if (identity == null) {
if (identity == null && identical(account, organization)) {
// The organization's issuer binds the proven key on first issuance;
// that binding is how a member's existing key joins the organization.
} else if (identity == null) {
// Issuance can create a binding. Reconnect must not silently give a
// different, unbound hosted account this community's saved identity.
final AccountIdentitySnapshot? saved;
try {
saved = await AccountIdentityStore()
@@ -180,11 +246,13 @@ class HostedRelayAuth extends ChangeNotifier {
// Issuance alone cannot prove admission after a relay refusal.
if (failure != AccountFailure.denied) connected(config);
} on AccountFailure catch (error) {
if (!_disposed) {
failedRelay = config.baseUrl;
failedPubkey = pubkey;
failure = error;
notifyListeners();
// Issuers run in parallel, so a failure that started before another
// relay's unresolved failure was recorded must not replace it.
final otherScope =
failure != null &&
(failedRelay != config.baseUrl || failedPubkey != pubkey);
if (!_disposed && !(otherScope && _failureSequence > started)) {
_record(config.baseUrl, pubkey, error, started);
}
rethrow;
}
@@ -209,13 +277,28 @@ class HostedRelayAuth extends ChangeNotifier {
if (!protects(config) || usedToken == null) return;
if ('Bearer ${_cached?.token}' == usedToken) _cached = null;
if (terminal && !_disposed) {
failedRelay = config.baseUrl;
failedPubkey = pubkeyFromNsec(config.nsec);
failure = AccountFailure.denied;
notifyListeners();
_record(
config.baseUrl,
pubkeyFromNsec(config.nsec),
AccountFailure.denied,
++_sequence,
);
}
}
void _record(
String relay,
String? pubkey,
AccountFailure error,
int sequence,
) {
failedRelay = relay;
failedPubkey = pubkey;
failure = error;
_failureSequence = sequence;
notifyListeners();
}
@override
void dispose() {
_disposed = true;

View File

@@ -0,0 +1,90 @@
import 'package:flutter/foundation.dart';
import 'hosted_environment.dart';
/// Trusted build configuration for an organization's identity provider (a
/// Builderlab tenant), never supplied by a deep link or relay. Organization
/// relays get NIP-FI assertions from this origin's account session, separately
/// from hosted communities' account.
abstract final class OrganizationEnvironment {
static bool get enabled => origin != null;
/// `BUZZ_ORGANIZATION_URL`: the tenant's HTTPS origin, for example
/// `https://block.builderlab.xyz`.
static final Uri? origin = parseOrigin(
const String.fromEnvironment('BUZZ_ORGANIZATION_URL'),
const String.fromEnvironment('BUZZ_ORGANIZATION_RELAYS'),
);
/// `BUZZ_ORGANIZATION_RELAYS`: comma-separated exact relay hosts that trust
/// this tenant. Subdomains and other ports never match.
static final List<String> relayHosts = parseRelayHosts(
const String.fromEnvironment('BUZZ_ORGANIZATION_RELAYS'),
);
/// Whether this build attaches its organization session's assertions here.
static bool protects(Uri relay) => enabled && matches(relay, relayHosts);
@visibleForTesting
static bool matches(Uri relay, List<String> hosts) =>
['wss', 'https'].contains(relay.scheme) &&
relay.userInfo.isEmpty &&
!relay.hasQuery &&
!relay.hasFragment &&
(relay.path.isEmpty || relay.path == '/') &&
(!relay.hasPort || relay.port == 443) &&
hosts.contains(relay.host.toLowerCase());
@visibleForTesting
static Uri? parseOrigin(String url, String relays) {
if (url.isEmpty != relays.trim().isEmpty) {
throw StateError(
'BUZZ_ORGANIZATION_URL and BUZZ_ORGANIZATION_RELAYS must be set together',
);
}
if (url.isEmpty) return null;
final uri = Uri.parse(url);
if (uri.scheme != 'https' ||
uri.host.isEmpty ||
uri.userInfo.isNotEmpty ||
uri.hasPort ||
uri.hasQuery ||
uri.hasFragment ||
(uri.path.isNotEmpty && uri.path != '/')) {
throw StateError('BUZZ_ORGANIZATION_URL must be an HTTPS origin');
}
final origin = Uri.parse(uri.origin);
// Sessions and browser attempts are stored per origin host.
if (origin.host == HostedEnvironment.origin.host) {
throw StateError(
'BUZZ_ORGANIZATION_URL must differ from the hosted account origin',
);
}
return origin;
}
@visibleForTesting
static List<String> parseRelayHosts(String relays) => List.unmodifiable(
relays
.split(',')
.map((host) => host.trim().toLowerCase())
.where((host) => host.isNotEmpty)
.map((host) {
if (!RegExp(
r'^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$',
).hasMatch(host)) {
throw StateError(
'BUZZ_ORGANIZATION_RELAYS must contain DNS names, not URLs or wildcards',
);
}
// Never send one realm's session to another realm's relay.
if (HostedEnvironment.accountOriginFor(Uri.parse('wss://$host')) !=
null) {
throw StateError(
'BUZZ_ORGANIZATION_RELAYS must not include hosted communities',
);
}
return host;
}),
);
}

View File

@@ -0,0 +1,194 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../relay/relay_provider.dart';
import '../relay/relay_session.dart';
import 'browser_auth.dart';
import 'builderlab_account.dart';
import 'hosted_relay_auth.dart';
enum OrganizationSessionStatus { checking, signedOut, signingIn, signedIn }
/// Browser sign-in to the build's organization account. A completed sign-in
/// reconnects the relay so its next upgrade carries a fresh assertion. Null
/// unless this build configures an organization.
final organizationSignInProvider = Provider<OrganizationSignIn?>((ref) {
final account = ref.watch(organizationAccountProvider);
if (account == null) return null;
final signIn = OrganizationSignIn(
account: account,
browser: BrowserAuth(origin: account.accountOrigin),
// Only the organization's relay needs a fresh assertion.
reconnect: () async {
final config = ref.read(relayConfigProvider);
if (ref.read(hostedRelayAuthProvider).organizationProtects(config)) {
await ref.read(relaySessionProvider.notifier).reconnect();
}
},
);
ref.onDispose(signIn.dispose);
unawaited(signIn.initialize());
return signIn;
});
class OrganizationSignIn extends ChangeNotifier {
OrganizationSignIn({
required this.account,
required this.browser,
required this.reconnect,
});
final BuilderlabAccountClient account;
final BrowserAuth browser;
final Future<void> Function() reconnect;
OrganizationSessionStatus status = OrganizationSessionStatus.checking;
/// The last sign-in or session-check failure, cleared by the next attempt.
Object? failure;
final _subscriptions = <StreamSubscription<Object>>[];
/// Advanced by every start or cancel, so an exchange from an abandoned
/// attempt can never change the state of a newer one.
int _attempt = 0;
bool _initialized = false;
bool _disposed = false;
Future<void> initialize() async {
if (_initialized || _disposed) return;
_initialized = true;
account.subjectChanges.addListener(_onSubject);
_subscriptions
..add(browser.codes.listen((code) => unawaited(_exchange(code))))
..add(browser.failures.listen(_onBrowserFailure));
await browser.initialize();
try {
final restored = account.account ?? await account.restore();
_set(
restored == null
? OrganizationSessionStatus.signedOut
: OrganizationSessionStatus.signedIn,
);
} on AccountFailure catch (error) {
// Unknown, for example offline. Relay connection retries the restore.
// An unreadable record is kept so the next sign-in can replace it.
if (error == AccountFailure.storage) failure = error;
_set(OrganizationSessionStatus.signedOut);
}
}
Future<void> signIn() =>
// An unreadable saved session blocks every exchange, and its subject is
// unknown. Signing in again explicitly replaces only that session.
_begin(
clear: failure == AccountFailure.storage && account.account == null,
);
/// Drops only the organization session and signs in again, for an account
/// the relay refuses for this key. The hosted account is untouched.
Future<void> changeAccount() => _begin(clear: true);
Future<void> _begin({required bool clear}) async {
if (_disposed || status == OrganizationSessionStatus.signingIn) return;
// Busy before any await, so a second tap cannot start another attempt.
final attempt = ++_attempt;
failure = null;
_set(OrganizationSessionStatus.signingIn);
if (clear) {
try {
await account.clearLocalSession();
} on AccountFailure catch (error) {
if (attempt != _attempt) return;
failure = error;
_set(_currentStatus());
return;
}
if (_disposed || attempt != _attempt) return;
}
try {
await browser.start(const {'purpose': 'organization'});
} on BrowserAuthFailure catch (error) {
if (attempt != _attempt) return;
failure = error;
_set(_currentStatus());
}
}
Future<void> cancel() async {
++_attempt;
try {
await browser.cancel();
} on BrowserAuthFailure catch (error) {
failure = error;
} finally {
_set(_currentStatus());
}
}
Future<void> _exchange(BrowserAuthCode code) async {
final attempt = _attempt;
try {
await account.exchange(
code: code.code,
verifier: code.verifier,
// A cancelled attempt must never save its account.
isCurrent: () => !_disposed && attempt == _attempt,
);
} on AccountFailure catch (error) {
if (attempt != _attempt) return;
failure = error;
_set(_currentStatus());
return;
}
if (_disposed || attempt != _attempt) return;
failure = null;
try {
await browser.clearIntent();
} on BrowserAuthFailure catch (error) {
// The session is saved; a leftover intent must not keep sign-in busy.
failure = error;
}
if (_disposed || attempt != _attempt) return;
_set(OrganizationSessionStatus.signedIn);
await reconnect();
}
void _onBrowserFailure(BrowserAuthFailure error) {
if (status != OrganizationSessionStatus.signingIn) return;
failure = error == BrowserAuthFailure.cancelled ? null : error;
_set(_currentStatus());
}
void _onSubject() {
if (status == OrganizationSessionStatus.signingIn ||
status == OrganizationSessionStatus.checking) {
return;
}
if (account.account != null) failure = null;
_set(_currentStatus());
}
OrganizationSessionStatus _currentStatus() => account.account == null
? OrganizationSessionStatus.signedOut
: OrganizationSessionStatus.signedIn;
void _set(OrganizationSessionStatus next) {
if (_disposed) return;
status = next;
notifyListeners();
}
@override
void dispose() {
_disposed = true;
account.subjectChanges.removeListener(_onSubject);
for (final subscription in _subscriptions) {
unawaited(subscription.cancel());
}
unawaited(browser.dispose());
super.dispose();
}
}

View File

@@ -8,6 +8,19 @@ const mobileTabBarHeight = 56.0;
/// Gap between the floating mobile tab bar and the bottom safe area.
const mobileTabBarBottomGap = Grid.twelve;
/// Distance from the screen bottom to the floating mobile tab bar's bottom.
double mobileTabBarBottom(BuildContext context) {
final inset = MediaQuery.viewPaddingOf(context).bottom;
return inset > mobileTabBarBottomGap ? inset : mobileTabBarBottomGap;
}
/// Distance from the screen bottom to the floating mobile tab bar's top.
///
/// Overlays drawn above Home, such as sign-in banners, sit above this so the
/// bar's controls do not cover them.
double mobileTabBarTop(BuildContext context) =>
mobileTabBarBottom(context) + mobileTabBarHeight;
/// Fixed visual height of the shared footer fade behind the floating tab bar.
double mobileTabFooterBackdropHeight(BuildContext _) => 180;

View File

@@ -18,6 +18,7 @@ import 'package:buzz/features/profile/profile_avatar_draft.dart';
import 'package:buzz/shared/emoji/emoji_avatar.dart';
import 'package:buzz/shared/widgets/playing_avatar_image.dart';
import 'package:buzz/features/pairing/pairing_page.dart';
import 'package:buzz/hosted_app_gate.dart';
import 'package:buzz/onboarding_app_entry.dart';
import 'package:buzz/shared/auth/auth.dart';
import 'package:buzz/shared/hosted/builderlab_account.dart';
@@ -27,6 +28,7 @@ import 'package:buzz/shared/relay/relay_session.dart';
import 'package:buzz/shared/relay/relay_provider.dart';
import 'package:buzz/shared/relay/media_image.dart';
import 'package:buzz/shared/utils/string_utils.dart';
import 'package:buzz/shared/widgets/mobile_tab_footer_backdrop.dart';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
@@ -36,6 +38,16 @@ import 'package:http/http.dart' as http;
import '../../helpers/hosted_journey_fixture.dart';
void main() {
// Bottom banners over Home must stay above its floating tab bar, which the
// fixture's stand-in home does not draw.
void expectAboveTabBar(WidgetTester tester, String label) {
final screen = tester.getSize(find.byType(HostedAppGate)).height;
expect(
tester.getRect(find.text(label)).bottom,
lessThanOrEqualTo(screen - mobileTabBarHeight - mobileTabBarBottomGap),
);
}
void journey(
String name,
Future<void> Function(WidgetTester, HostedJourneyFixture) run, {
@@ -527,6 +539,93 @@ void main() {
requiresBuilderlab: false,
);
journey(
'a refused organization account changes to another without the hosted one',
(tester, f) async {
f.relayAuthEnabled = true;
f.organizationEnabled = true;
await f.initialize();
// Signed in to the organization as an account bound to another key.
f.organizationSubject = 'other-member';
f.organizationBound = f.secondKeys.public;
await f.signInOrganization();
final community = Community.create(
name: 'Block',
relayUrl: 'wss://${HostedJourneyFixture.organizationRelayHost}',
pubkey: f.keys.public,
nsec: f.keys.nsec,
);
await f.storage.save(community);
await f.storage.saveActiveId(community.id);
await f.mount(tester, openEntry: false);
final sessionSubscription = f.container.listen(
relaySessionProvider,
(_, _) {},
);
addTearDown(sessionSubscription.close);
await f.settle(tester);
const refused =
'Your organization account cannot use this community with this key.';
expect(find.text(refused), findsOneWidget);
expectAboveTabBar(tester, 'Use a different account');
expect(f.sent('/identity/assertions'), isEmpty);
// Retry keeps the refused account and fails the same way.
await f.tap(tester, 'Retry');
expect(find.text(refused), findsOneWidget);
expect(f.organizationBrowserStarts, 0);
f.organizationSubject = 'journey-owner';
f.organizationBound = f.keys.public;
final hosted = f.container.read(builderlabAccountProvider);
final hostedCredential = await const FlutterSecureStorage().read(
key:
'buzz_hosted_session_'
'${hosted.accountOrigin.host.replaceAll('.', '_')}_v1',
);
expect(hostedCredential, isNotNull);
final connects = f.socketHeaders.length;
await f.tap(tester, 'Use a different account');
await f.settle(tester);
expect(f.organizationBrowserStarts, 1);
expect(
f.container.read(organizationAccountProvider)!.account!.subject,
'journey-owner',
);
expect(find.text(refused), findsNothing);
expect(find.text('Use a different account'), findsNothing);
// The real relay session reconnects with the new account's assertion.
expect(f.socketHeaders.length, greaterThan(connects));
expect(
f.socketHeaders.last['Nostr-Federated-Identity'],
'Bearer organization-assertion',
);
expect(
f.container.read(relaySessionProvider).status,
SessionStatus.connected,
);
// Only the organization session changed.
expect(
await const FlutterSecureStorage().read(
key:
'buzz_hosted_session_'
'${hosted.accountOrigin.host.replaceAll('.', '_')}_v1',
),
hostedCredential,
);
expect(
f.requests.where(
(r) =>
r.url.origin != HostedJourneyFixture.organizationOrigin.origin &&
r.url.path.endsWith('/identity/assertions'),
),
isEmpty,
);
expect((await f.storage.loadAll()).single.nsec, community.nsec);
},
requiresBuilderlab: false,
);
journey(
'saved community signs in again without changing identity or onboarding',
(tester, f) async {

View File

@@ -1,5 +1,6 @@
import 'package:buzz/shared/hosted/hosted_environment.dart';
import 'package:buzz/features/pairing/pairing_provider.dart';
import 'dart:async';
import 'dart:io';
import 'dart:convert';
import 'package:flutter/services.dart';
@@ -19,6 +20,8 @@ import 'package:buzz/shared/hosted/browser_auth.dart';
import 'package:buzz/shared/hosted/builderlab_account.dart';
import 'package:buzz/shared/hosted/hosted_flow.dart';
import 'package:buzz/shared/hosted/hosted_relay_auth.dart';
import 'package:buzz/shared/hosted/organization_sign_in.dart';
import 'package:buzz/shared/relay/relay_provider.dart';
import 'package:buzz/shared/relay/relay_session.dart';
import 'package:buzz/shared/relay/relay_socket.dart';
import 'package:buzz/shared/hosted/self_hosted_entry.dart';
@@ -67,6 +70,15 @@ class HostedJourneyFixture {
bool denyAssertion = false;
bool refuseUpgrade = false;
String subject = 'journey-owner';
/// A separate organization realm, as BUZZ_ORGANIZATION_URL and
/// BUZZ_ORGANIZATION_RELAYS configure. Enable before [mount].
static final organizationOrigin = Uri.parse('https://block.example.com');
static const organizationRelayHost = 'buzz.block.example.com';
bool organizationEnabled = false;
String organizationSubject = 'journey-owner';
String? organizationBound;
int organizationBrowserStarts = 0;
Future<void> Function()? beforeAssertionReturn;
final socketHeaders = <Map<String, String>>[];
bool offerProfile = true;
@@ -301,6 +313,60 @@ class HostedJourneyFixture {
);
});
http.Client organizationClient() => MockClient((request) async {
requests.add(request);
expect(request.url.origin, organizationOrigin.origin);
final path = request.url.path;
Object result;
if (path.endsWith('/exchange')) {
result = {
'session_credential': 'organization-session',
'expires_at': expiry,
};
} else if (path.endsWith('/me')) {
result = {'subject': organizationSubject, 'expires_at': expiry};
} else if (path.endsWith('/current')) {
result = {
'identity': organizationBound == null
? null
: {'pubkey_hex': organizationBound},
};
} else if (path.endsWith('/identity/assertions')) {
final body = jsonDecode(request.body) as Map;
if (organizationBound != null &&
body['nostr_pubkey'] != organizationBound) {
return http.Response('{}', 403);
}
organizationBound ??= body['nostr_pubkey'] as String;
result = {
'assertion': 'organization-assertion',
'nostr_pubkey': organizationBound,
'expires_at':
assertionNow
.add(const Duration(minutes: 5))
.millisecondsSinceEpoch ~/
1000,
};
} else {
return http.Response('{}', 404);
}
return http.Response(
jsonEncode(result),
200,
headers: {'content-type': 'application/json'},
);
});
/// Stores an organization session as a completed sign-in would.
Future<void> signInOrganization() async {
final account = BuilderlabAccountClient(
accountOrigin: organizationOrigin,
httpClient: organizationClient(),
);
await account.exchange(code: 'synthetic-code', verifier: 'v' * 43);
account.dispose();
}
HostedFlow createFlow(Ref ref) => activeFlow = createHostedFlow(
ref,
browserAuth: _Browser(() async {
@@ -349,10 +415,44 @@ class HostedJourneyFixture {
socketFactory: pairingSocketFactory,
),
),
if (organizationEnabled) ...[
organizationAccountProvider.overrideWith((ref) {
final account = BuilderlabAccountClient(
accountOrigin: organizationOrigin,
httpClient: organizationClient(),
);
ref.onDispose(account.dispose);
return account;
}),
organizationSignInProvider.overrideWith((ref) {
final signIn = OrganizationSignIn(
account: ref.watch(organizationAccountProvider)!,
browser: _Browser(
() async => organizationBrowserStarts++,
origin: organizationOrigin,
),
// As in production: only the organization's relay reconnects.
reconnect: () async {
final config = ref.read(relayConfigProvider);
if (ref
.read(hostedRelayAuthProvider)
.organizationProtects(config)) {
await ref.read(relaySessionProvider.notifier).reconnect();
}
},
);
ref.onDispose(signIn.dispose);
unawaited(signIn.initialize());
return signIn;
}),
],
if (relayAuthEnabled) ...[
hostedRelayAuthProvider.overrideWith((ref) {
final auth = HostedRelayAuth(
ref.watch(builderlabAccountProvider),
organization: ref.watch(organizationAccountProvider),
organizationRelay: (relay) =>
organizationEnabled && relay.host == organizationRelayHost,
enabled: true,
now: () => assertionNow,
);
@@ -564,7 +664,7 @@ class _Recovery implements InviteJoinRecovery {
}
class _Browser extends BrowserAuth {
_Browser(Future<void> Function() onStart)
_Browser(Future<void> Function() onStart, {super.origin})
: super(
openBrowser: (uri) async {
await onStart();
@@ -600,9 +700,14 @@ class _RelaySocket extends RelaySocket {
@override
Future<void> connect() async {
fixture.socketHeaders.add(Map.of(headers));
final host = Uri.parse(url).host;
expect(
headers['Nostr-Federated-Identity'],
HostedEnvironment.requiresAuth(Uri.parse(url).host)
fixture.organizationEnabled &&
host == HostedJourneyFixture.organizationRelayHost
// Signed out connects without one and lets the relay decide.
? anyOf(isNull, 'Bearer organization-assertion')
: HostedEnvironment.requiresAuth(host)
? 'Bearer synthetic-assertion'
: isNull,
);

View File

@@ -186,6 +186,31 @@ void main() {
expect(jsonDecode((await stored())!)['credential'], 'new');
});
test('concurrent restores share one adopted account', () async {
FlutterSecureStorage.setMockInitialValues({
storageKey: record('account-1', 'old'),
});
var verifications = 0;
final client = BuilderlabAccountClient(
httpClient: MockClient((request) async {
verifications++;
return http.Response(
jsonEncode({'subject': 'account-1', 'expires_at': expiry}),
200,
);
}),
);
final first = client.restore();
final second = client.restore();
final adopted = await first;
expect(await second, same(adopted));
expect(client.account, same(adopted));
expect(verifications, 1);
// A later restore still re-verifies, so expiry is detected on retry.
await client.restore();
expect(verifications, 2);
});
test('login for another account keeps the existing session', () async {
FlutterSecureStorage.setMockInitialValues({
storageKey: record('account-1', 'old'),

View File

@@ -0,0 +1,511 @@
import 'dart:async';
import 'dart:convert';
import 'package:buzz/shared/hosted/browser_auth.dart';
import 'package:buzz/shared/hosted/builderlab_account.dart';
import 'package:buzz/shared/hosted/hosted_relay_auth.dart';
import 'package:buzz/shared/hosted/organization_environment.dart';
import 'package:buzz/shared/hosted/organization_sign_in.dart';
import 'package:buzz/shared/relay/relay_provider.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:nostr/nostr.dart' as nostr;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final keys = nostr.Keys(
'0000000000000000000000000000000000000000000000000000000000000001',
);
final organization = Uri.parse('https://block.example.com');
const relayHost = 'buzz.block.example.com';
final relay = RelayConfig(baseUrl: 'https://$relayHost', nsec: keys.nsec);
final expiry = DateTime.now().toUtc().add(const Duration(days: 1));
setUp(() => FlutterSecureStorage.setMockInitialValues({}));
group('OrganizationEnvironment', () {
test('origin and relays are configured together as an HTTPS origin', () {
expect(OrganizationEnvironment.parseOrigin('', ''), isNull);
expect(
OrganizationEnvironment.parseOrigin(
'https://block.example.com/',
relayHost,
),
organization,
);
for (final (url, relays) in [
('https://block.example.com', ''),
('', relayHost),
('http://block.example.com', relayHost),
('https://block.example.com/api/goose', relayHost),
('https://block.example.com:8443', relayHost),
('https://block.example.com?x=1', relayHost),
('https://app.builderlab.xyz', relayHost),
]) {
expect(
() => OrganizationEnvironment.parseOrigin(url, relays),
throwsStateError,
reason: '$url $relays',
);
}
});
test('relays are exact DNS hosts outside hosted communities', () {
expect(
OrganizationEnvironment.parseRelayHosts(' Buzz.Block.Example.com ,a.b'),
[relayHost, 'a.b'],
);
for (final relays in [
'wss://$relayHost',
'*.block.example.com',
'localhost',
'team.communities.buzz.xyz',
]) {
expect(
() => OrganizationEnvironment.parseRelayHosts(relays),
throwsStateError,
reason: relays,
);
}
});
test('matches only the configured host on the default port', () {
bool matches(String url) =>
OrganizationEnvironment.matches(Uri.parse(url), [relayHost]);
expect(matches('wss://$relayHost'), isTrue);
expect(matches('https://$relayHost/'), isTrue);
expect(matches('wss://$relayHost:443'), isTrue);
expect(matches('wss://team.$relayHost'), isFalse);
expect(matches('wss://$relayHost:8443'), isFalse);
expect(matches('wss://$relayHost/path'), isFalse);
expect(matches('ws://$relayHost'), isFalse);
});
});
group('organization relay assertions', () {
late List<http.Request> requests;
late Object? boundIdentity;
BuilderlabAccountClient organizationClient() => BuilderlabAccountClient(
accountOrigin: organization,
httpClient: MockClient((request) async {
requests.add(request);
expect(request.url.origin, organization.origin);
expect(request.headers['X-BB-Session-Credential'], 'org-secret');
return switch (request.url.path) {
'/api/goose/v1/auth/me' => http.Response(
jsonEncode({
'subject': 'member',
'expires_at': expiry.toIso8601String(),
}),
200,
),
'/api/goose/v1/buzz/nostr-identities/current' => http.Response(
jsonEncode({'identity': boundIdentity}),
200,
),
'/api/goose/v1/buzz/identity/assertions' => http.Response(
jsonEncode({
'assertion': 'org-token',
'nostr_pubkey': keys.public,
'expires_at': DateTime.now().millisecondsSinceEpoch ~/ 1000 + 120,
}),
200,
),
_ => http.Response('{}', 404),
};
}),
);
Future<void> saveSession() => const FlutterSecureStorage().write(
key: 'buzz_hosted_session_block_example_com_v1',
value: jsonEncode({
'origin': organization.toString(),
'credential': 'org-secret',
'subject': 'member',
'expires_at': expiry.toIso8601String(),
}),
);
HostedRelayAuth relayAuth(BuilderlabAccountClient organizationAccount) =>
HostedRelayAuth(
BuilderlabAccountClient(
httpClient: MockClient(
(request) async => fail('Hosted account must not be used'),
),
),
organization: organizationAccount,
organizationRelay: (relay) =>
OrganizationEnvironment.matches(relay, [relayHost]),
enabled: true,
);
setUp(() {
requests = [];
boundIdentity = null;
});
test(
'signed-in member gets an assertion for the exact relay and its key',
() async {
await saveSession();
final auth = relayAuth(organizationClient());
expect(auth.protects(relay), isTrue);
expect(auth.organizationProtects(relay), isTrue);
expect(await auth.headers(relay), {
'Nostr-Federated-Identity': 'Bearer org-token',
});
final issue = requests.last;
expect(issue.url.path, '/api/goose/v1/buzz/identity/assertions');
expect(jsonDecode(issue.body), {
'relay_url': 'wss://$relayHost',
'nostr_pubkey': keys.public,
});
expect(issue.headers['Authorization'], startsWith('Nostr '));
// Cached until near expiry.
await auth.headers(relay);
expect(
requests.where((r) => r.url.path.endsWith('/assertions')),
hasLength(1),
);
},
);
test('a key bound to the same member is accepted', () async {
await saveSession();
boundIdentity = {'pubkey_hex': keys.public};
final auth = relayAuth(organizationClient());
expect(await auth.headers(relay), contains('Nostr-Federated-Identity'));
});
test('an issued assertion serves cached reads for the relay', () async {
await saveSession();
final auth = relayAuth(organizationClient());
expect(auth.cachedHeaders(relay), isNull);
await auth.headers(relay);
expect(auth.cachedHeaders(relay), {
'Nostr-Federated-Identity': 'Bearer org-token',
});
});
test('a different bound key is refused before issuance', () async {
await saveSession();
boundIdentity = {'pubkey_hex': 'f' * 64};
final auth = relayAuth(organizationClient());
await expectLater(auth.headers(relay), throwsA(AccountFailure.denied));
expect(auth.failure, AccountFailure.denied);
expect(
requests.where((r) => r.url.path.endsWith('/assertions')),
isEmpty,
);
});
test('signed out connects without an assertion and records it', () async {
final auth = relayAuth(organizationClient());
expect(await auth.headers(relay), isEmpty);
expect(auth.failure, AccountFailure.signedOut);
expect(requests, isEmpty);
});
test('other relays never receive the organization assertion', () async {
await saveSession();
final auth = relayAuth(organizationClient());
for (final other in [
'https://team.$relayHost',
'https://other.example.com',
]) {
final config = RelayConfig(baseUrl: other, nsec: keys.nsec);
expect(auth.protects(config), isFalse);
expect(await auth.headers(config), isEmpty);
}
expect(requests, isEmpty);
});
});
test('each origin owner consumes only its own shared callback', () async {
late Uri hostedLogin;
late Uri organizationLogin;
final hosted = BrowserAuth(
openBrowser: (uri) async {
hostedLogin = uri;
return null;
},
);
final org = BrowserAuth(
origin: organization,
openBrowser: (uri) async {
organizationLogin = uri;
return null;
},
);
final hostedCodes = <BrowserAuthCode>[];
final orgCodes = <BrowserAuthCode>[];
final hostedFailures = <BrowserAuthFailure>[];
final orgFailures = <BrowserAuthFailure>[];
hosted.codes.listen(hostedCodes.add);
hosted.failures.listen(hostedFailures.add);
org.codes.listen(orgCodes.add);
org.failures.listen(orgFailures.add);
await org.start({'purpose': 'organization'});
expect(organizationLogin.origin, organization.origin);
expect(organizationLogin.path, '/api/goose/v1/auth/login');
expect(organizationLogin.queryParameters['returnTo'], BrowserAuth.callback);
final callback = Uri.parse(BrowserAuth.callback).replace(
queryParameters: {
'state': organizationLogin.queryParameters['state']!,
'code': 'org-code',
},
);
// Both owners see every app link; the hosted owner has no attempt.
await Future.wait([hosted.receive(callback), org.receive(callback)]);
await Future<void>.delayed(Duration.zero);
expect(orgCodes.single.code, 'org-code');
expect(hostedCodes, isEmpty);
expect(hostedFailures, isEmpty);
expect(orgFailures, isEmpty);
// A pending hosted login is untouched by the organization callback.
await hosted.start({'relay': 'wss://example.communities.buzz.xyz'});
await org.start({'purpose': 'organization'});
final second = Uri.parse(BrowserAuth.callback).replace(
queryParameters: {
'state': organizationLogin.queryParameters['state']!,
'code': 'org-code-2',
},
);
await hosted.receive(second);
await org.receive(second);
await Future<void>.delayed(Duration.zero);
expect(hostedFailures, isEmpty);
expect(orgCodes.last.code, 'org-code-2');
await hosted.receive(
Uri.parse(BrowserAuth.callback).replace(
queryParameters: {
'state': hostedLogin.queryParameters['state']!,
'code': 'hosted-code',
},
),
);
await Future<void>.delayed(Duration.zero);
expect(hostedCodes.single.code, 'hosted-code');
await hosted.dispose();
await org.dispose();
});
test(
'an expired abandoned attempt ignores another owner\'s callback',
() async {
var now = DateTime.utc(2026, 10, 29);
final hosted = BrowserAuth(
openBrowser: (_) async => null,
now: () => now,
);
late Uri organizationLogin;
final org = BrowserAuth(
origin: organization,
now: () => now,
openBrowser: (uri) async {
organizationLogin = uri;
return null;
},
);
final hostedFailures = <BrowserAuthFailure>[];
final orgCodes = <BrowserAuthCode>[];
hosted.failures.listen(hostedFailures.add);
org.codes.listen(orgCodes.add);
await hosted.start({'relay': 'wss://example.communities.buzz.xyz'});
await hosted.dispose();
// After a restart the abandoned hosted attempt is still saved, and stale.
final restarted = BrowserAuth(now: () => now);
restarted.failures.listen(hostedFailures.add);
now = now.add(const Duration(minutes: 30));
await org.start({'purpose': 'organization'});
final callback = Uri.parse(BrowserAuth.callback).replace(
queryParameters: {
'state': organizationLogin.queryParameters['state']!,
'code': 'org-code',
},
);
await restarted.receive(callback);
await org.receive(callback);
await Future<void>.delayed(Duration.zero);
expect(hostedFailures, isEmpty);
expect(orgCodes.single.code, 'org-code');
await restarted.dispose();
await org.dispose();
},
);
test(
'a restored owner consuming its callback is never failed by a peer',
() async {
late Uri organizationLogin;
final first = BrowserAuth(
origin: organization,
openBrowser: (uri) async {
organizationLogin = uri;
return null;
},
);
await first.start({'purpose': 'organization'});
await first.dispose();
// After a restart neither owner has the attempt in memory.
final gated = _PausedReadStorage();
final org = BrowserAuth(origin: organization, storage: gated);
final hosted = BrowserAuth(openBrowser: (_) async => null);
final hostedFailures = <BrowserAuthFailure>[];
final orgCodes = <BrowserAuthCode>[];
hosted.failures.listen(hostedFailures.add);
org.codes.listen(orgCodes.add);
final callback = Uri.parse(BrowserAuth.callback).replace(
queryParameters: {
'state': organizationLogin.queryParameters['state']!,
'code': 'org-code',
},
);
// The hosted owner's ownership check reads the peer attempt, which
// the organization owner consumes before that read returns.
final hostedReceive = hosted.receive(callback);
await gated.paused.future;
await org.receive(callback);
gated.resume.complete();
await hostedReceive;
await pumpEventQueue();
expect(orgCodes.single.code, 'org-code');
expect(hostedFailures, isEmpty);
await hosted.dispose();
await org.dispose();
},
);
test('completed sign-in stores the session and reconnects once', () async {
late Uri login;
var reconnects = 0;
final account = BuilderlabAccountClient(
accountOrigin: organization,
httpClient: MockClient((request) async {
expect(request.url.origin, organization.origin);
if (request.url.path.endsWith('/v1/auth/login/exchange')) {
return http.Response(
jsonEncode({
'session_credential': 'org-secret',
'expires_at': expiry.toIso8601String(),
}),
200,
);
}
if (request.url.path.endsWith('/v1/auth/me')) {
return http.Response(
jsonEncode({
'subject': 'member',
'expires_at': expiry.toIso8601String(),
}),
200,
);
}
return http.Response('{}', 404);
}),
);
final signIn = OrganizationSignIn(
account: account,
browser: BrowserAuth(
origin: organization,
openBrowser: (uri) async {
login = uri;
return null;
},
),
reconnect: () async => reconnects++,
);
await signIn.initialize();
expect(signIn.status, OrganizationSessionStatus.signedOut);
await signIn.signIn();
expect(signIn.status, OrganizationSessionStatus.signingIn);
await signIn.browser.receive(
Uri.parse(BrowserAuth.callback).replace(
queryParameters: {
'state': login.queryParameters['state']!,
'code': 'org-code',
},
),
);
await pumpEventQueue();
expect(signIn.status, OrganizationSessionStatus.signedIn);
expect(signIn.failure, isNull);
expect(reconnects, 1);
expect(account.account?.subject, 'member');
signIn.dispose();
});
test(
'a restore after an offline start shows the session signed in',
() async {
await const FlutterSecureStorage().write(
key: 'buzz_hosted_session_block_example_com_v1',
value: jsonEncode({
'origin': organization.toString(),
'credential': 'org-secret',
'subject': 'member',
'expires_at': expiry.toIso8601String(),
}),
);
var online = false;
final account = BuilderlabAccountClient(
accountOrigin: organization,
httpClient: MockClient((request) async {
if (!online) return http.Response('{}', 503);
expect(request.url.path, '/api/goose/v1/auth/me');
return http.Response(
jsonEncode({
'subject': 'member',
'expires_at': expiry.toIso8601String(),
}),
200,
);
}),
);
final signIn = OrganizationSignIn(
account: account,
browser: BrowserAuth(
origin: organization,
openBrowser: (uri) async => null,
),
reconnect: () async {},
);
await signIn.initialize();
expect(signIn.status, OrganizationSessionStatus.signedOut);
// A relay connection retry restores the session once back online.
online = true;
await account.restore();
expect(signIn.status, OrganizationSessionStatus.signedIn);
signIn.dispose();
},
);
}
/// Pauses the first read, then serves reads after the peer's changes.
class _PausedReadStorage extends FlutterSecureStorage {
final paused = Completer<void>();
final resume = Completer<void>();
@override
Future<String?> read({
required String key,
AppleOptions? iOptions,
AndroidOptions? aOptions,
LinuxOptions? lOptions,
WebOptions? webOptions,
AppleOptions? mOptions,
WindowsOptions? wOptions,
}) async {
if (!paused.isCompleted) {
paused.complete();
await resume.future;
}
return super.read(key: key);
}
}

View File

@@ -0,0 +1,284 @@
import 'dart:async';
import 'dart:convert';
import 'package:buzz/shared/hosted/browser_auth.dart';
import 'package:buzz/shared/hosted/builderlab_account.dart';
import 'package:buzz/shared/hosted/organization_sign_in.dart';
import 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final organization = Uri.parse('https://block.example.com');
const orgSessionKey = 'buzz_hosted_session_block_example_com_v1';
final expiry = DateTime.now().toUtc().add(const Duration(days: 1));
setUp(() => FlutterSecureStorage.setMockInitialValues({}));
http.Response me() => http.Response(
jsonEncode({'subject': 'member', 'expires_at': expiry.toIso8601String()}),
200,
);
http.Response exchanged() => http.Response(
jsonEncode({
'session_credential': 'org-secret',
'expires_at': expiry.toIso8601String(),
}),
200,
);
Uri callback(Uri login, String code) =>
Uri.parse(BrowserAuth.callback).replace(
queryParameters: {
'state': login.queryParameters['state']!,
'code': code,
},
);
test('a cancelled exchange never changes a newer sign-in', () async {
final logins = <Uri>[];
final held = [Completer<http.Response>(), Completer<http.Response>()];
var exchanges = 0;
http.Response exchangedAs(String credential) => http.Response(
jsonEncode({
'session_credential': credential,
'expires_at': expiry.toIso8601String(),
}),
200,
);
final signIn = OrganizationSignIn(
account: BuilderlabAccountClient(
accountOrigin: organization,
httpClient: MockClient((request) async {
if (request.url.path.endsWith('/v1/auth/login/exchange')) {
final index = exchanges++;
return index < held.length
? held[index].future
: exchangedAs('org-member-b');
}
final credential = request.headers['X-BB-Session-Credential']!;
return http.Response(
jsonEncode({
'subject': credential.substring('org-'.length),
'expires_at': expiry.toIso8601String(),
}),
200,
);
}),
),
browser: BrowserAuth(
origin: organization,
openBrowser: (uri) async {
logins.add(uri);
return null;
},
),
reconnect: () async {},
);
await signIn.initialize();
await signIn.signIn();
await signIn.browser.receive(callback(logins.last, 'code-1'));
await pumpEventQueue();
expect(exchanges, 1);
// A cancelled exchange that fails late leaves the newer attempt alone.
await signIn.cancel();
await signIn.signIn();
expect(signIn.status, OrganizationSessionStatus.signingIn);
held[0].complete(http.Response('{}', 503));
await pumpEventQueue();
expect(signIn.status, OrganizationSessionStatus.signingIn);
expect(signIn.failure, isNull);
// The newer attempt still owns its browser and can be cancelled.
await signIn.cancel();
expect(signIn.status, OrganizationSessionStatus.signedOut);
await signIn.signIn();
// A cancelled exchange that succeeds late is never saved, so the newer
// sign-in as a different member still succeeds.
await signIn.browser.receive(callback(logins.last, 'code-2'));
await pumpEventQueue();
expect(exchanges, 2);
await signIn.cancel();
await signIn.signIn();
await signIn.browser.receive(callback(logins.last, 'code-3'));
held[1].complete(exchangedAs('org-member-a'));
await pumpEventQueue();
expect(signIn.failure, isNull);
expect(signIn.status, OrganizationSessionStatus.signedIn);
expect(signIn.account.account?.subject, 'member-b');
expect(
jsonDecode((await const FlutterSecureStorage().read(key: orgSessionKey))!)
as Map,
containsPair('subject', 'member-b'),
);
signIn.dispose();
});
test('a second tap while the session clears starts no attempt', () async {
await const FlutterSecureStorage().write(
key: orgSessionKey,
value: 'not json',
);
final logins = <Uri>[];
final browserStorage = _CountingStorage();
final signIn = OrganizationSignIn(
account: BuilderlabAccountClient(
accountOrigin: organization,
httpClient: MockClient((request) async => me()),
),
browser: BrowserAuth(
origin: organization,
storage: browserStorage,
openBrowser: (uri) async {
logins.add(uri);
return null;
},
),
reconnect: () async {},
);
await signIn.initialize();
expect(signIn.failure, AccountFailure.storage);
await Future.wait([signIn.signIn(), signIn.signIn()]);
expect(signIn.status, OrganizationSessionStatus.signingIn);
expect(browserStorage.attempts, 1);
signIn.dispose();
});
test('a failed intent cleanup still leaves sign-in signed in', () async {
late Uri login;
var reconnects = 0;
final storage = _FailingDeleteStorage('buzz_browser_auth_intent_v1_');
final signIn = OrganizationSignIn(
account: BuilderlabAccountClient(
accountOrigin: organization,
httpClient: MockClient(
(request) async =>
request.url.path.endsWith('/exchange') ? exchanged() : me(),
),
),
browser: BrowserAuth(
origin: organization,
storage: storage,
openBrowser: (uri) async {
login = uri;
return null;
},
),
reconnect: () async => reconnects++,
);
await signIn.initialize();
await signIn.signIn();
await signIn.browser.receive(callback(login, 'org-code'));
await pumpEventQueue();
expect(signIn.status, OrganizationSessionStatus.signedIn);
expect(signIn.failure, BrowserAuthFailure.storage);
expect(reconnects, 1);
signIn.dispose();
});
test('a failed cancel cleanup still leaves the busy state', () async {
final storage = _FailingDeleteStorage('buzz_browser_auth_attempt_v1_');
final signIn = OrganizationSignIn(
account: BuilderlabAccountClient(
accountOrigin: organization,
httpClient: MockClient((request) async => me()),
),
browser: BrowserAuth(
origin: organization,
storage: storage,
openBrowser: (uri) async => null,
),
reconnect: () async {},
);
await signIn.initialize();
await signIn.signIn();
expect(signIn.status, OrganizationSessionStatus.signingIn);
await signIn.cancel();
expect(signIn.status, OrganizationSessionStatus.signedOut);
expect(signIn.failure, BrowserAuthFailure.storage);
signIn.dispose();
});
test('an unreadable saved session is replaced by signing in', () async {
await const FlutterSecureStorage().write(
key: orgSessionKey,
value: 'not json',
);
late Uri login;
final account = BuilderlabAccountClient(
accountOrigin: organization,
httpClient: MockClient(
(request) async =>
request.url.path.endsWith('/exchange') ? exchanged() : me(),
),
);
final signIn = OrganizationSignIn(
account: account,
browser: BrowserAuth(
origin: organization,
openBrowser: (uri) async {
login = uri;
return null;
},
),
reconnect: () async {},
);
await signIn.initialize();
expect(signIn.status, OrganizationSessionStatus.signedOut);
expect(signIn.failure, AccountFailure.storage);
await signIn.signIn();
await signIn.browser.receive(callback(login, 'org-code'));
await pumpEventQueue();
expect(signIn.status, OrganizationSessionStatus.signedIn);
expect(signIn.failure, isNull);
expect(account.account?.subject, 'member');
signIn.dispose();
});
}
/// Fails deletions of keys with [prefix], as an unavailable Keychain would.
class _FailingDeleteStorage extends FlutterSecureStorage {
_FailingDeleteStorage(this.prefix);
final String prefix;
@override
Future<void> delete({
required String key,
AppleOptions? iOptions,
AndroidOptions? aOptions,
LinuxOptions? lOptions,
WebOptions? webOptions,
AppleOptions? mOptions,
WindowsOptions? wOptions,
}) {
if (key.startsWith(prefix)) {
throw PlatformException(code: 'keychain', message: 'unavailable');
}
return super.delete(key: key);
}
}
/// Counts browser sign-in attempts as they are saved.
class _CountingStorage extends FlutterSecureStorage {
int attempts = 0;
@override
Future<void> write({
required String key,
required String? value,
AppleOptions? iOptions,
AndroidOptions? aOptions,
LinuxOptions? lOptions,
WebOptions? webOptions,
AppleOptions? mOptions,
WindowsOptions? wOptions,
}) {
if (key.startsWith('buzz_browser_auth_attempt_v1_')) attempts++;
return super.write(key: key, value: value);
}
}

View File

@@ -0,0 +1,115 @@
import 'dart:async';
import 'dart:convert';
import 'package:buzz/shared/hosted/builderlab_account.dart';
import 'package:buzz/shared/hosted/hosted_environment.dart';
import 'package:buzz/shared/hosted/hosted_relay_auth.dart';
import 'package:buzz/shared/hosted/organization_environment.dart';
import 'package:buzz/shared/relay/relay_provider.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:nostr/nostr.dart' as nostr;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final keys = nostr.Keys(
'0000000000000000000000000000000000000000000000000000000000000001',
);
final organization = Uri.parse('https://block.example.com');
const relayHost = 'buzz.block.example.com';
const orgSessionKey = 'buzz_hosted_session_block_example_com_v1';
final expiry = DateTime.now().toUtc().add(const Duration(days: 1));
setUp(() => FlutterSecureStorage.setMockInitialValues({}));
http.Response me() => http.Response(
jsonEncode({'subject': 'member', 'expires_at': expiry.toIso8601String()}),
200,
);
test(
'a stalled organization issuer never delays a hosted relay',
() async {
final hostedRelay = RelayConfig(
baseUrl: 'https://team.communities.buzz.xyz',
nsec: keys.nsec,
);
final orgRelay = RelayConfig(
baseUrl: 'https://$relayHost',
nsec: keys.nsec,
);
for (final (origin, key) in [
(organization, orgSessionKey),
(
BuilderlabAccountClient.origin,
'buzz_hosted_session_app_builderlab_xyz_v1',
),
]) {
await const FlutterSecureStorage().write(
key: key,
value: jsonEncode({
'origin': origin.toString(),
'credential': 'secret',
'subject': 'member',
'expires_at': expiry.toIso8601String(),
}),
);
}
final stalled = Completer<http.Response>();
BuilderlabAccountClient client({required bool stall}) =>
BuilderlabAccountClient(
accountOrigin: stall ? organization : null,
httpClient: MockClient((request) async {
return switch (request.url.path) {
'/api/goose/v1/auth/me' => me(),
'/api/goose/v1/buzz/nostr-identities/current' => http.Response(
jsonEncode({
'identity': {'pubkey_hex': keys.public},
}),
200,
),
'/api/goose/v1/buzz/identity/assertions' =>
stall
? stalled.future
: http.Response(
jsonEncode({
'assertion': 'hosted-token',
'nostr_pubkey': keys.public,
'expires_at':
DateTime.now().millisecondsSinceEpoch ~/ 1000 +
120,
}),
200,
),
_ => http.Response('{}', 404),
};
}),
);
final auth = HostedRelayAuth(
client(stall: false),
organization: client(stall: true),
organizationRelay: (relay) =>
OrganizationEnvironment.matches(relay, [relayHost]),
enabled: true,
);
final orgHeaders = auth.headers(orgRelay);
await pumpEventQueue();
expect(
await auth.headers(hostedRelay).timeout(const Duration(seconds: 2)),
{'Nostr-Federated-Identity': 'Bearer hosted-token'},
);
// The hosted relay then refuses this key.
auth.rejected(hostedRelay, 'Bearer hosted-token', terminal: true);
stalled.complete(http.Response('{}', 503));
await expectLater(orgHeaders, throwsA(isA<AccountFailure>()));
// The stale organization failure never hides the hosted recovery.
expect(auth.failedRelay, hostedRelay.baseUrl);
expect(auth.failure, AccountFailure.denied);
auth.dispose();
},
// Hosted relays are protected only in builds with authentication domains.
skip: !HostedEnvironment.enabled,
);
}