-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Added working VAPID authorization header generation
- Loading branch information
1 parent
5b99b5d
commit d023085
Showing
5 changed files
with
189 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,87 @@ | ||
// | ||
// VAPIDToken.swift | ||
// swift-webpush | ||
// | ||
// Created by Dimitri Bouniol on 2024-12-07. | ||
// Copyright © 2024 Mochi Development, Inc. All rights reserved. | ||
// | ||
|
||
@preconcurrency import Crypto | ||
import Foundation | ||
|
||
extension VAPID { | ||
/// An internal representation the token and authorization headers used self-identification. | ||
/// | ||
/// - SeeAlso: [RFC8292 Voluntary Application Server Identification (VAPID) for Web Push §2. Application Server Self-Identification](https://datatracker.ietf.org/doc/html/rfc8292#section-2) | ||
struct Token: Hashable, Codable, Sendable { | ||
enum CodingKeys: String, CodingKey { | ||
case audience = "aud" | ||
case subject = "sub" | ||
case expiration = "exp" | ||
} | ||
|
||
var audience: String | ||
var subject: VAPID.Configuration.ContactInformation | ||
var expiration: Int | ||
|
||
static let jwtHeader = Array(#"{"typ":"JWT","alg":"ES256"}"#.utf8).base64URLEncodedString() | ||
|
||
init( | ||
origin: String, | ||
contactInformation: VAPID.Configuration.ContactInformation, | ||
expiresIn: VAPID.Configuration.Duration | ||
) { | ||
audience = origin | ||
subject = contactInformation | ||
expiration = Int(Date.now.timeIntervalSince1970) + expiresIn.seconds | ||
} | ||
|
||
init?(token: String, key: String) { | ||
let components = token.split(separator: ".") | ||
|
||
guard | ||
components.count == 3, | ||
components[0] == Self.jwtHeader, | ||
let bodyBytes = Data(base64URLEncoded: components[1]), | ||
let signatureBytes = Data(base64URLEncoded: components[2]), | ||
let publicKeyBytes = Data(base64URLEncoded: key) | ||
else { return nil } | ||
|
||
let message = Data("\(components[0]).\(components[1])".utf8) | ||
let publicKey = try? P256.Signing.PublicKey(x963Representation: publicKeyBytes) | ||
let isValid = try? publicKey?.isValidSignature(.init(rawRepresentation: signatureBytes), for: SHA256.hash(data: message)) | ||
|
||
guard | ||
isValid == true, | ||
let token = try? JSONDecoder().decode(Self.self, from: bodyBytes) | ||
else { return nil } | ||
|
||
self = token | ||
} | ||
|
||
func generateJWT(signedBy signingKey: some VAPIDKeyProtocol) throws -> String { | ||
let header = Self.jwtHeader | ||
|
||
let encoder = JSONEncoder() | ||
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] | ||
let body = try encoder.encode(self).base64URLEncodedString() | ||
|
||
var message = "\(header).\(body)" | ||
let signature = try message.withUTF8 { try signingKey.signature(for: $0) }.base64URLEncodedString() | ||
return "\(message).\(signature)" | ||
} | ||
|
||
func generateAuthorization(signedBy signingKey: some VAPIDKeyProtocol) throws -> String { | ||
let token = try generateJWT(signedBy: signingKey) | ||
let key = signingKey.id | ||
|
||
return "vapid t=\(token), k=\(key)" | ||
} | ||
} | ||
} | ||
|
||
protocol VAPIDKeyProtocol: Identifiable, Sendable { | ||
associatedtype Signature: ContiguousBytes | ||
|
||
func signature(for message: some DataProtocol) throws -> Signature | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,69 @@ | ||
// | ||
// VAPIDTokenTests.swift | ||
// swift-webpush | ||
// | ||
// Created by Dimitri Bouniol on 2024-12-07. | ||
// Copyright © 2024 Mochi Development, Inc. All rights reserved. | ||
// | ||
|
||
import Crypto | ||
import Foundation | ||
import Testing | ||
@testable import WebPush | ||
|
||
struct MockVAPIDKey<Bytes: ContiguousBytes & Sendable>: VAPIDKeyProtocol { | ||
var id: String | ||
var signature: Bytes | ||
|
||
func signature(for message: some DataProtocol) throws -> Bytes { | ||
signature | ||
} | ||
} | ||
|
||
@Suite struct VAPIDTokenTests { | ||
@Test func generatesValidSignedToken() throws { | ||
let key = VAPID.Key() | ||
|
||
let token = VAPID.Token( | ||
origin: "https://push.example.net", | ||
contactInformation: .email("[email protected]"), | ||
expiresIn: .hours(22) | ||
) | ||
|
||
let signedJWT = try token.generateJWT(signedBy: key) | ||
#expect(VAPID.Token(token: signedJWT, key: "\(key.id)") == token) | ||
} | ||
|
||
/// 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. | ||
@Test func tokenVerificationMatchesSpec() throws { | ||
var expectedToken = VAPID.Token( | ||
origin: "https://push.example.net", | ||
contactInformation: .email("[email protected]"), | ||
expiresIn: 0 | ||
) | ||
expectedToken.expiration = 1453523768 | ||
|
||
let receivedToken = VAPID.Token( | ||
token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJodHRwczovL3B1c2guZXhhbXBsZS5uZXQiLCJleHAiOjE0NTM1MjM3NjgsInN1YiI6Im1haWx0bzpwdXNoQGV4YW1wbGUuY29tIn0.i3CYb7t4xfxCDquptFOepC9GAu_HLGkMlMuCGSK2rpiUfnK9ojFwDXb1JrErtmysazNjjvW2L9OkSSHzvoD1oA", | ||
key: "BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrAIxHF6YK5h4SDYic-dRuU_RCPCfA5aq9ojSwk5Y2EmClBPs" | ||
) | ||
#expect(receivedToken == expectedToken) | ||
} | ||
|
||
@Test func authorizationHeaderGeneration() throws { | ||
var expectedToken = VAPID.Token( | ||
origin: "https://push.example.net", | ||
contactInformation: .email("[email protected]"), | ||
expiresIn: 0 | ||
) | ||
expectedToken.expiration = 1453523768 | ||
|
||
let mockKey = MockVAPIDKey( | ||
id: "BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrAIxHF6YK5h4SDYic-dRuU_RCPCfA5aq9ojSwk5Y2EmClBPs", | ||
signature: Data(base64URLEncoded: "i3CYb7t4xfxCDquptFOepC9GAu_HLGkMlMuCGSK2rpiUfnK9ojFwDXb1JrErtmysazNjjvW2L9OkSSHzvoD1oA")! | ||
) | ||
|
||
let generatedHeader = try expectedToken.generateAuthorization(signedBy: mockKey) | ||
#expect(generatedHeader == "vapid t=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJodHRwczovL3B1c2guZXhhbXBsZS5uZXQiLCJleHAiOjE0NTM1MjM3NjgsInN1YiI6Im1haWx0bzpwdXNoQGV4YW1wbGUuY29tIn0.i3CYb7t4xfxCDquptFOepC9GAu_HLGkMlMuCGSK2rpiUfnK9ojFwDXb1JrErtmysazNjjvW2L9OkSSHzvoD1oA, k=BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrAIxHF6YK5h4SDYic-dRuU_RCPCfA5aq9ojSwk5Y2EmClBPs") | ||
} | ||
} |