diff --git a/.github/workflows/_ci-clients.yml b/.github/workflows/_ci-clients.yml index 16fd64a403..442e66034b 100644 --- a/.github/workflows/_ci-clients.yml +++ b/.github/workflows/_ci-clients.yml @@ -137,17 +137,27 @@ jobs: - name: Test native secp256k1 package if: matrix.lane == 'checks' run: cd mobile/packages/buzz_secp256k1 && dart pub get && dart test - - name: Test configured authentication domains + - name: Test internal build if: matrix.lane == 'checks' run: >- cd mobile && flutter test --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example - --dart-define=BUZZ_AUTH_DOMAINS=buzz.xyz,builderlab.xyz,builderlabstaging.xyz + --dart-define=BUZZ_DISTRIBUTION=internal + --dart-define=BUZZ_BUILDERLAB_URL=https://id.example.org + --dart-define=BUZZ_BUILDERLAB_RELAYS=buzz.example.org + test/features/onboarding/internal_build_entry_test.dart + - name: Test public build + if: matrix.lane == 'checks' + run: >- + cd mobile && flutter test + --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example + --dart-define=BUZZ_DISTRIBUTION=public + --dart-define=BUZZ_BUILDERLAB_URL=https://app.builderlab.xyz + --dart-define=BUZZ_BUILDERLAB_RELAYS=communities.buzz.xyz test/shared/auth/community_entry_requirements_test.dart test/features/agents/agent_creation_link_journey_test.dart test/features/invites/invite_join_provider_test.dart test/features/onboarding/onboarding_entry_routes_test.dart - test/shared/hosted/hosted_environment_test.dart test/shared/hosted/self_hosted_entry_test.dart test/features/agents/agents_signed_out_prompt_test.dart test/features/agents/agents_restore_prompt_test.dart diff --git a/CONTEXT.md b/CONTEXT.md index c9f74be0df..102b959dc3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,6 +1,6 @@ # Buzz domain language -Shared terminology for Buzz product behavior. This glossary currently covers mobile [push suppression](#push-suppression); it is not a complete model of Buzz. +Shared terminology for Buzz product behavior. This glossary currently covers mobile [push suppression](#push-suppression) and [mobile builds](#mobile-builds); it is not a complete model of Buzz. ## Notifications @@ -51,3 +51,17 @@ A [suppression activity signal](#suppression-activity-signal) that extends how l ### Inactivity cutoff The time of the last observed [desktop activity](#desktop-activity) plus the duration specified in the spec, when [desktop inactivity](#desktop-inactivity) begins. New desktop activity moves this cutoff forward. + +## Mobile builds + +### Internal build + +A mobile app an organization distributes only to its own members, for example through device management. It uses only that organization's Builderlab and reaches only that organization's community. + +_Avoid_: Dogfood build, enterprise build, org build + +### Public build + +The mobile app distributed through the App Store and TestFlight. It uses only the public Builderlab and cannot reach any organization's community. + +_Avoid_: App Store build, external build diff --git a/docs/adr/0001-internal-and-public-mobile-builds.md b/docs/adr/0001-internal-and-public-mobile-builds.md new file mode 100644 index 0000000000..bbf71f0f08 --- /dev/null +++ b/docs/adr/0001-internal-and-public-mobile-builds.md @@ -0,0 +1,18 @@ +# Internal and public mobile builds + +The mobile app is built either as a [public build](../../CONTEXT.md#public-build) or as an [internal build](../../CONTEXT.md#internal-build), chosen by an explicit build-time setting, `BUZZ_DISTRIBUTION=internal|public`. Each build trusts exactly one Builderlab, named by `BUZZ_BUILDERLAB_URL`, and sends that Builderlab's relay assertions only to the relays listed in `BUZZ_BUILDERLAB_RELAYS`. With none of these set, the app is a Nostr-only development build. The former `BUZZ_AUTH_DOMAINS`, `BUZZ_ORGANIZATION_URL` and `BUZZ_ORGANIZATION_RELAYS` settings stop the app at startup with a migration error rather than silently produce a Nostr-only build. A public build keeps QR pairing and multiple communities. An internal build takes exactly one relay host and reaches only its organization's community: its first screen offers a single "Sign in" button that goes to the organization's single sign-on, with no QR pairing and no community switching, and signing out removes that community from the device but keeps its key there. Communities that a previous build saved on other relays stay stored but hidden. + +We chose this so that an organization's members cannot reach public communities from the organization's app, and the public app cannot reach an organization's community, while the app keeps a single Builderlab session. The public build needs no knowledge of any organization: an organization's relay already rejects clients that cannot present its Builderlab's assertion, so the public build relies on that rejection instead of a list of organization hosts. + +## Considered options + +- **One build that supports both Builderlabs at once.** This was the previous design. It needed separate account and organization settings and two concurrent sessions, and it let an organization's app sign in to the public Builderlab from its first screen. +- **Inferring the build kind from the Builderlab URL.** Rejected because it requires a table of known Builderlab hosts in code and breaks for any organization not in that table. The explicit `BUZZ_DISTRIBUTION` setting lets any organization ship its own internal build against its own Builderlab. + +## Consequences + +Upgrading an internal build that had joined public communities or signed in to the public Builderlab clears that public session and hides those communities. Their saved keys stay on the device. + +An internal build has no QR pairing, so it cannot receive a member's key from another device. When the organization's Builderlab already binds an identity to the member, Sign in uses that key from this device or restores it from the Builderlab's key backup. An identity the Builderlab never backed up cannot be restored, and Sign in says so rather than creating a new identity. + +On iOS, the native sign-in browser currently opens only Builderlab's own hosts, so an internal build's `BUZZ_BUILDERLAB_URL` must be a Builderlab tenant until that allowlist follows build configuration. diff --git a/mobile/README.md b/mobile/README.md index aa3c3aab58..5a27149bdd 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -5,29 +5,49 @@ Flutter mobile client for Buzz. See [VISION_MOBILE.md](../VISION_MOBILE.md) for intended behavior and architecture. -## Optional Builderlab login +## Builderlab builds -`BUZZ_AUTH_DOMAINS` is a comma-separated build-time environment setting, passed -with Flutter's `--dart-define` (shell environment variables alone are not read): +Three build-time settings, passed with Flutter's `--dart-define` (shell +environment variables alone are not read), choose the build's single +Builderlab. Set all three or none: ```bash -flutter run --dart-define=BUZZ_AUTH_DOMAINS=communities.buzz.xyz,communities.public.builderlabstaging.xyz +flutter run \ + --dart-define=BUZZ_DISTRIBUTION=public \ + --dart-define=BUZZ_BUILDERLAB_URL=https://app.builderlab.xyz \ + --dart-define=BUZZ_BUILDERLAB_RELAYS=communities.buzz.xyz ``` -Unset or empty means **Builderlab features are disabled**: no account login, +- `BUZZ_DISTRIBUTION`: `public` or `internal`. See the + [public build](../CONTEXT.md#public-build) and + [internal build](../CONTEXT.md#internal-build), and + [ADR 0001](../docs/adr/0001-internal-and-public-mobile-builds.md). +- `BUZZ_BUILDERLAB_URL`: the build's Builderlab HTTPS origin. On iOS, sign-in + currently opens only Builderlab's own hosts (`app.builderlab.xyz`, or a + `.builderlab.xyz` organization) and their staging equivalents; the + native browser bridge refuses any other origin. +- `BUZZ_BUILDERLAB_RELAYS`: comma-separated DNS domains whose relays use that + Builderlab's session. Entries match the domain itself and its subdomains, + case-insensitively. Use DNS names, not URLs or wildcards. + +With none set, **Builderlab features are disabled**: no account login, community creation, backup/recovery UI, reconnect prompts, or background account restoration. QR/code pairing, community links and invites use Nostr-only entry. Saved account keys and sessions are retained for a later enabled build. -A nonempty list enables Builderlab features and requires account setup only for -matching domains. Those features need their backend endpoints. Normal relay key -authentication remains in place. `BUZZ_BUILDERLAB_URL` selects the backend; setting -it alone does not enable Builderlab features. +A public build signs in with Builderlab's web account setup, and requires it for +communities under `BUZZ_BUILDERLAB_RELAYS`, which also places new communities +under the first domain. QR pairing and other communities stay available. -Entries match the domain itself and its subdomains, case-insensitively. Use DNS -names, not URLs or wildcards. The list does not add trusted account servers: -configured domains outside the supported Builderlab realm cannot use hosted -setup. `BUZZ_BUILDERLAB_URL` still selects the trusted account environment. +An internal build offers one **Sign in** action that goes to the organization's +single sign-on and then joins the community at its +`BUZZ_BUILDERLAB_RELAYS` host. It has no QR pairing, community switching or +community creation, refuses links to other relays, and hides communities on +other relays that an earlier build saved, keeping their keys. + +An internal build takes exactly one `BUZZ_BUILDERLAB_RELAYS` entry, its +organization's relay host. It matches that exact host, not a domain, and the +relay always receives its Builderlab session's relay assertions. ## Agent creation links @@ -80,8 +100,9 @@ is `lib/features/agents/agent_creation_draft.dart`. ## Hosted relay authentication `--dart-define=BUZZ_HOSTED_RELAY_AUTH=true` enables Builderlab relay assertions -and reconnect sign-in **only for `BUZZ_AUTH_DOMAINS` matches**. It defaults off: configure backend assertion issuance -before enabling mobile authentication, and enable relay enforcement last. +and reconnect sign-in for a public build's `BUZZ_BUILDERLAB_RELAYS`. It +defaults off: configure backend assertion issuance before enabling mobile +authentication, and enable relay enforcement last. ## Setup diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 4a3cd4320e..a30bcb9783 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -6,6 +6,8 @@ import 'dart:async'; import 'hosted_app_gate.dart'; import 'onboarding_app_entry.dart'; +import 'organization_app_entry.dart'; +import 'shared/hosted/organization_environment.dart'; import 'shared/widgets/ios_navigation_metrics.dart'; import 'shared/community/paired_community_landing.dart'; import 'shared/community/community_loading_surface.dart'; @@ -466,7 +468,11 @@ class App extends HookConsumerWidget { }, home: authState.when( loading: () => const _SplashScreen(), - error: (_, _) => const PairingPage(), + // An internal build never offers pairing, even when auth state is + // unreadable: pairing could make another relay the active community. + error: (_, _) => OrganizationEnvironment.enabled + ? const OrganizationAppEntry() + : const PairingPage(), data: (state) => switch (state.status) { AuthStatus.authenticated => HostedAppGate( authenticated: true, diff --git a/mobile/lib/features/agents/agent_creation_draft.dart b/mobile/lib/features/agents/agent_creation_draft.dart index 1a0028c73f..d1f4a0f3b3 100644 --- a/mobile/lib/features/agents/agent_creation_draft.dart +++ b/mobile/lib/features/agents/agent_creation_draft.dart @@ -59,6 +59,11 @@ class AgentCreationDraft { http.Client client, ) async { final cdn = HostedEnvironment.avatarsCdnOrigin; + if (cdn == null) { + throw const AgentCardException( + 'Agent cards aren’t available in this build.', + ); + } // The id is opaque; pathSegments percent-encodes anything path-unsafe. final url = cdn.replace(pathSegments: [link.cardId, 'agent.json']); final BoundedResponse response; diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 45d5052162..ede8679d84 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -4,6 +4,7 @@ import 'channel_unread.dart'; import '../../shared/widgets/collapsible_section_body.dart'; import '../../shared/me/me_sync.dart'; import '../../shared/hosted/hosted_relay_auth.dart'; +import '../../shared/hosted/organization_environment.dart'; import '../../shared/hosted/organization_sign_in.dart'; import 'dart:async'; diff --git a/mobile/lib/features/channels/channels_page/community.dart b/mobile/lib/features/channels/channels_page/community.dart index b5592c683d..671783a0e5 100644 --- a/mobile/lib/features/channels/channels_page/community.dart +++ b/mobile/lib/features/channels/channels_page/community.dart @@ -57,17 +57,19 @@ class _CommunityMenuSheet extends StatelessWidget { ), ], ), - AppListCard( - children: [ - AppListRow( - key: const Key('community-menu-switch'), - icon: BuzzIcons.arrowLeftRight, - title: 'Switch Community', - trailing: const Icon(BuzzIcons.chevronRight, size: 18), - onTap: openSwitcher, - ), - ], - ), + // An internal build reaches only its organization's community. + if (!OrganizationEnvironment.enabled) + AppListCard( + children: [ + AppListRow( + key: const Key('community-menu-switch'), + icon: BuzzIcons.arrowLeftRight, + title: 'Switch Community', + trailing: const Icon(BuzzIcons.chevronRight, size: 18), + onTap: openSwitcher, + ), + ], + ), ], ), ), diff --git a/mobile/lib/features/home/home_page.dart b/mobile/lib/features/home/home_page.dart index aa35a2fdb0..5c8f59ba66 100644 --- a/mobile/lib/features/home/home_page.dart +++ b/mobile/lib/features/home/home_page.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../shared/hosted/organization_environment.dart'; import '../../shared/theme/theme.dart'; import '../../shared/theme/buzz_icons.dart'; import '../../shared/community/community_membership_provider.dart'; @@ -100,11 +101,13 @@ class HomePage extends HookConsumerWidget { settingsPageBuilder: settingsPageBuilder, messagesActionsBuilder: (openSwitcher, avatarKey) { final moreActions = [ - ( - label: 'Switch community', - symbol: 'arrow.left.arrow.right', - onPressed: openSwitcher, - ), + // An internal build reaches only its organization's community. + if (!OrganizationEnvironment.enabled) + ( + label: 'Switch community', + symbol: 'arrow.left.arrow.right', + onPressed: openSwitcher, + ), if (canInvite && communityInvitePageBuilder != null) ( label: 'Invite', @@ -208,7 +211,9 @@ class HomePage extends HookConsumerWidget { child: defaultTargetPlatform == TargetPlatform.iOS ? IosMessageCreateButton( actions: moreActions, - dividerBefore: const {1}, + dividerBefore: OrganizationEnvironment.enabled + ? const {} + : const {1}, icon: 'more', avatarUrl: communityIcon, avatarInitial: communityName.isEmpty @@ -223,7 +228,9 @@ class HomePage extends HookConsumerWidget { label: 'Community menu', backgroundColor: context.colors.surfaceContainerHighest, actions: moreActions, - dividerBefore: const {1}, + dividerBefore: OrganizationEnvironment.enabled + ? const {} + : const {1}, width: 48, height: 48, trigger: CommunityAvatar( diff --git a/mobile/lib/features/onboarding/self_hosted_entry_sheet.dart b/mobile/lib/features/onboarding/self_hosted_entry_sheet.dart index 0988c9af84..c0ffd2bef7 100644 --- a/mobile/lib/features/onboarding/self_hosted_entry_sheet.dart +++ b/mobile/lib/features/onboarding/self_hosted_entry_sheet.dart @@ -5,6 +5,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/auth/auth.dart'; import '../../shared/hosted/device_identity_source.dart'; +import '../../shared/hosted/hosted_relay_auth.dart'; +import '../../shared/relay/relay_provider.dart'; import '../../shared/hosted/self_hosted_entry.dart'; import '../../shared/identity_transfer/nsec_import_sheet.dart'; import '../../shared/identity_transfer/native_community_entry.dart'; @@ -16,10 +18,31 @@ import '../../shared/widgets/onboarding_step_page.dart'; import '../../shared/widgets/onboarding_loading_page.dart'; import '../../shared/widgets/buzz_titled_sheet_layout.dart'; +/// The socket boundary every entry probe connects through. +final relayProbeProvider = + Provider Function(Community, Map)>( + (ref) => SelfHostedEntry.probeRelay, + ); + /// Local preparation seam; the production service validates and retains keys. -final selfHostedEntryProvider = Provider( - (ref) => SelfHostedEntry(), -); +/// An organization relay refuses the upgrade without the session's assertion, +/// so its probe carries one; every other relay gets none. +final selfHostedEntryProvider = Provider((ref) { + final auth = ref.watch(hostedRelayAuthProvider); + final probe = ref.watch(relayProbeProvider); + return SelfHostedEntry( + probe: (community) async { + final config = RelayConfig( + baseUrl: community.relayUrl, + nsec: community.nsec, + ); + await probe( + community, + auth.organizationProtects(config) ? await auth.headers(config) : {}, + ); + }, + ); +}); /// Uses an explicit identity choice without involving a hosted account. Future showSelfHostedEntry( diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart index 7221f8feb7..11f6e02444 100644 --- a/mobile/lib/features/pairing/pairing_page.dart +++ b/mobile/lib/features/pairing/pairing_page.dart @@ -83,6 +83,11 @@ class PairingPage extends HookConsumerWidget { final bool builderlabBusy; final bool signInPending; + /// An internal build's only way in: its organization's single sign-on. + /// When set, the landing offers this one action and no pairing. + final VoidCallback? onSignIn; + final bool signInBusy; + const PairingPage({ super.key, this.addingCommunity = false, @@ -106,6 +111,8 @@ class PairingPage extends HookConsumerWidget { this.builderlabLabel = 'Connect to Builderlab', this.builderlabBusy = false, this.signInPending = false, + this.onSignIn, + this.signInBusy = false, }); @override @@ -409,6 +416,8 @@ class PairingPage extends HookConsumerWidget { builderlabLabel: builderlabLabel, builderlabBusy: builderlabBusy, signInPending: signInPending, + onSignIn: onSignIn, + signInBusy: signInBusy, codeController: codeController, isBusy: isBusy, pairingCodeExpanded: pairingCodeExpanded.value, diff --git a/mobile/lib/features/pairing/pairing_page/pairing_welcome_view.dart b/mobile/lib/features/pairing/pairing_page/pairing_welcome_view.dart index d49fc1ddf6..d63d271472 100644 --- a/mobile/lib/features/pairing/pairing_page/pairing_welcome_view.dart +++ b/mobile/lib/features/pairing/pairing_page/pairing_welcome_view.dart @@ -25,6 +25,8 @@ class _PairingWelcomeView extends HookWidget { final String builderlabLabel; final bool builderlabBusy; final bool signInPending; + final VoidCallback? onSignIn; + final bool signInBusy; final ValueChanged onOpenCommunity; const _PairingWelcomeView({ @@ -53,6 +55,8 @@ class _PairingWelcomeView extends HookWidget { this.builderlabLabel = 'Connect to Builderlab', this.builderlabBusy = false, this.signInPending = false, + this.onSignIn, + this.signInBusy = false, }); @override @@ -125,7 +129,22 @@ class _PairingWelcomeView extends HookWidget { ), const SizedBox(height: Grid.xs), ], - if (page == 1) ...[ + if (page == 1 && onSignIn != null) + SizedBox( + width: double.infinity, + child: FilledButton( + style: context._onboardingButtonStyle, + onPressed: signInBusy ? null : onSignIn, + child: signInBusy + ? BuzzLoadingIndicator( + size: 20, + color: context._onboardingInk, + semanticLabel: 'Signing in', + ) + : const Text('Sign in'), + ), + ) + else if (page == 1) ...[ if (!showConnectionOptions && !signInPending) SizedBox( width: double.infinity, diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index 8fd32af987..28da21583d 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -14,6 +14,8 @@ import 'package:package_info_plus/package_info_plus.dart'; import '../../shared/auth/auth.dart'; import '../../shared/identity_transfer/nsec_export_sheet.dart'; import '../../shared/clipboard_utils.dart'; +import '../../shared/hosted/organization_environment.dart'; +import '../../shared/hosted/organization_sign_out.dart'; import '../../shared/push/push_relay_capability_provider.dart'; import '../../shared/push/push_bootstrap.dart'; import '../../shared/relay/relay.dart'; @@ -130,6 +132,21 @@ class SettingsPage extends HookConsumerWidget { _ConnectionSection( identityRecoveryPageBuilder: identityRecoveryPageBuilder, ), + // An internal build's only way out of its organization, rarely + // needed, so it sits last. + if (OrganizationEnvironment.enabled) + AppListCard( + verticalPadding: Grid.twelve, + children: [ + AppListRow( + key: const Key('settings-sign-out'), + icon: BuzzIcons.logOut, + title: 'Sign out', + titleColor: context.colors.error, + onTap: () => confirmOrganizationSignOut(context, ref), + ), + ], + ), if (packageInfo.hasData) _VersionFooter( version: packageInfo.data!.version, diff --git a/mobile/lib/hosted_app_gate.dart b/mobile/lib/hosted_app_gate.dart index 25f750d453..735f1500bb 100644 --- a/mobile/lib/hosted_app_gate.dart +++ b/mobile/lib/hosted_app_gate.dart @@ -16,6 +16,8 @@ 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 'shared/hosted/hosted_relay_auth.dart'; +import 'shared/hosted/organization_environment.dart'; import 'features/onboarding/hosted_reconnect_banner.dart'; import 'features/onboarding/organization_sign_in_banner.dart'; @@ -53,6 +55,21 @@ class HostedAppGate extends HookConsumerWidget { final inlineEntry = ref.watch(hostedInlineEntryProvider); // Create the organization's browser owner before any callback is handled. ref.watch(organizationSignInProvider); + useEffect(() { + // An internal build keeps one Builderlab session: drop any public + // session a previous build saved. Its communities stay stored, hidden. + if (OrganizationEnvironment.enabled) { + unawaited( + ref + .read(builderlabAccountProvider) + .clearLocalSession() + .catchError( + (Object error) => debugPrint('Public session cleanup: $error'), + ), + ); + } + return null; + }, const []); final visible = flow != null && flow.stage != HostedFlowStage.idle && @@ -70,6 +87,28 @@ class HostedAppGate extends HookConsumerWidget { dispatchMessageLinks: authenticated && !visible, onboardingHandler: (routeContext, link) async { final relay = link.relayUrl; + if (relay != null && + OrganizationEnvironment.enabled && + !OrganizationEnvironment.protects(Uri.parse(relay))) { + if (routeContext.mounted) { + await showDialog( + context: routeContext, + builder: (context) => AlertDialog( + title: const Text('Community unavailable'), + content: const Text( + 'This app connects only to your organization’s community.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), + ), + ], + ), + ); + } + return true; + } if (relay != null) { final host = Uri.parse(relay).host.toLowerCase(); final requiresAuth = HostedEnvironment.requiresAuth(host); diff --git a/mobile/lib/onboarding_app_entry.dart b/mobile/lib/onboarding_app_entry.dart index e1b407a1b3..65b59beab2 100644 --- a/mobile/lib/onboarding_app_entry.dart +++ b/mobile/lib/onboarding_app_entry.dart @@ -5,12 +5,14 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'features/pairing/pairing_page.dart'; +import 'organization_app_entry.dart'; import 'features/pairing/pairing_provider.dart'; import 'shared/hosted/hosted_flow_provider.dart'; import 'shared/hosted/hosted_flow.dart'; import 'shared/hosted/hosted_communities.dart'; import 'shared/hosted/use_known_communities.dart'; import 'shared/hosted/hosted_environment.dart'; +import 'shared/hosted/organization_environment.dart'; import 'shared/hosted/hosted_community_creation.dart'; import 'shared/hosted/hosted_flow_problem_message.dart'; import 'shared/widgets/confirmation_dialog.dart'; @@ -33,6 +35,8 @@ class OnboardingAppEntry extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + // Fixed per build, so the hooks below always run in the same order. + if (OrganizationEnvironment.enabled) return const OrganizationAppEntry(); final existingUser = useState(false); final creating = useState(false); final enteringCommunityLink = useState(false); diff --git a/mobile/lib/organization_app_entry.dart b/mobile/lib/organization_app_entry.dart new file mode 100644 index 0000000000..0fd28af40b --- /dev/null +++ b/mobile/lib/organization_app_entry.dart @@ -0,0 +1,162 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'features/onboarding/self_hosted_entry_sheet.dart'; +import 'features/pairing/pairing_page.dart'; +import 'shared/auth/auth_provider.dart'; +import 'shared/community/community.dart'; +import 'shared/community/community_provider.dart'; +import 'shared/hosted/account_identity_store.dart'; +import 'shared/hosted/hosted_relay_auth.dart'; +import 'shared/hosted/organization_environment.dart'; +import 'shared/hosted/organization_sign_in.dart'; +import 'shared/hosted/organization_identity.dart'; + +/// An internal build's landing: one "Sign in" that goes to the organization's +/// single sign-on, then joins the organization's community. +class OrganizationAppEntry extends HookConsumerWidget { + const OrganizationAppEntry({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final signIn = ref.watch(organizationSignInProvider)!; + useListenable(signIn); + final joining = useState(false); + final joinFailed = useState<_JoinFailure?>(null); + // Set by a tap, so restoring a saved session never joins on its own. + final joinRequested = useRef(false); + final relay = 'wss://${OrganizationEnvironment.relayHosts.first}'; + + Future join() async { + if (joining.value) return; + joinRequested.value = false; + joining.value = true; + joinFailed.value = null; + bool current() => context.mounted; + final entry = ref.read(selfHostedEntryProvider); + final account = ref.read(organizationAccountProvider)!; + final storage = ref.read(communityStorageProvider); + try { + final bound = await organizationBoundPubkey(account); + if (!current()) return; + Community community; + if (bound != null) { + // The relay admits only the bound key: use this device's copy, or + // the organization's backup of it. Never offer or create another. + try { + community = await entry.prepare( + relay: relay, + name: '', + savedPubkey: bound, + isCurrent: current, + ); + } on AccountIdentityFailure catch (error) { + if (error != AccountIdentityFailure.absent) rethrow; + final nsec = await recoverOrganizationKey(account, bound); + if (!current()) return; + if (nsec == null) { + joinFailed.value = _JoinFailure.notRestored; + return; + } + community = await entry.prepare( + relay: relay, + name: '', + nsec: nsec, + isCurrent: current, + ); + } + } else { + try { + community = await entry.prepare( + relay: relay, + name: '', + create: true, + isCurrent: current, + ); + } on AccountIdentityFailure catch (error) { + // Saved identities exist: the member picks one or a new key, which + // the organization binds at its first assertion. + if (error != AccountIdentityFailure.absent || !context.mounted) { + rethrow; + } + await showSelfHostedEntry(context, relay: relay, name: ''); + return; + } + } + if (!current()) return; + await ref + .read(authProvider.notifier) + .authenticateWithCommunity(community, shouldContinue: current); + // Admission can return early after cancellation. Check the durable + // record before retiring the only retry copy of a generated key. + final saved = await storage.loadAll(); + if (saved.any( + (item) => item.id == community.id && item.nsec == community.nsec, + )) { + await entry.complete(community); + } + } catch (error) { + debugPrint('Organization community entry failed: $error'); + if (context.mounted) joinFailed.value = _JoinFailure.failed; + } finally { + if (context.mounted) joining.value = false; + } + } + + // This landing shows only without the organization's community, so the + // member has signed out: end the session rather than reuse it. + useEffect(() { + unawaited(signIn.signOut()); + return null; + }, [signIn]); + + useEffect(() { + if (joinRequested.value && + signIn.status == OrganizationSessionStatus.signedIn) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) unawaited(join()); + }); + } + if (signIn.status == OrganizationSessionStatus.signedOut) { + joinRequested.value = false; + } + return null; + }, [signIn.status]); + + final busy = + joining.value || + signIn.status == OrganizationSessionStatus.checking || + signIn.status == OrganizationSessionStatus.signingIn; + final status = + switch (joinFailed.value) { + _JoinFailure.failed => + 'Couldn’t connect to your organization. Try again.', + _JoinFailure.notRestored => + 'Couldn’t restore your organization identity on this device. ' + 'Sign in on a device that has it, then try again.', + null => null, + } ?? + (signIn.failure != null ? 'Couldn’t sign in. Try again.' : null); + + return PairingPage( + connectionLanding: true, + showConnectionOptions: false, + accountStatus: status, + signInBusy: busy, + onSignIn: () { + if (signIn.status == OrganizationSessionStatus.signedIn) { + unawaited(join()); + return; + } + joinRequested.value = true; + joinFailed.value = null; + unawaited(signIn.signIn()); + }, + ); + } +} + +enum _JoinFailure { failed, notRestored } diff --git a/mobile/lib/shared/community/community_storage.dart b/mobile/lib/shared/community/community_storage.dart index 1e400fcefd..d885cef896 100644 --- a/mobile/lib/shared/community/community_storage.dart +++ b/mobile/lib/shared/community/community_storage.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:crypto/crypto.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import '../hosted/organization_environment.dart'; import '../storage/secure_storage.dart'; import '../storage/serial_queue.dart'; @@ -22,12 +23,27 @@ class CommunityStorage { final FlutterSecureStorage _secure; - CommunityStorage({FlutterSecureStorage? secure}) - : _secure = secure ?? communitySecureStorage; + /// Whether this build shows a saved community. Others stay stored, with + /// their keys, for a build that can reach them. + final bool Function(Community community) _visible; - /// Load all communities. On first call, migrates legacy single-community - /// credentials if present. - Future> loadAll() async { + CommunityStorage({ + FlutterSecureStorage? secure, + bool Function(Community community)? visible, + }) : _secure = secure ?? communitySecureStorage, + _visible = visible ?? reachableInBuild; + + /// An internal build reaches only its organization's community. + static bool reachableInBuild(Community community) => + !OrganizationEnvironment.enabled || + OrganizationEnvironment.protects(Uri.parse(community.relayUrl)); + + /// Load all communities this build shows. On first call, migrates legacy + /// single-community credentials if present. + Future> loadAll() async => + (await _loadStored()).where(_visible).toList(); + + Future> _loadStored() async { final raw = await _secure.read(key: _keyCommunities); if (raw != null) return _decodeList(raw); @@ -180,8 +196,15 @@ class CommunityStorage { .toList(); } + /// Replaces the communities this build shows, keeping the others. Future _saveList(List communities) async { - final json = jsonEncode(communities.map((item) => item.toJson()).toList()); + final raw = await _secure.read(key: _keyCommunities); + final hidden = raw == null + ? const [] + : _decodeList(raw).where((item) => !_visible(item)); + final json = jsonEncode( + [...hidden, ...communities].map((item) => item.toJson()).toList(), + ); await _secure.write(key: _keyCommunities, value: json); } } diff --git a/mobile/lib/shared/hosted/browser_auth.dart b/mobile/lib/shared/hosted/browser_auth.dart index 8d4406958f..694ab76a40 100644 --- a/mobile/lib/shared/hosted/browser_auth.dart +++ b/mobile/lib/shared/hosted/browser_auth.dart @@ -12,6 +12,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import '../storage/secure_storage.dart'; import 'hosted_environment.dart'; +import 'organization_environment.dart'; /// Safe to show as a generic retry prompt. Never carries a URL or server text. enum BrowserAuthFailure { restartLogin, cancelled, unavailable, storage } @@ -143,8 +144,10 @@ class BrowserAuth { throw BrowserAuthFailure.storage; } // Public accounts must finish web setup before native code issuance. - // Organization accounts retain their existing direct CLI login. - final publicAccount = origin == HostedEnvironment.origin; + // Organization accounts go straight to their single sign-on. + final publicAccount = + !OrganizationEnvironment.enabled && + origin == HostedEnvironment.origin; return origin.replace( path: publicAccount ? '/auth/native' : '/api/goose/v1/auth/login', queryParameters: { diff --git a/mobile/lib/shared/hosted/builderlab_account.dart b/mobile/lib/shared/hosted/builderlab_account.dart index 22ab2d84b9..9bf7f0a029 100644 --- a/mobile/lib/shared/hosted/builderlab_account.dart +++ b/mobile/lib/shared/hosted/builderlab_account.dart @@ -111,7 +111,13 @@ class BuilderlabAccountClient { String? _unshared; bool _sharing = false; BuilderlabAccount? _account; + // The session that last signed out, in memory only, so push-stop requests + // already pending can still reach their relays for a short while. + ({String credential, DateTime until})? _signedOut; final _queue = SerialQueue(); + + /// How long after sign-out [pushStopAssertion] can still issue. + static const pushStopWindow = Duration(minutes: 10); bool _disposed = false; BuilderlabAccount? get account => _account; @@ -382,6 +388,35 @@ class BuilderlabAccountClient { expectedSubject: expectedSubject, ); + /// A relay assertion from the session that signed out within + /// [pushStopWindow], for a push-stop request only. That session is never + /// stored, shared or used for anything else, so it cannot sign back in. + Future> pushStopAssertion(String relay, String nsec) => + _serial(() async { + final signedOut = _signedOut; + if (signedOut == null || !DateTime.now().isBefore(signedOut.until)) { + _signedOut = null; + throw AccountFailure.signedOut; + } + final pubkey = AccountIdentityStore.publicKeyForNsec(nsec); + // Never creates a binding: the key must already belong to the account. + final current = await _request( + '/v1/buzz/nostr-identities/current', + credential: signedOut.credential, + body: {}, + ); + final identity = current['identity']; + if (identity is! Map || identity['pubkey_hex'] != pubkey) { + throw AccountFailure.denied; + } + return _request( + '/v1/buzz/identity/assertions', + credential: signedOut.credential, + body: {'relay_url': relay, 'nostr_pubkey': pubkey}, + signingKey: nsec, + ); + }); + Future clearLocalSession() => _serial(_clear); Future _clear() async { // Attempt both removals so one failure never leaves the other copy live. @@ -402,6 +437,13 @@ class BuilderlabAccountClient { _share(); throw AccountFailure.storage; } + final credential = _credential; + if (credential != null) { + _signedOut = ( + credential: credential, + until: DateTime.now().add(pushStopWindow), + ); + } _credential = null; _unshared = null; _account = null; @@ -482,6 +524,7 @@ class BuilderlabAccountClient { void dispose() { _disposed = true; _credential = null; + _signedOut = null; _account = null; _subject.dispose(); _http.close(); diff --git a/mobile/lib/shared/hosted/builderlab_build.dart b/mobile/lib/shared/hosted/builderlab_build.dart new file mode 100644 index 0000000000..839ae8bc43 --- /dev/null +++ b/mobile/lib/shared/hosted/builderlab_build.dart @@ -0,0 +1,153 @@ +import 'package:flutter/foundation.dart'; + +/// Who a build is distributed to. See `CONTEXT.md` and +/// `docs/adr/0001-internal-and-public-mobile-builds.md`. +enum BuilderlabDistribution { + /// An organization's own app: only its Builderlab and its community. + internal, + + /// The publicly distributed app: only the public Builderlab. + public, +} + +/// Trusted build configuration naming this build's single Builderlab, never +/// supplied by a deep link or relay. With none of the settings present, the +/// build is Nostr-only. +abstract final class BuilderlabBuild { + static final ({ + BuilderlabDistribution? distribution, + Uri? origin, + List relayDomains, + }) + _config = parse( + distribution: const String.fromEnvironment('BUZZ_DISTRIBUTION'), + url: const String.fromEnvironment('BUZZ_BUILDERLAB_URL'), + relays: const String.fromEnvironment('BUZZ_BUILDERLAB_RELAYS'), + retired: [ + if (const bool.hasEnvironment('BUZZ_AUTH_DOMAINS')) 'BUZZ_AUTH_DOMAINS', + if (const bool.hasEnvironment('BUZZ_ORGANIZATION_URL')) + 'BUZZ_ORGANIZATION_URL', + if (const bool.hasEnvironment('BUZZ_ORGANIZATION_RELAYS')) + 'BUZZ_ORGANIZATION_RELAYS', + ], + ); + + /// Keys the public Builderlab session's storage in builds without a public + /// Builderlab, which an internal build clears at launch. + static final Uri publicStorageOrigin = Uri.parse( + 'https://app.builderlab.xyz', + ); + + /// `BUZZ_DISTRIBUTION`: `internal` or `public`. Null for a Nostr-only build. + static BuilderlabDistribution? get distribution => _config.distribution; + + /// `BUZZ_BUILDERLAB_URL`: this build's Builderlab HTTPS origin. + static Uri? get origin => _config.origin; + + /// `BUZZ_BUILDERLAB_RELAYS`: comma-separated DNS domains whose relays use + /// this build's Builderlab session. A public build also matches their + /// subdomains; an internal build matches only these exact hosts. + static List get relayDomains => _config.relayDomains; + + /// Matches a configured domain and its subdomains, never a string suffix. + static bool matchesHost(String host, List domains) { + final normalized = host.toLowerCase().replaceFirst(RegExp(r'\.+$'), ''); + return domains.any( + (domain) => normalized == domain || normalized.endsWith('.$domain'), + ); + } + + /// Whether [relay] is a plain origin on a configured domain, so this build + /// may send its Builderlab session's assertions there. + static bool matchesRelay(Uri relay, List domains) => + ['wss', 'https'].contains(relay.scheme) && + relay.userInfo.isEmpty && + !relay.hasQuery && + !relay.hasFragment && + (relay.path.isEmpty || relay.path == '/') && + (!relay.hasPort || relay.port == 443) && + !relay.host.endsWith('.') && + matchesHost(relay.host, domains); + + @visibleForTesting + static ({ + BuilderlabDistribution? distribution, + Uri? origin, + List relayDomains, + }) + parse({ + required String distribution, + required String url, + required String relays, + List retired = const [], + }) { + // A pipeline still passing the former settings would otherwise ship a + // Nostr-only build without its organization restriction. + if (retired.isNotEmpty) { + throw StateError( + '${retired.join(', ')} retired: set BUZZ_DISTRIBUTION, ' + 'BUZZ_BUILDERLAB_URL and BUZZ_BUILDERLAB_RELAYS instead', + ); + } + final domains = _parseDomains(relays); + if (distribution.isEmpty && url.isEmpty && domains.isEmpty) { + return (distribution: null, origin: null, relayDomains: const []); + } + if (distribution.isEmpty || url.isEmpty || domains.isEmpty) { + throw StateError( + 'BUZZ_DISTRIBUTION, BUZZ_BUILDERLAB_URL and BUZZ_BUILDERLAB_RELAYS must be set together', + ); + } + final kind = BuilderlabDistribution.values + .where((value) => value.name == distribution) + .singleOrNull; + if (kind == null) { + throw StateError('BUZZ_DISTRIBUTION must be internal or public'); + } + // An internal build reaches one organization community and has no + // switcher, so a second relay could strand its members there. + if (kind == BuilderlabDistribution.internal && domains.length != 1) { + throw StateError('An internal build takes exactly one relay host'); + } + 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_BUILDERLAB_URL must be an HTTPS origin'); + } + final origin = Uri.parse(uri.origin); + // Sessions are stored per origin host, so the internal build's cleanup of + // the public session would delete its own. + if (kind == BuilderlabDistribution.internal && + origin.host == publicStorageOrigin.host) { + throw StateError( + 'An internal build needs its own Builderlab, not ${origin.host}', + ); + } + return (distribution: kind, origin: origin, relayDomains: domains); + } + + static List _parseDomains(String relays) => List.unmodifiable( + relays + .split(',') + .map( + (domain) => + domain.trim().toLowerCase().replaceFirst(RegExp(r'\.+$'), ''), + ) + .where((domain) => domain.isNotEmpty) + .map((domain) { + if (!RegExp( + r'^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$', + ).hasMatch(domain)) { + throw StateError( + 'BUZZ_BUILDERLAB_RELAYS must contain DNS names, not URLs or wildcards', + ); + } + return domain; + }), + ); +} diff --git a/mobile/lib/shared/hosted/device_identity_source.dart b/mobile/lib/shared/hosted/device_identity_source.dart index e66369dad1..de0609556c 100644 --- a/mobile/lib/shared/hosted/device_identity_source.dart +++ b/mobile/lib/shared/hosted/device_identity_source.dart @@ -5,6 +5,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import '../storage/secure_storage.dart'; import 'account_identity_store.dart'; +import 'organization_environment.dart'; import '../profile/user_profile.dart'; import '../relay/relay_provider.dart'; @@ -28,8 +29,9 @@ class DeviceIdentitySource { DeviceIdentityChoice(entry.key, entry.value.label), ]; - /// Read profile metadata only from communities already saved for this key. - /// A failed/offline community does not prevent trying another saved source. + /// Read profile metadata only from communities already saved for this key, + /// and in an internal build only from its organization's relay. A + /// failed/offline community does not prevent trying another saved source. Future loadProfile( String pubkey, Future Function(RelayConfig config) fetch, @@ -45,6 +47,8 @@ class DeviceIdentitySource { final relay = value['relayUrl'] as String?; if (nsec == null || relay == null || + (OrganizationEnvironment.enabled && + !OrganizationEnvironment.protects(Uri.parse(relay))) || AccountIdentityStore.publicKeyForNsec(nsec) != pubkey || (value['pubkey'] != null && value['pubkey'] != pubkey)) { continue; diff --git a/mobile/lib/shared/hosted/hosted_environment.dart b/mobile/lib/shared/hosted/hosted_environment.dart index 8292738342..241db7a76a 100644 --- a/mobile/lib/shared/hosted/hosted_environment.dart +++ b/mobile/lib/shared/hosted/hosted_environment.dart @@ -1,32 +1,43 @@ -/// Trusted build configuration, never supplied by a deep link or relay. -abstract final class HostedEnvironment { - /// Account UI and startup work are opt-in, independently of the backend URL. - static bool get enabled => authDomains.isNotEmpty; +import 'builderlab_build.dart'; - static final Uri origin = _origin(); - static const _communityAccounts = { - 'communities.buzz.xyz': 'https://app.builderlab.xyz', - 'communities.public.builderlabstaging.xyz': - 'https://app.builderlabstaging.xyz', - }; +/// The public Builderlab account of a public build. Trusted build +/// configuration from [BuilderlabBuild], never supplied by a deep link or +/// relay. +abstract final class HostedEnvironment { + /// Account UI and startup work run only in a public build. + static bool get enabled => + BuilderlabBuild.distribution == BuilderlabDistribution.public; + + /// The public build's Builderlab. Other builds never contact it; their + /// value only keys inert local storage. + static final Uri origin = enabled + ? BuilderlabBuild.origin! + : BuilderlabBuild.publicStorageOrigin; /// Public CDN serving each published Builderlab agent card's /// `/agent.json` and images, for this build's account environment. - static Uri get avatarsCdnOrigin => Uri.parse(switch (origin.host) { - 'app.builderlab.xyz' => 'https://d2z0pbxrxbsiz5.cloudfront.net', - 'app.builderlabstaging.xyz' => 'https://d10hx6rb3fdt3l.cloudfront.net', - _ => throw StateError('Unsupported hosted account environment'), - }); + /// Null for an internal build or a Builderlab without a known CDN, which + /// have no agent cards. + static Uri? get avatarsCdnOrigin => + BuilderlabBuild.distribution == BuilderlabDistribution.internal + ? null + : switch (origin.host) { + 'app.builderlab.xyz' => Uri.parse( + 'https://d2z0pbxrxbsiz5.cloudfront.net', + ), + 'app.builderlabstaging.xyz' => Uri.parse( + 'https://d10hx6rb3fdt3l.cloudfront.net', + ), + _ => null, + }; /// Shortens hostname fallbacks for display without changing relay identity. static String communityDisplayName(String name, String relayUrl) { final relay = Uri.tryParse(relayUrl); - if (relay == null || - name != relay.host || - accountOriginFor(relay) == null) { + if (relay == null || name != relay.host || !supportsRelay(relay)) { return name; } - for (final domain in _communityAccounts.keys) { + for (final domain in _relayDomains) { final suffix = '.$domain'; if (name.endsWith(suffix)) { return name.substring(0, name.length - suffix.length); @@ -35,74 +46,33 @@ abstract final class HostedEnvironment { return name; } - /// Creation and admission use the same trusted build-specific host mapping. - static String get communitySuffix => _communityAccounts.entries - .singleWhere((entry) => Uri.parse(entry.value) == origin) - .key; + /// Creation and admission place new communities under the first domain. + static String get communitySuffix => + enabled ? BuilderlabBuild.relayDomains.first : _publicCommunities; - /// Comma-separated DNS domains requiring Builderlab authentication. - /// Empty builds keep Nostr-only entry available, including hosted QR pairing. - static final List authDomains = List.unmodifiable( - const String.fromEnvironment('BUZZ_AUTH_DOMAINS') - .split(',') - .map( - (domain) => - domain.trim().toLowerCase().replaceFirst(RegExp(r'\.+$'), ''), - ) - .where((domain) => domain.isNotEmpty) - .map((domain) { - if (!RegExp( - r'^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$', - ).hasMatch(domain)) { - throw StateError( - 'BUZZ_AUTH_DOMAINS must contain DNS names, not URLs or wildcards', - ); - } - return domain; - }), - ); + /// Domains whose communities require this build's Builderlab account. + /// Empty unless [enabled]; such builds keep Nostr-only entry available, + /// including hosted QR pairing. + static List get authDomains => + enabled ? BuilderlabBuild.relayDomains : const []; /// Matches a configured domain and its subdomains, never a string suffix. - static bool requiresAuth(String host) { - final normalized = host.toLowerCase().replaceFirst(RegExp(r'\.+$'), ''); - return authDomains.any( - (domain) => normalized == domain || normalized.endsWith('.$domain'), - ); - } - - /// Explicit community-to-account mapping. Never infer an account endpoint - /// from arbitrary relay metadata or send this build's session across realms. - static Uri? accountOriginFor(Uri relay) { - if (!['wss', 'https'].contains(relay.scheme) || - relay.userInfo.isNotEmpty || - relay.hasQuery || - relay.hasFragment || - (relay.path.isNotEmpty && relay.path != '/') || - (relay.hasPort && relay.port != 443)) { - return null; - } - final host = relay.host.toLowerCase(); - for (final entry in _communityAccounts.entries) { - if (host.endsWith('.${entry.key}')) return Uri.parse(entry.value); - } - return null; - } + static bool requiresAuth(String host) => + BuilderlabBuild.matchesHost(host, authDomains); /// Whether this build can use its account session for this relay. - static bool supportsRelay(Uri relay) => accountOriginFor(relay) == origin; + static bool supportsRelay(Uri relay) => + BuilderlabBuild.matchesRelay(relay, _relayDomains); - static Uri _origin() { - const configured = String.fromEnvironment( - 'BUZZ_BUILDERLAB_URL', - defaultValue: 'https://app.builderlab.xyz', - ); - final uri = Uri.parse(configured); - if (!{ - 'https://app.builderlab.xyz', - 'https://app.builderlabstaging.xyz', - }.contains(configured)) { - throw StateError('Unsupported hosted account environment'); - } - return uri; - } + /// The public Builderlab's communities, which a build without Builderlab + /// still recognizes so a later public build finds them unchanged. + static const _publicCommunities = 'communities.buzz.xyz'; + + /// An internal build never treats a relay as a public community. + static List get _relayDomains => + switch (BuilderlabBuild.distribution) { + BuilderlabDistribution.public => BuilderlabBuild.relayDomains, + BuilderlabDistribution.internal => const [], + null => const [_publicCommunities], + }; } diff --git a/mobile/lib/shared/hosted/hosted_identity_service.dart b/mobile/lib/shared/hosted/hosted_identity_service.dart index aebf0ce63e..e6fecb3293 100644 --- a/mobile/lib/shared/hosted/hosted_identity_service.dart +++ b/mobile/lib/shared/hosted/hosted_identity_service.dart @@ -216,55 +216,17 @@ class HostedIdentityService { final recovered = await account.post('/v1/buzz/nsec-backups/recovery', {}); _sameAccount(subject); - final backups = recovered['backups']; - final failed = recovered['failed_backup_ids']; - if (recovered['error'] != null || - backups is! List || - failed is! List || - failed.any((id) => id is! String)) { - throw AccountFailure.invalidResponse; - } - if (failed.isNotEmpty) { + final candidates = openRecoveredBackups(recovered); + if (candidates == null) { return const HostedIdentityResult(HostedIdentityState.recoveryBlocked); } - if (backups.isEmpty) { + if (candidates.isEmpty) { return HostedIdentityResult( bound == null ? HostedIdentityState.needsAuthoritativeAbsence : HostedIdentityState.recoveryBlocked, ); } - // Validate the entire result before storing anything. Never trust its - // advertised pubkey without deriving it from the actual secret. - final candidates = {}; - try { - for (final backup in backups) { - if (backup is! Map) throw const FormatException(); - final secret = backup['secret_key']; - final backupId = backup['backup_id']; - if (secret is! String || - !RegExp(r'^[A-Za-z0-9_-]{43}$').hasMatch(secret) || - backupId is! String || - backupId.isEmpty) { - throw const FormatException(); - } - final bytes = base64Url.decode(base64Url.normalize(secret)); - if (bytes.length != 32) throw const FormatException(); - final hex = bytes - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join(); - final keys = AccountIdentityStore.keysForHex(hex); - if (keys.public != _pubkey(backup['nostr_pubkey'])) { - throw const FormatException(); - } - candidates.putIfAbsent( - keys.public, - () => (keys: keys, backupId: backupId), - ); - } - } catch (_) { - throw AccountFailure.invalidResponse; - } if (bound == null && (statusKeys.length != candidates.length || !statusKeys.every(candidates.containsKey))) { @@ -413,3 +375,53 @@ class HostedIdentityService { return value.toLowerCase(); } } + +/// A recovered backup's key, derived from its secret, and its backup id. +typedef RecoveredBackup = ({nostr.Keys keys, String backupId}); + +/// Validates an `/v1/buzz/nsec-backups/recovery` response, keyed by the public +/// key derived from each secret, never by the advertised one. Null when any +/// backup failed to open; throws [AccountFailure.invalidResponse] when the +/// response is malformed, so nothing is stored from a partial result. +Map? openRecoveredBackups( + Map recovered, +) { + final backups = recovered['backups']; + final failed = recovered['failed_backup_ids']; + if (recovered['error'] != null || + backups is! List || + failed is! List || + failed.any((id) => id is! String)) { + throw AccountFailure.invalidResponse; + } + if (failed.isNotEmpty) return null; + final candidates = {}; + try { + for (final backup in backups) { + if (backup is! Map) throw const FormatException(); + final secret = backup['secret_key']; + final backupId = backup['backup_id']; + if (secret is! String || + !RegExp(r'^[A-Za-z0-9_-]{43}$').hasMatch(secret) || + backupId is! String || + backupId.isEmpty) { + throw const FormatException(); + } + final bytes = base64Url.decode(base64Url.normalize(secret)); + if (bytes.length != 32) throw const FormatException(); + final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + final keys = AccountIdentityStore.keysForHex(hex); + if (keys.public != + HostedIdentityService._pubkey(backup['nostr_pubkey'])) { + throw const FormatException(); + } + candidates.putIfAbsent( + keys.public, + () => (keys: keys, backupId: backupId), + ); + } + } catch (_) { + throw AccountFailure.invalidResponse; + } + return candidates; +} diff --git a/mobile/lib/shared/hosted/hosted_relay_auth.dart b/mobile/lib/shared/hosted/hosted_relay_auth.dart index be41787785..da81f8c550 100644 --- a/mobile/lib/shared/hosted/hosted_relay_auth.dart +++ b/mobile/lib/shared/hosted/hosted_relay_auth.dart @@ -73,11 +73,11 @@ final hostedHttpClientFactoryProvider = Provider((ref) { return () => HostedRelayHttpClient(auth, config); }); -/// One account session per realm, with a short-lived assertion for the current -/// relay/key. Hosted communities use [account] when [enabled] and their relay's -/// NIP-FI discovery requires evidence; the build's organization relays always -/// use [organization]. Enable alongside backend issuance; legacy and -/// self-hosted builds stay usable. +/// The build's one Builderlab session, with a short-lived assertion for the +/// current relay/key. A public build's hosted communities use [account] when +/// [enabled] and their relay's NIP-FI discovery requires evidence; an internal +/// build's organization relays always use [organization]. Enable alongside +/// backend issuance; self-hosted communities stay usable. class HostedRelayAuth extends ChangeNotifier { HostedRelayAuth( this.account, { @@ -336,7 +336,11 @@ class HostedRelayAuth extends ChangeNotifier { final started = ++_sequence; try { if (account.account == null && await account.restore() == null) { - throw AccountFailure.signedOut; + // A push stop pending at sign-out still gets a few attempts. + if (!background) throw AccountFailure.signedOut; + final value = await account.pushStopAssertion(relay, config.nsec!); + if (_disposed) throw AccountFailure.unavailable; + return _token(value, pubkey).token; } final session = account.account!; final current = await account.currentIdentity(); @@ -376,20 +380,8 @@ class HostedRelayAuth extends ChangeNotifier { config.nsec!, expectedSubject: session.subject, ); - final token = value['assertion']; - final expiry = value['expires_at']; - if (token is! String || - token.isEmpty || - token.length > 16384 || - token.contains(RegExp(r'[\r\n]')) || - value['nostr_pubkey'] != pubkey || - expiry is! int) { - throw AccountFailure.invalidResponse; - } + final (:token, :expiry) = _token(value, pubkey); final now = _now().millisecondsSinceEpoch ~/ 1000; - if (expiry <= now) { - throw AccountFailure.invalidResponse; - } final expires = DateTime.fromMillisecondsSinceEpoch( expiry * 1000, isUtc: true, @@ -430,6 +422,25 @@ class HostedRelayAuth extends ChangeNotifier { } } + /// An issued assertion for [pubkey], with its expiry in Unix seconds. + ({String token, int expiry}) _token( + Map value, + String pubkey, + ) { + final token = value['assertion']; + final expiry = value['expires_at']; + if (token is! String || + token.isEmpty || + token.length > 16384 || + token.contains(RegExp(r'[\r\n]')) || + value['nostr_pubkey'] != pubkey || + expiry is! int || + expiry <= _now().millisecondsSinceEpoch ~/ 1000) { + throw AccountFailure.invalidResponse; + } + return (token: token, expiry: expiry); + } + /// Clear a scoped failure only after this community actually reconnects. void connected(RelayConfig config) { if (_disposed || (failedRelay != null && failedRelay != config.baseUrl)) { diff --git a/mobile/lib/shared/hosted/organization_environment.dart b/mobile/lib/shared/hosted/organization_environment.dart index 59113293b0..2f27e63cc8 100644 --- a/mobile/lib/shared/hosted/organization_environment.dart +++ b/mobile/lib/shared/hosted/organization_environment.dart @@ -1,90 +1,30 @@ import 'package:flutter/foundation.dart'; -import 'hosted_environment.dart'; +import 'builderlab_build.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. +/// The organization's identity provider (a Builderlab tenant) of an internal +/// build. Trusted build configuration from [BuilderlabBuild], never supplied +/// by a deep link or relay. Organization relays get NIP-FI assertions from +/// this origin's account session. abstract final class OrganizationEnvironment { - static bool get enabled => origin != null; + static bool get enabled => + BuilderlabBuild.distribution == BuilderlabDistribution.internal; - /// `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'), - ); + /// The internal build's Builderlab, for example + /// `https://organization.example.com`. + static Uri? get origin => enabled ? BuilderlabBuild.origin : null; - /// `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'), - ); + /// Relay domains that trust this tenant. The first is the organization's + /// community, which the build's sign-in joins. + static List get relayHosts => + enabled ? BuilderlabBuild.relayDomains : const []; /// Whether this build attaches its organization session's assertions here. - static bool protects(Uri relay) => enabled && matches(relay, relayHosts); + static bool protects(Uri relay) => matches(relay, relayHosts); + /// Only the exact configured hosts, never their subdomains. @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) && + BuilderlabBuild.matchesRelay(relay, hosts) && 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_identity.dart b/mobile/lib/shared/hosted/organization_identity.dart new file mode 100644 index 0000000000..8125f055b0 --- /dev/null +++ b/mobile/lib/shared/hosted/organization_identity.dart @@ -0,0 +1,35 @@ +import 'builderlab_account.dart'; +import 'hosted_identity_service.dart'; + +/// The organization account's bound identity, which its relay admits and +/// nothing else. Null when the account has no binding yet: the tenant binds +/// the key of its first relay assertion. +Future organizationBoundPubkey(BuilderlabAccountClient account) async { + final response = await account.currentIdentity(); + final identity = response['identity']; + if (response['error'] != null) throw AccountFailure.invalidResponse; + if (identity == null) return null; + final pubkey = identity is Map ? identity['pubkey_hex'] : null; + if (pubkey is! String || !RegExp(r'^[a-fA-F0-9]{64}$').hasMatch(pubkey)) { + throw AccountFailure.invalidResponse; + } + return pubkey.toLowerCase(); +} + +/// The bound key from the organization's backups, for a member whose key is +/// not on this device: after signing out, or first use of this device. Null +/// when no openable backup holds that exact key. +Future recoverOrganizationKey( + BuilderlabAccountClient account, + String bound, +) async { + final Map recovered; + try { + recovered = await account.post('/v1/buzz/nsec-backups/recovery', {}); + } on AccountFailure catch (error) { + // A tenant without backups refuses definitively. + if (error == AccountFailure.rejected) return null; + rethrow; + } + return openRecoveredBackups(recovered)?[bound]?.keys.nsec; +} diff --git a/mobile/lib/shared/hosted/organization_sign_in.dart b/mobile/lib/shared/hosted/organization_sign_in.dart index 3dd85533fa..0a9f3353d2 100644 --- a/mobile/lib/shared/hosted/organization_sign_in.dart +++ b/mobile/lib/shared/hosted/organization_sign_in.dart @@ -57,6 +57,11 @@ class OrganizationSignIn extends ChangeNotifier { bool _initialized = false; bool _disposed = false; + /// A sign-out whose session is still saved. Until it is cleared, the + /// session counts as signed out and the next sign-in clears it first, so a + /// retired credential is never reused. + bool _retiring = false; + Future initialize() async { if (_initialized || _disposed) return; _initialized = true; @@ -64,15 +69,15 @@ class OrganizationSignIn extends ChangeNotifier { _subscriptions ..add(browser.codes.listen((code) => unawaited(_exchange(code)))) ..add(browser.failures.listen(_onBrowserFailure)); + // A sign-in or sign-out started meanwhile owns the status. + final attempt = _attempt; await browser.initialize(); try { - final restored = account.account ?? await account.restore(); - _set( - restored == null - ? OrganizationSessionStatus.signedOut - : OrganizationSessionStatus.signedIn, - ); + if (account.account == null) await account.restore(); + if (attempt != _attempt) return; + _set(_currentStatus()); } on AccountFailure catch (error) { + if (attempt != _attempt) return; // 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; @@ -84,7 +89,9 @@ class OrganizationSignIn extends ChangeNotifier { // 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, + clear: + _retiring || + (failure == AccountFailure.storage && account.account == null), ); /// Drops only the organization session and signs in again, for an account @@ -106,6 +113,7 @@ class OrganizationSignIn extends ChangeNotifier { _set(_currentStatus()); return; } + _retiring = false; if (_disposed || attempt != _attempt) return; } try { @@ -117,6 +125,25 @@ class OrganizationSignIn extends ChangeNotifier { } } + /// Ends the organization session, so the next sign-in goes through single + /// sign-on again. + Future signOut() async { + if (_disposed) return; + final attempt = ++_attempt; + failure = null; + _set(OrganizationSessionStatus.checking); + try { + await account.clearLocalSession(); + _retiring = false; + } on AccountFailure catch (error) { + _retiring = true; + if (attempt != _attempt) return; + failure = error; + } + if (attempt != _attempt) return; + _set(_currentStatus()); + } + Future cancel() async { ++_attempt; try { @@ -171,7 +198,8 @@ class OrganizationSignIn extends ChangeNotifier { _set(_currentStatus()); } - OrganizationSessionStatus _currentStatus() => account.account == null + OrganizationSessionStatus _currentStatus() => + account.account == null || _retiring ? OrganizationSessionStatus.signedOut : OrganizationSessionStatus.signedIn; diff --git a/mobile/lib/shared/hosted/organization_sign_out.dart b/mobile/lib/shared/hosted/organization_sign_out.dart new file mode 100644 index 0000000000..431647c4df --- /dev/null +++ b/mobile/lib/shared/hosted/organization_sign_out.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../community/community_provider.dart'; +import 'account_identity_store.dart'; +import '../widgets/confirmation_dialog.dart'; +import '../widgets/feedback_snackbar.dart'; + +/// Signs an internal build out of its organization. Removing the +/// organization's community returns to the Sign in landing, which ends the +/// organization session; it is the only way an internal build signs out. +/// +/// The community's key stays on this device as a saved identity, so signing +/// back in reuses it even when the organization holds no backup of it. +Future confirmOrganizationSignOut( + BuildContext context, + WidgetRef ref, +) async { + final messenger = ScaffoldMessenger.of(context); + final navigator = Navigator.of(context); + try { + final community = await ref.read(activeCommunityProvider.future); + if (community == null || !context.mounted) return; + final confirmed = await showDestructiveConfirmation( + context: context, + title: 'Sign out?', + message: 'You’ll need to sign in again to use Buzz on this device.', + confirmLabel: 'Sign out', + ); + if (!confirmed || !context.mounted) return; + final nsec = community.nsec; + if (nsec != null && nsec.isNotEmpty) { + // A failure here keeps the community rather than delete the only copy + // of its key. + await AccountIdentityStore() + .forPairing(AccountIdentityStore.publicKeyForNsec(nsec)) + .create(nsec); + } + await ref + .read(communityListProvider.notifier) + .removeCommunity(community.id); + // Reveal the Sign in landing under the page that signed out. + navigator.popUntil((route) => route.isFirst); + } catch (e) { + messenger.showFeedbackSnackBar( + SnackBar(content: Text('Failed to sign out: $e')), + kind: ToastKind.error, + ); + } +} diff --git a/mobile/lib/shared/hosted/self_hosted_entry.dart b/mobile/lib/shared/hosted/self_hosted_entry.dart index 9113991ed2..ccb987a482 100644 --- a/mobile/lib/shared/hosted/self_hosted_entry.dart +++ b/mobile/lib/shared/hosted/self_hosted_entry.dart @@ -85,7 +85,11 @@ class SelfHostedEntry { final key = _pendingKey(relay); final retained = await _storage.read(key: key); String? candidate = nsec; - if (savedPubkey != null) { + // A retained key for this exact identity is the one an interrupted + // attempt chose; it may exist nowhere else yet. + if (savedPubkey != null && + (retained == null || + AccountIdentityStore.publicKeyForNsec(retained) != savedPubkey)) { // Selection is explicit: resolve the exact key without requiring a // complete inventory of unrelated account slots. try { @@ -157,7 +161,11 @@ class SelfHostedEntry { }); /// Authentication must succeed before a new community becomes active. - static Future probeRelay(Community community) async { + /// [headers] go on the upgrade, for a relay that requires an assertion. + static Future probeRelay( + Community community, [ + Map headers = const {}, + ]) async { final socket = RelaySocket( wsUrl: community.relayUrl, nsec: community.nsec, @@ -166,6 +174,7 @@ class SelfHostedEntry { onDisconnected: (_) {}, ); try { + socket.headers = headers; await socket.connect().timeout(const Duration(seconds: 20)); if (socket.state != SocketState.connected) { throw StateError('Community refused entry'); diff --git a/mobile/test/features/onboarding/hosted_community_labels_test.dart b/mobile/test/features/onboarding/hosted_community_labels_test.dart index d7c69a8328..2512945f4e 100644 --- a/mobile/test/features/onboarding/hosted_community_labels_test.dart +++ b/mobile/test/features/onboarding/hosted_community_labels_test.dart @@ -19,11 +19,6 @@ void main() { for (final (host, name, expected) in [ ('mobile.communities.buzz.xyz', 'mobile.communities.buzz.xyz', 'mobile'), - ( - 'mobile.communities.public.builderlabstaging.xyz', - 'mobile.communities.public.builderlabstaging.xyz', - 'mobile', - ), ('mobile.communities.buzz.xyz', 'Mobile Team', 'Mobile Team'), ('chat.example.com', 'chat.example.com', 'chat.example.com'), ( diff --git a/mobile/test/features/onboarding/internal_build_entry_test.dart b/mobile/test/features/onboarding/internal_build_entry_test.dart new file mode 100644 index 0000000000..a64e30280e --- /dev/null +++ b/mobile/test/features/onboarding/internal_build_entry_test.dart @@ -0,0 +1,524 @@ +import 'dart:convert'; + +import 'package:buzz/features/onboarding/self_hosted_entry_sheet.dart'; +import 'package:buzz/features/settings/settings_page.dart'; +import 'package:buzz/onboarding_app_entry.dart'; +import 'package:buzz/shared/auth/auth.dart'; +import 'package:buzz/shared/hosted/account_identity_store.dart'; +import 'package:buzz/shared/hosted/browser_auth.dart'; +import 'package:buzz/shared/hosted/builderlab_account.dart'; +import 'package:buzz/shared/hosted/device_identity_source.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/push/push_bridge.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:nostr/nostr.dart' as nostr; + +import '../../helpers/widget_helpers.dart'; + +/// Runs as an internal build, for example: +/// `--dart-define=BUZZ_DISTRIBUTION=internal` +/// `--dart-define=BUZZ_BUILDERLAB_URL=https://id.example.org` +/// `--dart-define=BUZZ_BUILDERLAB_RELAYS=buzz.example.org` +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final skip = !OrganizationEnvironment.enabled; + + testWidgets( + 'one Sign in joins the organization; after signing out, Sign in reuses ' + 'the same identity from this device', + (tester) async { + FlutterSecureStorage.setMockInitialValues({}); + final tenant = _Tenant(); + final harness = _Harness(tester, tenant); + await harness.open(); + + expect(find.text('Sign in'), findsOneWidget); + for (final other in [ + 'Scan a QR code', + 'or sign in with email', + 'Enter a community link', + 'Create a community', + ]) { + expect(find.text(other), findsNothing, reason: other); + } + + // The first join is cancelled after its probe bound the new key. The + // retry must reuse that key, which exists only in the pending slot. + _Auth.dropNext = true; + await harness.signIn(); + expect(await harness.saved(), isEmpty); + final cancelled = harness.probes.single.community; + expect(tenant.bound, cancelled.pubkey); + await harness.signIn(); + + final login = harness.browserUrls.single; + expect(login.origin, OrganizationEnvironment.origin!.origin); + expect(login.path, '/api/goose/v1/auth/login'); + expect(login.queryParameters['product'], 'buzz'); + final first = await harness.joinedCommunity(); + expect(first.relayUrl, harness.relay); + expect(first.pubkey, cancelled.pubkey); + expect(tenant.recoveries, 0); + // The probe carried the session's assertion, which bound the new key. + expect( + harness.probes.last.headers['Nostr-Federated-Identity'], + 'Bearer assertion-${first.pubkey}', + ); + expect(tenant.bound, first.pubkey); + expect(harness.accounts.last.account, isNotNull); + + // The app backed the new key up to the organization on its own. + expect(tenant.backups.keys, [first.pubkey]); + expect( + tenant.backups[first.pubkey], + AccountIdentityStore.keysForNsec(first.nsec!).secret, + ); + + // Sign out, at the bottom of Settings, removes the community; the + // landing that replaces Home then ends the session. Clearing the + // notification extension's copy fails, so the session stays live. + const push = MethodChannel('buzz/push'); + final messenger = tester.binding.defaultBinaryMessenger; + var extensionFails = true; + messenger.setMockMethodCallHandler(push, (call) async { + final args = call.arguments; + if (extensionFails && + args is Map && + args['section'] == 'organizationSession' && + args['credential'] == null) { + throw PlatformException(code: 'unavailable'); + } + return null; + }); + addTearDown(() => messenger.setMockMethodCallHandler(push, null)); + await harness.open(child: const _SignedInGate()); + await harness.settle(); + await tester.tap(find.text('Home')); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.byKey(const Key('settings-sign-out')), + 200, + ); + await tester.tap(find.byKey(const Key('settings-sign-out'))); + await tester.pumpAndSettle(); + // Only iOS shares the session with its notification extension. + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + await tester.tap(find.widgetWithText(FilledButton, 'Sign out')); + await harness.settle(); + expect(await harness.saved(), isEmpty); + // The key stays on this device: sign-in must not depend on the backup, + // which a device without HPKE sealing never uploads. + tenant.backups.clear(); + // Signing out closes Settings, uncovering the Sign in landing. + expect(find.byType(SettingsPage), findsNothing); + expect(find.text('Sign in'), findsOneWidget); + expect(harness.accounts.last.account, isNotNull); + extensionFails = false; + + // Sign in must go through single sign-on again rather than reuse the + // credential that sign-out was retiring. + await harness.signIn(); + debugDefaultTargetPlatformOverride = null; + expect(harness.browserUrls, hasLength(2)); + final second = await harness.joinedCommunity(); + expect(second.pubkey, first.pubkey); + expect(tenant.recoveries, 0); + }, + skip: skip, + ); + + // Each row starts signed out with [stored] communities on other relays, + // which an internal build keeps but hides. + final rows = + < + ({ + String name, + String? Function(nostr.Keys stored) bound, + bool backedUp, + String? expectedError, + bool picks, + }) + >[ + ( + name: 'bound to a key on this device uses it without recovery', + bound: (stored) => stored.public, + backedUp: false, + expectedError: null, + picks: false, + ), + ( + name: 'bound to a key only in the organization backup recovers it', + bound: (_) => _otherKey.public, + backedUp: true, + expectedError: null, + picks: false, + ), + ( + name: 'bound to a key with no backup says it cannot restore', + bound: (_) => _otherKey.public, + backedUp: false, + expectedError: + 'Couldn’t restore your organization identity on this device. ' + 'Sign in on a device that has it, then try again.', + picks: false, + ), + ( + name: 'unbound with saved keys offers them, then joins', + bound: (_) => null, + backedUp: false, + expectedError: null, + picks: true, + ), + ]; + for (final row in rows) { + testWidgets(row.name, (tester) async { + FlutterSecureStorage.setMockInitialValues({}); + final stored = nostr.Keys.generate(); + await CommunityStorage(visible: (_) => true).saveAll([ + Community.create( + name: 'Elsewhere', + relayUrl: 'wss://relay.example.net', + pubkey: stored.public, + nsec: stored.nsec, + ), + ]); + final tenant = _Tenant()..bound = row.bound(stored); + if (row.backedUp) tenant.backups[_otherKey.public] = _otherKey.secret; + final harness = _Harness(tester, tenant); + await harness.open(); + await harness.signIn(); + + if (row.picks) { + await tester.tap(find.bySemanticsLabel(RegExp('^Use '))); + await tester.pumpAndSettle(); + } + if (row.expectedError != null) { + expect(find.text(row.expectedError!), findsOneWidget); + expect(harness.probes, isEmpty); + expect(await harness.saved(), isEmpty); + return; + } + final joined = await harness.joinedCommunity(); + final expected = tenant.bound ?? stored.public; + expect(joined.pubkey, expected); + expect(tenant.bound, expected); + expect( + harness.probes.single.headers['Nostr-Federated-Identity'], + 'Bearer assertion-$expected', + ); + expect(tenant.recoveries, row.backedUp ? 1 : 0); + }, skip: skip); + } + + test('communities on other relays stay stored but hidden and are never ' + 'queried', () async { + FlutterSecureStorage.setMockInitialValues({}); + final storage = CommunityStorage(); + final keys = nostr.Keys.generate(); + final organization = Community.create( + name: 'Organization', + relayUrl: 'wss://${OrganizationEnvironment.relayHosts.first}', + ); + final other = Community.create( + name: 'Other', + relayUrl: 'wss://relay.example.net', + pubkey: keys.public, + nsec: keys.nsec, + ); + final everything = CommunityStorage(visible: (_) => true); + await everything.saveAll([other, organization]); + + expect((await storage.loadAll()).map((item) => item.id), [organization.id]); + + // A saved identity's profile is never fetched from a hidden relay. + final queried = []; + await DeviceIdentitySource().loadProfile(keys.public, (config) async { + queried.add(config.baseUrl); + return null; + }); + expect(queried, isEmpty); + + // Removing the organization's community keeps the hidden one stored. + await storage.remove(organization.id); + expect(await storage.loadAll(), isEmpty); + expect((await everything.loadAll()).map((item) => item.id), [other.id]); + }, skip: skip); +} + +final _otherKey = nostr.Keys.generate(); + +/// The organization's Builderlab at its HTTP boundary: one member whose +/// account binds the key of its first relay assertion and refuses any other. +/// It holds each backup as the secret key hex that the app sealed for it. +class _Tenant { + String? bound; + final backups = {}; + String? sealed; + var enrollments = 0; + var recoveries = 0; + final expiry = DateTime.now().toUtc().add(const Duration(days: 1)); + + late final client = MockClient((request) async { + expect(request.url.origin, OrganizationEnvironment.origin!.origin); + final path = request.url.path.replaceFirst('/api/goose', ''); + final Object result; + switch (path) { + case '/v1/auth/login/exchange': + result = { + 'session_credential': 'session', + 'expires_at': expiry.toIso8601String(), + }; + case '/v1/auth/me': + result = {'subject': 'member', 'expires_at': expiry.toIso8601String()}; + case '/v1/buzz/nostr-identities/current': + result = { + 'identity': bound == null ? null : {'pubkey_hex': bound}, + }; + case '/v1/buzz/identity/assertions': + final pubkey = + (jsonDecode(request.body) as Map)['nostr_pubkey'] as String; + bound ??= pubkey; + if (bound != pubkey) return http.Response('{}', 403); + result = { + 'assertion': 'assertion-$pubkey', + 'expires_at': expiry.millisecondsSinceEpoch ~/ 1000, + 'nostr_pubkey': pubkey, + }; + case '/v1/buzz/nsec-backups/status': + result = { + 'backup_nostr_pubkeys': backups.keys.toList(), + 'current_nostr_pubkey': bound, + }; + case '/v1/buzz/nsec-backups/enrollment': + enrollments++; + result = { + 'version': 1, + 'suite': {'kem_id': 16, 'kdf_id': 1, 'aead_id': 2}, + 'recipient_key_id': 'test-key', + 'service_namespace': 'buzz', + 'owner_id': 'member', + 'backup_id': + '12345678-1234-1234-1234-${enrollments.toString().padLeft(12, '0')}', + 'nostr_pubkey': bound, + 'recipient_public_key': base64Url + .encode([4, ...List.filled(64, 1)]) + .replaceAll('=', ''), + }; + case '/v1/buzz/nsec-backups': + final envelope = jsonDecode(request.body) as Map; + backups[envelope['nostr_pubkey'] as String] = sealed!; + result = {'stored': true, 'backup_id': envelope['backup_id']}; + case '/v1/buzz/nsec-backups/recovery': + recoveries++; + result = { + 'backups': [ + for (final MapEntry(key: pubkey, value: secret) in backups.entries) + { + 'backup_id': 'backup-$pubkey', + 'nostr_pubkey': pubkey, + 'secret_key': base64Url + .encode(_hexBytes(secret)) + .replaceAll('=', ''), + }, + ], + 'failed_backup_ids': [], + }; + default: + return http.Response('{}', 404); + } + return http.Response( + jsonEncode(result), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + static List _hexBytes(String hex) => [ + for (var i = 0; i < hex.length; i += 2) + int.parse(hex.substring(i, i + 2), radix: 16), + ]; +} + +class _Harness { + _Harness(this.tester, this.tenant); + final WidgetTester tester; + final _Tenant tenant; + final relay = 'wss://${OrganizationEnvironment.relayHosts.first}'; + final browserUrls = []; + final probes = <({Community community, Map headers})>[]; + final accounts = []; + + Future> saved() => CommunityStorage().loadAll(); + + /// The organization's community, durably saved by the join. + Future joinedCommunity() async { + final communities = await saved(); + expect(communities, hasLength(1)); + expect(probes.last.community.nsec, communities.single.nsec); + return communities.single; + } + + Future open({Widget child = const OnboardingAppEntry()}) async { + final origin = OrganizationEnvironment.origin!; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('buzz/browser_auth'), + (_) async => null, + ); + // Sealing records the plaintext so the fake tenant can later recover it. + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('buzz/hosted_hpke'), + (call) async { + if (call.method == 'isSupported') return true; + final plaintext = (call.arguments as Map)['plaintext'] as Uint8List; + tenant.sealed = [ + for (final byte in plaintext) byte.toRadixString(16).padLeft(2, '0'), + ].join(); + return { + 'enc': Uint8List.fromList([4, ...List.filled(64, 2)]), + 'ciphertext': Uint8List(48), + }; + }, + ); + await tester.pumpWidget(const SizedBox()); + await tester.pumpWidget( + WidgetHelpers.testable( + disableAnimations: true, + overrides: [ + organizationAccountProvider.overrideWith((ref) { + final account = BuilderlabAccountClient( + accountOrigin: origin, + httpClient: tenant.client, + onSessionChanged: (credential) => + shareOrganizationSessionWithNotifications( + origin: origin, + credential: credential, + relayHosts: OrganizationEnvironment.relayHosts, + ), + ); + accounts.add(account); + ref.onDispose(account.dispose); + return account; + }), + organizationSignInProvider.overrideWith((ref) { + final signIn = OrganizationSignIn( + account: ref.watch(organizationAccountProvider)!, + browser: _Browser(browserUrls.add, origin: origin), + reconnect: () async {}, + ); + ref.onDispose(signIn.dispose); + signIn.initialize(); + return signIn; + }), + relayProbeProvider.overrideWithValue( + (community, headers) async => + probes.add((community: community, headers: headers)), + ), + authProvider.overrideWith(_Auth.new), + communityStorageProvider.overrideWithValue(CommunityStorage()), + ], + child: child, + ), + ); + await tester.pumpAndSettle(); + } + + Future signIn() async { + await tester.tap(find.text('Sign in')); + await tester.pumpAndSettle(); + await settle(); + } + + /// Secure storage and HTTP complete outside the fake clock. + Future settle() async { + for (var i = 0; i < 5; i++) { + await tester.runAsync(() => Future.delayed(Duration.zero)); + await tester.pumpAndSettle(); + } + } +} + +/// Home while the organization's community is saved, else the landing, as +/// the app's root does. +class _SignedInGate extends ConsumerWidget { + const _SignedInGate(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // As HostedAppGate does, keep the organization session alive under Home. + ref.watch(organizationSignInProvider); + return ref + .watch(activeCommunityProvider) + .when( + data: (community) => community == null + ? const OnboardingAppEntry() + : TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SettingsPage( + profileHeader: const SizedBox.shrink(), + identityRecoveryPageBuilder: (_) => + const SizedBox.shrink(), + ), + ), + ), + child: const Text('Home'), + ), + loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + ); + } +} + +class _Browser extends BrowserAuth { + _Browser(void Function(Uri) onStart, {super.origin}) + : super( + openBrowser: (uri) async { + onStart(uri); + return Uri.parse(BrowserAuth.callback) + .replace( + queryParameters: { + 'state': uri.queryParameters['state']!, + 'code': 'synthetic-code', + }, + ) + .toString(); + }, + ); + @override + Future initialize() async {} +} + +/// Admission at the relay boundary: saves the community as the real +/// notifier does once the relay accepts it. +class _Auth extends AuthNotifier { + /// The next admission returns without saving, as a cancelled one does. + static var dropNext = false; + + @override + Future build() async => + const AuthState(status: AuthStatus.unauthenticated); + + @override + Future authenticateWithCommunity( + Community community, { + bool Function()? shouldContinue, + bool activate = true, + }) async { + if (dropNext) { + dropNext = false; + return; + } + final storage = CommunityStorage(); + await storage.saveAll([...await storage.loadAll(), community]); + } +} diff --git a/mobile/test/helpers/hosted_journey_fixture.dart b/mobile/test/helpers/hosted_journey_fixture.dart index ab453c419a..9203b74465 100644 --- a/mobile/test/helpers/hosted_journey_fixture.dart +++ b/mobile/test/helpers/hosted_journey_fixture.dart @@ -84,8 +84,8 @@ class HostedJourneyFixture { bool refuseUpgrade = false; String subject = 'journey-owner'; - /// A separate organization realm, as BUZZ_ORGANIZATION_URL and - /// BUZZ_ORGANIZATION_RELAYS configure. Enable before [mount]. + /// An organization realm, as an internal build's BUZZ_BUILDERLAB_URL and + /// BUZZ_BUILDERLAB_RELAYS configure. Enable before [mount]. static final organizationOrigin = Uri.parse('https://block.example.com'); static const organizationRelayHost = 'buzz.block.example.com'; bool organizationEnabled = false; diff --git a/mobile/test/shared/deeplink/agent_creation_link_test.dart b/mobile/test/shared/deeplink/agent_creation_link_test.dart index f13a5af77b..c93de13b81 100644 --- a/mobile/test/shared/deeplink/agent_creation_link_test.dart +++ b/mobile/test/shared/deeplink/agent_creation_link_test.dart @@ -180,7 +180,7 @@ void main() { 'v': 1, 'id': 'av_1', 'name': 'Scout', - 'avatarUrl': HostedEnvironment.avatarsCdnOrigin + 'avatarUrl': HostedEnvironment.avatarsCdnOrigin! .replace(path: '/av_1/r/avatar.png') .toString(), }), @@ -190,7 +190,7 @@ void main() { expect(result, isA()); expect( seen.url, - HostedEnvironment.avatarsCdnOrigin.replace(path: '/av_1/agent.json'), + HostedEnvironment.avatarsCdnOrigin!.replace(path: '/av_1/agent.json'), ); expect(seen.followRedirects, isFalse); }); diff --git a/mobile/test/shared/hosted/builderlab_build_test.dart b/mobile/test/shared/hosted/builderlab_build_test.dart new file mode 100644 index 0000000000..5fe94e0b60 --- /dev/null +++ b/mobile/test/shared/hosted/builderlab_build_test.dart @@ -0,0 +1,92 @@ +import 'package:buzz/shared/hosted/builderlab_build.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const relays = 'communities.example.org'; + + test('distribution, origin and relays are configured together', () { + final none = BuilderlabBuild.parse(distribution: '', url: '', relays: ''); + expect(none.distribution, isNull); + expect(none.origin, isNull); + expect(none.relayDomains, isEmpty); + + final public = BuilderlabBuild.parse( + distribution: 'public', + url: 'https://id.example.org/', + relays: ' Buzz.Example.org ,other.example.org.', + ); + expect(public.distribution, BuilderlabDistribution.public); + expect(public.origin, Uri.parse('https://id.example.org')); + expect(public.relayDomains, ['buzz.example.org', 'other.example.org']); + + final internal = BuilderlabBuild.parse( + distribution: 'internal', + url: 'https://id.example.org', + relays: 'Buzz.Example.org.', + ); + expect(internal.distribution, BuilderlabDistribution.internal); + expect(internal.relayDomains, ['buzz.example.org']); + + for (final (distribution, url, relays) in [ + ('public', 'https://id.example.org', ''), + ('public', '', relays), + ('', 'https://id.example.org', relays), + ('enterprise', 'https://id.example.org', relays), + ('public', 'http://id.example.org', relays), + ('public', 'https://id.example.org/api', relays), + ('public', 'https://id.example.org:8443', relays), + ('public', 'https://id.example.org?x=1', relays), + ('public', 'https://id.example.org', 'wss://$relays'), + ('public', 'https://id.example.org', '*.example.org'), + ('public', 'https://id.example.org', 'localhost'), + ('internal', 'https://id.example.org', 'buzz.example.org,b.example.org'), + ('internal', 'https://app.builderlab.xyz', 'buzz.example.org'), + ]) { + expect( + () => BuilderlabBuild.parse( + distribution: distribution, + url: url, + relays: relays, + ), + throwsStateError, + reason: '$distribution $url $relays', + ); + } + expect( + () => BuilderlabBuild.parse( + distribution: '', + url: '', + relays: '', + retired: ['BUZZ_ORGANIZATION_URL'], + ), + throwsStateError, + ); + }); + + test('relays match configured domains and subdomains, never suffixes', () { + bool matches(String url) => + BuilderlabBuild.matchesRelay(Uri.parse(url), [relays]); + for (final url in [ + 'wss://$relays', + 'wss://team.$relays', + 'wss://TEAM.${relays.toUpperCase()}', + 'https://team.$relays/', + 'wss://team.$relays:443', + ]) { + expect(matches(url), isTrue, reason: url); + } + for (final url in [ + 'wss://team.$relays.attacker.test', + 'wss://team.$relays.', + 'wss://not$relays', + 'wss://team.$relays:8443', + 'ws://team.$relays', + 'wss://user@team.$relays', + 'wss://team.$relays/path', + 'wss://team.$relays?account=other', + 'wss://team.$relays#fragment', + ]) { + expect(matches(url), isFalse, reason: url); + } + }); +} diff --git a/mobile/test/shared/hosted/hosted_environment_test.dart b/mobile/test/shared/hosted/hosted_environment_test.dart deleted file mode 100644 index 8afc89f813..0000000000 --- a/mobile/test/shared/hosted/hosted_environment_test.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'package:buzz/shared/hosted/hosted_environment.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - test('community realms map only to their trusted account origin', () { - expect( - HostedEnvironment.accountOriginFor( - Uri.parse('wss://team.communities.buzz.xyz'), - ), - Uri.parse('https://app.builderlab.xyz'), - ); - expect( - HostedEnvironment.accountOriginFor( - Uri.parse('wss://team.communities.public.builderlabstaging.xyz'), - ), - Uri.parse('https://app.builderlabstaging.xyz'), - ); - for (final value in [ - 'wss://team.communities.public.builderlabstaging.xyz.attacker.test', - 'wss://communities.public.builderlabstaging.xyz', - 'wss://team.communities.public.builderlabstaging.xyz.', - 'wss://team.communities.public.builderlabstaging.xyz:8443', - 'ws://team.communities.public.builderlabstaging.xyz', - 'wss://user@team.communities.public.builderlabstaging.xyz', - 'wss://team.communities.public.builderlabstaging.xyz/path', - 'wss://team.communities.public.builderlabstaging.xyz?account=other', - 'wss://team.communities.public.builderlabstaging.xyz#fragment', - 'wss://team.builderlabstaging.xyz', - ]) { - expect( - HostedEnvironment.accountOriginFor(Uri.parse(value)), - isNull, - reason: value, - ); - } - }); - - test('authentication follows only configured DNS domain boundaries', () { - expect(HostedEnvironment.enabled, HostedEnvironment.authDomains.isNotEmpty); - for (final domain in HostedEnvironment.authDomains) { - for (final host in [ - domain, - 'team.$domain', - 'TEAM.${domain.toUpperCase()}.', - ]) { - expect(HostedEnvironment.requiresAuth(host), isTrue, reason: host); - } - expect(HostedEnvironment.requiresAuth('$domain.attacker.test'), isFalse); - expect(HostedEnvironment.requiresAuth('not$domain'), isFalse); - } - if (HostedEnvironment.authDomains.isEmpty) { - for (final host in [ - 'buzz.xyz', - 'team.communities.buzz.xyz', - 'team.communities.public.builderlabstaging.xyz', - ]) { - expect(HostedEnvironment.requiresAuth(host), isFalse); - } - } - }); -} diff --git a/mobile/test/shared/hosted/organization_key_backup_test.dart b/mobile/test/shared/hosted/organization_key_backup_test.dart index 392708e077..b853b2592a 100644 --- a/mobile/test/shared/hosted/organization_key_backup_test.dart +++ b/mobile/test/shared/hosted/organization_key_backup_test.dart @@ -5,7 +5,7 @@ import 'package:buzz/shared/hosted/builderlab_account.dart'; import 'package:buzz/shared/hosted/hosted_backup_service.dart'; import 'package:buzz/shared/hosted/hosted_relay_auth.dart'; import 'package:buzz/shared/hosted/hpke_backup.dart'; -import 'package:buzz/shared/hosted/organization_environment.dart'; +import 'package:buzz/shared/hosted/builderlab_build.dart'; import 'package:buzz/shared/hosted/organization_key_backup.dart'; import 'package:buzz/shared/relay/relay_provider.dart'; import 'package:flutter/services.dart'; @@ -128,7 +128,7 @@ void main() { organization: backup.account, organizationBackup: backup, organizationRelay: (relay) => - OrganizationEnvironment.matches(relay, [relayHost]), + BuilderlabBuild.matchesRelay(relay, [relayHost]), enabled: true, ); expect(await auth.headers(relay), { diff --git a/mobile/test/shared/hosted/organization_relay_auth_test.dart b/mobile/test/shared/hosted/organization_relay_auth_test.dart index 213a593b9f..178288eec0 100644 --- a/mobile/test/shared/hosted/organization_relay_auth_test.dart +++ b/mobile/test/shared/hosted/organization_relay_auth_test.dart @@ -27,63 +27,16 @@ void main() { 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); - }); + test('organization relays match only the configured host', () { + 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', () { @@ -401,6 +354,42 @@ void main() { } }); + test('after sign-out only a push stop for a bound key gets an assertion ' + 'and nothing can sign back in', () async { + await saveSession(); + final account = organizationClient(); + final auth = relayAuth(account); + boundIdentity = {'pubkey_hex': keys.public}; + expect(await auth.headers(relay), isNotEmpty); + + await account.clearLocalSession(); + expect( + await const FlutterSecureStorage().read( + key: 'buzz_hosted_session_block_example_com_v1', + ), + isNull, + ); + expect(await account.restore(), isNull); + expect(account.account, isNull); + requests.clear(); + tokenFor = (_) => 'push-stop-token'; + expect(await auth.headers(relay, background: true), { + 'Nostr-Federated-Identity': 'Bearer push-stop-token', + }); + expect(requests.last.headers['X-BB-Session-Credential'], 'org-secret'); + expect(await auth.headers(relay), isEmpty); + expect(account.account, isNull); + + // A key the account never bound gets nothing. + boundIdentity = null; + requests.clear(); + expect(await auth.headers(relay, background: true), isEmpty); + expect( + requests.map((request) => request.url.path), + isNot(contains('/api/goose/v1/buzz/identity/assertions')), + ); + }); + test('other relays never receive the organization assertion', () async { await saveSession(); final auth = relayAuth(organizationClient()); diff --git a/mobile/test/shared/hosted/organization_sign_in_test.dart b/mobile/test/shared/hosted/organization_sign_in_test.dart index 3b1c808fb5..92a700065f 100644 --- a/mobile/test/shared/hosted/organization_sign_in_test.dart +++ b/mobile/test/shared/hosted/organization_sign_in_test.dart @@ -239,6 +239,63 @@ void main() { expect(account.account?.subject, 'member'); signIn.dispose(); }); + + test( + 'a sign-out that cannot clear the saved session at launch keeps ' + 'the session signed out, so Sign in goes through single sign-on', + () async { + await const FlutterSecureStorage().write( + key: orgSessionKey, + value: jsonEncode({ + 'origin': organization.toString(), + 'credential': 'org-secret', + 'subject': 'member', + 'expires_at': expiry.toIso8601String(), + }), + ); + final browserReady = Completer(); + final logins = []; + final signIn = OrganizationSignIn( + account: BuilderlabAccountClient( + accountOrigin: organization, + storage: _FailingDeleteStorage(orgSessionKey), + httpClient: MockClient((request) async => me()), + ), + browser: _SlowBrowser( + browserReady.future, + origin: organization, + openBrowser: (uri) async { + logins.add(uri); + return null; + }, + ), + reconnect: () async {}, + ); + // The landing signs out while browser setup still delays the restore. + final initialized = signIn.initialize(); + await signIn.signOut(); + browserReady.complete(); + await initialized; + expect(signIn.account.account, isNotNull); + expect(signIn.status, OrganizationSessionStatus.signedOut); + + // Sign in retries the clear rather than reuse the session. + await signIn.signIn(); + expect(logins, isEmpty); + expect(signIn.failure, AccountFailure.storage); + expect(signIn.status, OrganizationSessionStatus.signedOut); + signIn.dispose(); + }, + ); +} + +/// Browser setup that completes only when [ready] does. +class _SlowBrowser extends BrowserAuth { + _SlowBrowser(this.ready, {super.origin, super.openBrowser}); + final Future ready; + + @override + Future initialize() => ready; } /// Fails deletions of keys with [prefix], as an unavailable Keychain would. diff --git a/mobile/test/shared/hosted/relay_auth_isolation_test.dart b/mobile/test/shared/hosted/relay_auth_isolation_test.dart index 009fe48c9c..599784e1e6 100644 --- a/mobile/test/shared/hosted/relay_auth_isolation_test.dart +++ b/mobile/test/shared/hosted/relay_auth_isolation_test.dart @@ -4,7 +4,7 @@ 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/hosted/builderlab_build.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'; @@ -91,7 +91,7 @@ void main() { client(stall: false), organization: client(stall: true), organizationRelay: (relay) => - OrganizationEnvironment.matches(relay, [relayHost]), + BuilderlabBuild.matchesRelay(relay, [relayHost]), enabled: true, discover: (_) async => true, );