import ComposableNotifications import Dependencies import Foundation import HTTPClient import HTTPTypes private enum NotificationHTTPAuthorizationKey: DependencyKey { static let liveValue = NotificationHTTPAuthorization( requestInterceptors: [ RequestInterceptor { _, _ in throw NotificationAuthenticationUnavailable() } ] ) static let testValue = NotificationHTTPAuthorization() } /// Notification registration cannot run until the authentication feature installs its signer. public struct NotificationAuthenticationUnavailable: Error, Equatable, Sendable { public init() {} } /// Request authorization and bounded credential recovery scoped to notification registration. public struct NotificationHTTPAuthorization: Sendable { public var errorInterceptors: [ErrorInterceptor] public var requestInterceptors: [RequestInterceptor] public init( requestInterceptors: [RequestInterceptor] = [], errorInterceptors: [ErrorInterceptor] = [] ) { self.errorInterceptors = errorInterceptors self.requestInterceptors = requestInterceptors } /// Authorizes notification requests with a persisted anonymous installation credential. /// /// The first request creates an installation through the server's public bootstrap endpoint. /// Concurrent callers share that result, and subsequent app launches load the credential from /// host-provided platform-protected storage. If the server rejects that credential, the client /// replaces it and retries the registration once. public static func anonymousNotificationInstallation( baseURL: URL, storage: NotificationInstallationCredentialStorage ) -> Self { let authorizer = AnonymousNotificationInstallationAuthorizer( baseURL: baseURL, storage: storage ) return Self( requestInterceptors: [ RequestInterceptor { request, _ in @Dependency(\.httpClient) var httpClient let credential = try await authorizer.credential(using: httpClient) request.headerFields[.authorization] = credential.authorizationHeader } ], errorInterceptors: [ ErrorInterceptor(retryBudget: 1) { failedRequest, failureCode, _, _, retry in guard failureCode == .unauthorized, failedRequest.method == .put, failedRequest.url?.isNotificationRegistrationURL == true, let rejectedCredential = failedRequest.notificationCredential else { return nil } @Dependency(\.httpClient) var httpClient _ = try await authorizer.replacementCredential( for: rejectedCredential, using: httpClient ) // The HTTP client uses nil to mean an unhandled error-interceptor result, while // successful registration endpoints return 204 with no body. Preserve that // success as handled empty data; the client normalizes it back to nil later. return try await retry(failedRequest) ?? Data() } ] ) } } public extension DependencyValues { /// Authentication and recovery scoped to notification registration requests. /// /// The authentication feature installs its anonymous-installation authorization here. The live /// default fails closed. Keeping it separate from general HTTP configuration also prevents /// unrelated authentication retries from handling notification installation failures. var notificationHTTPAuthorization: NotificationHTTPAuthorization { get { self[NotificationHTTPAuthorizationKey.self] } set { self[NotificationHTTPAuthorizationKey.self] = newValue } } /// Request-only compatibility for clients that have not adopted credential recovery yet. @available( *, deprecated, message: "Use notificationHTTPAuthorization to include bounded credential recovery." ) var notificationRequestInterceptors: [RequestInterceptor] { get { notificationHTTPAuthorization.requestInterceptors } set { var authorization = notificationHTTPAuthorization authorization.requestInterceptors = newValue notificationHTTPAuthorization = authorization } } } public extension RequestInterceptor { /// Request-only compatibility for clients that have not adopted credential recovery yet. @available( *, deprecated, message: "Use NotificationHTTPAuthorization.anonymousNotificationInstallation instead." ) static func anonymousNotificationInstallation( baseURL: URL, storage: NotificationInstallationCredentialStorage ) -> Self { NotificationHTTPAuthorization.anonymousNotificationInstallation( baseURL: baseURL, storage: storage ) .requestInterceptors[0] } } private actor AnonymousNotificationInstallationAuthorizer { let baseURL: URL let storage: NotificationInstallationCredentialStorage var cachedCredential: NotificationInstallation.Credential? var isResolvingCredential = false var waiters: [CheckedContinuation] = [] init( baseURL: URL, storage: NotificationInstallationCredentialStorage ) { self.baseURL = baseURL self.storage = storage } func credential( using httpClient: HTTPClient ) async throws -> NotificationInstallation.Credential { if isResolvingCredential { return try await withCheckedThrowingContinuation { continuation in waiters.append(continuation) } } if let cachedCredential { return cachedCredential } return try await resolveCredential(using: httpClient) } func replacementCredential( for rejectedCredential: NotificationInstallation.Credential, using httpClient: HTTPClient ) async throws -> NotificationInstallation.Credential { if isResolvingCredential { let resolvedCredential = try await withCheckedThrowingContinuation { continuation in waiters.append(continuation) } if resolvedCredential != rejectedCredential { return resolvedCredential } return try await replacementCredential( for: rejectedCredential, using: httpClient ) } if let cachedCredential, cachedCredential != rejectedCredential { return cachedCredential } cachedCredential = nil return try await resolveCredential( using: httpClient, replacing: rejectedCredential ) } func resolveCredential( using httpClient: HTTPClient, replacing rejectedCredential: NotificationInstallation.Credential? = nil ) async throws -> NotificationInstallation.Credential { if isResolvingCredential { return try await withCheckedThrowingContinuation { continuation in waiters.append(continuation) } } isResolvingCredential = true do { let credential: NotificationInstallation.Credential if let storedCredential = try await storage.credential(), storedCredential != rejectedCredential { credential = storedCredential } else { if rejectedCredential != nil { try await storage.setCredential(nil) } credential = try await createCredential(using: httpClient) try await storage.setCredential(credential) } cachedCredential = credential finishResolution(with: .success(credential)) return credential } catch { finishResolution(with: .failure(error)) throw error } } func createCredential( using httpClient: HTTPClient ) async throws -> NotificationInstallation.Credential { let path = NotificationRegistrationEndpoint.installationsPath .trimmingCharacters(in: CharacterSet(charactersIn: "/")) var request = HTTPRequest(url: baseURL.appendingPathComponent(path)) request.method = .post request.headerFields[.accept] = "application/json" let data = try await withDependencies { // Bootstrap carries no authorization and its response contains the reusable secret. // Do not run general request/response loggers for this exchange. $0.requestInterceptors = [] $0.responseInterceptors = [] $0.errorInterceptors = [] } operation: { try await httpClient.run(request, nil) } guard let data else { throw NotificationInstallationCredentialMissing() } return try JSONDecoder() .decode(NotificationInstallationCredentialPayload.self, from: data) .credential } func finishResolution( with result: Result ) { isResolvingCredential = false let waiters = self.waiters self.waiters.removeAll() for waiter in waiters { waiter.resume(with: result) } } } private struct NotificationInstallationCredentialMissing: Error {} private extension NotificationInstallation.Credential { var authorizationHeader: String { "\(NotificationRegistrationEndpoint.authorizationScheme) \(rawValue)" } } private extension HTTPRequest { var notificationCredential: NotificationInstallation.Credential? { let prefix = "\(NotificationRegistrationEndpoint.authorizationScheme) " guard let authorization = headerFields[.authorization], authorization.hasPrefix(prefix) else { return nil } let rawValue = String(authorization.dropFirst(prefix.count)) guard !rawValue.isEmpty else { return nil } return NotificationInstallation.Credential(rawValue: rawValue) } } private extension URL { var isNotificationRegistrationURL: Bool { path == NotificationRegistrationEndpoint.destinationPath || path == NotificationRegistrationEndpoint.subscriptionsPath } }