From db4478e8d2d8d0fd6582db7ae152e41d3e67e447 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Sun, 1 Nov 2026 07:23:12 -0800 Subject: [PATCH] feat(mobile): organization sign-in for NIP-FI relays (#8700) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Tom Brow Co-authored-by: Claude Opus 5.5 --- .github/workflows/_ci-clients.yml | 1 + mobile/ios/Runner/BuzzBrowserAuthBridge.swift | 20 +- .../RunnerTests/BrowserAuthBridgeTests.swift | 28 +- mobile/lib/features/home/home_page.dart | 5 +- .../onboarding/hosted_reconnect_banner.dart | 2 + .../organization_sign_in_banner.dart | 105 ++++ mobile/lib/hosted_app_gate.dart | 83 ++- mobile/lib/shared/hosted/browser_auth.dart | 86 ++- .../lib/shared/hosted/builderlab_account.dart | 39 +- .../lib/shared/hosted/hosted_relay_auth.dart | 139 ++++- .../hosted/organization_environment.dart | 90 +++ .../shared/hosted/organization_sign_in.dart | 194 +++++++ .../widgets/mobile_tab_footer_backdrop.dart | 13 + .../onboarding/hosted_journeys_test.dart | 99 ++++ .../test/helpers/hosted_journey_fixture.dart | 109 +++- .../hosted/builderlab_account_test.dart | 25 + .../hosted/organization_relay_auth_test.dart | 511 ++++++++++++++++++ .../hosted/organization_sign_in_test.dart | 284 ++++++++++ .../hosted/relay_auth_isolation_test.dart | 115 ++++ 19 files changed, 1852 insertions(+), 96 deletions(-) create mode 100644 mobile/lib/features/onboarding/organization_sign_in_banner.dart create mode 100644 mobile/lib/shared/hosted/organization_environment.dart create mode 100644 mobile/lib/shared/hosted/organization_sign_in.dart create mode 100644 mobile/test/shared/hosted/organization_relay_auth_test.dart create mode 100644 mobile/test/shared/hosted/organization_sign_in_test.dart create mode 100644 mobile/test/shared/hosted/relay_auth_isolation_test.dart diff --git a/.github/workflows/_ci-clients.yml b/.github/workflows/_ci-clients.yml index bbf352c1a8..ba683a8a62 100644 --- a/.github/workflows/_ci-clients.yml +++ b/.github/workflows/_ci-clients.yml @@ -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 diff --git a/mobile/ios/Runner/BuzzBrowserAuthBridge.swift b/mobile/ios/Runner/BuzzBrowserAuthBridge.swift index 40c85a4c79..f6a55fe2ea 100644 --- a/mobile/ios/Runner/BuzzBrowserAuthBridge.swift +++ b/mobile/ios/Runner/BuzzBrowserAuthBridge.swift @@ -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) { diff --git a/mobile/ios/RunnerTests/BrowserAuthBridgeTests.swift b/mobile/ios/RunnerTests/BrowserAuthBridgeTests.swift index cce8b3b36b..09cbfce7db 100644 --- a/mobile/ios/RunnerTests/BrowserAuthBridgeTests.swift +++ b/mobile/ios/RunnerTests/BrowserAuthBridgeTests.swift @@ -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 { diff --git a/mobile/lib/features/home/home_page.dart b/mobile/lib/features/home/home_page.dart index d5e79fc4ed..d98efc3781 100644 --- a/mobile/lib/features/home/home_page.dart +++ b/mobile/lib/features/home/home_page.dart @@ -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. diff --git a/mobile/lib/features/onboarding/hosted_reconnect_banner.dart b/mobile/lib/features/onboarding/hosted_reconnect_banner.dart index 05506d411f..a1226d4ad9 100644 --- a/mobile/lib/features/onboarding/hosted_reconnect_banner.dart +++ b/mobile/lib/features/onboarding/hosted_reconnect_banner.dart @@ -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; diff --git a/mobile/lib/features/onboarding/organization_sign_in_banner.dart b/mobile/lib/features/onboarding/organization_sign_in_banner.dart new file mode 100644 index 0000000000..b8b3a2fb82 --- /dev/null +++ b/mobile/lib/features/onboarding/organization_sign_in_banner.dart @@ -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 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], + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/hosted_app_gate.dart b/mobile/lib/hosted_app_gate.dart index ac13b78d68..25f750d453 100644 --- a/mobile/lib/hosted_app_gate.dart +++ b/mobile/lib/hosted_app_gate.dart @@ -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, diff --git a/mobile/lib/shared/hosted/browser_auth.dart b/mobile/lib/shared/hosted/browser_auth.dart index ba493f03e9..d437a6bd44 100644 --- a/mobile/lib/shared/hosted/browser_auth.dart +++ b/mobile/lib/shared/hosted/browser_auth.dart @@ -23,23 +23,33 @@ class BrowserAuthCode { final Map 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 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 = {}; + + /// 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 Function(Uri) _openBrowser; @@ -55,6 +65,7 @@ class BrowserAuth { int _generation = 0; String? _activeState; String? _consumedCallbackHash; + String? _consumedState; Future _serial(Future 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; @@ -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.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 clearIntent() => _serial(() => _storage.delete(key: _intentKey)); + Future clearIntent() => _delete(_intentKey); + + Future _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 _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)['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 dispose() async { _disposed = true; + _owners.remove(this); ++_generation; await _links?.cancel(); await _codes.close(); diff --git a/mobile/lib/shared/hosted/builderlab_account.dart b/mobile/lib/shared/hosted/builderlab_account.dart index 7b0ff11749..e7f8a7851c 100644 --- a/mobile/lib/shared/hosted/builderlab_account.dart +++ b/mobile/lib/shared/hosted/builderlab_account.dart @@ -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 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 restore() => + _restoring ??= _restore().whenComplete(() => _restoring = null); + Future? _restoring; + Future _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 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 && - 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 _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; diff --git a/mobile/lib/shared/hosted/hosted_relay_auth.dart b/mobile/lib/shared/hosted/hosted_relay_auth.dart index 851e4d7d06..5507a75683 100644 --- a/mobile/lib/shared/hosted/hosted_relay_auth.dart +++ b/mobile/lib/shared/hosted/hosted_relay_auth.dart @@ -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((ref) { final account = BuilderlabAccountClient(); @@ -15,8 +16,21 @@ final builderlabAccountProvider = Provider((ref) { return account; }); +/// The organization's own account session, separate from the hosted account. +/// Null unless this build configures [OrganizationEnvironment]. +final organizationAccountProvider = Provider((ref) { + final origin = OrganizationEnvironment.origin; + if (origin == null) return null; + final account = BuilderlabAccountClient(accountOrigin: origin); + ref.onDispose(account.dispose); + return account; +}); + final hostedRelayAuthProvider = Provider((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((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? _pending; + // Per account client, so a stalled issuer never delays an unrelated one. + final _pending = >{}; 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> 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> _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 _issue(RelayConfig config, String relay, String pubkey) async { + Future _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; diff --git a/mobile/lib/shared/hosted/organization_environment.dart b/mobile/lib/shared/hosted/organization_environment.dart new file mode 100644 index 0000000000..59113293b0 --- /dev/null +++ b/mobile/lib/shared/hosted/organization_environment.dart @@ -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 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 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 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; + }), + ); +} diff --git a/mobile/lib/shared/hosted/organization_sign_in.dart b/mobile/lib/shared/hosted/organization_sign_in.dart new file mode 100644 index 0000000000..a12ce5ca60 --- /dev/null +++ b/mobile/lib/shared/hosted/organization_sign_in.dart @@ -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((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 Function() reconnect; + + OrganizationSessionStatus status = OrganizationSessionStatus.checking; + + /// The last sign-in or session-check failure, cleared by the next attempt. + Object? failure; + + final _subscriptions = >[]; + + /// 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 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 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 changeAccount() => _begin(clear: true); + + Future _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 cancel() async { + ++_attempt; + try { + await browser.cancel(); + } on BrowserAuthFailure catch (error) { + failure = error; + } finally { + _set(_currentStatus()); + } + } + + Future _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(); + } +} diff --git a/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart b/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart index 36e0ce573e..615e4c8143 100644 --- a/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart +++ b/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart @@ -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; diff --git a/mobile/test/features/onboarding/hosted_journeys_test.dart b/mobile/test/features/onboarding/hosted_journeys_test.dart index 0fc4a1f6ac..0318db733d 100644 --- a/mobile/test/features/onboarding/hosted_journeys_test.dart +++ b/mobile/test/features/onboarding/hosted_journeys_test.dart @@ -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 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 { diff --git a/mobile/test/helpers/hosted_journey_fixture.dart b/mobile/test/helpers/hosted_journey_fixture.dart index 0fa4709430..e0dd1994f7 100644 --- a/mobile/test/helpers/hosted_journey_fixture.dart +++ b/mobile/test/helpers/hosted_journey_fixture.dart @@ -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 Function()? beforeAssertionReturn; final socketHeaders = >[]; 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 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 Function() onStart) + _Browser(Future Function() onStart, {super.origin}) : super( openBrowser: (uri) async { await onStart(); @@ -600,9 +700,14 @@ class _RelaySocket extends RelaySocket { @override Future 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, ); diff --git a/mobile/test/shared/hosted/builderlab_account_test.dart b/mobile/test/shared/hosted/builderlab_account_test.dart index de557706b7..0f66f75a51 100644 --- a/mobile/test/shared/hosted/builderlab_account_test.dart +++ b/mobile/test/shared/hosted/builderlab_account_test.dart @@ -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'), diff --git a/mobile/test/shared/hosted/organization_relay_auth_test.dart b/mobile/test/shared/hosted/organization_relay_auth_test.dart new file mode 100644 index 0000000000..3515256885 --- /dev/null +++ b/mobile/test/shared/hosted/organization_relay_auth_test.dart @@ -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 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 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 = []; + final orgCodes = []; + final hostedFailures = []; + final orgFailures = []; + 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.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.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.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 = []; + final orgCodes = []; + 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.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 = []; + final orgCodes = []; + 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(); + final resume = Completer(); + + @override + Future 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); + } +} diff --git a/mobile/test/shared/hosted/organization_sign_in_test.dart b/mobile/test/shared/hosted/organization_sign_in_test.dart new file mode 100644 index 0000000000..3b1c808fb5 --- /dev/null +++ b/mobile/test/shared/hosted/organization_sign_in_test.dart @@ -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 = []; + final held = [Completer(), Completer()]; + 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 = []; + 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 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 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); + } +} diff --git a/mobile/test/shared/hosted/relay_auth_isolation_test.dart b/mobile/test/shared/hosted/relay_auth_isolation_test.dart new file mode 100644 index 0000000000..4a68b35704 --- /dev/null +++ b/mobile/test/shared/hosted/relay_auth_isolation_test.dart @@ -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(); + 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())); + // 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, + ); +}