Skip to content

Commit f52da0b

Browse files
Added Authorization header caching
1 parent d023085 commit f52da0b

File tree

4 files changed

+165
-5
lines changed

4 files changed

+165
-5
lines changed
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
//
2+
// URL+Origin.swift
3+
// swift-webpush
4+
//
5+
// Created by Dimitri Bouniol on 2024-12-09.
6+
// Copyright © 2024 Mochi Development, Inc. All rights reserved.
7+
//
8+
9+
import Foundation
10+
11+
extension URL {
12+
/// Returns the origin for the receiving URL, as defined for use in signing headers for VAPID.
13+
///
14+
/// This implementation is similar to the [WHATWG Standard](https://url.spec.whatwg.org/#concept-url-origin), except that it uses the unicode form of the host, and is limited to HTTP and HTTPS schemas.
15+
///
16+
/// - SeeAlso: [RFC8292 Voluntary Application Server Identification (VAPID) for Web Push §2. Application Server Self-Identification](https://datatracker.ietf.org/doc/html/rfc8292#section-2)
17+
/// - SeeAlso: [RFC6454 The Web Origin Concept §6.1. Unicode Serialization of an Origin](https://datatracker.ietf.org/doc/html/rfc6454#section-6.1)
18+
var origin: String {
19+
/// Note that we need the unicode variant, which only URLComponents provides.
20+
let components = URLComponents(url: self, resolvingAgainstBaseURL: true)
21+
guard
22+
let scheme = components?.scheme?.lowercased(),
23+
let host = components?.host
24+
else { return "null" }
25+
26+
switch scheme {
27+
case "http":
28+
let port = components?.port ?? 80
29+
return "http://" + host + (port != 80 ? ":\(port)" : "")
30+
case "https":
31+
let port = components?.port ?? 443
32+
return "https://" + host + (port != 443 ? ":\(port)" : "")
33+
default: return "null"
34+
}
35+
}
36+
}

Sources/WebPush/VAPID/VAPIDConfiguration.swift

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ extension VoluntaryApplicationServerIdentification {
1515
/// This key should be shared by all instances of your push service, and should be kept secure. Rotating this key is not recommended as you'll lose access to subscribers that registered against it.
1616
///
1717
/// Some implementations will choose to use different keys per subscriber. In that case, choose to provide a set of keys instead.
18-
public var primaryKey: Key?
19-
public var keys: Set<Key>
20-
public var deprecatedKeys: Set<Key>?
18+
public private(set) var primaryKey: Key?
19+
public private(set) var keys: Set<Key>
20+
public private(set) var deprecatedKeys: Set<Key>?
2121
public var contactInformation: ContactInformation
2222
public var expirationDuration: Duration
2323
public var validityDuration: Duration
@@ -83,6 +83,25 @@ extension VoluntaryApplicationServerIdentification {
8383
validityDuration: validityDuration
8484
)
8585
}
86+
87+
mutating func updateKeys(
88+
primaryKey: Key?,
89+
keys: Set<Key>,
90+
deprecatedKeys: Set<Key>? = nil
91+
) throws {
92+
self.primaryKey = primaryKey
93+
var keys = keys
94+
if let primaryKey {
95+
keys.insert(primaryKey)
96+
}
97+
guard !keys.isEmpty
98+
else { throw CancellationError() } // TODO: No keys error
99+
100+
self.keys = keys
101+
var deprecatedKeys = deprecatedKeys ?? []
102+
deprecatedKeys.subtract(keys)
103+
self.deprecatedKeys = deprecatedKeys.isEmpty ? nil : deprecatedKeys
104+
}
86105
}
87106
}
88107

@@ -177,3 +196,9 @@ extension VAPID.Configuration {
177196
}
178197
}
179198
}
199+
200+
extension Date {
201+
func adding(_ duration: VAPID.Configuration.Duration) -> Self {
202+
addingTimeInterval(TimeInterval(duration.seconds))
203+
}
204+
}

Sources/WebPush/VAPID/VAPIDToken.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,16 @@ extension VAPID {
2626

2727
static let jwtHeader = Array(#"{"typ":"JWT","alg":"ES256"}"#.utf8).base64URLEncodedString()
2828

29+
init(
30+
origin: String,
31+
contactInformation: VAPID.Configuration.ContactInformation,
32+
expiration: Date
33+
) {
34+
self.audience = origin
35+
self.subject = contactInformation
36+
self.expiration = Int(expiration.timeIntervalSince1970)
37+
}
38+
2939
init(
3040
origin: String,
3141
contactInformation: VAPID.Configuration.ContactInformation,

Sources/WebPush/WebPushManager.swift

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,23 @@ import Logging
1212
import NIOCore
1313
import ServiceLifecycle
1414

15-
actor WebPushManager: Service, Sendable {
15+
actor WebPushManager: Sendable {
1616
public let vapidConfiguration: VAPID.Configuration
1717

1818
nonisolated let logger: Logger
1919
let httpClient: HTTPClient
2020

2121
let vapidKeyLookup: [VAPID.Key.ID : VAPID.Key]
22-
var vapidAuthorizationCache: [String : (authorization: String, expiration: Date)] = [:]
22+
var vapidAuthorizationCache: [String : (authorization: String, validUntil: Date)] = [:]
2323

2424
public init(
2525
vapidConfiguration: VAPID.Configuration,
2626
// TODO: Add networkConfiguration for proxy, number of simultaneous pushes, etc…
2727
logger: Logger? = nil,
2828
eventLoopGroupProvider: NIOEventLoopGroupProvider = .shared(.singletonMultiThreadedEventLoopGroup)
2929
) {
30+
assert(vapidConfiguration.validityDuration <= vapidConfiguration.expirationDuration, "The validity duration must be earlier than the expiration duration since it represents when the VAPID Authorization token will be refreshed ahead of it expiring.");
31+
assert(vapidConfiguration.expirationDuration <= .hours(24), "The expiration duration must be less than 24 hours or else push endpoints will reject messages sent to them.");
3032
self.vapidConfiguration = vapidConfiguration
3133
let allKeys = vapidConfiguration.keys + Array(vapidConfiguration.deprecatedKeys ?? [])
3234
self.vapidKeyLookup = Dictionary(
@@ -54,6 +56,93 @@ actor WebPushManager: Service, Sendable {
5456
}
5557
}
5658

59+
func loadCurrentVAPIDAuthorizationHeader(
60+
endpoint: URL,
61+
signingKey: VAPID.Key
62+
) throws -> String {
63+
let origin = endpoint.origin
64+
let cacheKey = "\(signingKey.id)|\(origin)"
65+
66+
let now = Date()
67+
let expirationDate = min(now.adding(vapidConfiguration.expirationDuration), now.adding(.hours(24)))
68+
let renewalDate = min(now.adding(vapidConfiguration.validityDuration), expirationDate)
69+
70+
if let cachedHeader = vapidAuthorizationCache[cacheKey],
71+
now < cachedHeader.validUntil
72+
{ return cachedHeader.authorization }
73+
74+
let token = VAPID.Token(
75+
origin: origin,
76+
contactInformation: vapidConfiguration.contactInformation,
77+
expiration: expirationDate
78+
)
79+
80+
let authorization = try token.generateAuthorization(signedBy: signingKey)
81+
vapidAuthorizationCache[cacheKey] = (authorization, validUntil: renewalDate)
82+
83+
return authorization
84+
}
85+
86+
/// Request a VAPID key to supply to the client when requesting a new subscription.
87+
///
88+
/// The ID returned is already in a format that browsers expect `applicationServerKey` to be:
89+
/// ```js
90+
/// const serviceRegistration = await navigator.serviceWorker?.register("/serviceWorker.mjs", { type: "module" });
91+
/// const applicationServerKey = await loadVAPIDKey();
92+
/// const subscription = await serviceRegistration.pushManager.subscribe({
93+
/// userVisibleOnly: true,
94+
/// applicationServerKey,
95+
/// });
96+
///
97+
/// ...
98+
///
99+
/// async function loadVAPIDKey() {
100+
/// const httpResponse = await fetch(`/vapidKey`);
101+
///
102+
/// const webPushOptions = await httpResponse.json();
103+
/// if (httpResponse.status != 200) throw new Error(webPushOptions.reason);
104+
///
105+
/// return webPushOptions.vapid;
106+
/// }
107+
/// ```
108+
///
109+
/// Simply provide a route to supply the key, as shown for Vapor below:
110+
/// ```swift
111+
/// app.get("vapidKey", use: loadVapidKey)
112+
///
113+
/// ...
114+
///
115+
/// struct WebPushOptions: Codable, Content, Hashable, Sendable {
116+
/// static let defaultContentType = HTTPMediaType(type: "application", subType: "webpush-options+json")
117+
///
118+
/// var vapid: VAPID.Key.ID
119+
/// }
120+
///
121+
/// @Sendable func loadVapidKey(request: Request) async throws -> WebPushOptions {
122+
/// WebPushOptions(vapid: manager.nextVAPIDKeyID)
123+
/// }
124+
/// ```
125+
///
126+
/// - Note: If you supplied multiple keys in your VAPID configuration, you must specify the key ID along with the subscription you received from the browser. This can be easily done client side:
127+
/// ```js
128+
/// export async function registerSubscription(subscription, applicationServerKey) {
129+
/// const subscriptionStatusResponse = await fetch(`/registerSubscription`, {
130+
/// method: "POST",
131+
/// body: {
132+
/// ...subscription.toJSON(),
133+
/// applicationServerKey
134+
/// }
135+
/// });
136+
///
137+
/// ...
138+
/// }
139+
/// ```
140+
public nonisolated var nextVAPIDKeyID: VAPID.Key.ID {
141+
vapidConfiguration.primaryKey?.id ?? vapidConfiguration.keys.randomElement()!.id
142+
}
143+
}
144+
145+
extension WebPushManager: Service {
57146
public func run() async throws {
58147
logger.info("Starting up WebPushManager")
59148
try await withTaskCancellationOrGracefulShutdownHandler {

0 commit comments

Comments
 (0)