mirror of
https://github.com/block/buzz.git
synced 2026-11-02 07:22:55 +00:00
feat(mobile): organization sign-in for NIP-FI relays (#8700)
Block is turning on NIP-FI enforcement on the Block relay. Desktop gets organization sign-in in block/buzz-app#1424. Mobile can already attach NIP-FI assertions, but only for Builderlab-hosted communities, through the build's single Builderlab account. The dogfood build needs that account for public hosted communities, so it can't simply be pointed at the Block tenant. This adds a separate **organization realm**, configured at build time: - `BUZZ_ORGANIZATION_URL`: the tenant's HTTPS origin (for example `https://block.builderlab.xyz`). - `BUZZ_ORGANIZATION_RELAYS`: the exact relay hosts that trust that tenant. The two must be set together. The app refuses to start if they're malformed, overlap a hosted community, or reuse the hosted origin. Without them, nothing changes. For a configured relay: - The app keeps its own organization session and signs in through the browser at the tenant's existing login (`/api/goose/v1/auth/login`). Block SSO happens there. - It requests assertions from the same `/v1/buzz/identity/assertions` route that hosted communities use, and attaches them to the relay socket, HTTP, media, huddle and remote-agent requests. - Unlike the hosted path, it doesn't require an identity backup before the first binding. As on desktop, the tenant binds the member's existing key on first issuance. A key already bound to a different one is refused before any request is sent. - When signed out, it connects without an assertion and lets the relay decide. A bottom banner offers **Sign in**. After sign-in, the relay reconnects with a fresh assertion. Hosted communities keep their existing banner and flow. The tenant accepts only the registered `xyz.block.buzz.mobile:/auth/callback` return URL, so both realms share it. Each `BrowserAuth` owner leaves callbacks whose `state` belongs to another owner's attempt to that owner. ### Before merge - [ ] Kenny Lopez reviews the visible changes. ### Validation Added tests (`organization_relay_auth_test.dart`): - Configuration parsing and exact host matching. - An assertion is issued for the exact relay and key, then cached. - A different bound key is refused. - Signed out connects without an assertion. - Other relays never receive the assertion. - The shared callback is routed between the hosted and organization owners. Full `mobile-check` and `mobile-test` pass. RunnerTests pass locally except `testNativeNavigationTitleExpandsAgainAtTop`, which this PR doesn't touch. Added tests (`organization_sign_in_test.dart`, `relay_auth_isolation_test.dart`, `builderlab_account_test.dart`), each failing without its fix: - Concurrent account restores at cold start share one adopted account. - An exchange from a cancelled attempt never changes a newer sign-in. - Storage failures while clearing the intent or cancelling leave the busy state. - An unreadable saved organization session is replaced by signing in again. - A stalled organization issuer never delays a hosted relay's assertion. This test needs `BUZZ_AUTH_DOMAINS` and skips without it. CI's configured-auth-domains step doesn't list it yet, because that workflow change needs a token with `workflow` scope. Added tests also cover a restore after an offline start (the banner clears once a relay retry restores the session) and the iOS bridge allowlist (`/auth/native` and tenant login on Builderlab subdomains; other hosts, paths, ports, userinfo and fragments refused). Simulator (iPhone 17, iOS 27) with the dogfood defines against the production Block relay `buzz.block.builderlab.xyz`, on a WARP Mac: - The app starts with the organization configuration and connects to the relay while signed out, showing the organization **Sign in** banner. - **Sign in** opens the system sign-in session for `block.builderlab.xyz`, and the banner shows **Finishing organization sign-in…**. Cancelling returns to **Sign in** with no error. Before the bridge fix, no browser opened and the banner reported that sign-in did not finish. - The bottom banners (organization sign-in, hosted reconnect, and the sign-in recovery row) now sit above Home's floating Search/New bar, which used to cover **Sign in**. A journey test checks the banner clears the bar, and fails without the fix.  - The simulator run stopped at the system consent prompt (SSO does not work in the simulator). The app calls were driven through the VM service (the same `signIn()`/`cancel()` the banner buttons call) because the simulator could not be tapped. Device (iPhone 12 mini) against bb-block staging in enforce mode, where every probe without an assertion gets 401: - Signed out, the organization banner shows and the app stays responsive. Cancel at the consent prompt returns to **Sign in**, and retry works. - Block SSO through Okta completes, and the relay admits the reconnect with the assertion. - After relaunch the app connects, loads channel history, and shows no banner for over 60 seconds. - Before the restore fix, 2 of 4 cold starts showed "cannot use this community with this key" although the session was valid. With it, 8 of 8 cold starts connected. Known limitation: adding an enforcing organization relay by community link is tracked in #8791. The dogfood build enables this in squareup/buzz-releases (`tomb/dogfood-organization-sign-in`). App Store builds stay unchanged. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1
.github/workflows/_ci-clients.yml
vendored
1
.github/workflows/_ci-clients.yml
vendored
@@ -148,6 +148,7 @@ jobs:
|
||||
test/features/onboarding/onboarding_entry_routes_test.dart
|
||||
test/shared/hosted/hosted_environment_test.dart
|
||||
test/shared/hosted/self_hosted_entry_test.dart
|
||||
test/shared/hosted/relay_auth_isolation_test.dart
|
||||
test/features/onboarding/hosted_app_gate_environment_test.dart
|
||||
test/features/onboarding/hosted_journeys_test.dart
|
||||
test/features/onboarding/hosted_communities_test.dart
|
||||
|
||||
@@ -35,10 +35,7 @@ final class BuzzBrowserAuthBridge: NSObject, ASWebAuthenticationPresentationCont
|
||||
guard self.session == nil,
|
||||
let args = call.arguments as? [String: String],
|
||||
let raw = args["url"], let url = URL(string: raw),
|
||||
url.scheme == "https",
|
||||
["app.builderlab.xyz", "app.builderlabstaging.xyz"].contains(url.host ?? ""),
|
||||
url.path == "/api/goose/v1/auth/login", url.user == nil, url.password == nil,
|
||||
url.port == nil, url.fragment == nil
|
||||
Self.allowsLogin(url)
|
||||
else { result(FlutterError(code: "unavailable", message: nil, details: nil)); return }
|
||||
self.pending = result
|
||||
let attemptID = UUID()
|
||||
@@ -61,6 +58,21 @@ final class BuzzBrowserAuthBridge: NSObject, ASWebAuthenticationPresentationCont
|
||||
}
|
||||
}
|
||||
|
||||
/// Builderlab login pages only: the public account's `/auth/native`, and a
|
||||
/// tenant's CLI login on its own host for organization sign-in.
|
||||
static func allowsLogin(_ url: URL) -> Bool {
|
||||
guard url.scheme == "https", let host = url.host?.lowercased(),
|
||||
url.user == nil, url.password == nil, url.port == nil, url.fragment == nil
|
||||
else { return false }
|
||||
let publicHosts = ["app.builderlab.xyz", "app.builderlabstaging.xyz"]
|
||||
if publicHosts.contains(host) { return url.path == "/auth/native" }
|
||||
let tenant = ["builderlab.xyz", "builderlabstaging.xyz"].contains { domain in
|
||||
let label = host.dropLast(domain.count + 1)
|
||||
return host.hasSuffix(".\(domain)") && !label.isEmpty && !label.contains(".")
|
||||
}
|
||||
return tenant && url.path == "/api/goose/v1/auth/login"
|
||||
}
|
||||
|
||||
// Safari delivers a deep link before UIKit finishes activating our app.
|
||||
// ASWebAuthenticationSession.start() rejects presentation during that handoff.
|
||||
private func startWhenActive(_ id: UUID) {
|
||||
|
||||
@@ -12,7 +12,7 @@ final class BrowserAuthBridgeTests: XCTestCase {
|
||||
let bridge = BuzzBrowserAuthBridge(messenger: messenger, isActive: { active },
|
||||
startSession: { _ in starts += 1; return true })
|
||||
defer { withExtendedLifetime(bridge) {} }
|
||||
let arguments = ["url": "https://app.builderlabstaging.xyz/api/goose/v1/auth/login"]
|
||||
let arguments = ["url": "https://app.builderlabstaging.xyz/auth/native"]
|
||||
var results: [String] = []
|
||||
messenger.invoke("start", arguments: arguments) {
|
||||
results.append(($0 as? FlutterError)?.code ?? "success")
|
||||
@@ -42,6 +42,32 @@ final class BrowserAuthBridgeTests: XCTestCase {
|
||||
XCTAssertEqual(starts, 2, "Ordinary active-app login should start immediately")
|
||||
messenger.invoke("cancel", arguments: [:]) { _ in }
|
||||
}
|
||||
|
||||
func testAllowsOnlyBuilderlabLoginPages() {
|
||||
for allowed in [
|
||||
"https://app.builderlab.xyz/auth/native?state=s",
|
||||
"https://app.builderlabstaging.xyz/auth/native",
|
||||
"https://block.builderlab.xyz/api/goose/v1/auth/login?type=cli",
|
||||
"https://block.builderlabstaging.xyz/api/goose/v1/auth/login",
|
||||
] {
|
||||
XCTAssertTrue(BuzzBrowserAuthBridge.allowsLogin(URL(string: allowed)!), allowed)
|
||||
}
|
||||
for refused in [
|
||||
"http://block.builderlab.xyz/api/goose/v1/auth/login",
|
||||
"https://builderlab.xyz/auth/native",
|
||||
"https://evilbuilderlab.xyz/api/goose/v1/auth/login",
|
||||
"https://block.builderlab.xyz.example.com/api/goose/v1/auth/login",
|
||||
"https://block.builderlab.xyz/api/goose/v1/auth/me",
|
||||
"https://app.builderlab.xyz/api/goose/v1/auth/login",
|
||||
"https://block.builderlab.xyz/auth/native",
|
||||
"https://a.block.builderlab.xyz/api/goose/v1/auth/login",
|
||||
"https://app.builderlab.xyz:8443/auth/native",
|
||||
"https://user@app.builderlab.xyz/auth/native",
|
||||
"https://app.builderlab.xyz/auth/native#fragment",
|
||||
] {
|
||||
XCTAssertFalse(BuzzBrowserAuthBridge.allowsLogin(URL(string: refused)!), refused)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class BrowserAuthTestMessenger: NSObject, FlutterBinaryMessenger {
|
||||
|
||||
@@ -177,10 +177,7 @@ class HomePage extends HookConsumerWidget {
|
||||
communitySurfaceProgress.value = progress;
|
||||
},
|
||||
);
|
||||
final bottom =
|
||||
MediaQuery.viewPaddingOf(context).bottom > mobileTabBarBottomGap
|
||||
? MediaQuery.viewPaddingOf(context).bottom
|
||||
: mobileTabBarBottomGap;
|
||||
final bottom = mobileTabBarBottom(context);
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
// Register the floating controls with Scaffold so snackbars clear them.
|
||||
|
||||
@@ -22,7 +22,9 @@ class HostedReconnectBanner extends HookConsumerWidget {
|
||||
final auth = ref.watch(hostedRelayAuthProvider);
|
||||
useListenable(auth);
|
||||
final config = ref.watch(relayConfigProvider);
|
||||
// Organization relays have their own sign-in banner.
|
||||
final active =
|
||||
!auth.organizationProtects(config) &&
|
||||
auth.failedRelay == config.baseUrl &&
|
||||
auth.failedPubkey == pubkeyFromNsec(config.nsec) &&
|
||||
auth.failure != null;
|
||||
|
||||
105
mobile/lib/features/onboarding/organization_sign_in_banner.dart
Normal file
105
mobile/lib/features/onboarding/organization_sign_in_banner.dart
Normal file
@@ -0,0 +1,105 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../shared/hosted/builderlab_account.dart';
|
||||
import '../../shared/hosted/hosted_relay_auth.dart';
|
||||
import '../../shared/hosted/organization_sign_in.dart';
|
||||
import '../../shared/relay/relay_provider.dart';
|
||||
import '../../shared/relay/relay_session.dart';
|
||||
import '../../shared/theme/app_theme.dart';
|
||||
import '../../shared/theme/grid.dart';
|
||||
|
||||
/// Offers organization sign-in for the active community when it trusts the
|
||||
/// build's organization. Cached content stays available underneath.
|
||||
class OrganizationSignInBanner extends HookConsumerWidget {
|
||||
const OrganizationSignInBanner({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final signIn = ref.watch(organizationSignInProvider);
|
||||
final auth = ref.watch(hostedRelayAuthProvider);
|
||||
useListenable(signIn);
|
||||
useListenable(auth);
|
||||
final config = ref.watch(relayConfigProvider);
|
||||
if (signIn == null || !auth.organizationProtects(config)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final relayFailure =
|
||||
auth.failedRelay == config.baseUrl &&
|
||||
auth.failedPubkey == pubkeyFromNsec(config.nsec)
|
||||
? auth.failure
|
||||
: null;
|
||||
final String message;
|
||||
final List<Widget> actions;
|
||||
switch (signIn.status) {
|
||||
case OrganizationSessionStatus.checking:
|
||||
return const SizedBox.shrink();
|
||||
case OrganizationSessionStatus.signingIn:
|
||||
message = 'Finishing organization sign-in…';
|
||||
actions = [
|
||||
TextButton(
|
||||
onPressed: () => unawaited(signIn.cancel()),
|
||||
child: const Text('Cancel sign-in'),
|
||||
),
|
||||
];
|
||||
case OrganizationSessionStatus.signedOut:
|
||||
message = switch (signIn.failure) {
|
||||
null =>
|
||||
'Sign in with your organization to stay connected to this community.',
|
||||
AccountFailure.storage =>
|
||||
'Your saved organization sign-in could not be read. Sign in again to replace it.',
|
||||
_ => 'Organization sign-in did not finish. Try again.',
|
||||
};
|
||||
actions = [
|
||||
TextButton(
|
||||
onPressed: () => unawaited(signIn.signIn()),
|
||||
child: const Text('Sign in'),
|
||||
),
|
||||
];
|
||||
case OrganizationSessionStatus.signedIn:
|
||||
// Signed out is handled above; unavailable keeps reconnecting.
|
||||
if (relayFailure == null ||
|
||||
relayFailure == AccountFailure.signedOut ||
|
||||
relayFailure == AccountFailure.unavailable) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
message = relayFailure == AccountFailure.denied
|
||||
? 'Your organization account cannot use this community with this key.'
|
||||
: 'Could not reconnect with your organization account.';
|
||||
actions = [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
unawaited(ref.read(relaySessionProvider.notifier).reconnect()),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
// Retrying the same refused account fails the same way.
|
||||
if (relayFailure == AccountFailure.denied)
|
||||
TextButton(
|
||||
onPressed: () => unawaited(signIn.changeAccount()),
|
||||
child: const Text('Use a different account'),
|
||||
),
|
||||
];
|
||||
}
|
||||
return Positioned(
|
||||
left: Grid.sm,
|
||||
right: Grid.sm,
|
||||
bottom: 0,
|
||||
child: SafeArea(
|
||||
child: Material(
|
||||
elevation: 4,
|
||||
borderRadius: BorderRadius.circular(Radii.lg),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Grid.sm),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [Text(message), ...actions],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,13 +11,16 @@ import 'features/onboarding/self_hosted_entry_sheet.dart';
|
||||
import 'shared/auth/auth.dart';
|
||||
import 'shared/theme/grid.dart';
|
||||
import 'shared/theme/theme_extensions.dart';
|
||||
import 'shared/widgets/mobile_tab_footer_backdrop.dart';
|
||||
import 'shared/deeplink/deep_link.dart';
|
||||
import 'shared/hosted/hosted_community_connection.dart';
|
||||
import 'shared/hosted/hosted_flow.dart';
|
||||
import 'shared/hosted/hosted_environment.dart';
|
||||
import 'features/onboarding/hosted_reconnect_banner.dart';
|
||||
import 'features/onboarding/organization_sign_in_banner.dart';
|
||||
|
||||
import 'shared/hosted/hosted_flow_provider.dart';
|
||||
import 'shared/hosted/organization_sign_in.dart';
|
||||
|
||||
/// Composition seam shared by signed-in and signed-out application states.
|
||||
class HostedAppGate extends HookConsumerWidget {
|
||||
@@ -48,6 +51,8 @@ class HostedAppGate extends HookConsumerWidget {
|
||||
return () => flow.removeListener(onFlowChanged);
|
||||
}, [flow]);
|
||||
final inlineEntry = ref.watch(hostedInlineEntryProvider);
|
||||
// Create the organization's browser owner before any callback is handled.
|
||||
ref.watch(organizationSignInProvider);
|
||||
final visible =
|
||||
flow != null &&
|
||||
flow.stage != HostedFlowStage.idle &&
|
||||
@@ -138,38 +143,60 @@ class HostedAppGate extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
if (fullScreenSetup) ColoredBox(color: context.colors.surface),
|
||||
if (flow != null) ...[
|
||||
if (authenticated && !visible) HostedReconnectBanner(flow: flow),
|
||||
if (flow.stage == HostedFlowStage.signIn &&
|
||||
authenticated &&
|
||||
!inlineEntry)
|
||||
Positioned(
|
||||
left: Grid.sm,
|
||||
right: Grid.sm,
|
||||
bottom: 0,
|
||||
child: SafeArea(
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: flow.retry,
|
||||
child: const Text('Start login again'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: flow.leave,
|
||||
child: Text(
|
||||
flow.reconnecting
|
||||
? 'Cancel sign-in'
|
||||
: 'Leave setup',
|
||||
// These overlays only show over Home, so keep them above its
|
||||
// floating tab bar.
|
||||
Positioned.fill(
|
||||
child: MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
padding: MediaQuery.paddingOf(
|
||||
context,
|
||||
).copyWith(bottom: mobileTabBarTop(context) + Grid.xxs),
|
||||
),
|
||||
child: Stack(
|
||||
// Banners with their own Stack need the full layer to place
|
||||
// bottom-anchored content inside hit-testable bounds.
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (authenticated && !visible)
|
||||
const OrganizationSignInBanner(),
|
||||
if (flow != null && authenticated && !visible)
|
||||
HostedReconnectBanner(flow: flow),
|
||||
if (flow != null &&
|
||||
flow.stage == HostedFlowStage.signIn &&
|
||||
authenticated &&
|
||||
!inlineEntry)
|
||||
Positioned(
|
||||
left: Grid.sm,
|
||||
right: Grid.sm,
|
||||
bottom: 0,
|
||||
child: SafeArea(
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: flow.retry,
|
||||
child: const Text('Start login again'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: flow.leave,
|
||||
child: Text(
|
||||
flow.reconnecting
|
||||
? 'Cancel sign-in'
|
||||
: 'Leave setup',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (flow != null) ...[
|
||||
if (fullScreenSetup)
|
||||
HostedConnectPage(
|
||||
flow: flow,
|
||||
|
||||
@@ -23,23 +23,33 @@ class BrowserAuthCode {
|
||||
final Map<String, String> intent;
|
||||
}
|
||||
|
||||
/// One app-scoped owner. Subscribe to [codes] and [failures] before [initialize].
|
||||
/// Callbacks bypass the community deep-link FIFO. A consumed code is never
|
||||
/// replayed automatically: exchange failure requires a fresh browser login.
|
||||
/// One app-scoped owner per account origin. Subscribe to [codes] and
|
||||
/// [failures] before [initialize]. Callbacks bypass the community deep-link
|
||||
/// FIFO. A consumed code is never replayed automatically: exchange failure
|
||||
/// requires a fresh browser login.
|
||||
///
|
||||
/// Every origin shares the one registered [callback], so an owner leaves a
|
||||
/// callback whose state belongs to another live owner's attempt to that owner.
|
||||
class BrowserAuth {
|
||||
BrowserAuth({
|
||||
FlutterSecureStorage? storage,
|
||||
Future<String?> Function(Uri)? openBrowser,
|
||||
DateTime Function()? now,
|
||||
Uri? origin,
|
||||
}) : _storage = storage ?? accountSecureStorage,
|
||||
_openBrowser = openBrowser ?? _openPlatformBrowser,
|
||||
_now = now ?? DateTime.now;
|
||||
_now = now ?? DateTime.now,
|
||||
origin = origin ?? HostedEnvironment.origin {
|
||||
_owners.add(this);
|
||||
}
|
||||
|
||||
static const callback = 'xyz.block.buzz.mobile:/auth/callback';
|
||||
static final _attemptKey =
|
||||
'buzz_browser_auth_attempt_v1_${HostedEnvironment.origin.host}';
|
||||
static final _intentKey =
|
||||
'buzz_browser_auth_intent_v1_${HostedEnvironment.origin.host}';
|
||||
static final _owners = <BrowserAuth>{};
|
||||
|
||||
/// The account origin this owner signs in to.
|
||||
final Uri origin;
|
||||
late final _attemptKey = 'buzz_browser_auth_attempt_v1_${origin.host}';
|
||||
late final _intentKey = 'buzz_browser_auth_intent_v1_${origin.host}';
|
||||
static const _channel = MethodChannel('buzz/browser_auth');
|
||||
final FlutterSecureStorage _storage;
|
||||
final Future<String?> Function(Uri) _openBrowser;
|
||||
@@ -55,6 +65,7 @@ class BrowserAuth {
|
||||
int _generation = 0;
|
||||
String? _activeState;
|
||||
String? _consumedCallbackHash;
|
||||
String? _consumedState;
|
||||
|
||||
Future<T> _serial<T>(Future<T> Function() task) {
|
||||
final result = _queue.run(() {
|
||||
@@ -131,7 +142,7 @@ class BrowserAuth {
|
||||
} catch (_) {
|
||||
throw BrowserAuthFailure.storage;
|
||||
}
|
||||
return HostedEnvironment.origin.replace(
|
||||
return origin.replace(
|
||||
path: '/api/goose/v1/auth/login',
|
||||
queryParameters: {
|
||||
'type': 'cli',
|
||||
@@ -189,7 +200,9 @@ class BrowserAuth {
|
||||
try {
|
||||
final saved = await _storage.read(key: _attemptKey);
|
||||
if (saved == null) {
|
||||
fail(BrowserAuthFailure.restartLogin);
|
||||
if (!await _peerOwns(query['state']?.single)) {
|
||||
fail(BrowserAuthFailure.restartLogin);
|
||||
}
|
||||
return;
|
||||
}
|
||||
final record = jsonDecode(saved) as Map<String, dynamic>;
|
||||
@@ -198,18 +211,20 @@ class BrowserAuth {
|
||||
!RegExp(r'^[A-Za-z0-9_-]{43}$').hasMatch(expectedState)) {
|
||||
throw BrowserAuthFailure.storage;
|
||||
}
|
||||
// Another owner's callback never expires this owner's attempt.
|
||||
if (query['state']?.single != expectedState) {
|
||||
if (_activeState != expectedState &&
|
||||
!await _peerOwns(query['state']?.single)) {
|
||||
fail(BrowserAuthFailure.restartLogin);
|
||||
}
|
||||
return;
|
||||
}
|
||||
final age =
|
||||
_now().toUtc().millisecondsSinceEpoch - (record['created'] as int);
|
||||
if (age < 0 || age > const Duration(minutes: 10).inMilliseconds) {
|
||||
fail(BrowserAuthFailure.restartLogin);
|
||||
return;
|
||||
}
|
||||
if (query['state']?.single != expectedState) {
|
||||
if (_activeState != expectedState) {
|
||||
fail(BrowserAuthFailure.restartLogin);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (generation != _generation) return;
|
||||
final code = query['code']?.single;
|
||||
if (query.containsKey('error') ||
|
||||
@@ -224,6 +239,9 @@ class BrowserAuth {
|
||||
throw BrowserAuthFailure.storage;
|
||||
}
|
||||
final intent = Map<String, String>.from(record['intent'] as Map);
|
||||
// Claimed before the saved attempt disappears, so a peer checking
|
||||
// ownership concurrently never sees neither.
|
||||
_consumedState = expectedState;
|
||||
await _storage.delete(key: _attemptKey);
|
||||
if (await _storage.read(key: _attemptKey) != null) {
|
||||
throw BrowserAuthFailure.storage;
|
||||
@@ -250,11 +268,42 @@ class BrowserAuth {
|
||||
/* No session may be active. */
|
||||
}
|
||||
}
|
||||
await _serial(() => _storage.delete(key: _attemptKey));
|
||||
await _delete(_attemptKey);
|
||||
}
|
||||
|
||||
/// Call only after completing or explicitly abandoning the retained intent.
|
||||
Future<void> clearIntent() => _serial(() => _storage.delete(key: _intentKey));
|
||||
Future<void> clearIntent() => _delete(_intentKey);
|
||||
|
||||
Future<void> _delete(String key) => _serial(() async {
|
||||
try {
|
||||
await _storage.delete(key: key);
|
||||
} catch (_) {
|
||||
throw BrowserAuthFailure.storage;
|
||||
}
|
||||
});
|
||||
|
||||
/// Whether another live owner has a saved attempt with this state.
|
||||
Future<bool> _peerOwns(String? state) async {
|
||||
if (state == null) return false;
|
||||
for (final peer in _owners.toList()) {
|
||||
if (identical(peer, this) || peer._disposed) continue;
|
||||
if (peer._activeState == state || peer._consumedState == state) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
final saved = await peer._storage.read(key: peer._attemptKey);
|
||||
if (saved != null &&
|
||||
(jsonDecode(saved) as Map<String, dynamic>)['state'] == state) {
|
||||
return true;
|
||||
}
|
||||
// The peer may have consumed its attempt during the read.
|
||||
if (peer._consumedState == state) return true;
|
||||
} catch (_) {
|
||||
// An unreadable peer attempt is that owner's failure to report.
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void _fail(BrowserAuthFailure failure) {
|
||||
if (!_disposed) _failures.add(failure);
|
||||
@@ -262,6 +311,7 @@ class BrowserAuth {
|
||||
|
||||
Future<void> dispose() async {
|
||||
_disposed = true;
|
||||
_owners.remove(this);
|
||||
++_generation;
|
||||
await _links?.cancel();
|
||||
await _codes.close();
|
||||
|
||||
@@ -52,17 +52,22 @@ class BuilderlabAccount {
|
||||
/// subject is refused until the caller clears the session and every fact it
|
||||
/// holds for the previous subject.
|
||||
class BuilderlabAccountClient {
|
||||
/// [accountOrigin] defaults to the hosted account environment. An
|
||||
/// organization client uses its own trusted origin and session record.
|
||||
BuilderlabAccountClient({
|
||||
http.Client? httpClient,
|
||||
FlutterSecureStorage? storage,
|
||||
Uri? accountOrigin,
|
||||
}) : _http = httpClient ?? http.Client(),
|
||||
_storage = storage ?? defaultStorage;
|
||||
_storage = storage ?? defaultStorage,
|
||||
accountOrigin = accountOrigin ?? origin;
|
||||
|
||||
/// Device-only: the session never syncs or restores onto another device.
|
||||
@visibleForTesting
|
||||
static const defaultStorage = accountSecureStorage;
|
||||
|
||||
static final origin = HostedEnvironment.origin;
|
||||
final Uri accountOrigin;
|
||||
static const _prefix = '/api/goose';
|
||||
|
||||
/// Remote-agent registry routes, shared with desktop.
|
||||
@@ -79,8 +84,8 @@ class BuilderlabAccountClient {
|
||||
};
|
||||
|
||||
/// Sessions are scoped per origin so staging never reads production state.
|
||||
static final _storageKey =
|
||||
'buzz_hosted_session_${origin.host.replaceAll('.', '_')}_v1';
|
||||
late final _storageKey =
|
||||
'buzz_hosted_session_${accountOrigin.host.replaceAll('.', '_')}_v1';
|
||||
final http.Client _http;
|
||||
final FlutterSecureStorage _storage;
|
||||
String? _credential;
|
||||
@@ -104,7 +109,12 @@ class BuilderlabAccountClient {
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<BuilderlabAccount?> restore() => _serial(() async {
|
||||
/// Concurrent callers share one restore, so a second one cannot replace the
|
||||
/// account the first adopted while its callers still hold that session.
|
||||
Future<BuilderlabAccount?> restore() =>
|
||||
_restoring ??= _restore().whenComplete(() => _restoring = null);
|
||||
Future<BuilderlabAccount?>? _restoring;
|
||||
Future<BuilderlabAccount?> _restore() => _serial(() async {
|
||||
final record = await _readRecord();
|
||||
if (record == null) return null;
|
||||
try {
|
||||
@@ -124,9 +134,12 @@ class BuilderlabAccountClient {
|
||||
|
||||
/// Receives a one-use code only from a validated browser callback. PKCE is
|
||||
/// verified by Builderlab; state and callback origin are checked by the caller.
|
||||
/// [isCurrent] is checked just before the session is saved, so a caller can
|
||||
/// abandon an exchange without it later replacing a newer session.
|
||||
Future<BuilderlabAccount> exchange({
|
||||
required String code,
|
||||
required String verifier,
|
||||
bool Function()? isCurrent,
|
||||
}) => _serial(() async {
|
||||
if (code.isEmpty ||
|
||||
!RegExp(r'^[A-Za-z0-9._~-]{43,128}$').hasMatch(verifier)) {
|
||||
@@ -153,9 +166,11 @@ class BuilderlabAccountClient {
|
||||
if (stored != null && stored.subject != verified.subject) {
|
||||
throw AccountFailure.differentAccount;
|
||||
}
|
||||
if (_disposed) throw AccountFailure.unavailable;
|
||||
if (_disposed || (isCurrent != null && !isCurrent())) {
|
||||
throw AccountFailure.unavailable;
|
||||
}
|
||||
final record = jsonEncode({
|
||||
'origin': origin.toString(),
|
||||
'origin': accountOrigin.toString(),
|
||||
'credential': credential,
|
||||
'subject': verified.subject,
|
||||
'expires_at': verified.expiresAt.toIso8601String(),
|
||||
@@ -184,7 +199,7 @@ class BuilderlabAccountClient {
|
||||
try {
|
||||
final value = jsonDecode(saved);
|
||||
if (value is Map<String, dynamic> &&
|
||||
value['origin'] == origin.toString() &&
|
||||
value['origin'] == accountOrigin.toString() &&
|
||||
value['credential'] is String &&
|
||||
value['subject'] is String) {
|
||||
return (
|
||||
@@ -201,8 +216,10 @@ class BuilderlabAccountClient {
|
||||
BuilderlabAccount _adopt(String credential, BuilderlabAccount verified) {
|
||||
if (_disposed) throw AccountFailure.unavailable;
|
||||
_credential = credential;
|
||||
// Assigned before notifying, so subject listeners see the new account.
|
||||
_account = verified;
|
||||
_subject.value = verified.subject;
|
||||
return _account = verified;
|
||||
return verified;
|
||||
}
|
||||
|
||||
Future<BuilderlabAccount> _verify(String credential) async {
|
||||
@@ -337,7 +354,7 @@ class BuilderlabAccountClient {
|
||||
final request =
|
||||
http.Request(
|
||||
body == null ? 'GET' : 'POST',
|
||||
origin.replace(path: '$_prefix$path'),
|
||||
accountOrigin.replace(path: '$_prefix$path'),
|
||||
)
|
||||
..followRedirects = false
|
||||
..headers['Accept'] = 'application/json';
|
||||
@@ -346,7 +363,7 @@ class BuilderlabAccountClient {
|
||||
}
|
||||
if (body != null) {
|
||||
request.headers['Content-Type'] = 'application/json';
|
||||
request.headers['Origin'] = origin.toString();
|
||||
request.headers['Origin'] = accountOrigin.toString();
|
||||
request.body = jsonEncode(body);
|
||||
if (signingKey != null) {
|
||||
request.headers['Authorization'] = buildNip98AuthHeader(
|
||||
@@ -360,7 +377,7 @@ class BuilderlabAccountClient {
|
||||
final response = await boundedHttpResponse(_http, request);
|
||||
if (kDebugMode && response.statusCode >= 400) {
|
||||
debugPrint(
|
||||
'Builderlab ${origin.host} ${request.method} $path: HTTP ${response.statusCode} contentType=${response.headers['content-type']?.split(';').first}',
|
||||
'Builderlab ${accountOrigin.host} ${request.method} $path: HTTP ${response.statusCode} contentType=${response.headers['content-type']?.split(';').first}',
|
||||
);
|
||||
}
|
||||
if (response.statusCode == 401) throw AccountFailure.signedOut;
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../relay/relay_provider.dart';
|
||||
import 'builderlab_account.dart';
|
||||
import 'account_identity_store.dart';
|
||||
import 'hosted_environment.dart';
|
||||
import 'organization_environment.dart';
|
||||
|
||||
final builderlabAccountProvider = Provider<BuilderlabAccountClient>((ref) {
|
||||
final account = BuilderlabAccountClient();
|
||||
@@ -15,8 +16,21 @@ final builderlabAccountProvider = Provider<BuilderlabAccountClient>((ref) {
|
||||
return account;
|
||||
});
|
||||
|
||||
/// The organization's own account session, separate from the hosted account.
|
||||
/// Null unless this build configures [OrganizationEnvironment].
|
||||
final organizationAccountProvider = Provider<BuilderlabAccountClient?>((ref) {
|
||||
final origin = OrganizationEnvironment.origin;
|
||||
if (origin == null) return null;
|
||||
final account = BuilderlabAccountClient(accountOrigin: origin);
|
||||
ref.onDispose(account.dispose);
|
||||
return account;
|
||||
});
|
||||
|
||||
final hostedRelayAuthProvider = Provider<HostedRelayAuth>((ref) {
|
||||
final auth = HostedRelayAuth(ref.watch(builderlabAccountProvider));
|
||||
final auth = HostedRelayAuth(
|
||||
ref.watch(builderlabAccountProvider),
|
||||
organization: ref.watch(organizationAccountProvider),
|
||||
);
|
||||
ref.onDispose(auth.dispose);
|
||||
return auth;
|
||||
});
|
||||
@@ -28,16 +42,22 @@ final hostedHttpClientFactoryProvider = Provider<http.Client Function()>((ref) {
|
||||
return () => HostedRelayHttpClient(auth, config);
|
||||
});
|
||||
|
||||
/// One account session, with a short-lived assertion for the current relay/key.
|
||||
/// Enable alongside backend issuance; legacy and self-hosted builds stay usable.
|
||||
/// One account session per realm, with a short-lived assertion for the current
|
||||
/// relay/key. Hosted communities use [account] when [enabled]; the build's
|
||||
/// organization relays use [organization]. Enable alongside backend issuance;
|
||||
/// legacy and self-hosted builds stay usable.
|
||||
class HostedRelayAuth extends ChangeNotifier {
|
||||
HostedRelayAuth(
|
||||
this.account, {
|
||||
this.organization,
|
||||
this.organizationRelay = OrganizationEnvironment.protects,
|
||||
this.enabled = const bool.fromEnvironment('BUZZ_HOSTED_RELAY_AUTH'),
|
||||
DateTime Function()? now,
|
||||
}) : _now = now ?? DateTime.now;
|
||||
|
||||
final BuilderlabAccountClient account;
|
||||
final BuilderlabAccountClient? organization;
|
||||
final bool Function(Uri relay) organizationRelay;
|
||||
final bool enabled;
|
||||
final DateTime Function() _now;
|
||||
({
|
||||
@@ -48,16 +68,35 @@ class HostedRelayAuth extends ChangeNotifier {
|
||||
BuilderlabAccount account,
|
||||
})?
|
||||
_cached;
|
||||
Future<void>? _pending;
|
||||
// Per account client, so a stalled issuer never delays an unrelated one.
|
||||
final _pending = <BuilderlabAccountClient, Future<void>>{};
|
||||
bool _disposed = false;
|
||||
// Orders issuance and refusals, so a stale result never replaces a newer
|
||||
// failure for another relay or key.
|
||||
int _sequence = 0;
|
||||
int _failureSequence = 0;
|
||||
String? failedRelay;
|
||||
String? failedPubkey;
|
||||
AccountFailure? failure;
|
||||
|
||||
bool protects(RelayConfig config) =>
|
||||
enabled &&
|
||||
HostedEnvironment.requiresAuth(Uri.parse(config.baseUrl).host) &&
|
||||
HostedEnvironment.supportsRelay(Uri.parse(config.baseUrl));
|
||||
bool protects(RelayConfig config) => _accountFor(config) != null;
|
||||
|
||||
/// Whether this relay uses the organization's session, not the hosted one.
|
||||
bool organizationProtects(RelayConfig config) =>
|
||||
organization != null && identical(_accountFor(config), organization);
|
||||
|
||||
BuilderlabAccountClient? _accountFor(RelayConfig config) {
|
||||
final relay = Uri.parse(config.baseUrl);
|
||||
if (organization != null && organizationRelay(relay)) {
|
||||
return organization;
|
||||
}
|
||||
if (enabled &&
|
||||
HostedEnvironment.requiresAuth(relay.host) &&
|
||||
HostedEnvironment.supportsRelay(relay)) {
|
||||
return account;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Headers for optional reads, without starting account work or changing
|
||||
/// recovery state. Null means protected metadata must wait for active auth.
|
||||
@@ -68,7 +107,7 @@ class HostedRelayAuth extends ChangeNotifier {
|
||||
if (cached == null ||
|
||||
cached.relay != Uri.parse(config.wsUrl).replace(path: '').toString() ||
|
||||
cached.pubkey != pubkeyFromNsec(config.nsec) ||
|
||||
!identical(cached.account, account.account) ||
|
||||
!identical(cached.account, _accountFor(config)?.account) ||
|
||||
!cached.expiry.isAfter(_now())) {
|
||||
return null;
|
||||
}
|
||||
@@ -76,7 +115,25 @@ class HostedRelayAuth extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<Map<String, String>> headers(RelayConfig config) async {
|
||||
if (!protects(config)) return const {};
|
||||
final client = _accountFor(config);
|
||||
if (client == null) return const {};
|
||||
try {
|
||||
return await _headers(config, client);
|
||||
} on AccountFailure catch (failure) {
|
||||
// Without an organization session, connect as before and let the relay
|
||||
// decide: an enforcing relay refuses, and sign-in is offered.
|
||||
if (failure == AccountFailure.signedOut &&
|
||||
identical(client, organization)) {
|
||||
return const {};
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, String>> _headers(
|
||||
RelayConfig config,
|
||||
BuilderlabAccountClient client,
|
||||
) async {
|
||||
final relay = Uri.parse(config.wsUrl).replace(path: '').toString();
|
||||
final pubkey = pubkeyFromNsec(config.nsec);
|
||||
if (pubkey == null) throw AccountFailure.denied;
|
||||
@@ -85,11 +142,11 @@ class HostedRelayAuth extends ChangeNotifier {
|
||||
if (cached != null &&
|
||||
cached.relay == relay &&
|
||||
cached.pubkey == pubkey &&
|
||||
identical(cached.account, account.account) &&
|
||||
identical(cached.account, client.account) &&
|
||||
cached.expiry.isAfter(_now())) {
|
||||
return {'Nostr-Federated-Identity': 'Bearer ${cached.token}'};
|
||||
}
|
||||
final pending = _pending;
|
||||
final pending = _pending[client];
|
||||
if (pending != null) {
|
||||
// Different communities share account restoration, never assertions.
|
||||
try {
|
||||
@@ -99,30 +156,39 @@ class HostedRelayAuth extends ChangeNotifier {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
final operation = _issue(config, relay, pubkey);
|
||||
_pending = operation;
|
||||
final operation = _issue(config, client, relay, pubkey);
|
||||
_pending[client] = operation;
|
||||
try {
|
||||
await operation;
|
||||
} finally {
|
||||
_pending = null;
|
||||
if (identical(_pending[client], operation)) _pending.remove(client);
|
||||
}
|
||||
}
|
||||
throw AccountFailure.unavailable;
|
||||
}
|
||||
|
||||
Future<void> _issue(RelayConfig config, String relay, String pubkey) async {
|
||||
Future<void> _issue(
|
||||
RelayConfig config,
|
||||
BuilderlabAccountClient account,
|
||||
String relay,
|
||||
String pubkey,
|
||||
) async {
|
||||
final started = ++_sequence;
|
||||
try {
|
||||
if (account.account == null && await account.restore() == null) {
|
||||
throw AccountFailure.signedOut;
|
||||
}
|
||||
final session = account.account!;
|
||||
// Issuance can create a binding. Reconnect must not silently give a
|
||||
// different, unbound account this community's saved identity.
|
||||
final current = await account.currentIdentity();
|
||||
if (!identical(session, account.account)) throw AccountFailure.denied;
|
||||
final identity = current['identity'];
|
||||
if (current['error'] != null) throw AccountFailure.invalidResponse;
|
||||
if (identity == null) {
|
||||
if (identity == null && identical(account, organization)) {
|
||||
// The organization's issuer binds the proven key on first issuance;
|
||||
// that binding is how a member's existing key joins the organization.
|
||||
} else if (identity == null) {
|
||||
// Issuance can create a binding. Reconnect must not silently give a
|
||||
// different, unbound hosted account this community's saved identity.
|
||||
final AccountIdentitySnapshot? saved;
|
||||
try {
|
||||
saved = await AccountIdentityStore()
|
||||
@@ -180,11 +246,13 @@ class HostedRelayAuth extends ChangeNotifier {
|
||||
// Issuance alone cannot prove admission after a relay refusal.
|
||||
if (failure != AccountFailure.denied) connected(config);
|
||||
} on AccountFailure catch (error) {
|
||||
if (!_disposed) {
|
||||
failedRelay = config.baseUrl;
|
||||
failedPubkey = pubkey;
|
||||
failure = error;
|
||||
notifyListeners();
|
||||
// Issuers run in parallel, so a failure that started before another
|
||||
// relay's unresolved failure was recorded must not replace it.
|
||||
final otherScope =
|
||||
failure != null &&
|
||||
(failedRelay != config.baseUrl || failedPubkey != pubkey);
|
||||
if (!_disposed && !(otherScope && _failureSequence > started)) {
|
||||
_record(config.baseUrl, pubkey, error, started);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
@@ -209,13 +277,28 @@ class HostedRelayAuth extends ChangeNotifier {
|
||||
if (!protects(config) || usedToken == null) return;
|
||||
if ('Bearer ${_cached?.token}' == usedToken) _cached = null;
|
||||
if (terminal && !_disposed) {
|
||||
failedRelay = config.baseUrl;
|
||||
failedPubkey = pubkeyFromNsec(config.nsec);
|
||||
failure = AccountFailure.denied;
|
||||
notifyListeners();
|
||||
_record(
|
||||
config.baseUrl,
|
||||
pubkeyFromNsec(config.nsec),
|
||||
AccountFailure.denied,
|
||||
++_sequence,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _record(
|
||||
String relay,
|
||||
String? pubkey,
|
||||
AccountFailure error,
|
||||
int sequence,
|
||||
) {
|
||||
failedRelay = relay;
|
||||
failedPubkey = pubkey;
|
||||
failure = error;
|
||||
_failureSequence = sequence;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
|
||||
90
mobile/lib/shared/hosted/organization_environment.dart
Normal file
90
mobile/lib/shared/hosted/organization_environment.dart
Normal file
@@ -0,0 +1,90 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'hosted_environment.dart';
|
||||
|
||||
/// Trusted build configuration for an organization's identity provider (a
|
||||
/// Builderlab tenant), never supplied by a deep link or relay. Organization
|
||||
/// relays get NIP-FI assertions from this origin's account session, separately
|
||||
/// from hosted communities' account.
|
||||
abstract final class OrganizationEnvironment {
|
||||
static bool get enabled => origin != null;
|
||||
|
||||
/// `BUZZ_ORGANIZATION_URL`: the tenant's HTTPS origin, for example
|
||||
/// `https://block.builderlab.xyz`.
|
||||
static final Uri? origin = parseOrigin(
|
||||
const String.fromEnvironment('BUZZ_ORGANIZATION_URL'),
|
||||
const String.fromEnvironment('BUZZ_ORGANIZATION_RELAYS'),
|
||||
);
|
||||
|
||||
/// `BUZZ_ORGANIZATION_RELAYS`: comma-separated exact relay hosts that trust
|
||||
/// this tenant. Subdomains and other ports never match.
|
||||
static final List<String> relayHosts = parseRelayHosts(
|
||||
const String.fromEnvironment('BUZZ_ORGANIZATION_RELAYS'),
|
||||
);
|
||||
|
||||
/// Whether this build attaches its organization session's assertions here.
|
||||
static bool protects(Uri relay) => enabled && matches(relay, relayHosts);
|
||||
|
||||
@visibleForTesting
|
||||
static bool matches(Uri relay, List<String> hosts) =>
|
||||
['wss', 'https'].contains(relay.scheme) &&
|
||||
relay.userInfo.isEmpty &&
|
||||
!relay.hasQuery &&
|
||||
!relay.hasFragment &&
|
||||
(relay.path.isEmpty || relay.path == '/') &&
|
||||
(!relay.hasPort || relay.port == 443) &&
|
||||
hosts.contains(relay.host.toLowerCase());
|
||||
|
||||
@visibleForTesting
|
||||
static Uri? parseOrigin(String url, String relays) {
|
||||
if (url.isEmpty != relays.trim().isEmpty) {
|
||||
throw StateError(
|
||||
'BUZZ_ORGANIZATION_URL and BUZZ_ORGANIZATION_RELAYS must be set together',
|
||||
);
|
||||
}
|
||||
if (url.isEmpty) return null;
|
||||
final uri = Uri.parse(url);
|
||||
if (uri.scheme != 'https' ||
|
||||
uri.host.isEmpty ||
|
||||
uri.userInfo.isNotEmpty ||
|
||||
uri.hasPort ||
|
||||
uri.hasQuery ||
|
||||
uri.hasFragment ||
|
||||
(uri.path.isNotEmpty && uri.path != '/')) {
|
||||
throw StateError('BUZZ_ORGANIZATION_URL must be an HTTPS origin');
|
||||
}
|
||||
final origin = Uri.parse(uri.origin);
|
||||
// Sessions and browser attempts are stored per origin host.
|
||||
if (origin.host == HostedEnvironment.origin.host) {
|
||||
throw StateError(
|
||||
'BUZZ_ORGANIZATION_URL must differ from the hosted account origin',
|
||||
);
|
||||
}
|
||||
return origin;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
static List<String> parseRelayHosts(String relays) => List.unmodifiable(
|
||||
relays
|
||||
.split(',')
|
||||
.map((host) => host.trim().toLowerCase())
|
||||
.where((host) => host.isNotEmpty)
|
||||
.map((host) {
|
||||
if (!RegExp(
|
||||
r'^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$',
|
||||
).hasMatch(host)) {
|
||||
throw StateError(
|
||||
'BUZZ_ORGANIZATION_RELAYS must contain DNS names, not URLs or wildcards',
|
||||
);
|
||||
}
|
||||
// Never send one realm's session to another realm's relay.
|
||||
if (HostedEnvironment.accountOriginFor(Uri.parse('wss://$host')) !=
|
||||
null) {
|
||||
throw StateError(
|
||||
'BUZZ_ORGANIZATION_RELAYS must not include hosted communities',
|
||||
);
|
||||
}
|
||||
return host;
|
||||
}),
|
||||
);
|
||||
}
|
||||
194
mobile/lib/shared/hosted/organization_sign_in.dart
Normal file
194
mobile/lib/shared/hosted/organization_sign_in.dart
Normal file
@@ -0,0 +1,194 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../relay/relay_provider.dart';
|
||||
import '../relay/relay_session.dart';
|
||||
import 'browser_auth.dart';
|
||||
import 'builderlab_account.dart';
|
||||
import 'hosted_relay_auth.dart';
|
||||
|
||||
enum OrganizationSessionStatus { checking, signedOut, signingIn, signedIn }
|
||||
|
||||
/// Browser sign-in to the build's organization account. A completed sign-in
|
||||
/// reconnects the relay so its next upgrade carries a fresh assertion. Null
|
||||
/// unless this build configures an organization.
|
||||
final organizationSignInProvider = Provider<OrganizationSignIn?>((ref) {
|
||||
final account = ref.watch(organizationAccountProvider);
|
||||
if (account == null) return null;
|
||||
final signIn = OrganizationSignIn(
|
||||
account: account,
|
||||
browser: BrowserAuth(origin: account.accountOrigin),
|
||||
// Only the organization's relay needs a fresh assertion.
|
||||
reconnect: () async {
|
||||
final config = ref.read(relayConfigProvider);
|
||||
if (ref.read(hostedRelayAuthProvider).organizationProtects(config)) {
|
||||
await ref.read(relaySessionProvider.notifier).reconnect();
|
||||
}
|
||||
},
|
||||
);
|
||||
ref.onDispose(signIn.dispose);
|
||||
unawaited(signIn.initialize());
|
||||
return signIn;
|
||||
});
|
||||
|
||||
class OrganizationSignIn extends ChangeNotifier {
|
||||
OrganizationSignIn({
|
||||
required this.account,
|
||||
required this.browser,
|
||||
required this.reconnect,
|
||||
});
|
||||
|
||||
final BuilderlabAccountClient account;
|
||||
final BrowserAuth browser;
|
||||
final Future<void> Function() reconnect;
|
||||
|
||||
OrganizationSessionStatus status = OrganizationSessionStatus.checking;
|
||||
|
||||
/// The last sign-in or session-check failure, cleared by the next attempt.
|
||||
Object? failure;
|
||||
|
||||
final _subscriptions = <StreamSubscription<Object>>[];
|
||||
|
||||
/// Advanced by every start or cancel, so an exchange from an abandoned
|
||||
/// attempt can never change the state of a newer one.
|
||||
int _attempt = 0;
|
||||
bool _initialized = false;
|
||||
bool _disposed = false;
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (_initialized || _disposed) return;
|
||||
_initialized = true;
|
||||
account.subjectChanges.addListener(_onSubject);
|
||||
_subscriptions
|
||||
..add(browser.codes.listen((code) => unawaited(_exchange(code))))
|
||||
..add(browser.failures.listen(_onBrowserFailure));
|
||||
await browser.initialize();
|
||||
try {
|
||||
final restored = account.account ?? await account.restore();
|
||||
_set(
|
||||
restored == null
|
||||
? OrganizationSessionStatus.signedOut
|
||||
: OrganizationSessionStatus.signedIn,
|
||||
);
|
||||
} on AccountFailure catch (error) {
|
||||
// Unknown, for example offline. Relay connection retries the restore.
|
||||
// An unreadable record is kept so the next sign-in can replace it.
|
||||
if (error == AccountFailure.storage) failure = error;
|
||||
_set(OrganizationSessionStatus.signedOut);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> signIn() =>
|
||||
// An unreadable saved session blocks every exchange, and its subject is
|
||||
// unknown. Signing in again explicitly replaces only that session.
|
||||
_begin(
|
||||
clear: failure == AccountFailure.storage && account.account == null,
|
||||
);
|
||||
|
||||
/// Drops only the organization session and signs in again, for an account
|
||||
/// the relay refuses for this key. The hosted account is untouched.
|
||||
Future<void> changeAccount() => _begin(clear: true);
|
||||
|
||||
Future<void> _begin({required bool clear}) async {
|
||||
if (_disposed || status == OrganizationSessionStatus.signingIn) return;
|
||||
// Busy before any await, so a second tap cannot start another attempt.
|
||||
final attempt = ++_attempt;
|
||||
failure = null;
|
||||
_set(OrganizationSessionStatus.signingIn);
|
||||
if (clear) {
|
||||
try {
|
||||
await account.clearLocalSession();
|
||||
} on AccountFailure catch (error) {
|
||||
if (attempt != _attempt) return;
|
||||
failure = error;
|
||||
_set(_currentStatus());
|
||||
return;
|
||||
}
|
||||
if (_disposed || attempt != _attempt) return;
|
||||
}
|
||||
try {
|
||||
await browser.start(const {'purpose': 'organization'});
|
||||
} on BrowserAuthFailure catch (error) {
|
||||
if (attempt != _attempt) return;
|
||||
failure = error;
|
||||
_set(_currentStatus());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancel() async {
|
||||
++_attempt;
|
||||
try {
|
||||
await browser.cancel();
|
||||
} on BrowserAuthFailure catch (error) {
|
||||
failure = error;
|
||||
} finally {
|
||||
_set(_currentStatus());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _exchange(BrowserAuthCode code) async {
|
||||
final attempt = _attempt;
|
||||
try {
|
||||
await account.exchange(
|
||||
code: code.code,
|
||||
verifier: code.verifier,
|
||||
// A cancelled attempt must never save its account.
|
||||
isCurrent: () => !_disposed && attempt == _attempt,
|
||||
);
|
||||
} on AccountFailure catch (error) {
|
||||
if (attempt != _attempt) return;
|
||||
failure = error;
|
||||
_set(_currentStatus());
|
||||
return;
|
||||
}
|
||||
if (_disposed || attempt != _attempt) return;
|
||||
failure = null;
|
||||
try {
|
||||
await browser.clearIntent();
|
||||
} on BrowserAuthFailure catch (error) {
|
||||
// The session is saved; a leftover intent must not keep sign-in busy.
|
||||
failure = error;
|
||||
}
|
||||
if (_disposed || attempt != _attempt) return;
|
||||
_set(OrganizationSessionStatus.signedIn);
|
||||
await reconnect();
|
||||
}
|
||||
|
||||
void _onBrowserFailure(BrowserAuthFailure error) {
|
||||
if (status != OrganizationSessionStatus.signingIn) return;
|
||||
failure = error == BrowserAuthFailure.cancelled ? null : error;
|
||||
_set(_currentStatus());
|
||||
}
|
||||
|
||||
void _onSubject() {
|
||||
if (status == OrganizationSessionStatus.signingIn ||
|
||||
status == OrganizationSessionStatus.checking) {
|
||||
return;
|
||||
}
|
||||
if (account.account != null) failure = null;
|
||||
_set(_currentStatus());
|
||||
}
|
||||
|
||||
OrganizationSessionStatus _currentStatus() => account.account == null
|
||||
? OrganizationSessionStatus.signedOut
|
||||
: OrganizationSessionStatus.signedIn;
|
||||
|
||||
void _set(OrganizationSessionStatus next) {
|
||||
if (_disposed) return;
|
||||
status = next;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
account.subjectChanges.removeListener(_onSubject);
|
||||
for (final subscription in _subscriptions) {
|
||||
unawaited(subscription.cancel());
|
||||
}
|
||||
unawaited(browser.dispose());
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,19 @@ const mobileTabBarHeight = 56.0;
|
||||
/// Gap between the floating mobile tab bar and the bottom safe area.
|
||||
const mobileTabBarBottomGap = Grid.twelve;
|
||||
|
||||
/// Distance from the screen bottom to the floating mobile tab bar's bottom.
|
||||
double mobileTabBarBottom(BuildContext context) {
|
||||
final inset = MediaQuery.viewPaddingOf(context).bottom;
|
||||
return inset > mobileTabBarBottomGap ? inset : mobileTabBarBottomGap;
|
||||
}
|
||||
|
||||
/// Distance from the screen bottom to the floating mobile tab bar's top.
|
||||
///
|
||||
/// Overlays drawn above Home, such as sign-in banners, sit above this so the
|
||||
/// bar's controls do not cover them.
|
||||
double mobileTabBarTop(BuildContext context) =>
|
||||
mobileTabBarBottom(context) + mobileTabBarHeight;
|
||||
|
||||
/// Fixed visual height of the shared footer fade behind the floating tab bar.
|
||||
double mobileTabFooterBackdropHeight(BuildContext _) => 180;
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import 'package:buzz/features/profile/profile_avatar_draft.dart';
|
||||
import 'package:buzz/shared/emoji/emoji_avatar.dart';
|
||||
import 'package:buzz/shared/widgets/playing_avatar_image.dart';
|
||||
import 'package:buzz/features/pairing/pairing_page.dart';
|
||||
import 'package:buzz/hosted_app_gate.dart';
|
||||
import 'package:buzz/onboarding_app_entry.dart';
|
||||
import 'package:buzz/shared/auth/auth.dart';
|
||||
import 'package:buzz/shared/hosted/builderlab_account.dart';
|
||||
@@ -27,6 +28,7 @@ import 'package:buzz/shared/relay/relay_session.dart';
|
||||
import 'package:buzz/shared/relay/relay_provider.dart';
|
||||
import 'package:buzz/shared/relay/media_image.dart';
|
||||
import 'package:buzz/shared/utils/string_utils.dart';
|
||||
import 'package:buzz/shared/widgets/mobile_tab_footer_backdrop.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -36,6 +38,16 @@ import 'package:http/http.dart' as http;
|
||||
import '../../helpers/hosted_journey_fixture.dart';
|
||||
|
||||
void main() {
|
||||
// Bottom banners over Home must stay above its floating tab bar, which the
|
||||
// fixture's stand-in home does not draw.
|
||||
void expectAboveTabBar(WidgetTester tester, String label) {
|
||||
final screen = tester.getSize(find.byType(HostedAppGate)).height;
|
||||
expect(
|
||||
tester.getRect(find.text(label)).bottom,
|
||||
lessThanOrEqualTo(screen - mobileTabBarHeight - mobileTabBarBottomGap),
|
||||
);
|
||||
}
|
||||
|
||||
void journey(
|
||||
String name,
|
||||
Future<void> Function(WidgetTester, HostedJourneyFixture) run, {
|
||||
@@ -527,6 +539,93 @@ void main() {
|
||||
requiresBuilderlab: false,
|
||||
);
|
||||
|
||||
journey(
|
||||
'a refused organization account changes to another without the hosted one',
|
||||
(tester, f) async {
|
||||
f.relayAuthEnabled = true;
|
||||
f.organizationEnabled = true;
|
||||
await f.initialize();
|
||||
// Signed in to the organization as an account bound to another key.
|
||||
f.organizationSubject = 'other-member';
|
||||
f.organizationBound = f.secondKeys.public;
|
||||
await f.signInOrganization();
|
||||
final community = Community.create(
|
||||
name: 'Block',
|
||||
relayUrl: 'wss://${HostedJourneyFixture.organizationRelayHost}',
|
||||
pubkey: f.keys.public,
|
||||
nsec: f.keys.nsec,
|
||||
);
|
||||
await f.storage.save(community);
|
||||
await f.storage.saveActiveId(community.id);
|
||||
await f.mount(tester, openEntry: false);
|
||||
final sessionSubscription = f.container.listen(
|
||||
relaySessionProvider,
|
||||
(_, _) {},
|
||||
);
|
||||
addTearDown(sessionSubscription.close);
|
||||
await f.settle(tester);
|
||||
const refused =
|
||||
'Your organization account cannot use this community with this key.';
|
||||
expect(find.text(refused), findsOneWidget);
|
||||
expectAboveTabBar(tester, 'Use a different account');
|
||||
expect(f.sent('/identity/assertions'), isEmpty);
|
||||
|
||||
// Retry keeps the refused account and fails the same way.
|
||||
await f.tap(tester, 'Retry');
|
||||
expect(find.text(refused), findsOneWidget);
|
||||
expect(f.organizationBrowserStarts, 0);
|
||||
|
||||
f.organizationSubject = 'journey-owner';
|
||||
f.organizationBound = f.keys.public;
|
||||
final hosted = f.container.read(builderlabAccountProvider);
|
||||
final hostedCredential = await const FlutterSecureStorage().read(
|
||||
key:
|
||||
'buzz_hosted_session_'
|
||||
'${hosted.accountOrigin.host.replaceAll('.', '_')}_v1',
|
||||
);
|
||||
expect(hostedCredential, isNotNull);
|
||||
final connects = f.socketHeaders.length;
|
||||
await f.tap(tester, 'Use a different account');
|
||||
await f.settle(tester);
|
||||
expect(f.organizationBrowserStarts, 1);
|
||||
expect(
|
||||
f.container.read(organizationAccountProvider)!.account!.subject,
|
||||
'journey-owner',
|
||||
);
|
||||
expect(find.text(refused), findsNothing);
|
||||
expect(find.text('Use a different account'), findsNothing);
|
||||
// The real relay session reconnects with the new account's assertion.
|
||||
expect(f.socketHeaders.length, greaterThan(connects));
|
||||
expect(
|
||||
f.socketHeaders.last['Nostr-Federated-Identity'],
|
||||
'Bearer organization-assertion',
|
||||
);
|
||||
expect(
|
||||
f.container.read(relaySessionProvider).status,
|
||||
SessionStatus.connected,
|
||||
);
|
||||
// Only the organization session changed.
|
||||
expect(
|
||||
await const FlutterSecureStorage().read(
|
||||
key:
|
||||
'buzz_hosted_session_'
|
||||
'${hosted.accountOrigin.host.replaceAll('.', '_')}_v1',
|
||||
),
|
||||
hostedCredential,
|
||||
);
|
||||
expect(
|
||||
f.requests.where(
|
||||
(r) =>
|
||||
r.url.origin != HostedJourneyFixture.organizationOrigin.origin &&
|
||||
r.url.path.endsWith('/identity/assertions'),
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
expect((await f.storage.loadAll()).single.nsec, community.nsec);
|
||||
},
|
||||
requiresBuilderlab: false,
|
||||
);
|
||||
|
||||
journey(
|
||||
'saved community signs in again without changing identity or onboarding',
|
||||
(tester, f) async {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:buzz/shared/hosted/hosted_environment.dart';
|
||||
import 'package:buzz/features/pairing/pairing_provider.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -19,6 +20,8 @@ import 'package:buzz/shared/hosted/browser_auth.dart';
|
||||
import 'package:buzz/shared/hosted/builderlab_account.dart';
|
||||
import 'package:buzz/shared/hosted/hosted_flow.dart';
|
||||
import 'package:buzz/shared/hosted/hosted_relay_auth.dart';
|
||||
import 'package:buzz/shared/hosted/organization_sign_in.dart';
|
||||
import 'package:buzz/shared/relay/relay_provider.dart';
|
||||
import 'package:buzz/shared/relay/relay_session.dart';
|
||||
import 'package:buzz/shared/relay/relay_socket.dart';
|
||||
import 'package:buzz/shared/hosted/self_hosted_entry.dart';
|
||||
@@ -67,6 +70,15 @@ class HostedJourneyFixture {
|
||||
bool denyAssertion = false;
|
||||
bool refuseUpgrade = false;
|
||||
String subject = 'journey-owner';
|
||||
|
||||
/// A separate organization realm, as BUZZ_ORGANIZATION_URL and
|
||||
/// BUZZ_ORGANIZATION_RELAYS configure. Enable before [mount].
|
||||
static final organizationOrigin = Uri.parse('https://block.example.com');
|
||||
static const organizationRelayHost = 'buzz.block.example.com';
|
||||
bool organizationEnabled = false;
|
||||
String organizationSubject = 'journey-owner';
|
||||
String? organizationBound;
|
||||
int organizationBrowserStarts = 0;
|
||||
Future<void> Function()? beforeAssertionReturn;
|
||||
final socketHeaders = <Map<String, String>>[];
|
||||
bool offerProfile = true;
|
||||
@@ -301,6 +313,60 @@ class HostedJourneyFixture {
|
||||
);
|
||||
});
|
||||
|
||||
http.Client organizationClient() => MockClient((request) async {
|
||||
requests.add(request);
|
||||
expect(request.url.origin, organizationOrigin.origin);
|
||||
final path = request.url.path;
|
||||
Object result;
|
||||
if (path.endsWith('/exchange')) {
|
||||
result = {
|
||||
'session_credential': 'organization-session',
|
||||
'expires_at': expiry,
|
||||
};
|
||||
} else if (path.endsWith('/me')) {
|
||||
result = {'subject': organizationSubject, 'expires_at': expiry};
|
||||
} else if (path.endsWith('/current')) {
|
||||
result = {
|
||||
'identity': organizationBound == null
|
||||
? null
|
||||
: {'pubkey_hex': organizationBound},
|
||||
};
|
||||
} else if (path.endsWith('/identity/assertions')) {
|
||||
final body = jsonDecode(request.body) as Map;
|
||||
if (organizationBound != null &&
|
||||
body['nostr_pubkey'] != organizationBound) {
|
||||
return http.Response('{}', 403);
|
||||
}
|
||||
organizationBound ??= body['nostr_pubkey'] as String;
|
||||
result = {
|
||||
'assertion': 'organization-assertion',
|
||||
'nostr_pubkey': organizationBound,
|
||||
'expires_at':
|
||||
assertionNow
|
||||
.add(const Duration(minutes: 5))
|
||||
.millisecondsSinceEpoch ~/
|
||||
1000,
|
||||
};
|
||||
} else {
|
||||
return http.Response('{}', 404);
|
||||
}
|
||||
return http.Response(
|
||||
jsonEncode(result),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
});
|
||||
|
||||
/// Stores an organization session as a completed sign-in would.
|
||||
Future<void> signInOrganization() async {
|
||||
final account = BuilderlabAccountClient(
|
||||
accountOrigin: organizationOrigin,
|
||||
httpClient: organizationClient(),
|
||||
);
|
||||
await account.exchange(code: 'synthetic-code', verifier: 'v' * 43);
|
||||
account.dispose();
|
||||
}
|
||||
|
||||
HostedFlow createFlow(Ref ref) => activeFlow = createHostedFlow(
|
||||
ref,
|
||||
browserAuth: _Browser(() async {
|
||||
@@ -349,10 +415,44 @@ class HostedJourneyFixture {
|
||||
socketFactory: pairingSocketFactory,
|
||||
),
|
||||
),
|
||||
if (organizationEnabled) ...[
|
||||
organizationAccountProvider.overrideWith((ref) {
|
||||
final account = BuilderlabAccountClient(
|
||||
accountOrigin: organizationOrigin,
|
||||
httpClient: organizationClient(),
|
||||
);
|
||||
ref.onDispose(account.dispose);
|
||||
return account;
|
||||
}),
|
||||
organizationSignInProvider.overrideWith((ref) {
|
||||
final signIn = OrganizationSignIn(
|
||||
account: ref.watch(organizationAccountProvider)!,
|
||||
browser: _Browser(
|
||||
() async => organizationBrowserStarts++,
|
||||
origin: organizationOrigin,
|
||||
),
|
||||
// As in production: only the organization's relay reconnects.
|
||||
reconnect: () async {
|
||||
final config = ref.read(relayConfigProvider);
|
||||
if (ref
|
||||
.read(hostedRelayAuthProvider)
|
||||
.organizationProtects(config)) {
|
||||
await ref.read(relaySessionProvider.notifier).reconnect();
|
||||
}
|
||||
},
|
||||
);
|
||||
ref.onDispose(signIn.dispose);
|
||||
unawaited(signIn.initialize());
|
||||
return signIn;
|
||||
}),
|
||||
],
|
||||
if (relayAuthEnabled) ...[
|
||||
hostedRelayAuthProvider.overrideWith((ref) {
|
||||
final auth = HostedRelayAuth(
|
||||
ref.watch(builderlabAccountProvider),
|
||||
organization: ref.watch(organizationAccountProvider),
|
||||
organizationRelay: (relay) =>
|
||||
organizationEnabled && relay.host == organizationRelayHost,
|
||||
enabled: true,
|
||||
now: () => assertionNow,
|
||||
);
|
||||
@@ -564,7 +664,7 @@ class _Recovery implements InviteJoinRecovery {
|
||||
}
|
||||
|
||||
class _Browser extends BrowserAuth {
|
||||
_Browser(Future<void> Function() onStart)
|
||||
_Browser(Future<void> Function() onStart, {super.origin})
|
||||
: super(
|
||||
openBrowser: (uri) async {
|
||||
await onStart();
|
||||
@@ -600,9 +700,14 @@ class _RelaySocket extends RelaySocket {
|
||||
@override
|
||||
Future<void> connect() async {
|
||||
fixture.socketHeaders.add(Map.of(headers));
|
||||
final host = Uri.parse(url).host;
|
||||
expect(
|
||||
headers['Nostr-Federated-Identity'],
|
||||
HostedEnvironment.requiresAuth(Uri.parse(url).host)
|
||||
fixture.organizationEnabled &&
|
||||
host == HostedJourneyFixture.organizationRelayHost
|
||||
// Signed out connects without one and lets the relay decide.
|
||||
? anyOf(isNull, 'Bearer organization-assertion')
|
||||
: HostedEnvironment.requiresAuth(host)
|
||||
? 'Bearer synthetic-assertion'
|
||||
: isNull,
|
||||
);
|
||||
|
||||
@@ -186,6 +186,31 @@ void main() {
|
||||
expect(jsonDecode((await stored())!)['credential'], 'new');
|
||||
});
|
||||
|
||||
test('concurrent restores share one adopted account', () async {
|
||||
FlutterSecureStorage.setMockInitialValues({
|
||||
storageKey: record('account-1', 'old'),
|
||||
});
|
||||
var verifications = 0;
|
||||
final client = BuilderlabAccountClient(
|
||||
httpClient: MockClient((request) async {
|
||||
verifications++;
|
||||
return http.Response(
|
||||
jsonEncode({'subject': 'account-1', 'expires_at': expiry}),
|
||||
200,
|
||||
);
|
||||
}),
|
||||
);
|
||||
final first = client.restore();
|
||||
final second = client.restore();
|
||||
final adopted = await first;
|
||||
expect(await second, same(adopted));
|
||||
expect(client.account, same(adopted));
|
||||
expect(verifications, 1);
|
||||
// A later restore still re-verifies, so expiry is detected on retry.
|
||||
await client.restore();
|
||||
expect(verifications, 2);
|
||||
});
|
||||
|
||||
test('login for another account keeps the existing session', () async {
|
||||
FlutterSecureStorage.setMockInitialValues({
|
||||
storageKey: record('account-1', 'old'),
|
||||
|
||||
511
mobile/test/shared/hosted/organization_relay_auth_test.dart
Normal file
511
mobile/test/shared/hosted/organization_relay_auth_test.dart
Normal file
@@ -0,0 +1,511 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:buzz/shared/hosted/browser_auth.dart';
|
||||
import 'package:buzz/shared/hosted/builderlab_account.dart';
|
||||
import 'package:buzz/shared/hosted/hosted_relay_auth.dart';
|
||||
import 'package:buzz/shared/hosted/organization_environment.dart';
|
||||
import 'package:buzz/shared/hosted/organization_sign_in.dart';
|
||||
import 'package:buzz/shared/relay/relay_provider.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
final keys = nostr.Keys(
|
||||
'0000000000000000000000000000000000000000000000000000000000000001',
|
||||
);
|
||||
final organization = Uri.parse('https://block.example.com');
|
||||
const relayHost = 'buzz.block.example.com';
|
||||
final relay = RelayConfig(baseUrl: 'https://$relayHost', nsec: keys.nsec);
|
||||
final expiry = DateTime.now().toUtc().add(const Duration(days: 1));
|
||||
|
||||
setUp(() => FlutterSecureStorage.setMockInitialValues({}));
|
||||
|
||||
group('OrganizationEnvironment', () {
|
||||
test('origin and relays are configured together as an HTTPS origin', () {
|
||||
expect(OrganizationEnvironment.parseOrigin('', ''), isNull);
|
||||
expect(
|
||||
OrganizationEnvironment.parseOrigin(
|
||||
'https://block.example.com/',
|
||||
relayHost,
|
||||
),
|
||||
organization,
|
||||
);
|
||||
for (final (url, relays) in [
|
||||
('https://block.example.com', ''),
|
||||
('', relayHost),
|
||||
('http://block.example.com', relayHost),
|
||||
('https://block.example.com/api/goose', relayHost),
|
||||
('https://block.example.com:8443', relayHost),
|
||||
('https://block.example.com?x=1', relayHost),
|
||||
('https://app.builderlab.xyz', relayHost),
|
||||
]) {
|
||||
expect(
|
||||
() => OrganizationEnvironment.parseOrigin(url, relays),
|
||||
throwsStateError,
|
||||
reason: '$url $relays',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('relays are exact DNS hosts outside hosted communities', () {
|
||||
expect(
|
||||
OrganizationEnvironment.parseRelayHosts(' Buzz.Block.Example.com ,a.b'),
|
||||
[relayHost, 'a.b'],
|
||||
);
|
||||
for (final relays in [
|
||||
'wss://$relayHost',
|
||||
'*.block.example.com',
|
||||
'localhost',
|
||||
'team.communities.buzz.xyz',
|
||||
]) {
|
||||
expect(
|
||||
() => OrganizationEnvironment.parseRelayHosts(relays),
|
||||
throwsStateError,
|
||||
reason: relays,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('matches only the configured host on the default port', () {
|
||||
bool matches(String url) =>
|
||||
OrganizationEnvironment.matches(Uri.parse(url), [relayHost]);
|
||||
expect(matches('wss://$relayHost'), isTrue);
|
||||
expect(matches('https://$relayHost/'), isTrue);
|
||||
expect(matches('wss://$relayHost:443'), isTrue);
|
||||
expect(matches('wss://team.$relayHost'), isFalse);
|
||||
expect(matches('wss://$relayHost:8443'), isFalse);
|
||||
expect(matches('wss://$relayHost/path'), isFalse);
|
||||
expect(matches('ws://$relayHost'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('organization relay assertions', () {
|
||||
late List<http.Request> requests;
|
||||
late Object? boundIdentity;
|
||||
|
||||
BuilderlabAccountClient organizationClient() => BuilderlabAccountClient(
|
||||
accountOrigin: organization,
|
||||
httpClient: MockClient((request) async {
|
||||
requests.add(request);
|
||||
expect(request.url.origin, organization.origin);
|
||||
expect(request.headers['X-BB-Session-Credential'], 'org-secret');
|
||||
return switch (request.url.path) {
|
||||
'/api/goose/v1/auth/me' => http.Response(
|
||||
jsonEncode({
|
||||
'subject': 'member',
|
||||
'expires_at': expiry.toIso8601String(),
|
||||
}),
|
||||
200,
|
||||
),
|
||||
'/api/goose/v1/buzz/nostr-identities/current' => http.Response(
|
||||
jsonEncode({'identity': boundIdentity}),
|
||||
200,
|
||||
),
|
||||
'/api/goose/v1/buzz/identity/assertions' => http.Response(
|
||||
jsonEncode({
|
||||
'assertion': 'org-token',
|
||||
'nostr_pubkey': keys.public,
|
||||
'expires_at': DateTime.now().millisecondsSinceEpoch ~/ 1000 + 120,
|
||||
}),
|
||||
200,
|
||||
),
|
||||
_ => http.Response('{}', 404),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
Future<void> saveSession() => const FlutterSecureStorage().write(
|
||||
key: 'buzz_hosted_session_block_example_com_v1',
|
||||
value: jsonEncode({
|
||||
'origin': organization.toString(),
|
||||
'credential': 'org-secret',
|
||||
'subject': 'member',
|
||||
'expires_at': expiry.toIso8601String(),
|
||||
}),
|
||||
);
|
||||
|
||||
HostedRelayAuth relayAuth(BuilderlabAccountClient organizationAccount) =>
|
||||
HostedRelayAuth(
|
||||
BuilderlabAccountClient(
|
||||
httpClient: MockClient(
|
||||
(request) async => fail('Hosted account must not be used'),
|
||||
),
|
||||
),
|
||||
organization: organizationAccount,
|
||||
organizationRelay: (relay) =>
|
||||
OrganizationEnvironment.matches(relay, [relayHost]),
|
||||
enabled: true,
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
requests = [];
|
||||
boundIdentity = null;
|
||||
});
|
||||
|
||||
test(
|
||||
'signed-in member gets an assertion for the exact relay and its key',
|
||||
() async {
|
||||
await saveSession();
|
||||
final auth = relayAuth(organizationClient());
|
||||
expect(auth.protects(relay), isTrue);
|
||||
expect(auth.organizationProtects(relay), isTrue);
|
||||
expect(await auth.headers(relay), {
|
||||
'Nostr-Federated-Identity': 'Bearer org-token',
|
||||
});
|
||||
final issue = requests.last;
|
||||
expect(issue.url.path, '/api/goose/v1/buzz/identity/assertions');
|
||||
expect(jsonDecode(issue.body), {
|
||||
'relay_url': 'wss://$relayHost',
|
||||
'nostr_pubkey': keys.public,
|
||||
});
|
||||
expect(issue.headers['Authorization'], startsWith('Nostr '));
|
||||
// Cached until near expiry.
|
||||
await auth.headers(relay);
|
||||
expect(
|
||||
requests.where((r) => r.url.path.endsWith('/assertions')),
|
||||
hasLength(1),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('a key bound to the same member is accepted', () async {
|
||||
await saveSession();
|
||||
boundIdentity = {'pubkey_hex': keys.public};
|
||||
final auth = relayAuth(organizationClient());
|
||||
expect(await auth.headers(relay), contains('Nostr-Federated-Identity'));
|
||||
});
|
||||
|
||||
test('an issued assertion serves cached reads for the relay', () async {
|
||||
await saveSession();
|
||||
final auth = relayAuth(organizationClient());
|
||||
expect(auth.cachedHeaders(relay), isNull);
|
||||
await auth.headers(relay);
|
||||
expect(auth.cachedHeaders(relay), {
|
||||
'Nostr-Federated-Identity': 'Bearer org-token',
|
||||
});
|
||||
});
|
||||
|
||||
test('a different bound key is refused before issuance', () async {
|
||||
await saveSession();
|
||||
boundIdentity = {'pubkey_hex': 'f' * 64};
|
||||
final auth = relayAuth(organizationClient());
|
||||
await expectLater(auth.headers(relay), throwsA(AccountFailure.denied));
|
||||
expect(auth.failure, AccountFailure.denied);
|
||||
expect(
|
||||
requests.where((r) => r.url.path.endsWith('/assertions')),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
test('signed out connects without an assertion and records it', () async {
|
||||
final auth = relayAuth(organizationClient());
|
||||
expect(await auth.headers(relay), isEmpty);
|
||||
expect(auth.failure, AccountFailure.signedOut);
|
||||
expect(requests, isEmpty);
|
||||
});
|
||||
|
||||
test('other relays never receive the organization assertion', () async {
|
||||
await saveSession();
|
||||
final auth = relayAuth(organizationClient());
|
||||
for (final other in [
|
||||
'https://team.$relayHost',
|
||||
'https://other.example.com',
|
||||
]) {
|
||||
final config = RelayConfig(baseUrl: other, nsec: keys.nsec);
|
||||
expect(auth.protects(config), isFalse);
|
||||
expect(await auth.headers(config), isEmpty);
|
||||
}
|
||||
expect(requests, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
test('each origin owner consumes only its own shared callback', () async {
|
||||
late Uri hostedLogin;
|
||||
late Uri organizationLogin;
|
||||
final hosted = BrowserAuth(
|
||||
openBrowser: (uri) async {
|
||||
hostedLogin = uri;
|
||||
return null;
|
||||
},
|
||||
);
|
||||
final org = BrowserAuth(
|
||||
origin: organization,
|
||||
openBrowser: (uri) async {
|
||||
organizationLogin = uri;
|
||||
return null;
|
||||
},
|
||||
);
|
||||
final hostedCodes = <BrowserAuthCode>[];
|
||||
final orgCodes = <BrowserAuthCode>[];
|
||||
final hostedFailures = <BrowserAuthFailure>[];
|
||||
final orgFailures = <BrowserAuthFailure>[];
|
||||
hosted.codes.listen(hostedCodes.add);
|
||||
hosted.failures.listen(hostedFailures.add);
|
||||
org.codes.listen(orgCodes.add);
|
||||
org.failures.listen(orgFailures.add);
|
||||
|
||||
await org.start({'purpose': 'organization'});
|
||||
expect(organizationLogin.origin, organization.origin);
|
||||
expect(organizationLogin.path, '/api/goose/v1/auth/login');
|
||||
expect(organizationLogin.queryParameters['returnTo'], BrowserAuth.callback);
|
||||
final callback = Uri.parse(BrowserAuth.callback).replace(
|
||||
queryParameters: {
|
||||
'state': organizationLogin.queryParameters['state']!,
|
||||
'code': 'org-code',
|
||||
},
|
||||
);
|
||||
// Both owners see every app link; the hosted owner has no attempt.
|
||||
await Future.wait([hosted.receive(callback), org.receive(callback)]);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(orgCodes.single.code, 'org-code');
|
||||
expect(hostedCodes, isEmpty);
|
||||
expect(hostedFailures, isEmpty);
|
||||
expect(orgFailures, isEmpty);
|
||||
|
||||
// A pending hosted login is untouched by the organization callback.
|
||||
await hosted.start({'relay': 'wss://example.communities.buzz.xyz'});
|
||||
await org.start({'purpose': 'organization'});
|
||||
final second = Uri.parse(BrowserAuth.callback).replace(
|
||||
queryParameters: {
|
||||
'state': organizationLogin.queryParameters['state']!,
|
||||
'code': 'org-code-2',
|
||||
},
|
||||
);
|
||||
await hosted.receive(second);
|
||||
await org.receive(second);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(hostedFailures, isEmpty);
|
||||
expect(orgCodes.last.code, 'org-code-2');
|
||||
await hosted.receive(
|
||||
Uri.parse(BrowserAuth.callback).replace(
|
||||
queryParameters: {
|
||||
'state': hostedLogin.queryParameters['state']!,
|
||||
'code': 'hosted-code',
|
||||
},
|
||||
),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(hostedCodes.single.code, 'hosted-code');
|
||||
await hosted.dispose();
|
||||
await org.dispose();
|
||||
});
|
||||
|
||||
test(
|
||||
'an expired abandoned attempt ignores another owner\'s callback',
|
||||
() async {
|
||||
var now = DateTime.utc(2026, 10, 29);
|
||||
final hosted = BrowserAuth(
|
||||
openBrowser: (_) async => null,
|
||||
now: () => now,
|
||||
);
|
||||
late Uri organizationLogin;
|
||||
final org = BrowserAuth(
|
||||
origin: organization,
|
||||
now: () => now,
|
||||
openBrowser: (uri) async {
|
||||
organizationLogin = uri;
|
||||
return null;
|
||||
},
|
||||
);
|
||||
final hostedFailures = <BrowserAuthFailure>[];
|
||||
final orgCodes = <BrowserAuthCode>[];
|
||||
hosted.failures.listen(hostedFailures.add);
|
||||
org.codes.listen(orgCodes.add);
|
||||
await hosted.start({'relay': 'wss://example.communities.buzz.xyz'});
|
||||
await hosted.dispose();
|
||||
// After a restart the abandoned hosted attempt is still saved, and stale.
|
||||
final restarted = BrowserAuth(now: () => now);
|
||||
restarted.failures.listen(hostedFailures.add);
|
||||
now = now.add(const Duration(minutes: 30));
|
||||
await org.start({'purpose': 'organization'});
|
||||
final callback = Uri.parse(BrowserAuth.callback).replace(
|
||||
queryParameters: {
|
||||
'state': organizationLogin.queryParameters['state']!,
|
||||
'code': 'org-code',
|
||||
},
|
||||
);
|
||||
await restarted.receive(callback);
|
||||
await org.receive(callback);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(hostedFailures, isEmpty);
|
||||
expect(orgCodes.single.code, 'org-code');
|
||||
await restarted.dispose();
|
||||
await org.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'a restored owner consuming its callback is never failed by a peer',
|
||||
() async {
|
||||
late Uri organizationLogin;
|
||||
final first = BrowserAuth(
|
||||
origin: organization,
|
||||
openBrowser: (uri) async {
|
||||
organizationLogin = uri;
|
||||
return null;
|
||||
},
|
||||
);
|
||||
await first.start({'purpose': 'organization'});
|
||||
await first.dispose();
|
||||
// After a restart neither owner has the attempt in memory.
|
||||
final gated = _PausedReadStorage();
|
||||
final org = BrowserAuth(origin: organization, storage: gated);
|
||||
final hosted = BrowserAuth(openBrowser: (_) async => null);
|
||||
final hostedFailures = <BrowserAuthFailure>[];
|
||||
final orgCodes = <BrowserAuthCode>[];
|
||||
hosted.failures.listen(hostedFailures.add);
|
||||
org.codes.listen(orgCodes.add);
|
||||
final callback = Uri.parse(BrowserAuth.callback).replace(
|
||||
queryParameters: {
|
||||
'state': organizationLogin.queryParameters['state']!,
|
||||
'code': 'org-code',
|
||||
},
|
||||
);
|
||||
// The hosted owner's ownership check reads the peer attempt, which
|
||||
// the organization owner consumes before that read returns.
|
||||
final hostedReceive = hosted.receive(callback);
|
||||
await gated.paused.future;
|
||||
await org.receive(callback);
|
||||
gated.resume.complete();
|
||||
await hostedReceive;
|
||||
await pumpEventQueue();
|
||||
expect(orgCodes.single.code, 'org-code');
|
||||
expect(hostedFailures, isEmpty);
|
||||
await hosted.dispose();
|
||||
await org.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test('completed sign-in stores the session and reconnects once', () async {
|
||||
late Uri login;
|
||||
var reconnects = 0;
|
||||
final account = BuilderlabAccountClient(
|
||||
accountOrigin: organization,
|
||||
httpClient: MockClient((request) async {
|
||||
expect(request.url.origin, organization.origin);
|
||||
if (request.url.path.endsWith('/v1/auth/login/exchange')) {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'session_credential': 'org-secret',
|
||||
'expires_at': expiry.toIso8601String(),
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
if (request.url.path.endsWith('/v1/auth/me')) {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'subject': 'member',
|
||||
'expires_at': expiry.toIso8601String(),
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response('{}', 404);
|
||||
}),
|
||||
);
|
||||
final signIn = OrganizationSignIn(
|
||||
account: account,
|
||||
browser: BrowserAuth(
|
||||
origin: organization,
|
||||
openBrowser: (uri) async {
|
||||
login = uri;
|
||||
return null;
|
||||
},
|
||||
),
|
||||
reconnect: () async => reconnects++,
|
||||
);
|
||||
await signIn.initialize();
|
||||
expect(signIn.status, OrganizationSessionStatus.signedOut);
|
||||
await signIn.signIn();
|
||||
expect(signIn.status, OrganizationSessionStatus.signingIn);
|
||||
await signIn.browser.receive(
|
||||
Uri.parse(BrowserAuth.callback).replace(
|
||||
queryParameters: {
|
||||
'state': login.queryParameters['state']!,
|
||||
'code': 'org-code',
|
||||
},
|
||||
),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
expect(signIn.status, OrganizationSessionStatus.signedIn);
|
||||
expect(signIn.failure, isNull);
|
||||
expect(reconnects, 1);
|
||||
expect(account.account?.subject, 'member');
|
||||
signIn.dispose();
|
||||
});
|
||||
|
||||
test(
|
||||
'a restore after an offline start shows the session signed in',
|
||||
() async {
|
||||
await const FlutterSecureStorage().write(
|
||||
key: 'buzz_hosted_session_block_example_com_v1',
|
||||
value: jsonEncode({
|
||||
'origin': organization.toString(),
|
||||
'credential': 'org-secret',
|
||||
'subject': 'member',
|
||||
'expires_at': expiry.toIso8601String(),
|
||||
}),
|
||||
);
|
||||
var online = false;
|
||||
final account = BuilderlabAccountClient(
|
||||
accountOrigin: organization,
|
||||
httpClient: MockClient((request) async {
|
||||
if (!online) return http.Response('{}', 503);
|
||||
expect(request.url.path, '/api/goose/v1/auth/me');
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'subject': 'member',
|
||||
'expires_at': expiry.toIso8601String(),
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}),
|
||||
);
|
||||
final signIn = OrganizationSignIn(
|
||||
account: account,
|
||||
browser: BrowserAuth(
|
||||
origin: organization,
|
||||
openBrowser: (uri) async => null,
|
||||
),
|
||||
reconnect: () async {},
|
||||
);
|
||||
await signIn.initialize();
|
||||
expect(signIn.status, OrganizationSessionStatus.signedOut);
|
||||
|
||||
// A relay connection retry restores the session once back online.
|
||||
online = true;
|
||||
await account.restore();
|
||||
expect(signIn.status, OrganizationSessionStatus.signedIn);
|
||||
signIn.dispose();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Pauses the first read, then serves reads after the peer's changes.
|
||||
class _PausedReadStorage extends FlutterSecureStorage {
|
||||
final paused = Completer<void>();
|
||||
final resume = Completer<void>();
|
||||
|
||||
@override
|
||||
Future<String?> read({
|
||||
required String key,
|
||||
AppleOptions? iOptions,
|
||||
AndroidOptions? aOptions,
|
||||
LinuxOptions? lOptions,
|
||||
WebOptions? webOptions,
|
||||
AppleOptions? mOptions,
|
||||
WindowsOptions? wOptions,
|
||||
}) async {
|
||||
if (!paused.isCompleted) {
|
||||
paused.complete();
|
||||
await resume.future;
|
||||
}
|
||||
return super.read(key: key);
|
||||
}
|
||||
}
|
||||
284
mobile/test/shared/hosted/organization_sign_in_test.dart
Normal file
284
mobile/test/shared/hosted/organization_sign_in_test.dart
Normal file
@@ -0,0 +1,284 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:buzz/shared/hosted/browser_auth.dart';
|
||||
import 'package:buzz/shared/hosted/builderlab_account.dart';
|
||||
import 'package:buzz/shared/hosted/organization_sign_in.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
final organization = Uri.parse('https://block.example.com');
|
||||
const orgSessionKey = 'buzz_hosted_session_block_example_com_v1';
|
||||
final expiry = DateTime.now().toUtc().add(const Duration(days: 1));
|
||||
|
||||
setUp(() => FlutterSecureStorage.setMockInitialValues({}));
|
||||
|
||||
http.Response me() => http.Response(
|
||||
jsonEncode({'subject': 'member', 'expires_at': expiry.toIso8601String()}),
|
||||
200,
|
||||
);
|
||||
http.Response exchanged() => http.Response(
|
||||
jsonEncode({
|
||||
'session_credential': 'org-secret',
|
||||
'expires_at': expiry.toIso8601String(),
|
||||
}),
|
||||
200,
|
||||
);
|
||||
Uri callback(Uri login, String code) =>
|
||||
Uri.parse(BrowserAuth.callback).replace(
|
||||
queryParameters: {
|
||||
'state': login.queryParameters['state']!,
|
||||
'code': code,
|
||||
},
|
||||
);
|
||||
|
||||
test('a cancelled exchange never changes a newer sign-in', () async {
|
||||
final logins = <Uri>[];
|
||||
final held = [Completer<http.Response>(), Completer<http.Response>()];
|
||||
var exchanges = 0;
|
||||
http.Response exchangedAs(String credential) => http.Response(
|
||||
jsonEncode({
|
||||
'session_credential': credential,
|
||||
'expires_at': expiry.toIso8601String(),
|
||||
}),
|
||||
200,
|
||||
);
|
||||
final signIn = OrganizationSignIn(
|
||||
account: BuilderlabAccountClient(
|
||||
accountOrigin: organization,
|
||||
httpClient: MockClient((request) async {
|
||||
if (request.url.path.endsWith('/v1/auth/login/exchange')) {
|
||||
final index = exchanges++;
|
||||
return index < held.length
|
||||
? held[index].future
|
||||
: exchangedAs('org-member-b');
|
||||
}
|
||||
final credential = request.headers['X-BB-Session-Credential']!;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'subject': credential.substring('org-'.length),
|
||||
'expires_at': expiry.toIso8601String(),
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}),
|
||||
),
|
||||
browser: BrowserAuth(
|
||||
origin: organization,
|
||||
openBrowser: (uri) async {
|
||||
logins.add(uri);
|
||||
return null;
|
||||
},
|
||||
),
|
||||
reconnect: () async {},
|
||||
);
|
||||
await signIn.initialize();
|
||||
await signIn.signIn();
|
||||
await signIn.browser.receive(callback(logins.last, 'code-1'));
|
||||
await pumpEventQueue();
|
||||
expect(exchanges, 1);
|
||||
|
||||
// A cancelled exchange that fails late leaves the newer attempt alone.
|
||||
await signIn.cancel();
|
||||
await signIn.signIn();
|
||||
expect(signIn.status, OrganizationSessionStatus.signingIn);
|
||||
held[0].complete(http.Response('{}', 503));
|
||||
await pumpEventQueue();
|
||||
expect(signIn.status, OrganizationSessionStatus.signingIn);
|
||||
expect(signIn.failure, isNull);
|
||||
|
||||
// The newer attempt still owns its browser and can be cancelled.
|
||||
await signIn.cancel();
|
||||
expect(signIn.status, OrganizationSessionStatus.signedOut);
|
||||
await signIn.signIn();
|
||||
|
||||
// A cancelled exchange that succeeds late is never saved, so the newer
|
||||
// sign-in as a different member still succeeds.
|
||||
await signIn.browser.receive(callback(logins.last, 'code-2'));
|
||||
await pumpEventQueue();
|
||||
expect(exchanges, 2);
|
||||
await signIn.cancel();
|
||||
await signIn.signIn();
|
||||
await signIn.browser.receive(callback(logins.last, 'code-3'));
|
||||
held[1].complete(exchangedAs('org-member-a'));
|
||||
await pumpEventQueue();
|
||||
expect(signIn.failure, isNull);
|
||||
expect(signIn.status, OrganizationSessionStatus.signedIn);
|
||||
expect(signIn.account.account?.subject, 'member-b');
|
||||
expect(
|
||||
jsonDecode((await const FlutterSecureStorage().read(key: orgSessionKey))!)
|
||||
as Map,
|
||||
containsPair('subject', 'member-b'),
|
||||
);
|
||||
signIn.dispose();
|
||||
});
|
||||
|
||||
test('a second tap while the session clears starts no attempt', () async {
|
||||
await const FlutterSecureStorage().write(
|
||||
key: orgSessionKey,
|
||||
value: 'not json',
|
||||
);
|
||||
final logins = <Uri>[];
|
||||
final browserStorage = _CountingStorage();
|
||||
final signIn = OrganizationSignIn(
|
||||
account: BuilderlabAccountClient(
|
||||
accountOrigin: organization,
|
||||
httpClient: MockClient((request) async => me()),
|
||||
),
|
||||
browser: BrowserAuth(
|
||||
origin: organization,
|
||||
storage: browserStorage,
|
||||
openBrowser: (uri) async {
|
||||
logins.add(uri);
|
||||
return null;
|
||||
},
|
||||
),
|
||||
reconnect: () async {},
|
||||
);
|
||||
await signIn.initialize();
|
||||
expect(signIn.failure, AccountFailure.storage);
|
||||
await Future.wait([signIn.signIn(), signIn.signIn()]);
|
||||
expect(signIn.status, OrganizationSessionStatus.signingIn);
|
||||
expect(browserStorage.attempts, 1);
|
||||
signIn.dispose();
|
||||
});
|
||||
|
||||
test('a failed intent cleanup still leaves sign-in signed in', () async {
|
||||
late Uri login;
|
||||
var reconnects = 0;
|
||||
final storage = _FailingDeleteStorage('buzz_browser_auth_intent_v1_');
|
||||
final signIn = OrganizationSignIn(
|
||||
account: BuilderlabAccountClient(
|
||||
accountOrigin: organization,
|
||||
httpClient: MockClient(
|
||||
(request) async =>
|
||||
request.url.path.endsWith('/exchange') ? exchanged() : me(),
|
||||
),
|
||||
),
|
||||
browser: BrowserAuth(
|
||||
origin: organization,
|
||||
storage: storage,
|
||||
openBrowser: (uri) async {
|
||||
login = uri;
|
||||
return null;
|
||||
},
|
||||
),
|
||||
reconnect: () async => reconnects++,
|
||||
);
|
||||
await signIn.initialize();
|
||||
await signIn.signIn();
|
||||
await signIn.browser.receive(callback(login, 'org-code'));
|
||||
await pumpEventQueue();
|
||||
expect(signIn.status, OrganizationSessionStatus.signedIn);
|
||||
expect(signIn.failure, BrowserAuthFailure.storage);
|
||||
expect(reconnects, 1);
|
||||
signIn.dispose();
|
||||
});
|
||||
|
||||
test('a failed cancel cleanup still leaves the busy state', () async {
|
||||
final storage = _FailingDeleteStorage('buzz_browser_auth_attempt_v1_');
|
||||
final signIn = OrganizationSignIn(
|
||||
account: BuilderlabAccountClient(
|
||||
accountOrigin: organization,
|
||||
httpClient: MockClient((request) async => me()),
|
||||
),
|
||||
browser: BrowserAuth(
|
||||
origin: organization,
|
||||
storage: storage,
|
||||
openBrowser: (uri) async => null,
|
||||
),
|
||||
reconnect: () async {},
|
||||
);
|
||||
await signIn.initialize();
|
||||
await signIn.signIn();
|
||||
expect(signIn.status, OrganizationSessionStatus.signingIn);
|
||||
await signIn.cancel();
|
||||
expect(signIn.status, OrganizationSessionStatus.signedOut);
|
||||
expect(signIn.failure, BrowserAuthFailure.storage);
|
||||
signIn.dispose();
|
||||
});
|
||||
|
||||
test('an unreadable saved session is replaced by signing in', () async {
|
||||
await const FlutterSecureStorage().write(
|
||||
key: orgSessionKey,
|
||||
value: 'not json',
|
||||
);
|
||||
late Uri login;
|
||||
final account = BuilderlabAccountClient(
|
||||
accountOrigin: organization,
|
||||
httpClient: MockClient(
|
||||
(request) async =>
|
||||
request.url.path.endsWith('/exchange') ? exchanged() : me(),
|
||||
),
|
||||
);
|
||||
final signIn = OrganizationSignIn(
|
||||
account: account,
|
||||
browser: BrowserAuth(
|
||||
origin: organization,
|
||||
openBrowser: (uri) async {
|
||||
login = uri;
|
||||
return null;
|
||||
},
|
||||
),
|
||||
reconnect: () async {},
|
||||
);
|
||||
await signIn.initialize();
|
||||
expect(signIn.status, OrganizationSessionStatus.signedOut);
|
||||
expect(signIn.failure, AccountFailure.storage);
|
||||
|
||||
await signIn.signIn();
|
||||
await signIn.browser.receive(callback(login, 'org-code'));
|
||||
await pumpEventQueue();
|
||||
expect(signIn.status, OrganizationSessionStatus.signedIn);
|
||||
expect(signIn.failure, isNull);
|
||||
expect(account.account?.subject, 'member');
|
||||
signIn.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
/// Fails deletions of keys with [prefix], as an unavailable Keychain would.
|
||||
class _FailingDeleteStorage extends FlutterSecureStorage {
|
||||
_FailingDeleteStorage(this.prefix);
|
||||
final String prefix;
|
||||
|
||||
@override
|
||||
Future<void> delete({
|
||||
required String key,
|
||||
AppleOptions? iOptions,
|
||||
AndroidOptions? aOptions,
|
||||
LinuxOptions? lOptions,
|
||||
WebOptions? webOptions,
|
||||
AppleOptions? mOptions,
|
||||
WindowsOptions? wOptions,
|
||||
}) {
|
||||
if (key.startsWith(prefix)) {
|
||||
throw PlatformException(code: 'keychain', message: 'unavailable');
|
||||
}
|
||||
return super.delete(key: key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts browser sign-in attempts as they are saved.
|
||||
class _CountingStorage extends FlutterSecureStorage {
|
||||
int attempts = 0;
|
||||
|
||||
@override
|
||||
Future<void> write({
|
||||
required String key,
|
||||
required String? value,
|
||||
AppleOptions? iOptions,
|
||||
AndroidOptions? aOptions,
|
||||
LinuxOptions? lOptions,
|
||||
WebOptions? webOptions,
|
||||
AppleOptions? mOptions,
|
||||
WindowsOptions? wOptions,
|
||||
}) {
|
||||
if (key.startsWith('buzz_browser_auth_attempt_v1_')) attempts++;
|
||||
return super.write(key: key, value: value);
|
||||
}
|
||||
}
|
||||
115
mobile/test/shared/hosted/relay_auth_isolation_test.dart
Normal file
115
mobile/test/shared/hosted/relay_auth_isolation_test.dart
Normal file
@@ -0,0 +1,115 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:buzz/shared/hosted/builderlab_account.dart';
|
||||
import 'package:buzz/shared/hosted/hosted_environment.dart';
|
||||
import 'package:buzz/shared/hosted/hosted_relay_auth.dart';
|
||||
import 'package:buzz/shared/hosted/organization_environment.dart';
|
||||
import 'package:buzz/shared/relay/relay_provider.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
final keys = nostr.Keys(
|
||||
'0000000000000000000000000000000000000000000000000000000000000001',
|
||||
);
|
||||
final organization = Uri.parse('https://block.example.com');
|
||||
const relayHost = 'buzz.block.example.com';
|
||||
const orgSessionKey = 'buzz_hosted_session_block_example_com_v1';
|
||||
final expiry = DateTime.now().toUtc().add(const Duration(days: 1));
|
||||
|
||||
setUp(() => FlutterSecureStorage.setMockInitialValues({}));
|
||||
|
||||
http.Response me() => http.Response(
|
||||
jsonEncode({'subject': 'member', 'expires_at': expiry.toIso8601String()}),
|
||||
200,
|
||||
);
|
||||
|
||||
test(
|
||||
'a stalled organization issuer never delays a hosted relay',
|
||||
() async {
|
||||
final hostedRelay = RelayConfig(
|
||||
baseUrl: 'https://team.communities.buzz.xyz',
|
||||
nsec: keys.nsec,
|
||||
);
|
||||
final orgRelay = RelayConfig(
|
||||
baseUrl: 'https://$relayHost',
|
||||
nsec: keys.nsec,
|
||||
);
|
||||
for (final (origin, key) in [
|
||||
(organization, orgSessionKey),
|
||||
(
|
||||
BuilderlabAccountClient.origin,
|
||||
'buzz_hosted_session_app_builderlab_xyz_v1',
|
||||
),
|
||||
]) {
|
||||
await const FlutterSecureStorage().write(
|
||||
key: key,
|
||||
value: jsonEncode({
|
||||
'origin': origin.toString(),
|
||||
'credential': 'secret',
|
||||
'subject': 'member',
|
||||
'expires_at': expiry.toIso8601String(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
final stalled = Completer<http.Response>();
|
||||
BuilderlabAccountClient client({required bool stall}) =>
|
||||
BuilderlabAccountClient(
|
||||
accountOrigin: stall ? organization : null,
|
||||
httpClient: MockClient((request) async {
|
||||
return switch (request.url.path) {
|
||||
'/api/goose/v1/auth/me' => me(),
|
||||
'/api/goose/v1/buzz/nostr-identities/current' => http.Response(
|
||||
jsonEncode({
|
||||
'identity': {'pubkey_hex': keys.public},
|
||||
}),
|
||||
200,
|
||||
),
|
||||
'/api/goose/v1/buzz/identity/assertions' =>
|
||||
stall
|
||||
? stalled.future
|
||||
: http.Response(
|
||||
jsonEncode({
|
||||
'assertion': 'hosted-token',
|
||||
'nostr_pubkey': keys.public,
|
||||
'expires_at':
|
||||
DateTime.now().millisecondsSinceEpoch ~/ 1000 +
|
||||
120,
|
||||
}),
|
||||
200,
|
||||
),
|
||||
_ => http.Response('{}', 404),
|
||||
};
|
||||
}),
|
||||
);
|
||||
final auth = HostedRelayAuth(
|
||||
client(stall: false),
|
||||
organization: client(stall: true),
|
||||
organizationRelay: (relay) =>
|
||||
OrganizationEnvironment.matches(relay, [relayHost]),
|
||||
enabled: true,
|
||||
);
|
||||
final orgHeaders = auth.headers(orgRelay);
|
||||
await pumpEventQueue();
|
||||
expect(
|
||||
await auth.headers(hostedRelay).timeout(const Duration(seconds: 2)),
|
||||
{'Nostr-Federated-Identity': 'Bearer hosted-token'},
|
||||
);
|
||||
// The hosted relay then refuses this key.
|
||||
auth.rejected(hostedRelay, 'Bearer hosted-token', terminal: true);
|
||||
stalled.complete(http.Response('{}', 503));
|
||||
await expectLater(orgHeaders, throwsA(isA<AccountFailure>()));
|
||||
// The stale organization failure never hides the hosted recovery.
|
||||
expect(auth.failedRelay, hostedRelay.baseUrl);
|
||||
expect(auth.failure, AccountFailure.denied);
|
||||
auth.dispose();
|
||||
},
|
||||
// Hosted relays are protected only in builds with authentication domains.
|
||||
skip: !HostedEnvironment.enabled,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user