Skip to content

Commit

Permalink
Implement JWT signing (#22)
Browse files Browse the repository at this point in the history
JWT signatures are required for accessing authenticated tbDEX endpoints.

Currently, there isn't a need for Swift to verify a JWT, as that is done on the server side only, so that is not implemented yet.
  • Loading branch information
amika-sq authored Feb 27, 2024
1 parent b7f13ff commit 34411b0
Show file tree
Hide file tree
Showing 5 changed files with 173 additions and 14 deletions.
30 changes: 30 additions & 0 deletions Sources/Web5/Common/ISO8601Date.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Foundation

/// Wrapper used to easily encode a `Date` to and decode a `Date` from an ISO 8601 formatted date string.
@propertyWrapper
struct ISO8601Date: Codable {
var wrappedValue: Date?

init(wrappedValue: Date?) {
self.wrappedValue = wrappedValue
}

init(from decoder: Decoder) throws {
let value = try decoder.singleValueContainer()
let stringValue = try value.decode(String.self)
if let date = ISO8601DateFormatter().date(from: stringValue) {
wrappedValue = date
} else {
throw DecodingError.typeMismatch(Date.self, DecodingError.Context(codingPath: [], debugDescription: "Failed to decode ISO Date. Invalid string format."))
}
}

func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()

if let wrappedValue {
let string = ISO8601DateFormatter().string(from: wrappedValue)
try container.encode(string)
}
}
}
37 changes: 29 additions & 8 deletions Sources/Web5/Crypto/JOSE/JWS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,22 +107,42 @@ public struct JWS {
}
}

/// Options that can be used to configure the Sign operation of a JWS
public struct SignOptions {
/// Boolean determining if the payload is detached or not in the resulting JWS signature
public let detached: Bool

/// Optional `VerificationMethod` ID to use for signing. If not provided, the first
/// verificationMethod in the `BearerDID`'s document will be used.
public let verificationMethodID: String?

// Optional type of verification method to use for signing. If not provided, the first
public let type: String?

public init(
detached: Bool = false,
verificationMethodID: String? = nil,
type: String? = nil
) {
self.detached = detached
self.verificationMethodID = verificationMethodID
self.type = type
}
}

/// Signs the provided payload with a key associated with the provided `BearerDID`.
/// - Parameters:
/// - did: `BearerDID` to use for signing
/// - payload: Data to be signed
/// - detached: If true, the payload will not be included in the resulting compactJWS
/// - verificationID: Optional `VerificationMethod` ID to use for signing. If not provided, the first
/// assertionMethod in the `BearerDID`'s document will be used.
/// - options: Options to configure the signing operation
/// - Returns: compactJWS representation of the signed payload
public static func sign<D>(
did: BearerDID,
payload: D,
detached: Bool,
verificationMethodID: String? = nil
options: SignOptions
) throws -> String
where D: DataProtocol {
let signer = try did.getSigner(verificationMethodID: verificationMethodID)
let signer = try did.getSigner(verificationMethodID: options.verificationMethodID)

guard let publicKey = signer.verificationMethod.publicKeyJwk else {
throw Error.signError("Public key not found")
Expand All @@ -134,7 +154,8 @@ public struct JWS {

let header = Header(
algorithm: algorithm.jwsAlgorithm,
keyID: signer.verificationMethod.id
keyID: signer.verificationMethod.id,
type: options.type
)

let base64UrlEncodedHeader = try JSONEncoder().encode(header).base64UrlEncodedString()
Expand All @@ -143,7 +164,7 @@ public struct JWS {
let base64UrlEncodedSignature = try signer.sign(payload: Data(toSign.utf8)).base64UrlEncodedString()

let compactJWS: String
if detached {
if options.detached {
compactJWS = "\(base64UrlEncodedHeader)..\(base64UrlEncodedSignature)"
} else {
compactJWS = "\(base64UrlEncodedHeader).\(base64UrlEncodedPayload).\(base64UrlEncodedSignature)"
Expand Down
93 changes: 93 additions & 0 deletions Sources/Web5/Crypto/JOSE/JWT.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import Foundation

public struct JWT {

/// Claims represents JWT (JSON Web Token) Claims
///
/// See [RFC7519](https://tools.ietf.org/html/rfc7519#section-4) for more information
public struct Claims: Codable {
/// The "iss" (issuer) claim identifies the principal that issued the JWT.
///
/// [Spec](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.1)
let issuer: String?

/// The "sub" (subject) claim identifies the principal that is the subject of the JWT.
///
/// [Spec](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.2)
let subject: String?

/// The "aud" (audience) claim identifies the recipients that the JWT is intended for.
///
/// [Spec](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3)
let audience: String?

/// The "exp" (expiration time) claim identifies the expiration time on
/// or after which the JWT must not be accepted for processing.
///
/// [Spec](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.4)
@ISO8601Date private(set) var expiration: Date?

/// The "nbf" (not before) claim identifies the time before which the JWT
/// must not be accepted for processing.
///
/// [Spec](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.5)
@ISO8601Date private(set) var notBefore: Date?

/// The "iat" (issued at) claim identifies the time at which the JWT was issued.
///
/// [Spec](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.6)
@ISO8601Date private(set) var issuedAt: Date?

/// The "jti" (JWT ID) claim provides a unique identifier for the JWT.
///
/// [Spec](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.7)
let jwtID: String?

// Default Initializer
public init(
issuer: String? = nil,
subject: String? = nil,
audience: String? = nil,
expiration: Date? = nil,
notBefore: Date? = nil,
issuedAt: Date? = nil,
jwtID: String? = nil
) {
self.issuer = issuer
self.subject = subject
self.audience = audience
self.expiration = expiration
self.notBefore = notBefore
self.issuedAt = issuedAt
self.jwtID = jwtID
}

enum CodingKeys: String, CodingKey {
case issuer = "iss"
case subject = "sub"
case audience = "aud"
case expiration = "exp"
case notBefore = "nbf"
case issuedAt = "iat"
case jwtID = "jti"
}
}

/// Signs the provied JWT claims with the provided BearerDID.
/// - Parameters:
/// - did: The BearerDID to sign the JWT with
/// - claims: The claims to sign
/// - Returns: The signed JWT
public static func sign(did: BearerDID, claims: Claims) throws -> String {
let payload = try JSONEncoder().encode(claims)

return try JWS.sign(
did: did,
payload: payload,
options: .init(
detached: false,
type: "JWT"
)
)
}
}
12 changes: 6 additions & 6 deletions Tests/Web5Tests/Crypto/JOSE/JWSTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ final class JWSTests: XCTestCase {
let payload = "Hello, World!".data(using: .utf8)!

func test_sign_detachedPayload() throws {
let compactJWS = try JWS.sign(did: did, payload: payload, detached: true)
let compactJWS = try JWS.sign(did: did, payload: payload, options: .init(detached: true))
let compactJWSParts = compactJWS.split(separator: ".", omittingEmptySubsequences: false)

XCTAssertEqual(compactJWSParts.count, 3)
Expand All @@ -17,7 +17,7 @@ final class JWSTests: XCTestCase {
}

func test_sign_attachedPayload() throws {
let compactJWS = try JWS.sign(did: did, payload: payload, detached: false)
let compactJWS = try JWS.sign(did: did, payload: payload, options: .init(detached: false))
let compactJWSParts = compactJWS.split(separator: ".", omittingEmptySubsequences: false)

XCTAssertEqual(compactJWSParts.count, 3)
Expand All @@ -26,28 +26,28 @@ final class JWSTests: XCTestCase {
}

func test_verify_detachedPayload() async throws {
let compactJWS = try JWS.sign(did: did, payload: payload, detached: true)
let compactJWS = try JWS.sign(did: did, payload: payload, options: .init(detached: true))
let isValid = try await JWS.verify(compactJWS: compactJWS, detachedPayload: payload)

XCTAssertTrue(isValid)
}

func test_verify_attachedPayload() async throws {
let compactJWS = try JWS.sign(did: did, payload: payload, detached: false)
let compactJWS = try JWS.sign(did: did, payload: payload, options: .init(detached: false))
let isValid = try await JWS.verify(compactJWS: compactJWS)

XCTAssertTrue(isValid)
}

func test_verify_expectedSigningDIDURI_match() async throws {
let compactJWS = try JWS.sign(did: did, payload: payload, detached: false)
let compactJWS = try JWS.sign(did: did, payload: payload, options: .init(detached: false))
let isValid = try await JWS.verify(compactJWS: compactJWS, expectedSigningDIDURI: did.uri)

XCTAssertTrue(isValid)
}

func test_verify_expectedSigningDIDURI_noMatch() async throws {
let compactJWS = try JWS.sign(did: did, payload: payload, detached: false)
let compactJWS = try JWS.sign(did: did, payload: payload, options: .init(detached: false))
let isValid = try await JWS.verify(compactJWS: compactJWS, expectedSigningDIDURI: "did:example:1234")

// compactJWS was signed by `did`, but we're expecting it to be signed by a different DID.
Expand Down
15 changes: 15 additions & 0 deletions Tests/Web5Tests/Crypto/JOSE/JWTTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import XCTest

@testable import Web5

final class JWTTests: XCTestCase {

func test_sign() throws {
let did = try DIDJWK.create(keyManager: InMemoryKeyManager())

let claims = JWT.Claims(issuer: did.identifier)
let jwt = try JWT.sign(did: did, claims: claims)

XCTAssertFalse(jwt.isEmpty)
}
}

0 comments on commit 34411b0

Please sign in to comment.