From bf13f7c83bdd871828e9cb4b9be5e03197de84c6 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Tue, 3 Nov 2026 11:01:42 -0800 Subject: [PATCH] fix(ios): mint NIP-FI assertions in the notification extension (#8982) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Organization relays that enforce NIP-FI (federated identity) require a `Nostr-Federated-Identity` assertion on `/query`. The iOS notification service extension signed its `/query` with NIP-98 only, so the relay answered `401` and every push from an enforcing organization relay showed the generic "You have a new message." body. The app already mints these assertions in Dart from its organization account session, but that session lives in the app's own keychain, and assertions last minutes, so a cached one is useless by the time a push arrives. This change lets the extension mint its own: - **App:** whenever the organization `BuilderlabAccountClient` adopts or clears its session, the app writes or deletes a device-only keychain item in the group it already shares with the extension. The item holds the organization origin, the session credential and the organization's exact relay hosts (from `BUZZ_ORGANIZATION_RELAYS`). Builds without an organization never write it. - **Extension:** for a community whose relay host is one of those exact hosts, `BuzzRelayAssertionIssuer` requests an assertion from the organization issuer (`/api/goose/v1/buzz/identity/assertions`, session credential plus NIP-98 with the community's account key, the same request the app makes), checks it was issued for that key, and caches it in-process until 30 seconds before expiry. Both `/query` paths (message lookup and sender/channel enrichment) send it. Other relays get no header and never see the session. - Signing out awaits removal of the shared copy and fails if removal fails; each restore that finds no session retries the removal. - If issuance fails or the user is signed out, the extension sends the query without the header and the relay refuses it, which keeps today's generic fallback. Not covered here: relay-hosted avatars on enforcing relays still load without the assertion (the notification text and title are unaffected), hosted Builderlab communities, and Android. ### Validation Added tests: - `testOrganizationRelayQueriesCarryMintedAssertion`: against a stub relay that answers `401` without the assertion, two organization pushes resolve with message body and sender name from a single issuance, a non-organization relay gets neither the header nor the session, and after sign-out the notification falls back. Removing the header from the query path makes this test fail. - `testOrganizationAssertionIssuanceFailuresSendNoHeader`: refused, wrong-key, expired and header-injection responses produce no header. Subdomains and other ports of the organization relay host are never sent the session. - `builderlab_account_test.dart`: the shared copy follows exchange, restore and clear; a failed removal fails sign-out, and the next restore retries it. Focused existing validation: the full BuzzPushKit suite, the hosted Dart tests, `flutter analyze`, and an iOS device build of the app and extension. Not yet validated: a live push from the Block relay on a device. ## Before merge Live device push test: waived by Tom (2026-11-03). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow Co-authored-by: Tom Brow Co-authored-by: Claude Opus 5.5 --- .../BuzzPushNotificationResolver.swift | 177 ++++++++++----- .../BuzzRelayAssertionIssuer.swift | 158 ++++++++++++++ .../BuzzPushNotificationResolverTests.swift | 202 ++++++++++++++++++ .../NotificationService.swift | 43 +++- mobile/ios/Runner/PushNativeState.swift | 32 +++ mobile/ios/Runner/PushSnapshotBridge.swift | 46 ++++ .../lib/shared/hosted/builderlab_account.dart | 70 +++++- .../lib/shared/hosted/hosted_relay_auth.dart | 11 +- mobile/lib/shared/push/push_bridge.dart | 23 ++ .../hosted/builderlab_account_test.dart | 77 ++++++- 10 files changed, 778 insertions(+), 61 deletions(-) create mode 100644 mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzRelayAssertionIssuer.swift diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift index 71a539a578..004e6d001a 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift @@ -52,6 +52,8 @@ public protocol BuzzPushNotificationResolving { func resolve(completion: @escaping (BuzzPushResolution?) -> Void) /// Cancels any retry in flight and prevents new ones. Call when the notification is delivered. func cancel() + /// Whether the organization session behind the resolved message, if any, still holds. + func isStillAuthorized() -> Bool } /// Reads configured Buzz communities and resolves their newest message not yet shown. @@ -70,9 +72,12 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { private var retrySessions: [URLSession] = [] private let loadCommunitiesData: () -> Data? private let loadPrivateKey: (String) -> String? + private let assertionLock = NSLock() + private var resolvedAssertion: BuzzRelayAssertion? private let now: () -> Date private let notificationCache: BuzzNotificationCache? private let avatarLoader: BuzzNotificationAvatarLoader? + private let assertionIssuer: BuzzRelayAssertionIssuer? /// Creates a resolver around the notification extension's App Group and Keychain I/O. public init( @@ -81,12 +86,14 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { loadPrivateKey: @escaping (String) -> String?, notificationCache: BuzzNotificationCache? = nil, avatarLoader: BuzzNotificationAvatarLoader? = nil, + assertionIssuer: BuzzRelayAssertionIssuer? = nil, now: @escaping () -> Date = Date.init, makeRetrySession: (() -> URLSession)? = nil ) { self.makeRetrySession = makeRetrySession ?? { URLSession(configuration: session.configuration) } self.notificationCache = notificationCache self.avatarLoader = avatarLoader + self.assertionIssuer = assertionIssuer self.session = session self.loadCommunitiesData = loadCommunitiesData self.loadPrivateKey = loadPrivateKey @@ -105,12 +112,12 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { } let group = DispatchGroup() let lock = NSLock() - var candidates: [(VerifiedNostrEvent, PushLeaseCommunity)] = [] + var candidates: [(VerifiedNostrEvent, PushLeaseCommunity, BuzzRelayAssertion?)] = [] for community in communities { group.enter() - query(community) { messages in + query(community) { messages, assertion in lock.lock() - candidates += messages.map { ($0, community) } + candidates += messages.map { ($0, community, assertion) } lock.unlock() group.leave() } @@ -123,6 +130,9 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { completion(nil) return } + self.assertionLock.lock() + self.resolvedAssertion = newest.2 + self.assertionLock.unlock() self.resolvePresentation(event: newest.0, community: newest.1, completion: completion) } } @@ -133,12 +143,14 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { /// push shows the generic text and the message isn't offered again, which is better /// than repeating a message in later notifications. private func firstUnpresented( - _ newestFirst: [(VerifiedNostrEvent, PushLeaseCommunity)], + _ newestFirst: [(VerifiedNostrEvent, PushLeaseCommunity, BuzzRelayAssertion?)], communities: [PushLeaseCommunity] - ) -> (VerifiedNostrEvent, PushLeaseCommunity)? { + ) -> (VerifiedNostrEvent, PushLeaseCommunity, BuzzRelayAssertion?)? { guard let notificationCache else { return newestFirst.first } - let candidates = newestFirst.compactMap { event, community in - BuzzNotificationCacheScope(community: community).map { (candidate: (event, community), scope: $0) } + let candidates = newestFirst.compactMap { event, community, assertion in + BuzzNotificationCacheScope(community: community).map { + (candidate: (event, community, assertion), scope: $0) + } } do { // Every community's history bounds the others, even one whose query failed, so a @@ -172,10 +184,10 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { private func query( _ community: PushLeaseCommunity, - completion: @escaping ([VerifiedNostrEvent]) -> Void + completion: @escaping ([VerifiedNostrEvent], BuzzRelayAssertion?) -> Void ) { guard let privateKey = accountPrivateKey(for: community), community.pubkey?.isEmpty == false else { - completion([]) + completion([], nil) return } guard @@ -186,33 +198,64 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { withJSONObject: community.policies.map { $0.filter.queryFilter(since: nil, limit: 10) } ) else { - completion([]) + completion([], nil) return } - send( - url: url, body: body, privateKey: privateKey, session: session, - canRetry: { self.matchingPrivateKey(for: community) == privateKey } - ) { data, response in - guard self.matchingPrivateKey(for: community) != nil, - let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode), - let data, let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data) - else { - completion([]) - return + federatedIdentity(relayURL: relayURL, privateKey: privateKey) { assertion in + self.send( + url: url, body: body, privateKey: privateKey, federatedIdentity: assertion?.header, + session: self.session, + canRetry: { + self.matchingPrivateKey(for: community) == privateKey && self.isCurrent(assertion) + } + ) { data, response in + guard self.matchingPrivateKey(for: community) != nil, self.isCurrent(assertion), + let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode), + let data, let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data) + else { + completion([], assertion) + return + } + completion( + Self.messages( + events: events.filter { event in + event.hasValidIDAndSignature() + && community.policies.contains { policy in + PushLeaseMatcher.matches(event: event, policy: policy) + } + }, + community: community + ), assertion) } - completion( - Self.messages( - events: events.filter { event in - event.hasValidIDAndSignature() - && community.policies.contains { policy in - PushLeaseMatcher.matches(event: event, policy: policy) - } - }, - community: community - )) } } + /// The NIP-FI assertion an organization relay requires, or nil for any other relay. + private func federatedIdentity( + relayURL: URL, privateKey: String, completion: @escaping (BuzzRelayAssertion?) -> Void + ) { + guard let assertionIssuer else { + completion(nil) + return + } + assertionIssuer.header(relayURL: relayURL, privateKeyHex: privateKey, completion: completion) + } + + /// Whether the organization session behind the resolved message, if any, is still the shared + /// one. The extension checks this again before delivering, as it does the account scope. + public func isStillAuthorized() -> Bool { + assertionLock.lock() + let assertion = resolvedAssertion + assertionLock.unlock() + return isCurrent(assertion) + } + + /// Responses fetched with an assertion are accepted only while its organization session lasts. + private func isCurrent(_ assertion: BuzzRelayAssertion?) -> Bool { + guard let assertion else { return true } + return assertionIssuer?.isCurrent(assertion) ?? false + } + /// Sends one signed `/query`. A timeout or dropped connection is retried once on a new /// session, because the failed attempt may have used a pooled connection that went stale /// when the network path changed. Each attempt is signed afresh so the relay's NIP-98 @@ -227,7 +270,8 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { } private func send( - url: URL, body: Data, privateKey: String, session: URLSession, canRetry: (() -> Bool)?, + url: URL, body: Data, privateKey: String, federatedIdentity: String?, session: URLSession, + canRetry: (() -> Bool)?, completion: @escaping (Data?, URLResponse?) -> Void ) { var request = URLRequest(url: url) @@ -244,7 +288,10 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { return } request.setValue(auth, forHTTPHeaderField: "Authorization") - session.dataTask(with: request) { data, response, error in + if let federatedIdentity { + request.setValue(federatedIdentity, forHTTPHeaderField: "Nostr-Federated-Identity") + } + let task = session.dataTask(with: request) { data, response, error in guard let error = error as? URLError, [.timedOut, .networkConnectionLost].contains(error.code), canRetry?() == true else { @@ -262,12 +309,16 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { let retrySession = self.makeRetrySession() self.retrySessions.append(retrySession) self.send( - url: url, body: body, privateKey: privateKey, session: retrySession, canRetry: nil + url: url, body: body, privateKey: privateKey, federatedIdentity: federatedIdentity, + session: retrySession, canRetry: nil ) { data, response in retrySession.finishTasksAndInvalidate() completion(data, response) } - }.resume() + } + // A redirect would forward the federated identity header to its target, so it is refused. + task.delegate = BuzzRedirectRefusal.shared + task.resume() } private func resolvePresentation( @@ -288,7 +339,7 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { event: event, community: community, scope: scope, cache: notificationCache, avatarLoader: avatarLoader, now: now, isCurrent: { [self] in - matchingPrivateKey(for: community) != nil + matchingPrivateKey(for: community) != nil && isStillAuthorized() }, refresh: { [self] profile, channel, done in refreshPresentation(event: event, community: community, @@ -361,29 +412,34 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { completion([]) return } - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.httpBody = body - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - guard let auth = try? NostrHTTPAuth.authorizationHeader( - url: url, method: "POST", body: body, privateKeyHex: privateKey) - else { - completion([]) - return - } - request.setValue(auth, forHTTPHeaderField: "Authorization") - BuzzNotificationDownload.start( - request: request, configuration: session.configuration, - maximumBytes: Self.maximumPresentationResponseBytes - ) { result in - guard self.matchingPrivateKey(for: community) != nil, - case .success(let data) = result, - let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data) + federatedIdentity(relayURL: relayURL, privateKey: privateKey) { assertion in + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.httpBody = body + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + guard let auth = try? NostrHTTPAuth.authorizationHeader( + url: url, method: "POST", body: body, privateKeyHex: privateKey) else { completion([]) return } - completion(events.filter { $0.hasValidIDAndSignature() }) + request.setValue(auth, forHTTPHeaderField: "Authorization") + if let assertion { + request.setValue(assertion.header, forHTTPHeaderField: "Nostr-Federated-Identity") + } + BuzzNotificationDownload.start( + request: request, configuration: self.session.configuration, + maximumBytes: Self.maximumPresentationResponseBytes + ) { result in + guard self.matchingPrivateKey(for: community) != nil, self.isCurrent(assertion), + case .success(let data) = result, + let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data) + else { + completion([]) + return + } + completion(events.filter { $0.hasValidIDAndSignature() }) + } } } @@ -621,3 +677,16 @@ extension PushLeaseCommunity { return components.url } } + +/// Refuses every redirect, so a signed relay request never forwards its proof or assertion. +final class BuzzRedirectRefusal: NSObject, URLSessionTaskDelegate, Sendable { + static let shared = BuzzRedirectRefusal() + + func urlSession( + _ session: URLSession, task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + completionHandler(nil) + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzRelayAssertionIssuer.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzRelayAssertionIssuer.swift new file mode 100644 index 0000000000..4174b3ddff --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzRelayAssertionIssuer.swift @@ -0,0 +1,158 @@ +import Foundation + +/// The organization account session the app shares with the notification extension, so the +/// extension can mint the NIP-FI assertion an enforcing organization relay requires on `/query`. +public struct BuzzOrganizationSession: Codable, Equatable, Sendable { + /// Keychain item, in the access group the app shares with the extension. + public static let keychainService = "buzz.push.nse.organization-session" + public static let keychainAccount = "organization" + + /// The organization's Builderlab HTTPS origin, for example `https://block.builderlab.xyz`. + public let origin: String + /// The account session credential, sent only to [origin]. + public let credential: String + /// Exact relay hosts that trust this organization. Subdomains and other ports never match. + public let relayHosts: [String] + + public init(origin: String, credential: String, relayHosts: [String]) { + self.origin = origin + self.credential = credential + self.relayHosts = relayHosts + } + + /// The issuer route, when [origin] is a bare HTTPS origin. + var assertionURL: URL? { + guard let components = URLComponents(string: origin), + components.scheme == "https", components.host?.isEmpty == false, + components.user == nil, components.password == nil, components.port == nil, + components.query == nil, components.fragment == nil, + components.path.isEmpty || components.path == "/" + else { return nil } + var route = components + route.path = "/api/goose/v1/buzz/identity/assertions" + return route.url + } + + /// Matches the app's `OrganizationEnvironment.protects` for an HTTPS relay URL. + func protects(_ relayURL: URL) -> Bool { + guard relayURL.scheme == "https", relayURL.user == nil, relayURL.password == nil, + relayURL.query == nil, relayURL.fragment == nil, + relayURL.path.isEmpty || relayURL.path == "/", + relayURL.port == nil || relayURL.port == 443, + let host = relayURL.host?.lowercased() + else { return false } + return relayHosts.contains(host) + } +} + +/// A minted `Nostr-Federated-Identity` header and the organization session it was minted from. +public struct BuzzRelayAssertion { + public let header: String + let organization: BuzzOrganizationSession +} + +/// Mints NIP-FI assertions for organization relays from the shared organization session. +/// Relays outside the organization get no header, exactly as before. +public final class BuzzRelayAssertionIssuer: @unchecked Sendable { + static let maximumResponseBytes = 32 * 1_024 + static let maximumAssertionBytes = 16_384 + /// A reused assertion must outlive the requests that carry it. + static let reuseMargin: TimeInterval = 30 + + private let loadSession: () -> BuzzOrganizationSession? + private let session: URLSession + private let now: () -> Date + private let lock = NSLock() + private var cached: [String: (token: String, expiry: Date)] = [:] + + public init( + session: URLSession, + loadSession: @escaping () -> BuzzOrganizationSession?, + now: @escaping () -> Date = Date.init + ) { + self.session = session + self.loadSession = loadSession + self.now = now + } + + /// Whether the organization session that issued `assertion` is still the shared one. A + /// response fetched with an assertion from a signed-out or replaced session must be dropped. + public func isCurrent(_ assertion: BuzzRelayAssertion) -> Bool { + loadSession() == assertion.organization + } + + /// Completes with the `Nostr-Federated-Identity` value for an organization relay, or nil when + /// the relay is not an organization relay or no assertion could be issued. A nil value for an + /// enforcing relay leaves the relay to refuse the request. + public func header( + relayURL: URL, privateKeyHex: String, completion: @escaping (BuzzRelayAssertion?) -> Void + ) { + guard let organization = loadSession(), organization.protects(relayURL), + let issuerURL = organization.assertionURL, + let host = relayURL.host?.lowercased(), + let secret = VerifiedNostrEvent.hexBytes(privateKeyHex), secret.count == 32, + let pubkeyBytes = try? NostrSchnorr.publicKey(secret: secret) + else { + completion(nil) + return + } + let pubkey = VerifiedNostrEvent.hex(pubkeyBytes) + let relay = "wss://\(host)" + let cacheKey = "\(organization.origin)|\(relay)|\(pubkey)|\(organization.credential)" + lock.lock() + if let entry = cached[cacheKey], entry.expiry.timeIntervalSince(now()) > Self.reuseMargin { + lock.unlock() + completion(BuzzRelayAssertion(header: "Bearer \(entry.token)", organization: organization)) + return + } + lock.unlock() + + guard + let body = try? JSONSerialization.data( + withJSONObject: ["relay_url": relay, "nostr_pubkey": pubkey], options: [.sortedKeys]), + let auth = try? NostrHTTPAuth.authorizationHeader( + url: issuerURL, method: "POST", body: body, privateKeyHex: privateKeyHex) + else { + completion(nil) + return + } + var request = URLRequest(url: issuerURL) + request.httpMethod = "POST" + request.httpBody = body + request.timeoutInterval = 8 + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(organization.origin, forHTTPHeaderField: "Origin") + request.setValue(organization.credential, forHTTPHeaderField: "X-BB-Session-Credential") + request.setValue(auth, forHTTPHeaderField: "Authorization") + BuzzNotificationDownload.start( + request: request, configuration: session.configuration, + maximumBytes: Self.maximumResponseBytes + ) { [self] result in + guard case .success(let data) = result, + let value = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let token = value["assertion"] as? String, !token.isEmpty, + token.utf8.count <= Self.maximumAssertionBytes, + !token.contains(where: { $0.isNewline || $0 == "\0" }), + (value["nostr_pubkey"] as? String)?.lowercased() == pubkey, + let expiresAt = value["expires_at"] as? Int + else { + if case .failure(let error) = result { + NSLog("Notification assertion issuance failed: %@", String(describing: error)) + } + completion(nil) + return + } + let expiry = Date(timeIntervalSince1970: TimeInterval(expiresAt)) + // A sign-out or account change during issuance revokes this result. + guard expiry > now(), loadSession() == organization else { + completion(nil) + return + } + lock.lock() + cached[cacheKey] = (token, expiry) + lock.unlock() + completion(BuzzRelayAssertion(header: "Bearer \(token)", organization: organization)) + } + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift index f27861c959..2e0555d843 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift @@ -1055,6 +1055,193 @@ final class BuzzPushNotificationResolverTests: XCTestCase { wait(for: [cancelled], timeout: 1) } + /// An enforcing organization relay refuses `/query` without a NIP-FI assertion. The extension + /// mints one from the shared organization session, reuses it across requests and + /// notifications, and never sends the session or an assertion to any other relay. + func testOrganizationRelayQueriesCarryMintedAssertion() throws { + let message = try Self.signedEvent(privateKey: Self.profilePrivateKey, + createdAt: Self.now, kind: 9, tags: [["h", Self.channelID]], content: "Enforced hello") + let profile = try Self.signedEvent(privateKey: Self.profilePrivateKey, + createdAt: Self.now, kind: 0, content: #"{"display_name":"Fizz"}"#) + let organization = BuzzOrganizationSession( + origin: "https://identity.org.example", credential: "session-credential", + relayHosts: ["relay.org.example"]) + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [URLProtocolStub.self] + var sharedSession: BuzzOrganizationSession? = organization + let issuer = BuzzRelayAssertionIssuer( + session: URLSession(configuration: configuration), loadSession: { sharedSession }, + now: { Date(timeIntervalSince1970: TimeInterval(Self.now)) }) + let issued = "header.payload.signature" + var issuerBodies: [[String: Any]] = [] + var signOutDuringQuery = false + var signOutDuringProfile = false + var redirectedHeaders: [String?] = [] + URLProtocolStub.handler = { request in + let body = Self.body(of: request) + if request.url?.host == "identity.org.example" { + XCTAssertEqual(request.url?.path, "/api/goose/v1/buzz/identity/assertions") + XCTAssertEqual(request.value(forHTTPHeaderField: "X-BB-Session-Credential"), "session-credential") + XCTAssertEqual(request.value(forHTTPHeaderField: "Origin"), "https://identity.org.example") + XCTAssertTrue(request.value(forHTTPHeaderField: "Authorization")?.hasPrefix("Nostr ") == true) + issuerBodies.append(try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any])) + return Self.response(request, status: 200, data: try JSONSerialization.data(withJSONObject: [ + "assertion": issued, "nostr_pubkey": Self.ownPubkey, "expires_at": Self.now + 300, + ])) + } + XCTAssertNil(request.value(forHTTPHeaderField: "X-BB-Session-Credential")) + if request.url?.host == "elsewhere.example" { + redirectedHeaders.append(request.value(forHTTPHeaderField: "Nostr-Federated-Identity")) + return Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + let enforced = request.url?.host == "relay.org.example" + let assertion = request.value(forHTTPHeaderField: "Nostr-Federated-Identity") + if enforced, assertion != "Bearer \(issued)" { + return Self.response(request, status: 401, data: Data()) + } + if !enforced { XCTAssertNil(assertion) } + if enforced, signOutDuringQuery { sharedSession = nil } + let filters = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [[String: Any]]) + let kinds = filters.flatMap { $0["kinds"] as? [Int] ?? [] } + if enforced, signOutDuringProfile, kinds.contains(0) { sharedSession = nil } + return Self.response(request, status: 200, + data: try JSONEncoder().encode(kinds.contains(0) ? [profile] : kinds.contains(9) ? [message] : [])) + } + + for relayUrl in ["wss://relay.org.example", "wss://relay.org.example", "https://relay.example"] { + let configured = community(relayUrl: relayUrl) + let result = try XCTUnwrap(resolve(makeResolver( + communitiesData: try snapshotData([configured]), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)), assertionIssuer: issuer))) + XCTAssertEqual(result.body, "Enforced hello", relayUrl) + XCTAssertEqual(result.title, "Fizz", relayUrl) + } + // One issuance served the message and profile queries of both organization notifications. + XCTAssertEqual(issuerBodies.count, 1) + XCTAssertEqual(issuerBodies.first?["relay_url"] as? String, "wss://relay.org.example") + XCTAssertEqual(issuerBodies.first?["nostr_pubkey"] as? String, Self.ownPubkey) + + // A redirecting relay is refused, so its target never sees the assertion. + URLProtocolStub.redirect = { request in + request.url?.host == "relay.org.example" ? URL(string: "https://elsewhere.example/query") : nil + } + XCTAssertNil(resolve(makeResolver( + communitiesData: try snapshotData([community(relayUrl: "wss://relay.org.example")]), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)), assertionIssuer: issuer))) + XCTAssertEqual(redirectedHeaders, []) + URLProtocolStub.redirect = nil + + // Signing out while the relay query is in flight drops what that assertion fetched. + signOutDuringQuery = true + XCTAssertNil(resolve(makeResolver( + communitiesData: try snapshotData([community(relayUrl: "wss://relay.org.example")]), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)), assertionIssuer: issuer))) + signOutDuringQuery = false + + // Signing out after the message arrived revokes delivery, which rechecks the session. + sharedSession = organization + let enriching = makeResolver( + communitiesData: try snapshotData([community(relayUrl: "wss://relay.org.example")]), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)), assertionIssuer: issuer) + _ = resolve(enriching) + XCTAssertTrue(enriching.isStillAuthorized()) + signOutDuringProfile = true + let revoked = makeResolver( + communitiesData: try snapshotData([community(relayUrl: "wss://relay.org.example")]), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)), assertionIssuer: issuer) + _ = resolve(revoked) + XCTAssertFalse(revoked.isStillAuthorized()) + signOutDuringProfile = false + + // Signing out removes the shared session: the relay refuses, and the notification falls back. + sharedSession = nil + let signedOut = BuzzRelayAssertionIssuer( + session: URLSession(configuration: configuration), loadSession: { sharedSession }) + XCTAssertNil(resolve(makeResolver( + communitiesData: try snapshotData([community(relayUrl: "wss://relay.org.example")]), + assertionIssuer: signedOut))) + XCTAssertEqual(issuerBodies.count, 1) + } + + /// A refused or malformed issuance never produces a header. + func testOrganizationAssertionIssuanceFailuresSendNoHeader() throws { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [URLProtocolStub.self] + let organization = BuzzOrganizationSession( + origin: "https://identity.org.example", credential: "session-credential", + relayHosts: ["relay.org.example"]) + let cases: [(String, Int, [String: Any])] = [ + ("refused", 401, [:]), + ("other key", 200, ["assertion": "a.b.c", "nostr_pubkey": String(repeating: "f", count: 64), + "expires_at": Self.now + 300]), + ("expired", 200, ["assertion": "a.b.c", "nostr_pubkey": Self.ownPubkey, "expires_at": Self.now - 1]), + ("header injection", 200, ["assertion": "a.b\r\nX: y", "nostr_pubkey": Self.ownPubkey, + "expires_at": Self.now + 300]), + ] + for (name, status, body) in cases { + URLProtocolStub.handler = { request in + Self.response(request, status: status, data: try JSONSerialization.data(withJSONObject: body)) + } + let issuer = BuzzRelayAssertionIssuer( + session: URLSession(configuration: configuration), loadSession: { organization }) + let done = expectation(description: name) + issuer.header( + relayURL: URL(string: "https://relay.org.example")!, privateKeyHex: Self.privateKey + ) { header in + XCTAssertNil(header, name) + done.fulfill() + } + wait(for: [done], timeout: 2) + } + // Signing out while issuance is in flight discards the minted assertion. + var current: BuzzOrganizationSession? = organization + URLProtocolStub.handler = { request in + current = nil + return Self.response(request, status: 200, data: try JSONSerialization.data(withJSONObject: [ + "assertion": "a.b.c", "nostr_pubkey": Self.ownPubkey, "expires_at": Self.now + 300, + ])) + } + let racing = BuzzRelayAssertionIssuer( + session: URLSession(configuration: configuration), loadSession: { current }) + let raced = expectation(description: "sign-out during issuance") + racing.header( + relayURL: URL(string: "https://relay.org.example")!, privateKeyHex: Self.privateKey + ) { header in + XCTAssertNil(header) + raced.fulfill() + } + wait(for: [raced], timeout: 2) + + // Hosts are exact: a subdomain or another port is not the organization relay. + for relay in ["https://evil.relay.org.example", "https://relay.org.example:8443"] { + URLProtocolStub.reset() + let issuer = BuzzRelayAssertionIssuer( + session: URLSession(configuration: configuration), loadSession: { organization }) + let done = expectation(description: relay) + issuer.header(relayURL: URL(string: relay)!, privateKeyHex: Self.privateKey) { header in + XCTAssertNil(header, relay) + done.fulfill() + } + wait(for: [done], timeout: 2) + XCTAssertTrue(URLProtocolStub.requests.isEmpty, relay) + } + } + + static func body(of request: URLRequest) -> Data { + var body = request.httpBody ?? Data() + if let stream = request.httpBodyStream { + stream.open() + defer { stream.close() } + var buffer = [UInt8](repeating: 0, count: 4096) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + if count <= 0 { break } + body.append(contentsOf: buffer.prefix(count)) + } + } + return body + } + func testLargeRosterCannotDiscardSenderProfileOrChannelMetadata() throws { let message = try Self.signedEvent(privateKey: Self.profilePrivateKey, createdAt: Self.now, kind: 9, tags: [["h", Self.channelID]], content: "Large channel") @@ -1192,6 +1379,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase { presentationCacheData: Data? = nil, cacheOverride: BuzzNotificationCache? = nil, now: Date = Date(), + assertionIssuer: BuzzRelayAssertionIssuer? = nil, makeRetrySession: (() -> URLSession)? = nil ) -> BuzzPushNotificationResolver { let configuration = URLSessionConfiguration.ephemeral @@ -1201,6 +1389,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase { loadCommunitiesData: { communitiesData }, loadPrivateKey: { privateKeys[$0] }, notificationCache: cacheOverride ?? makeNotificationCache(communitiesData: communitiesData, fixture: presentationCacheData, now: now), + assertionIssuer: assertionIssuer, now: { now }, makeRetrySession: makeRetrySession ) @@ -1385,6 +1574,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase { static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? static var finishLoading: ((URLRequest) -> Bool)? static var onStopLoading: ((URLRequest) -> Void)? + static var redirect: ((URLRequest) -> URL?)? static var requests: [URLRequest] = [] override class func canInit(with request: URLRequest) -> Bool { true } @@ -1394,7 +1584,18 @@ final class BuzzPushNotificationResolverTests: XCTestCase { Self.lock.lock() Self.requests.append(request) let handler = Self.handler + let redirect = Self.redirect Self.lock.unlock() + if let url = request.url, let target = redirect?(request) { + var next = request + next.url = target + let response = HTTPURLResponse( + url: url, statusCode: 307, httpVersion: nil, headerFields: ["Location": target.absoluteString])! + client?.urlProtocol(self, wasRedirectedTo: next, redirectResponse: response) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocolDidFinishLoading(self) + return + } do { let (response, data) = try handler?(request) ?? { throw URLError(.unsupportedURL) }() client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) @@ -1414,6 +1615,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase { handler = nil finishLoading = nil onStopLoading = nil + redirect = nil requests = [] lock.unlock() } diff --git a/mobile/ios/NotificationService/NotificationService.swift b/mobile/ios/NotificationService/NotificationService.swift index 84b6106009..50c30a5335 100644 --- a/mobile/ios/NotificationService/NotificationService.swift +++ b/mobile/ios/NotificationService/NotificationService.swift @@ -56,10 +56,42 @@ final class NotificationService: UNNotificationServiceExtension { ) }, notificationCache: notificationCache, - avatarLoader: notificationAvatarLoader + avatarLoader: notificationAvatarLoader, + assertionIssuer: Self.assertionIssuer ) } + /// Process-wide, so consecutive notifications reuse an assertion until it nears expiry. + private static let assertionIssuer: BuzzRelayAssertionIssuer = { + let keychainAccessGroup = + Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") as? String + return BuzzRelayAssertionIssuer( + session: URLSession(configuration: .ephemeral), + loadSession: { loadOrganizationSession(keychainAccessGroup: keychainAccessGroup) } + ) + }() + + /// The organization account session the app shares for NIP-FI assertion issuance. + private static func loadOrganizationSession( + keychainAccessGroup: String? + ) -> BuzzOrganizationSession? { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: BuzzOrganizationSession.keychainService, + kSecAttrAccount as String: BuzzOrganizationSession.keychainAccount, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + if let keychainAccessGroup, !keychainAccessGroup.isEmpty { + query[kSecAttrAccessGroup as String] = keychainAccessGroup + } + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data + else { return nil } + return try? JSONDecoder().decode(BuzzOrganizationSession.self, from: data) + } + private var notificationCacheRoot: URL? { FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first? .appendingPathComponent("notification-enrichment-v1", isDirectory: true) @@ -128,7 +160,9 @@ final class NotificationService: UNNotificationServiceExtension { resolver.resolve { [weak self] resolution in guard let self else { return } if let resolution { - guard let scope = resolution.notificationScope, self.isCurrent(scope) else { + guard let scope = resolution.notificationScope, self.isCurrent(scope), + self.resolver?.isStillAuthorized() == true + else { self.finish(Self.restrictedFallback(from: content)) return } @@ -152,7 +186,8 @@ final class NotificationService: UNNotificationServiceExtension { resolution: resolution, isStillAllowed: { [weak self] in guard let self else { return false } - return self.isCurrent(scope) && !self.isAgeRestricted() + return self.isCurrent(scope) && self.resolver?.isStillAuthorized() == true + && !self.isAgeRestricted() }, onDeletionFailure: { error in NSLog("Notification cleanup retry failed: %@", String(describing: error)) @@ -178,7 +213,7 @@ final class NotificationService: UNNotificationServiceExtension { session?.invalidateAndCancel() session = nil handoff?.finish( - resolvedScope.map { isCurrent($0) } == false + resolvedScope.map { isCurrent($0) && resolver?.isStillAuthorized() != false } == false ? Self.restrictedFallback(from: content) : content, restrictedFallback: restrictedFallbackContent ?? Self.restrictedFallback(from: content), handoffIfAllowed: { deliver in diff --git a/mobile/ios/Runner/PushNativeState.swift b/mobile/ios/Runner/PushNativeState.swift index 5feba1cc2c..a16871fd3e 100644 --- a/mobile/ios/Runner/PushNativeState.swift +++ b/mobile/ios/Runner/PushNativeState.swift @@ -76,6 +76,38 @@ enum BuzzPushKeychain { } } + /// Replaces the shared organization session. Device-only, like the app's own copy. + static func replace(organizationSession: BuzzOrganizationSession?, accessGroup: String?) throws { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: BuzzOrganizationSession.keychainService, + kSecAttrAccount as String: BuzzOrganizationSession.keychainAccount, + ] + if let accessGroup, !accessGroup.isEmpty { + query[kSecAttrAccessGroup as String] = accessGroup + } + let deletionStatus = SecItemDelete(query as CFDictionary) + guard deletionStatus == errSecSuccess || deletionStatus == errSecItemNotFound else { + throw keychainError(deletionStatus, fallback: "Keychain deletion failed") + } + guard let organizationSession else { return } + query[kSecValueData as String] = try JSONEncoder().encode(organizationSession) + query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let status = SecItemAdd(query as CFDictionary, nil) + guard status == errSecSuccess else { + throw keychainError(status, fallback: "Keychain write failed") + } + } + + private static func keychainError(_ status: OSStatus, fallback: String) -> NSError { + NSError( + domain: NSOSStatusErrorDomain, code: Int(status), + userInfo: [ + NSLocalizedDescriptionKey: SecCopyErrorMessageString(status, nil) ?? fallback as CFString + ] + ) + } + private static func baseQuery(accessGroup: String?) -> [String: Any] { var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, diff --git a/mobile/ios/Runner/PushSnapshotBridge.swift b/mobile/ios/Runner/PushSnapshotBridge.swift index b735539c8e..0f760736cc 100644 --- a/mobile/ios/Runner/PushSnapshotBridge.swift +++ b/mobile/ios/Runner/PushSnapshotBridge.swift @@ -78,6 +78,8 @@ final class BuzzPushSnapshotBridge { switch section { case "communities": syncCommunities(arguments, result: result) + case "organizationSession": + syncOrganizationSession(arguments, result: result) default: return false } return true @@ -128,6 +130,50 @@ final class BuzzPushSnapshotBridge { } } + /// Shares the organization account session with the notification extension, or removes it + /// when the app has none, so the extension can mint NIP-FI assertions for organization relays. + private func syncOrganizationSession( + _ arguments: [String: Any], + result: @escaping FlutterResult + ) { + let session: BuzzOrganizationSession? + if arguments["credential"] is NSNull || arguments["credential"] == nil { + session = nil + } else if let origin = arguments["origin"] as? String, + let credential = arguments["credential"] as? String, !credential.isEmpty, + credential.utf8.count <= 512, + let relayHosts = arguments["relayHosts"] as? [String], !relayHosts.isEmpty + { + session = BuzzOrganizationSession( + origin: origin, credential: credential, relayHosts: relayHosts) + } else { + result( + FlutterError( + code: "invalid_arguments", + message: "Expected an organization origin, credential and relay hosts.", + details: nil + ) + ) + return + } + queue.async { [weak self] in + do { + try BuzzPushKeychain.replace( + organizationSession: session, accessGroup: self?.keychainAccessGroup) + Self.complete(result, value: nil) + } catch { + Self.complete( + result, + value: FlutterError( + code: "snapshot_sync_failed", + message: "Unable to share the organization session.", + details: error.localizedDescription + ) + ) + } + } + } + private func syncCommunities( _ arguments: [String: Any], result: @escaping FlutterResult diff --git a/mobile/lib/shared/hosted/builderlab_account.dart b/mobile/lib/shared/hosted/builderlab_account.dart index b13cb0f679..22ab2d84b9 100644 --- a/mobile/lib/shared/hosted/builderlab_account.dart +++ b/mobile/lib/shared/hosted/builderlab_account.dart @@ -61,6 +61,7 @@ class BuilderlabAccountClient { http.Client? httpClient, FlutterSecureStorage? storage, Uri? accountOrigin, + this.onSessionChanged, }) : _http = httpClient ?? http.Client(), _storage = storage ?? defaultStorage, accountOrigin = accountOrigin ?? origin; @@ -71,6 +72,14 @@ class BuilderlabAccountClient { static final origin = HostedEnvironment.origin; final Uri accountOrigin; + + /// Receives the session credential each time one is adopted, and null when + /// it is cleared or a restore finds no session. Only a trusted owner of + /// [accountOrigin]'s session may set it, for example to share the session + /// with the notification extension. A failed clear fails the operation that + /// cleared, so a signed-out session is never left shared; a failed share of + /// an adopted session is retried at the next adopt. + final Future Function(String? credential)? onSessionChanged; static const _prefix = '/api/goose'; /// Remote-agent registry routes, shared with desktop. @@ -98,6 +107,9 @@ class BuilderlabAccountClient { final http.Client _http; final FlutterSecureStorage _storage; String? _credential; + // The adopted credential while the notification extension lacks its copy. + String? _unshared; + bool _sharing = false; BuilderlabAccount? _account; final _queue = SerialQueue(); bool _disposed = false; @@ -125,7 +137,11 @@ class BuilderlabAccountClient { Future? _restoring; Future _restore() => _serial(() async { final record = await _readRecord(); - if (record == null) return null; + if (record == null) { + // Retries a removal that failed after an earlier sign-out. + await _unshare(); + return null; + } try { final verified = await _verify(record.credential); if (verified.subject != record.subject) { @@ -228,9 +244,36 @@ class BuilderlabAccountClient { // Assigned before notifying, so subject listeners see the new account. _account = verified; _subject.value = verified.subject; + _unshared = credential; + _share(); return verified; } + /// Shares the adopted credential with the notification extension. Sharing is + /// push enrichment and must never gate sign-in, so a failure is retried by + /// the next account request instead of being thrown. + void _share() { + final share = onSessionChanged; + final credential = _unshared; + if (share == null || credential == null || _sharing) return; + _sharing = true; + unawaited( + share(credential) + .then((_) { + if (_unshared == credential) _unshared = null; + }) + .catchError( + (Object error) => debugPrint('Session share failed: $error'), + ) + .whenComplete(() { + _sharing = false; + // A newer credential adopted while this write was in flight still needs sharing. + final pending = _unshared; + if (pending != null && pending != credential) _share(); + }), + ); + } + Future _verify(String credential) async { final result = await _request('/v1/auth/me', credential: credential); final subject = result['subject']; @@ -293,6 +336,7 @@ class BuilderlabAccountClient { return _serial(() async { final credential = _credential; if (credential == null) throw AccountFailure.signedOut; + _share(); if (expectedSubject != null && _account?.subject != expectedSubject) { throw AccountFailure.denied; } @@ -340,16 +384,40 @@ class BuilderlabAccountClient { Future clearLocalSession() => _serial(_clear); Future _clear() async { + // Attempt both removals so one failure never leaves the other copy live. + var failed = false; try { await _storage.delete(key: _storageKey); } catch (_) { + failed = true; + } + try { + await _unshare(); + } on AccountFailure { + failed = true; + } + if (failed) { + // The account stays signed in, so the extension gets its copy back. + _unshared = _credential; + _share(); throw AccountFailure.storage; } _credential = null; + _unshared = null; _account = null; if (!_disposed) _subject.value = null; } + Future _unshare() async { + final share = onSessionChanged; + if (share == null) return; + try { + await share(null); + } catch (_) { + throw AccountFailure.storage; + } + } + Future> _request( String path, { String? credential, diff --git a/mobile/lib/shared/hosted/hosted_relay_auth.dart b/mobile/lib/shared/hosted/hosted_relay_auth.dart index 9e6893b67e..e7dadcdc31 100644 --- a/mobile/lib/shared/hosted/hosted_relay_auth.dart +++ b/mobile/lib/shared/hosted/hosted_relay_auth.dart @@ -9,6 +9,7 @@ import 'builderlab_account.dart'; import 'account_identity_store.dart'; import 'hosted_environment.dart'; import 'organization_environment.dart'; +import '../push/push_bridge.dart'; final builderlabAccountProvider = Provider((ref) { final account = BuilderlabAccountClient(); @@ -21,7 +22,15 @@ final builderlabAccountProvider = Provider((ref) { final organizationAccountProvider = Provider((ref) { final origin = OrganizationEnvironment.origin; if (origin == null) return null; - final account = BuilderlabAccountClient(accountOrigin: origin); + final account = BuilderlabAccountClient( + accountOrigin: origin, + // The notification extension mints its own assertions for these relays. + onSessionChanged: (credential) => shareOrganizationSessionWithNotifications( + origin: origin, + credential: credential, + relayHosts: OrganizationEnvironment.relayHosts, + ), + ); ref.onDispose(account.dispose); return account; }); diff --git a/mobile/lib/shared/push/push_bridge.dart b/mobile/lib/shared/push/push_bridge.dart index 221a6331a8..c2ddedb264 100644 --- a/mobile/lib/shared/push/push_bridge.dart +++ b/mobile/lib/shared/push/push_bridge.dart @@ -238,6 +238,29 @@ Future registerBuzzPushCommunitySnapshot( } } +/// Shares the organization account session with the iOS notification +/// extension, or removes it when [credential] is null. Failures propagate to +/// the account client, which owns retrying them. Organization relays +/// enforce NIP-FI, so the extension needs the session to mint the assertion +/// that lets it read the message a push refers to. +Future shareOrganizationSessionWithNotifications({ + required Uri origin, + required String? credential, + required List relayHosts, +}) async { + if (defaultTargetPlatform != TargetPlatform.iOS) return; + try { + await _channel.invokeMethod('syncPushSnapshot', { + 'section': 'organizationSession', + 'origin': origin.toString(), + 'credential': credential, + 'relayHosts': relayHosts, + }); + } on MissingPluginException { + // Flutter tests and non-Runner embeddings do not install the native bridge. + } +} + void installBuzzPushMethodHandler() { _channel.setMethodCallHandler((call) async { switch (call.method) { diff --git a/mobile/test/shared/hosted/builderlab_account_test.dart b/mobile/test/shared/hosted/builderlab_account_test.dart index 30309044e4..d2e4321c7f 100644 --- a/mobile/test/shared/hosted/builderlab_account_test.dart +++ b/mobile/test/shared/hosted/builderlab_account_test.dart @@ -52,36 +52,111 @@ void main() { 200, ); }); + // The notification extension's copy follows every adopt and clear. + final shared = []; + var unshareFails = false; + var shareFails = true; + Future share(String? credential) async { + if (credential == null && unshareFails) throw StateError('keychain'); + if (credential != null && shareFails) throw StateError('keychain'); + shared.add(credential); + } + final storage = _SessionStorage(); final client = BuilderlabAccountClient( httpClient: transport, storage: storage, + onSessionChanged: share, ); expect( (await client.exchange(code: 'one-use', verifier: verifier)).subject, 'account-1', ); + // A failed share never blocks sign-in, and the next request retries it. + await Future.delayed(Duration.zero); + expect(shared, isEmpty); + shareFails = false; + await client.post('/v1/buzz/known-communities/list', {}); + await Future.delayed(Duration.zero); + expect(shared, ['secret']); + await client.post('/v1/buzz/known-communities/list', {}); + await Future.delayed(Duration.zero); + expect(shared, ['secret']); final restored = BuilderlabAccountClient( httpClient: transport, storage: storage, + onSessionChanged: share, ); final restoredAccount = (await restored.restore())!; expect(restoredAccount.subject, 'account-1'); expect(restoredAccount.email, 'owner@example.com'); - expect(requests.length, 3); + expect(requests.length, 5); + await Future.delayed(Duration.zero); + expect(shared, ['secret', 'secret']); + // A failed local deletion fails the sign-out and keeps the account + // signed in, so the extension gets its copy back after the removal. storage.failDelete = true; await expectLater( restored.clearLocalSession(), throwsA(AccountFailure.storage), ); + await Future.delayed(Duration.zero); expect(restored.account?.subject, 'account-1'); + expect(shared, ['secret', 'secret', null, 'secret']); expect(await client.restore(), isNotNull); storage.failDelete = false; + // A failed removal fails the sign-out instead of leaving it shared, and + // the next restore retries it. + unshareFails = true; + await expectLater( + restored.clearLocalSession(), + throwsA(AccountFailure.storage), + ); + unshareFails = false; + expect( + await BuilderlabAccountClient( + httpClient: transport, + onSessionChanged: share, + ).restore(), + isNull, + ); + expect(shared.last, isNull); await restored.clearLocalSession(); + expect(shared.last, isNull); + expect(restored.account, isNull); expect( await BuilderlabAccountClient(httpClient: transport).restore(), isNull, ); + // A credential adopted while an earlier share is in flight is shared + // once that write completes. + final gate = Completer(); + final delayed = []; + var issued = 0; + final rotating = BuilderlabAccountClient( + httpClient: MockClient( + (request) async => http.Response( + jsonEncode( + request.url.path.endsWith('/exchange') + ? { + 'session_credential': 'next-${++issued}', + 'expires_at': expiry, + } + : {'subject': 'account-1', 'expires_at': expiry}, + ), + 200, + ), + ), + onSessionChanged: (credential) async { + await gate.future; + delayed.add(credential); + }, + ); + await rotating.exchange(code: 'one-use', verifier: verifier); + await rotating.exchange(code: 'two-use', verifier: verifier); + gate.complete(); + await pumpEventQueue(); + expect(delayed, ['next-1', 'next-2']); }, );