Skip to content

Commit d023085

Browse files
Added working VAPID authorization header generation
1 parent 5b99b5d commit d023085

File tree

5 files changed

+189
-2
lines changed

5 files changed

+189
-2
lines changed

Sources/WebPush/Helpers/DataProtocol+Base64URLCoding.swift

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import Foundation
1010

1111
extension DataProtocol {
12+
@_disfavoredOverload
1213
func base64URLEncodedString() -> String {
1314
Data(self)
1415
.base64EncodedString()
@@ -18,8 +19,20 @@ extension DataProtocol {
1819
}
1920
}
2021

22+
extension ContiguousBytes {
23+
func base64URLEncodedString() -> String {
24+
withUnsafeBytes { bytes in
25+
Data(bytes)
26+
.base64EncodedString()
27+
.replacingOccurrences(of: "+", with: "-")
28+
.replacingOccurrences(of: "/", with: "_")
29+
.replacingOccurrences(of: "=", with: "")
30+
}
31+
}
32+
}
33+
2134
extension DataProtocol where Self: RangeReplaceableCollection {
22-
init?(base64URLEncoded string: String) {
35+
init?(base64URLEncoded string: some StringProtocol) {
2336
var base64String = string.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
2437
while base64String.count % 4 != 0 {
2538
base64String = base64String.appending("=")

Sources/WebPush/VAPID/VAPIDConfiguration.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,16 @@ extension VoluntaryApplicationServerIdentification {
8787
}
8888

8989
extension VAPID.Configuration {
90+
/// The contact information for the push service.
91+
///
92+
/// This allows administrators of push services to contact you should an issue arise with your application server.
93+
///
94+
/// - Note: Although the specification notes that this field is optional, some push services may refuse connection from serers without contact information.
95+
/// - SeeAlso: [RFC8292 Voluntary Application Server Identification (VAPID) for Web Push §2.1. Application Server Contact Information](https://datatracker.ietf.org/doc/html/rfc8292#section-2.1)
9096
public enum ContactInformation: Hashable, Codable, Sendable {
97+
/// A URL-based contact method, such as a support page on your website.
9198
case url(URL)
99+
/// An email-based contact method.
92100
case email(String)
93101

94102
var urlString: String {

Sources/WebPush/VAPID/VAPIDKey.swift

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ extension VAPID.Key: Codable {
4646
}
4747

4848
extension VAPID.Key: Identifiable {
49-
public struct ID: Hashable, Comparable, Codable, Sendable {
49+
public struct ID: Hashable, Comparable, Codable, Sendable, CustomStringConvertible {
5050
private var rawValue: String
5151

5252
init(_ rawValue: String) {
@@ -66,9 +66,19 @@ extension VAPID.Key: Identifiable {
6666
var container = encoder.singleValueContainer()
6767
try container.encode(self.rawValue)
6868
}
69+
70+
public var description: String {
71+
self.rawValue
72+
}
6973
}
7074

7175
public var id: ID {
7276
ID(privateKey.publicKey.x963Representation.base64URLEncodedString())
7377
}
7478
}
79+
80+
extension VAPID.Key: VAPIDKeyProtocol {
81+
func signature(for message: some DataProtocol) throws -> P256.Signing.ECDSASignature {
82+
try privateKey.signature(for: SHA256.hash(data: message))
83+
}
84+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
//
2+
// VAPIDToken.swift
3+
// swift-webpush
4+
//
5+
// Created by Dimitri Bouniol on 2024-12-07.
6+
// Copyright © 2024 Mochi Development, Inc. All rights reserved.
7+
//
8+
9+
@preconcurrency import Crypto
10+
import Foundation
11+
12+
extension VAPID {
13+
/// An internal representation the token and authorization headers used self-identification.
14+
///
15+
/// - SeeAlso: [RFC8292 Voluntary Application Server Identification (VAPID) for Web Push §2. Application Server Self-Identification](https://datatracker.ietf.org/doc/html/rfc8292#section-2)
16+
struct Token: Hashable, Codable, Sendable {
17+
enum CodingKeys: String, CodingKey {
18+
case audience = "aud"
19+
case subject = "sub"
20+
case expiration = "exp"
21+
}
22+
23+
var audience: String
24+
var subject: VAPID.Configuration.ContactInformation
25+
var expiration: Int
26+
27+
static let jwtHeader = Array(#"{"typ":"JWT","alg":"ES256"}"#.utf8).base64URLEncodedString()
28+
29+
init(
30+
origin: String,
31+
contactInformation: VAPID.Configuration.ContactInformation,
32+
expiresIn: VAPID.Configuration.Duration
33+
) {
34+
audience = origin
35+
subject = contactInformation
36+
expiration = Int(Date.now.timeIntervalSince1970) + expiresIn.seconds
37+
}
38+
39+
init?(token: String, key: String) {
40+
let components = token.split(separator: ".")
41+
42+
guard
43+
components.count == 3,
44+
components[0] == Self.jwtHeader,
45+
let bodyBytes = Data(base64URLEncoded: components[1]),
46+
let signatureBytes = Data(base64URLEncoded: components[2]),
47+
let publicKeyBytes = Data(base64URLEncoded: key)
48+
else { return nil }
49+
50+
let message = Data("\(components[0]).\(components[1])".utf8)
51+
let publicKey = try? P256.Signing.PublicKey(x963Representation: publicKeyBytes)
52+
let isValid = try? publicKey?.isValidSignature(.init(rawRepresentation: signatureBytes), for: SHA256.hash(data: message))
53+
54+
guard
55+
isValid == true,
56+
let token = try? JSONDecoder().decode(Self.self, from: bodyBytes)
57+
else { return nil }
58+
59+
self = token
60+
}
61+
62+
func generateJWT(signedBy signingKey: some VAPIDKeyProtocol) throws -> String {
63+
let header = Self.jwtHeader
64+
65+
let encoder = JSONEncoder()
66+
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
67+
let body = try encoder.encode(self).base64URLEncodedString()
68+
69+
var message = "\(header).\(body)"
70+
let signature = try message.withUTF8 { try signingKey.signature(for: $0) }.base64URLEncodedString()
71+
return "\(message).\(signature)"
72+
}
73+
74+
func generateAuthorization(signedBy signingKey: some VAPIDKeyProtocol) throws -> String {
75+
let token = try generateJWT(signedBy: signingKey)
76+
let key = signingKey.id
77+
78+
return "vapid t=\(token), k=\(key)"
79+
}
80+
}
81+
}
82+
83+
protocol VAPIDKeyProtocol: Identifiable, Sendable {
84+
associatedtype Signature: ContiguousBytes
85+
86+
func signature(for message: some DataProtocol) throws -> Signature
87+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
//
2+
// VAPIDTokenTests.swift
3+
// swift-webpush
4+
//
5+
// Created by Dimitri Bouniol on 2024-12-07.
6+
// Copyright © 2024 Mochi Development, Inc. All rights reserved.
7+
//
8+
9+
import Crypto
10+
import Foundation
11+
import Testing
12+
@testable import WebPush
13+
14+
struct MockVAPIDKey<Bytes: ContiguousBytes & Sendable>: VAPIDKeyProtocol {
15+
var id: String
16+
var signature: Bytes
17+
18+
func signature(for message: some DataProtocol) throws -> Bytes {
19+
signature
20+
}
21+
}
22+
23+
@Suite struct VAPIDTokenTests {
24+
@Test func generatesValidSignedToken() throws {
25+
let key = VAPID.Key()
26+
27+
let token = VAPID.Token(
28+
origin: "https://push.example.net",
29+
contactInformation: .email("push@example.com"),
30+
expiresIn: .hours(22)
31+
)
32+
33+
let signedJWT = try token.generateJWT(signedBy: key)
34+
#expect(VAPID.Token(token: signedJWT, key: "\(key.id)") == token)
35+
}
36+
37+
/// Make sure we can decode the example from https://datatracker.ietf.org/doc/html/rfc8292#section-2.4, as we use the same decoding logic to self-verify our own signing proceedure.
38+
@Test func tokenVerificationMatchesSpec() throws {
39+
var expectedToken = VAPID.Token(
40+
origin: "https://push.example.net",
41+
contactInformation: .email("push@example.com"),
42+
expiresIn: 0
43+
)
44+
expectedToken.expiration = 1453523768
45+
46+
let receivedToken = VAPID.Token(
47+
token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJodHRwczovL3B1c2guZXhhbXBsZS5uZXQiLCJleHAiOjE0NTM1MjM3NjgsInN1YiI6Im1haWx0bzpwdXNoQGV4YW1wbGUuY29tIn0.i3CYb7t4xfxCDquptFOepC9GAu_HLGkMlMuCGSK2rpiUfnK9ojFwDXb1JrErtmysazNjjvW2L9OkSSHzvoD1oA",
48+
key: "BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrAIxHF6YK5h4SDYic-dRuU_RCPCfA5aq9ojSwk5Y2EmClBPs"
49+
)
50+
#expect(receivedToken == expectedToken)
51+
}
52+
53+
@Test func authorizationHeaderGeneration() throws {
54+
var expectedToken = VAPID.Token(
55+
origin: "https://push.example.net",
56+
contactInformation: .email("push@example.com"),
57+
expiresIn: 0
58+
)
59+
expectedToken.expiration = 1453523768
60+
61+
let mockKey = MockVAPIDKey(
62+
id: "BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrAIxHF6YK5h4SDYic-dRuU_RCPCfA5aq9ojSwk5Y2EmClBPs",
63+
signature: Data(base64URLEncoded: "i3CYb7t4xfxCDquptFOepC9GAu_HLGkMlMuCGSK2rpiUfnK9ojFwDXb1JrErtmysazNjjvW2L9OkSSHzvoD1oA")!
64+
)
65+
66+
let generatedHeader = try expectedToken.generateAuthorization(signedBy: mockKey)
67+
#expect(generatedHeader == "vapid t=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJodHRwczovL3B1c2guZXhhbXBsZS5uZXQiLCJleHAiOjE0NTM1MjM3NjgsInN1YiI6Im1haWx0bzpwdXNoQGV4YW1wbGUuY29tIn0.i3CYb7t4xfxCDquptFOepC9GAu_HLGkMlMuCGSK2rpiUfnK9ojFwDXb1JrErtmysazNjjvW2L9OkSSHzvoD1oA, k=BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrAIxHF6YK5h4SDYic-dRuU_RCPCfA5aq9ojSwk5Y2EmClBPs")
68+
}
69+
}

0 commit comments

Comments
 (0)