Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Include misc in JWT.Claims struct. Use InMemoryKeyManager() by default in DIDJWK.create() #45

Merged
merged 8 commits into from
Apr 1, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Sources/Web5/Common/FlatMap.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ struct FlatMap: Codable {
if let mapValue = try? value.decode([String: AnyCodable].self) {
wrappedValue = mapValue
} else {
throw DecodingError.typeMismatch(Date.self, DecodingError.Context(codingPath: [], debugDescription: "TODO"))
throw DecodingError.typeMismatch(
FlatMap.self,
DecodingError.Context(
codingPath: decoder.codingPath, debugDescription: "TODO"
)
)
}
}

Expand Down
2 changes: 1 addition & 1 deletion Sources/Web5/Credentials/PresentationExchange.swift
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ public enum PresentationExchange {
) throws -> [InputDescriptorV2: [String]] {
let vcJWTListMap: [VCDataModel] = try vcJWTList.map { vcJWT in
let parsedJWT = try JWT.parse(jwtString: vcJWT)
guard let vcJSON = parsedJWT.payload["vc"]?.value as? [String: Any] else {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#47

guard let vcJSON = parsedJWT.payload.miscellaneous?["vc"]?.value as? [String: Any] else {
throw Error.missingCredentialObject
}

Expand Down
108 changes: 95 additions & 13 deletions Sources/Web5/Crypto/JOSE/JWT.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,33 +26,38 @@ public struct JWT {
/// 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?
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are date fields in jwt claims supposed to be a numeric value? is there any value in saving it as a date?

Copy link
Author

@jiyoonie9 jiyoonie9 Apr 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue created: #46

let expiration: Int?

/// 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?
let notBefore: Int?

/// 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?
let issuedAt: Int?

/// 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?

/// "misc" Miscellaneous claim is a map to store any additional claims
///
var miscellaneous: [String: AnyCodable]?

// 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
expiration: Int? = nil,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's make these Date and do the conversion for consumers

notBefore: Int? = nil,
issuedAt: Int? = nil,
jwtID: String? = nil,
misc: [String: AnyCodable] = [:]
jiyoonie9 marked this conversation as resolved.
Show resolved Hide resolved
) {
self.issuer = issuer
self.subject = subject
Expand All @@ -61,9 +66,11 @@ public struct JWT {
self.notBefore = notBefore
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...
            self.expiration = expiration != nil ? Int(expiration!.timeIntervalSince1970) : nil
            self.notBefore = notBefore != nil ? Int(notBefore!.timeIntervalSince1970) : nil
            self.issuedAt = issuedAt != nil ? Int(issuedAt!.timeIntervalSince1970) : nil
...

self.issuedAt = issuedAt
self.jwtID = jwtID
self.miscellaneous = misc
}

enum CodingKeys: String, CodingKey {

enum CodingKeys: String, CodingKey, CaseIterable {
case issuer = "iss"
case subject = "sub"
case audience = "aud"
Expand All @@ -72,6 +79,63 @@ public struct JWT {
case issuedAt = "iat"
case jwtID = "jti"
}

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)

// Decode the known properties
issuer = try container.decodeIfPresent(String.self, forKey: .issuer)
subject = try container.decodeIfPresent(String.self, forKey: .subject)
audience = try container.decodeIfPresent(String.self, forKey: .audience)
expiration = try container.decodeIfPresent(Int.self, forKey: .expiration)
notBefore = try container.decodeIfPresent(Int.self, forKey: .notBefore)
issuedAt = try container.decodeIfPresent(Int.self, forKey: .issuedAt)
jwtID = try container.decodeIfPresent(String.self, forKey: .jwtID)

// Initialize the miscellaneous dictionary
var misc = [String: AnyCodable]()

// Extract all rawValues from CodingKeys for comparison
let knownKeysRawValues = CodingKeys.allCases.map { $0.rawValue }

// Dynamically decode keys not in CodingKeys
let dynamicContainer = try decoder.container(keyedBy: DynamicCodingKey.self)
for key in dynamicContainer.allKeys {
// Convert DynamicCodingKey to String
let keyString = key.stringValue

// Skip keys that are part of the known CodingKeys
if !knownKeysRawValues.contains(keyString) {
if let value = try? dynamicContainer.decode(AnyCodable.self, forKey: key) {
misc[keyString] = value
}
}
}

miscellaneous = misc.isEmpty ? nil : misc
}

public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)

// Encode known properties
try container.encodeIfPresent(issuer, forKey: .issuer)
try container.encodeIfPresent(subject, forKey: .subject)
try container.encodeIfPresent(audience, forKey: .audience)
try container.encodeIfPresent(expiration, forKey: .expiration)
try container.encodeIfPresent(notBefore, forKey: .notBefore)
try container.encodeIfPresent(issuedAt, forKey: .issuedAt)
try container.encodeIfPresent(jwtID, forKey: .jwtID)

// Dynamically encode the miscellaneous properties at the top level

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

at the top level

So this means the claims body won't look like this, right?

{
  // existing well-defined claims properties
  "misc": {
    "something": "else"
  }
}

and instead will look like this, right?

{
  // existing well-defined claims properties
  "something": "else"
}

(idk Swift so apologies in advance)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes thats right! in swift we don't get the affordance of [k:string: any] type like in ts, so we effectively encode/decode to/from a known property that does have a dictionary of [String: Any]

so exactly as you said:

{
  // known property with codingKey "issuer"
  "iss": "abcCorp",
  // dynamic properties with unknown codingKeys
  "something": "else",
  "and": "something",
  "else": "again"
}

while in Swift it will look like

// maps to known codingKeys
"issuer":  "abcCorp",
"miscellaneous": { 
  "something": "else", 
  "and": "something", 
  "else", "again" 
}

var dynamicContainer = encoder.container(keyedBy: DynamicCodingKey.self)
if let misc = miscellaneous {
for (key, value) in misc {
let codingKey = DynamicCodingKey(stringValue: key)!
try dynamicContainer.encode(value, forKey: codingKey)
}
}
}
}

/// Signs the provied JWT claims with the provided BearerDID.
Expand All @@ -94,11 +158,11 @@ public struct JWT {

public struct ParsedJWT {
let header: JWS.Header
let payload: [String: AnyCodable]
let payload: JWT.Claims

public init(
header: JWS.Header,
payload: [String: AnyCodable]
payload: JWT.Claims
) {
self.header = header
self.payload = payload
Expand Down Expand Up @@ -129,9 +193,9 @@ public struct JWT {
}

let jwtPayload = try JSONDecoder().decode(
[String: AnyCodable].self,
from: base64urlEncodedJwtPayload.decodeBase64Url()
)
JWT.Claims.self,
from: try base64urlEncodedJwtPayload.decodeBase64Url())


return ParsedJWT(header: jwtHeader, payload: jwtPayload)
}
Expand All @@ -151,3 +215,21 @@ extension JWT {
}
}
}


// MARK: - DynamicCodingKey
// Define DynamicCodingKey to use for dynamic encoding
struct DynamicCodingKey: CodingKey {
var stringValue: String
var intValue: Int?

init?(stringValue: String) {
self.stringValue = stringValue
self.intValue = nil
}

init?(intValue: Int) {
self.stringValue = "\(intValue)"
self.intValue = intValue
}
}
2 changes: 1 addition & 1 deletion Sources/Web5/Dids/Methods/DIDJWK.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public enum DIDJWK {
/// - options: Options configuring how the DIDJWK is created
/// - Returns: `BearerDID` that represents the created DIDJWK
public static func create(
keyManager: KeyManager,
keyManager: KeyManager = InMemoryKeyManager(),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 🚀 🚀

options: CreateOptions = .default
) throws -> BearerDID {
let keyAlias = try keyManager.generatePrivateKey(algorithm: options.algorithm)
Expand Down
58 changes: 56 additions & 2 deletions Tests/Web5Tests/Crypto/JOSE/JWTTests.swift
Original file line number Diff line number Diff line change
@@ -1,15 +1,69 @@
import XCTest
import AnyCodable

@testable import Web5

final class JWTTests: XCTestCase {

func test_sign() throws {
let did = try DIDJWK.create(keyManager: InMemoryKeyManager())
let did = try DIDJWK.create()
let future = Int(Date.distantFuture.timeIntervalSince1970)

let claims = JWT.Claims(issuer: did.identifier)
let claims = JWT.Claims(
issuer: did.identifier,
expiration: future,
misc: ["nonce": 123]
)
let jwt = try JWT.sign(did: did, claims: claims)

XCTAssertFalse(jwt.isEmpty)

let decoded = try JWT.parse(jwtString: jwt)
let decodedNonceValue = decoded.payload.miscellaneous?["nonce"]?.value as? Int
XCTAssertEqual(decodedNonceValue, 123)

}
}

// todo consider adding more tests to verify encode and decode works as intended
class JWTClaimsTests: XCTestCase {

func testClaimsEncodingDecoding() {
let originalClaims = JWT.Claims(
issuer: "issuer",
subject: "subject",
audience: "audience",
expiration: Int(Date.distantFuture.timeIntervalSince1970),
notBefore: Int(Date.distantPast.timeIntervalSince1970),
issuedAt: Int(Date.now.timeIntervalSince1970),
jiyoonie9 marked this conversation as resolved.
Show resolved Hide resolved
jwtID: "jwtID",
misc: ["foo": AnyCodable("bar")]
)

do {
let encodedClaims = try JSONEncoder().encode(originalClaims)
let decodedClaims = try JSONDecoder().decode(JWT.Claims.self, from: encodedClaims)

XCTAssertEqual(originalClaims.issuer, decodedClaims.issuer)
XCTAssertEqual(originalClaims.subject, decodedClaims.subject)
XCTAssertEqual(originalClaims.audience, decodedClaims.audience)
XCTAssertEqual(originalClaims.expiration, decodedClaims.expiration)
XCTAssertEqual(originalClaims.notBefore, decodedClaims.notBefore)
XCTAssertEqual(originalClaims.issuedAt, decodedClaims.issuedAt)
XCTAssertEqual(originalClaims.jwtID, decodedClaims.jwtID)

// Log and compare custom claims
let originalMiscValue = originalClaims.miscellaneous?["foo"]?.value as? String
let decodedMiscValue = decodedClaims.miscellaneous?["foo"]?.value as? String

if let originalMiscValue = originalMiscValue, let decodedMiscValue = decodedMiscValue {
XCTAssertEqual(originalMiscValue, decodedMiscValue, "Misc claims did not match.")
} else {
XCTFail("Custom claims could not be found or did not match. Original: \(String(describing: originalMiscValue)), Decoded: \(String(describing: decodedMiscValue))")
}

} catch {
XCTFail("Encoding or decoding failed with error: \(error)")
}
}
}
8 changes: 4 additions & 4 deletions Tests/Web5Tests/Dids/BearerDIDTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import XCTest
final class BearerDIDTests: XCTestCase {

func test_export() throws {
let didJWK = try DIDJWK.create(keyManager: InMemoryKeyManager())
let didJWK = try DIDJWK.create()
let portableDID = try didJWK.export()

XCTAssertNoDifference(portableDID.uri, didJWK.uri)
Expand All @@ -18,7 +18,7 @@ final class BearerDIDTests: XCTestCase {
func test_getSigner() throws {
let payload = "Hello, world!".data(using: .utf8)!

let didJWK = try DIDJWK.create(keyManager: InMemoryKeyManager())
let didJWK = try DIDJWK.create()
let signer = try didJWK.getSigner()

let signature = try signer.sign(payload: payload)
Expand All @@ -30,7 +30,7 @@ final class BearerDIDTests: XCTestCase {
func test_getSigner_verificationMethodID() throws {
let payload = "Hello, world!".data(using: .utf8)!

let didJWK = try DIDJWK.create(keyManager: InMemoryKeyManager())
let didJWK = try DIDJWK.create()
let verificationMethodID = try XCTUnwrap(didJWK.document.verificationMethod?.first?.id)

let signer = try didJWK.getSigner(verificationMethodID: verificationMethodID)
Expand All @@ -41,7 +41,7 @@ final class BearerDIDTests: XCTestCase {
}

func test_getSigner_invalidVerificationMethodID() throws {
let didJWK = try DIDJWK.create(keyManager: InMemoryKeyManager())
let didJWK = try DIDJWK.create()
XCTAssertThrowsError(try didJWK.getSigner(verificationMethodID: "not-real"))
}
}
Loading