import ComposableNotifications import Dependencies import Foundation import HTTPClient import HTTPTypes import JWTKit import Tagged /// Provider metadata needed to turn a notification destination into an HTTP request. /// /// This configuration deliberately excludes provider credentials. The host supplies short-lived /// bearer tokens through ``NotificationProviderAuthorization`` so deployments can use their /// approved key service or credential library without teaching this package how to sign tokens. public struct NotificationProviderHTTPConfiguration: Sendable { public struct APNS: Sendable { public var allowedEnvironments: Set public var topic: String public init( allowedEnvironments: Set, topic: String ) { self.allowedEnvironments = allowedEnvironments self.topic = topic } } public struct FCM: Sendable { public var emergencyChannelID: String? public var projectID: String public init( projectID: String, emergencyChannelID: String? = nil ) { self.emergencyChannelID = emergencyChannelID self.projectID = projectID } } public var apns: [Push.ApplicationID: APNS] public var fcm: [Push.ApplicationID: FCM] public init( apns: [Push.ApplicationID: APNS] = [:], fcm: [Push.ApplicationID: FCM] = [:] ) { self.apns = apns self.fcm = fcm } } /// Supplies short-lived provider authorization to the HTTP delivery adapter. /// /// APNs provider tokens and Google OAuth access tokens have different creation and rotation rules. /// The package supplies reusable signing and exchange actors, while the host decides which /// credentials to load and installs only the authorizers appropriate for its deployment. public struct NotificationProviderAuthorization: Sendable { public var apnsBearerToken: @Sendable (Push.ApplicationID) async throws -> String public var fcmAccessToken: @Sendable (Push.ApplicationID) async throws -> String public init( apnsBearerToken: @escaping @Sendable (Push.ApplicationID) async throws -> String, fcmAccessToken: @escaping @Sendable (Push.ApplicationID) async throws -> String ) { self.apnsBearerToken = apnsBearerToken self.fcmAccessToken = fcmAccessToken } } public struct FCMServiceAccountCredentials: Decodable, Sendable { public var projectID: String var clientEmail: String var privateKeyID: String var privateKey: String var tokenURL: URL private enum CodingKeys: String, CodingKey { case projectID = "project_id" case clientEmail = "client_email" case privateKeyID = "private_key_id" case privateKey = "private_key" case tokenURL = "token_uri" } } /// Signs and reuses APNs provider authentication tokens for one Apple developer team key. /// /// Apple rejects tokens older than one hour and may reject a connection that receives a newly /// signed token more than once every 20 minutes. Keeping the cached token in this actor gives every /// concurrent notification send one serialized token lifetime without exposing the signing key. public actor APNSProviderTokenAuthorization { public init( teamID: String, keyID: String, privateKeyPEM: String, now: @escaping @Sendable () -> Date = Date.init ) async throws { self.teamID = teamID self.keyID = JWKIdentifier(string: keyID) self.keys = JWTKeyCollection() self.now = now await keys.add( ecdsa: try ES256PrivateKey(pem: privateKeyPEM), kid: self.keyID ) } public func bearerToken() async throws -> String { let now = now() if let cachedToken, now.timeIntervalSince(cachedToken.issuedAt) < Self.refreshInterval { return cachedToken.value } let value = try await keys.sign( APNSProviderTokenPayload( issuer: .init(value: teamID), issuedAt: .init(value: now) ), kid: keyID ) cachedToken = CachedToken(value: value, issuedAt: now) return value } private static let refreshInterval: TimeInterval = 20 * 60 private struct CachedToken { var value: String var issuedAt: Date } private var cachedToken: CachedToken? private let keyID: JWKIdentifier private let keys: JWTKeyCollection private let now: @Sendable () -> Date private let teamID: String } public actor FCMServiceAccountAuthorization { public init( credentials: FCMServiceAccountCredentials, now: @escaping @Sendable () -> Date = Date.init, httpClient providedHTTPClient: HTTPClient? = nil ) async throws { @Dependency(\.httpClient) var httpClient self.clientEmail = credentials.clientEmail self.httpClient = providedHTTPClient ?? httpClient self.keyID = JWKIdentifier(string: credentials.privateKeyID) self.keys = JWTKeyCollection() self.now = now self.tokenURL = credentials.tokenURL await keys.add( rsa: try Insecure.RSA.PrivateKey(pem: credentials.privateKey), digestAlgorithm: .sha256, kid: keyID ) } public func accessToken() async throws -> String { let now = now() if let cachedToken, now < cachedToken.expiresAt.addingTimeInterval(-Self.refreshLeeway) { return cachedToken.value } let assertion = try await keys.sign( FCMServiceAccountTokenPayload( issuer: .init(value: clientEmail), scope: Self.messagingScope, audience: tokenURL.absoluteString, issuedAt: .init(value: now), expiration: .init(value: now.addingTimeInterval(Self.assertionLifetime)) ), kid: keyID ) var components = URLComponents() components.queryItems = [ URLQueryItem(name: "grant_type", value: Self.jwtBearerGrantType), URLQueryItem(name: "assertion", value: assertion), ] var request = HTTPRequest(url: tokenURL) request.method = .post request.headerFields[.contentType] = "application/x-www-form-urlencoded" let responseData = try await httpClient.run( request, Data(components.percentEncodedQuery!.utf8) ) guard let responseData else { throw FCMServiceAccountAuthorizationError.emptyAccessTokenResponse } let response = try JSONDecoder().decode(FCMAccessTokenResponse.self, from: responseData) guard !response.accessToken.isEmpty else { throw FCMServiceAccountAuthorizationError.emptyAccessTokenResponse } guard response.expiresIn > 0 else { throw FCMServiceAccountAuthorizationError.invalidAccessTokenLifetime( response.expiresIn ) } cachedToken = CachedToken( value: response.accessToken, expiresAt: self.now().addingTimeInterval(TimeInterval(response.expiresIn)) ) return response.accessToken } private static let assertionLifetime: TimeInterval = 60 * 60 private static let jwtBearerGrantType = "urn:ietf:params:oauth:grant-type:jwt-bearer" private static let messagingScope = "https://www.googleapis.com/auth/firebase.messaging" private static let refreshLeeway: TimeInterval = 60 private struct CachedToken { var value: String var expiresAt: Date } private var cachedToken: CachedToken? private let clientEmail: String private let httpClient: HTTPClient private let keyID: JWKIdentifier private let keys: JWTKeyCollection private let now: @Sendable () -> Date private let tokenURL: URL } public enum FCMServiceAccountAuthorizationError: Error, Equatable, Sendable { case emptyAccessTokenResponse case invalidAccessTokenLifetime(Int) } public extension NotificationProviderAuthorization { /// Authorizes APNs requests with one team signing key and rejects unsupported FCM delivery. static func apns(providerTokens: APNSProviderTokenAuthorization) -> Self { Self( apnsBearerToken: { _ in try await providerTokens.bearerToken() }, fcmAccessToken: { applicationID in throw NotificationProviderHTTPError.missingFCMConfiguration(applicationID) } ) } } public enum NotificationProviderHTTPError: Error, Equatable, Sendable { case invalidHeaderName(String) case missingAPNSConfiguration(Push.ApplicationID) case missingFCMConfiguration(Push.ApplicationID) case unsupportedAPNSEnvironment(Push.ApplicationID, Push.APNS.Environment) } /// The complete provider request at the raw-network boundary. /// /// Tests can replace this one closure without importing or mocking the HTTP client used by the /// host application. Production uses ``live`` to translate the value into `HTTPTypes` and send it. public struct NotificationProviderHTTPRequest: Equatable, Sendable { public enum Method: Equatable, Sendable { case post } public var body: Data public var headerFields: [String: String] public var method: Method public var url: URL public init( method: Method, url: URL, headerFields: [String: String], body: Data ) { self.body = body self.headerFields = headerFields self.method = method self.url = url } } /// The raw response needed to classify a provider attempt without leaking HTTP types downstream. public struct NotificationProviderHTTPResponse: Equatable, Sendable { public init( status: Int, body: Data = Data(), retryAfter: String? = nil ) { self.status = status self.body = body self.retryAfter = retryAfter } public var status: Int public var body: Data public var retryAfter: String? } public struct NotificationProviderHTTPTransport: Sendable { public var send: @Sendable ( NotificationProviderHTTPRequest ) async throws -> NotificationProviderHTTPResponse public init( send: @escaping @Sendable ( NotificationProviderHTTPRequest ) async throws -> NotificationProviderHTTPResponse ) { self.send = send } public static var live: Self { @Dependency(\.httpClient) var httpClient return Self { providerRequest in var request = HTTPRequest(url: providerRequest.url) switch providerRequest.method { case .post: request.method = .post } for (name, value) in providerRequest.headerFields { guard let name = HTTPField.Name(name) else { throw NotificationProviderHTTPError.invalidHeaderName(name) } request.headerFields[name] = value } do { return NotificationProviderHTTPResponse( status: 200, body: try await httpClient.run(request, providerRequest.body) ?? Data() ) } catch HTTPError.httpError(let status, let body, let headerFields) { return NotificationProviderHTTPResponse( status: status.code, body: body ?? Data(), retryAfter: headerFields[.retryAfter] ) } } } } public extension NotificationProviderClient { /// Sends provider-neutral messages through the APNs or FCM HTTP API selected by the destination. static func http( configuration: NotificationProviderHTTPConfiguration, authorization: NotificationProviderAuthorization, transport providedTransport: NotificationProviderHTTPTransport? = nil, now: @escaping @Sendable () -> Date = Date.init ) -> Self { let transport = providedTransport ?? .live return Self { message, destination in switch destination { case .apns(let destination): guard let provider = configuration.apns[destination.applicationID] else { return .permanentFailure( NotificationProviderResponse(code: "MissingAPNSConfiguration") ) } guard provider.allowedEnvironments.contains(destination.environment) else { return .permanentFailure( NotificationProviderResponse(code: "UnsupportedAPNSEnvironment") ) } let token = try await authorization.apnsBearerToken(destination.applicationID) let presentation: NotificationPresentation? do { presentation = try message.presentation() } catch { return .permanentFailure( NotificationProviderResponse(code: "InvalidNotificationMessage") ) } let body = if let presentation { try notificationProviderJSONEncoder().encode( APNSAlertPayload(data: message.data, presentation: presentation) ) } else { try notificationProviderJSONEncoder().encode( APNSBackgroundPayload(data: message.data) ) } var headerFields = [ "Authorization": "bearer \(token)", "Content-Type": "application/json", "apns-priority": presentation == nil ? "5" : "10", "apns-push-type": presentation == nil ? "background" : "alert", "apns-topic": provider.topic, ] if case .emergency(let expiresAt) = presentation?.delivery { guard expiresAt > now() else { return .permanentFailure( NotificationProviderResponse(code: "ExpiredNotification") ) } headerFields["apns-expiration"] = String( Int(expiresAt.timeIntervalSince1970) ) } let response = try await transport.send( NotificationProviderHTTPRequest( method: .post, url: destination.environment.baseURL .appendingPathComponent("3") .appendingPathComponent("device") .appendingPathComponent(destination.deviceToken.rawValue), headerFields: headerFields, body: body ) ) return classifyAPNS(response, now: now()) case .fcm(let destination): guard let provider = configuration.fcm[destination.applicationID] else { return .permanentFailure( NotificationProviderResponse(code: "MissingFCMConfiguration") ) } let token = try await authorization.fcmAccessToken(destination.applicationID) let presentation: NotificationPresentation? do { presentation = try message.presentation() } catch { return .permanentFailure( NotificationProviderResponse(code: "InvalidNotificationMessage") ) } let requestDate = now() let android: FCMPayload.Message.Android? let notification: FCMPayload.Message.Notification? if let presentation { switch presentation.delivery { case .standard: android = .init(priority: "HIGH") notification = .init(body: presentation.body, title: presentation.title) case .emergency(let expiresAt): guard expiresAt > requestDate else { return .permanentFailure( NotificationProviderResponse(code: "ExpiredNotification") ) } guard let channelID = provider.emergencyChannelID else { return .permanentFailure( NotificationProviderResponse( code: "MissingFCMEmergencyChannelConfiguration" ) ) } android = .init( priority: "HIGH", ttl: "\(Int(ceil(expiresAt.timeIntervalSince(requestDate))))s", notification: .init(channelID: channelID) ) notification = .init( body: presentation.body, title: presentation.title ) } } else { android = nil notification = nil } let body = try notificationProviderJSONEncoder().encode( FCMPayload( message: .init( android: android, data: message.data, notification: notification, fid: destination.installationID.rawValue ) ) ) let response = try await transport.send( NotificationProviderHTTPRequest( method: .post, url: URL(string: "https://fcm.googleapis.com")! .appendingPathComponent("v1") .appendingPathComponent("projects") .appendingPathComponent(provider.projectID) .appendingPathComponent("messages:send"), headerFields: [ "Authorization": "Bearer \(token)", "Content-Type": "application/json", ], body: body ) ) return classifyFCM(response, now: now()) } } } } private func classifyAPNS( _ response: NotificationProviderHTTPResponse, now: Date ) -> NotificationProviderResult { let code = (try? JSONDecoder().decode(APNSErrorResponse.self, from: response.body))?.reason let metadata = NotificationProviderResponse(status: response.status, code: code) guard !(200..<300).contains(response.status) else { return .accepted(metadata) } if let code, ["BadDeviceToken", "DeviceTokenNotForTopic", "ExpiredToken", "Unregistered"] .contains(code) { return .invalidDestination(metadata) } if response.status == 429 || response.status >= 500 { let providerDelay: TimeInterval let retryAfter = retryAfterDelay(response.retryAfter, now: now) ?? 0 if code == "TooManyProviderTokenUpdates" { providerDelay = max(retryAfter, 20 * 60) } else if response.status >= 500 { providerDelay = max(retryAfter, 15 * 60) } else { providerDelay = retryAfter } return .retryableFailure( metadata, retryAfter: providerDelay ) } return .permanentFailure(metadata) } private func classifyFCM( _ response: NotificationProviderHTTPResponse, now: Date ) -> NotificationProviderResult { let error = try? JSONDecoder().decode(FCMErrorEnvelope.self, from: response.body).error let code = error?.details?.compactMap(\.errorCode).first ?? error?.status let metadata = NotificationProviderResponse(status: response.status, code: code) guard !(200..<300).contains(response.status) else { return .accepted(metadata) } if code == "UNREGISTERED" { return .invalidDestination(metadata) } if response.status == 429 || response.status == 500 || response.status == 503 { let providerDelay = retryAfterDelay(response.retryAfter, now: now) ?? (response.status == 429 ? 60 : 10) return .retryableFailure( metadata, retryAfter: providerDelay ) } return .permanentFailure(metadata) } private func retryAfterDelay(_ value: String?, now: Date) -> TimeInterval? { guard let value else { return nil } if let seconds = TimeInterval(value) { return max(0, seconds) } for format in [ "EEE',' dd MMM yyyy HH':'mm':'ss z", "EEEE',' dd-MMM-yy HH':'mm':'ss z", "EEE MMM d HH':'mm':'ss yyyy", ] { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.timeZone = TimeZone(secondsFromGMT: 0) formatter.dateFormat = format if let date = formatter.date(from: value) { return max(0, date.timeIntervalSince(now)) } } return nil } private struct APNSErrorResponse: Decodable { var reason: String } private struct FCMErrorEnvelope: Decodable { struct Detail: Decodable { var errorCode: String? } struct Error: Decodable { var details: [Detail]? var status: String? } var error: Error } private extension Push.APNS.Environment { var baseURL: URL { switch self { case .development: URL(string: "https://api.sandbox.push.apple.com")! case .production: URL(string: "https://api.push.apple.com")! } } } private struct APNSAlertPayload: Encodable { struct APS: Encodable { struct Alert: Encodable { let body: String let title: String } enum Sound: Encodable { struct Critical: Encodable { let critical = 1 let name = "default" let volume = 1.0 } case critical case standard func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() switch self { case .critical: try container.encode(Critical()) case .standard: try container.encode("default") } } } let alert: Alert let interruptionLevel: String? let sound: Sound enum CodingKeys: String, CodingKey { case alert case interruptionLevel = "interruption-level" case sound } } var data: [String: String] var presentation: NotificationPresentation func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: DynamicCodingKey.self) let aps: APS switch presentation.delivery { case .standard: aps = APS( alert: .init(body: presentation.body, title: presentation.title), interruptionLevel: nil, sound: .standard ) case .emergency: aps = APS( alert: .init(body: presentation.body, title: presentation.title), interruptionLevel: "critical", sound: .critical ) } try container.encode( aps, forKey: DynamicCodingKey("aps") ) for (key, value) in data { try container.encode(value, forKey: DynamicCodingKey(key)) } } } private struct APNSBackgroundPayload: Encodable { struct APS: Encodable { let contentAvailable = 1 enum CodingKeys: String, CodingKey { case contentAvailable = "content-available" } } var data: [String: String] func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: DynamicCodingKey.self) try container.encode(APS(), forKey: DynamicCodingKey("aps")) for (key, value) in data { try container.encode(value, forKey: DynamicCodingKey(key)) } } } private struct APNSProviderTokenPayload: JWTPayload { var iss: IssuerClaim var iat: IssuedAtClaim init(issuer: IssuerClaim, issuedAt: IssuedAtClaim) { self.iss = issuer self.iat = issuedAt } func verify(using _: some JWTAlgorithm) throws {} } private struct FCMServiceAccountTokenPayload: JWTPayload { var iss: IssuerClaim var scope: String var aud: String var iat: IssuedAtClaim var exp: ExpirationClaim init( issuer: IssuerClaim, scope: String, audience: String, issuedAt: IssuedAtClaim, expiration: ExpirationClaim ) { self.iss = issuer self.scope = scope self.aud = audience self.iat = issuedAt self.exp = expiration } func verify(using _: some JWTAlgorithm) throws {} } private struct FCMAccessTokenResponse: Decodable { var accessToken: String var expiresIn: Int private enum CodingKeys: String, CodingKey { case accessToken = "access_token" case expiresIn = "expires_in" } } private struct FCMPayload: Encodable { struct Message: Encodable { struct Android: Encodable { var priority: String var ttl: String? var notification: AndroidNotification? init( priority: String, ttl: String? = nil, notification: AndroidNotification? = nil ) { self.priority = priority self.ttl = ttl self.notification = notification } } struct AndroidNotification: Encodable { var channelID: String enum CodingKeys: String, CodingKey { case channelID = "channel_id" } } struct Notification: Encodable { var body: String var title: String init(body: String, title: String) { self.body = body self.title = title } } var android: Android? var data: [String: String] var notification: Notification? var fid: String } var message: Message } private struct DynamicCodingKey: CodingKey { var intValue: Int? { nil } var stringValue: String init(_ stringValue: String) { self.stringValue = stringValue } init?(intValue: Int) { return nil } init?(stringValue: String) { self.stringValue = stringValue } } private func notificationProviderJSONEncoder() -> JSONEncoder { let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] return encoder }