From 5feee57b57f49d5fea76c48a32a9be8ad9b3b136 Mon Sep 17 00:00:00 2001 From: Petr Pavlik Date: Tue, 15 Oct 2024 22:36:07 +0200 Subject: [PATCH 1/6] Add FirebaseIdentityToken --- .../JWTKit/Vendor/FirebaseIdentityToken.swift | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 Sources/JWTKit/Vendor/FirebaseIdentityToken.swift diff --git a/Sources/JWTKit/Vendor/FirebaseIdentityToken.swift b/Sources/JWTKit/Vendor/FirebaseIdentityToken.swift new file mode 100644 index 00000000..1e639c9a --- /dev/null +++ b/Sources/JWTKit/Vendor/FirebaseIdentityToken.swift @@ -0,0 +1,96 @@ +#if !canImport(Darwin) + import FoundationEssentials +#else + import Foundation +#endif + +public struct FirebaseIdentityToken: JWTPayload { + + /// Additional Firebase-specific claims + public struct Firebase: Codable, Sendable { + + enum CodingKeys: String, CodingKey { + case identities + case signInProvider = "sign_in_provider" + case signInSecondFactor = "sign_in_second_factor" + case secondFactorIdentifier = "second_factor_identifier" + case tenant + } + + public let identities: [String: [String]] + public let signInProvider: String + public let signInSecondFactor: String?; + public let secondFactorIdentifier: String?; + public let tenant: String?; + } + + enum CodingKeys: String, CodingKey { + case email, name, picture, firebase + case issuer = "iss" + case subject = "sub" + case audience = "aud" + case issuedAt = "iat" + case expires = "exp" + case emailVerified = "email_verified" + case userID = "user_id" + case authTime = "auth_time" + case phoneNumber = "phone_number" + } + + /// Issuer. It must be "https://securetoken.google.com/", where is the same project ID used for aud + public let issuer: IssuerClaim + + /// Issued-at time. It must be in the past. The time is measured in seconds since the UNIX epoch. + public let issuedAt: IssuedAtClaim + + /// Expiration time. It must be in the future. The time is measured in seconds since the UNIX epoch. + public let expires: ExpirationClaim + + /// The audience that this ID token is intended for. It must be your Firebase project ID, the unique identifier for your Firebase project, which can be found in the URL of that project's console. + public let audience: AudienceClaim + + /// Subject. It must be a non-empty string and must be the uid of the user or device. + public let subject: SubjectClaim + + /// Authentication time. It must be in the past. The time when the user authenticated. + public let authTime: Date? + + public let userID: String + + /// The user's email address. + public let email: String? + + /// The URL of the user's profile picture. + public let picture: String? + + /// The user's full name, in a displayable form. + public let name: String? + + /// `True` if the user's e-mail address has been verified; otherwise `false`. + public let emailVerified: Bool? + + /// The user's phone number. + public let phoneNumber: String? + + /// Additional Firebase-specific claims + public let firebase: Firebase? + + // TODO: support custom claims + + public func verify(using _: some JWTAlgorithm) throws { + + guard let projectId = self.audience.value.first else { + throw JWTError.claimVerificationFailure(failedClaim: issuer, reason: "Token not provided by Firebase") + } + + guard self.issuer.value == "https://securetoken.google.com/\(projectId)" else { + throw JWTError.claimVerificationFailure(failedClaim: issuer, reason: "Token not provided by Firebase") + } + + guard self.subject.value.count <= 255 else { + throw JWTError.claimVerificationFailure(failedClaim: subject, reason: "Subject claim beyond 255 ASCII characters long.") + } + + try self.expires.verifyNotExpired() + } +} \ No newline at end of file From b192b22eb4f236f0dc3c22be9535940cc0bf5bec Mon Sep 17 00:00:00 2001 From: Petr Pavlik Date: Wed, 16 Oct 2024 19:01:59 +0200 Subject: [PATCH 2/6] tests and fixes --- ...irebaseIdentityToken.swift => FirebaseAuthIdentityToken.swift} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Sources/JWTKit/Vendor/{FirebaseIdentityToken.swift => FirebaseAuthIdentityToken.swift} (100%) diff --git a/Sources/JWTKit/Vendor/FirebaseIdentityToken.swift b/Sources/JWTKit/Vendor/FirebaseAuthIdentityToken.swift similarity index 100% rename from Sources/JWTKit/Vendor/FirebaseIdentityToken.swift rename to Sources/JWTKit/Vendor/FirebaseAuthIdentityToken.swift From 64d07ece810926e8cf3020f8cc6082e8550d4f35 Mon Sep 17 00:00:00 2001 From: Petr Pavlik Date: Wed, 16 Oct 2024 19:02:23 +0200 Subject: [PATCH 3/6] Refactor FirebaseIdentityToken struct and add support for custom claims --- .../Vendor/FirebaseAuthIdentityToken.swift | 44 ++++++++- Tests/JWTKitTests/VendorTokenTests.swift | 90 +++++++++++++++++++ 2 files changed, 132 insertions(+), 2 deletions(-) diff --git a/Sources/JWTKit/Vendor/FirebaseAuthIdentityToken.swift b/Sources/JWTKit/Vendor/FirebaseAuthIdentityToken.swift index 1e639c9a..ed6f2288 100644 --- a/Sources/JWTKit/Vendor/FirebaseAuthIdentityToken.swift +++ b/Sources/JWTKit/Vendor/FirebaseAuthIdentityToken.swift @@ -4,7 +4,7 @@ import Foundation #endif -public struct FirebaseIdentityToken: JWTPayload { +public struct FirebaseAuthIdentityToken: JWTPayload { /// Additional Firebase-specific claims public struct Firebase: Codable, Sendable { @@ -17,6 +17,18 @@ public struct FirebaseIdentityToken: JWTPayload { case tenant } + public init(identities: [String : [String]], + signInProvider: String, + signInSecondFactor: String? = nil, + secondFactorIdentifier: String? = nil, + tenant: String? = nil) { + self.identities = identities + self.signInProvider = signInProvider + self.signInSecondFactor = signInSecondFactor + self.secondFactorIdentifier = secondFactorIdentifier + self.tenant = tenant + } + public let identities: [String: [String]] public let signInProvider: String public let signInSecondFactor: String?; @@ -76,6 +88,34 @@ public struct FirebaseIdentityToken: JWTPayload { public let firebase: Firebase? // TODO: support custom claims + + public init(issuer: IssuerClaim, + subject: SubjectClaim, + audience: AudienceClaim, + issuedAt: IssuedAtClaim, + expires: ExpirationClaim, + authTime: Date? = nil, + userID: String, + email: String? = nil, + emailVerified: Bool? = nil, + phoneNumber: String? = nil, + name: String? = nil, + picture: String? = nil, + firebase: FirebaseAuthIdentityToken.Firebase? = nil) { + self.issuer = issuer + self.issuedAt = issuedAt + self.expires = expires + self.audience = audience + self.subject = subject + self.authTime = authTime + self.userID = userID + self.email = email + self.picture = picture + self.name = name + self.emailVerified = emailVerified + self.phoneNumber = phoneNumber + self.firebase = firebase + } public func verify(using _: some JWTAlgorithm) throws { @@ -93,4 +133,4 @@ public struct FirebaseIdentityToken: JWTPayload { try self.expires.verifyNotExpired() } -} \ No newline at end of file +} diff --git a/Tests/JWTKitTests/VendorTokenTests.swift b/Tests/JWTKitTests/VendorTokenTests.swift index d2ade8df..12aa1903 100644 --- a/Tests/JWTKitTests/VendorTokenTests.swift +++ b/Tests/JWTKitTests/VendorTokenTests.swift @@ -39,6 +39,7 @@ struct VendorTokenTests { } } + @Test func testGoogleIDTokenNotFromGoogle() async throws { let token = GoogleIdentityToken( issuer: "https://example.com", @@ -72,6 +73,7 @@ struct VendorTokenTests { } } + @Test func testGoogleIDTokenWithBigSubjectClaim() async throws { let token = GoogleIdentityToken( issuer: "https://accounts.google.com", @@ -106,6 +108,7 @@ struct VendorTokenTests { } } + @Test func testAppleIDToken() async throws { let token = AppleIdentityToken( issuer: "https://appleid.apple.com", @@ -130,6 +133,7 @@ struct VendorTokenTests { } } + @Test func testAppleIDTokenNotFromApple() async throws { let token = AppleIdentityToken( issuer: "https://example.com", @@ -158,6 +162,7 @@ struct VendorTokenTests { } } + @Test func testMicrosoftIDToken() async throws { let tenantID = "some-id" @@ -190,6 +195,7 @@ struct VendorTokenTests { } } + @Test func testMicrosoftIDTokenNotFromMicrosoft() async throws { let token = MicrosoftIdentityToken( audience: "your-app-client-id", @@ -222,6 +228,7 @@ struct VendorTokenTests { } } + @Test func testMicrosoftIDTokenWithMissingTenantIDClaim() async throws { let token = MicrosoftIdentityToken( audience: "your-app-client-id", @@ -255,4 +262,87 @@ struct VendorTokenTests { try await collection.verify(jwt, as: MicrosoftIdentityToken.self) } } + + @Test + func testFirebaseIDToken() async throws { + + let token = FirebaseAuthIdentityToken( + issuer: "https://securetoken.google.com/firprojectname-12345", + subject: "1234567890", + audience: .init(value: ["firprojectname-12345"]), + issuedAt: .init(value: .now), expires: .init(value: .now + 3600), + authTime: .now, + userID: "1234567890", + email: "user@example.com", + emailVerified: true, + phoneNumber: nil, + name: "John Doe", + picture: "https://example.com/johndoe.png", + firebase: .init(identities: ["google.com": ["9876543210"], "email": ["user@example.com"]], signInProvider: "google.com")) + + let collection = await JWTKeyCollection().add(hmac: "secret", digestAlgorithm: .sha256) + let jwt = try await collection.sign(token) + + await #expect(throws: Never.self) { + try await collection.verify(jwt, as: FirebaseAuthIdentityToken.self) + } + } + + @Test + func testFirebaseIDTokenNotFromGoogle() async throws { + + let token = FirebaseAuthIdentityToken( + issuer: "https://example.com", + subject: "1234567890", + audience: .init(value: ["firprojectname-12345"]), + issuedAt: .init(value: .now), expires: .init(value: .now + 3600), + authTime: .now, + userID: "1234567890", + email: "user@example.com", + emailVerified: true, + phoneNumber: nil, + name: "John Doe", + picture: "https://example.com/johndoe.png", + firebase: .init(identities: ["google.com": ["9876543210"], "email": ["user@example.com"]], signInProvider: "google.com")) + + let collection = await JWTKeyCollection().add(hmac: "secret", digestAlgorithm: .sha256) + let jwt = try await collection.sign(token) + + await #expect( + throws: JWTError.claimVerificationFailure( + failedClaim: token.issuer, reason: "Token not provided by Google" + ) + ) { + try await collection.verify(jwt, as: FirebaseAuthIdentityToken.self) + } + } + + @Test + func testFirebaseIDTokenWithBigSubjectClaim() async throws { + let token = FirebaseAuthIdentityToken( + issuer: "https://securetoken.google.com/firprojectname-12345", + subject: .init(stringLiteral: String(repeating: "A", count: 1000)), + audience: .init(value: ["firprojectname-12345"]), + issuedAt: .init(value: .now), expires: .init(value: .now + 3600), + authTime: .now, + userID: "1234567890", + email: "user@example.com", + emailVerified: true, + phoneNumber: nil, + name: "John Doe", + picture: "https://example.com/johndoe.png", + firebase: .init(identities: ["google.com": ["9876543210"], "email": ["user@example.com"]], signInProvider: "google.com")) + + let collection = await JWTKeyCollection().add(hmac: "secret", digestAlgorithm: .sha256) + let jwt = try await collection.sign(token) + + await #expect( + throws: JWTError.claimVerificationFailure( + failedClaim: token.subject, + reason: "Subject claim beyond 255 ASCII characters long." + ) + ) { + try await collection.verify(jwt, as: FirebaseAuthIdentityToken.self) + } + } } From 1956aef03f017020585bbb60154ddfbfe3212a44 Mon Sep 17 00:00:00 2001 From: Petr Pavlik Date: Wed, 16 Oct 2024 22:19:25 +0200 Subject: [PATCH 4/6] fix --- Sources/JWTKit/Vendor/FirebaseAuthIdentityToken.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/JWTKit/Vendor/FirebaseAuthIdentityToken.swift b/Sources/JWTKit/Vendor/FirebaseAuthIdentityToken.swift index ed6f2288..d6f65196 100644 --- a/Sources/JWTKit/Vendor/FirebaseAuthIdentityToken.swift +++ b/Sources/JWTKit/Vendor/FirebaseAuthIdentityToken.swift @@ -120,7 +120,7 @@ public struct FirebaseAuthIdentityToken: JWTPayload { public func verify(using _: some JWTAlgorithm) throws { guard let projectId = self.audience.value.first else { - throw JWTError.claimVerificationFailure(failedClaim: issuer, reason: "Token not provided by Firebase") + throw JWTError.claimVerificationFailure(failedClaim: audience, reason: "Token not provided by Firebase") } guard self.issuer.value == "https://securetoken.google.com/\(projectId)" else { From e60ceb8d18f085e65251ca9b865a3e6e89656369 Mon Sep 17 00:00:00 2001 From: Petr Pavlik Date: Thu, 17 Oct 2024 15:21:51 +0200 Subject: [PATCH 5/6] swift format and give vendor tests human readable names --- .../Vendor/FirebaseAuthIdentityToken.swift | 86 ++++++++++--------- Tests/JWTKitTests/VendorTokenTests.swift | 26 +++--- 2 files changed, 58 insertions(+), 54 deletions(-) diff --git a/Sources/JWTKit/Vendor/FirebaseAuthIdentityToken.swift b/Sources/JWTKit/Vendor/FirebaseAuthIdentityToken.swift index d6f65196..7471dd71 100644 --- a/Sources/JWTKit/Vendor/FirebaseAuthIdentityToken.swift +++ b/Sources/JWTKit/Vendor/FirebaseAuthIdentityToken.swift @@ -8,7 +8,7 @@ public struct FirebaseAuthIdentityToken: JWTPayload { /// Additional Firebase-specific claims public struct Firebase: Codable, Sendable { - + enum CodingKeys: String, CodingKey { case identities case signInProvider = "sign_in_provider" @@ -16,26 +16,28 @@ public struct FirebaseAuthIdentityToken: JWTPayload { case secondFactorIdentifier = "second_factor_identifier" case tenant } - - public init(identities: [String : [String]], - signInProvider: String, - signInSecondFactor: String? = nil, - secondFactorIdentifier: String? = nil, - tenant: String? = nil) { + + public init( + identities: [String: [String]], + signInProvider: String, + signInSecondFactor: String? = nil, + secondFactorIdentifier: String? = nil, + tenant: String? = nil + ) { self.identities = identities self.signInProvider = signInProvider self.signInSecondFactor = signInSecondFactor self.secondFactorIdentifier = secondFactorIdentifier self.tenant = tenant } - + public let identities: [String: [String]] public let signInProvider: String - public let signInSecondFactor: String?; - public let secondFactorIdentifier: String?; - public let tenant: String?; + public let signInSecondFactor: String? + public let secondFactorIdentifier: String? + public let tenant: String? } - + enum CodingKeys: String, CodingKey { case email, name, picture, firebase case issuer = "iss" @@ -48,60 +50,62 @@ public struct FirebaseAuthIdentityToken: JWTPayload { case authTime = "auth_time" case phoneNumber = "phone_number" } - + /// Issuer. It must be "https://securetoken.google.com/", where is the same project ID used for aud public let issuer: IssuerClaim - + /// Issued-at time. It must be in the past. The time is measured in seconds since the UNIX epoch. public let issuedAt: IssuedAtClaim - + /// Expiration time. It must be in the future. The time is measured in seconds since the UNIX epoch. public let expires: ExpirationClaim - + /// The audience that this ID token is intended for. It must be your Firebase project ID, the unique identifier for your Firebase project, which can be found in the URL of that project's console. public let audience: AudienceClaim - + /// Subject. It must be a non-empty string and must be the uid of the user or device. public let subject: SubjectClaim - + /// Authentication time. It must be in the past. The time when the user authenticated. public let authTime: Date? - + public let userID: String - + /// The user's email address. public let email: String? - + /// The URL of the user's profile picture. public let picture: String? - + /// The user's full name, in a displayable form. public let name: String? - + /// `True` if the user's e-mail address has been verified; otherwise `false`. public let emailVerified: Bool? - + /// The user's phone number. public let phoneNumber: String? - + /// Additional Firebase-specific claims public let firebase: Firebase? - + // TODO: support custom claims - - public init(issuer: IssuerClaim, - subject: SubjectClaim, - audience: AudienceClaim, - issuedAt: IssuedAtClaim, - expires: ExpirationClaim, - authTime: Date? = nil, - userID: String, - email: String? = nil, - emailVerified: Bool? = nil, - phoneNumber: String? = nil, - name: String? = nil, - picture: String? = nil, - firebase: FirebaseAuthIdentityToken.Firebase? = nil) { + + public init( + issuer: IssuerClaim, + subject: SubjectClaim, + audience: AudienceClaim, + issuedAt: IssuedAtClaim, + expires: ExpirationClaim, + authTime: Date? = nil, + userID: String, + email: String? = nil, + emailVerified: Bool? = nil, + phoneNumber: String? = nil, + name: String? = nil, + picture: String? = nil, + firebase: FirebaseAuthIdentityToken.Firebase? = nil + ) { self.issuer = issuer self.issuedAt = issuedAt self.expires = expires @@ -122,7 +126,7 @@ public struct FirebaseAuthIdentityToken: JWTPayload { guard let projectId = self.audience.value.first else { throw JWTError.claimVerificationFailure(failedClaim: audience, reason: "Token not provided by Firebase") } - + guard self.issuer.value == "https://securetoken.google.com/\(projectId)" else { throw JWTError.claimVerificationFailure(failedClaim: issuer, reason: "Token not provided by Firebase") } diff --git a/Tests/JWTKitTests/VendorTokenTests.swift b/Tests/JWTKitTests/VendorTokenTests.swift index 12aa1903..c94837e9 100644 --- a/Tests/JWTKitTests/VendorTokenTests.swift +++ b/Tests/JWTKitTests/VendorTokenTests.swift @@ -39,7 +39,7 @@ struct VendorTokenTests { } } - @Test + @Test("Test Google ID Token that is not from Google (invalid issuer)") func testGoogleIDTokenNotFromGoogle() async throws { let token = GoogleIdentityToken( issuer: "https://example.com", @@ -73,7 +73,7 @@ struct VendorTokenTests { } } - @Test + @Test("Test Google ID Token with subject claim that's too big") func testGoogleIDTokenWithBigSubjectClaim() async throws { let token = GoogleIdentityToken( issuer: "https://accounts.google.com", @@ -108,7 +108,7 @@ struct VendorTokenTests { } } - @Test + @Test("Test Apple ID Token") func testAppleIDToken() async throws { let token = AppleIdentityToken( issuer: "https://appleid.apple.com", @@ -133,7 +133,7 @@ struct VendorTokenTests { } } - @Test + @Test("Test Apple ID Token that is not from Apple (invalid issuer)") func testAppleIDTokenNotFromApple() async throws { let token = AppleIdentityToken( issuer: "https://example.com", @@ -162,7 +162,7 @@ struct VendorTokenTests { } } - @Test + @Test("Test Microsoft ID Token") func testMicrosoftIDToken() async throws { let tenantID = "some-id" @@ -195,7 +195,7 @@ struct VendorTokenTests { } } - @Test + @Test("Test Microsoft ID Token that is not from Microsoft (invalid issuer)") func testMicrosoftIDTokenNotFromMicrosoft() async throws { let token = MicrosoftIdentityToken( audience: "your-app-client-id", @@ -228,7 +228,7 @@ struct VendorTokenTests { } } - @Test + @Test("Test Microsoft ID Token with missing tenant ID claim") func testMicrosoftIDTokenWithMissingTenantIDClaim() async throws { let token = MicrosoftIdentityToken( audience: "your-app-client-id", @@ -263,7 +263,7 @@ struct VendorTokenTests { } } - @Test + @Test("Test Firebase ID Token") func testFirebaseIDToken() async throws { let token = FirebaseAuthIdentityToken( @@ -287,10 +287,10 @@ struct VendorTokenTests { try await collection.verify(jwt, as: FirebaseAuthIdentityToken.self) } } - - @Test + + @Test("Test Firebase ID Token that is not from Google (invalid issuer)") func testFirebaseIDTokenNotFromGoogle() async throws { - + let token = FirebaseAuthIdentityToken( issuer: "https://example.com", subject: "1234567890", @@ -316,8 +316,8 @@ struct VendorTokenTests { try await collection.verify(jwt, as: FirebaseAuthIdentityToken.self) } } - - @Test + + @Test("Test Firebase ID Token with subject claim that's too big") func testFirebaseIDTokenWithBigSubjectClaim() async throws { let token = FirebaseAuthIdentityToken( issuer: "https://securetoken.google.com/firprojectname-12345", From 4d26e2c4f58c40808ecda763baaf88b4c414ebd5 Mon Sep 17 00:00:00 2001 From: Petr Pavlik Date: Fri, 18 Oct 2024 18:12:45 +0200 Subject: [PATCH 6/6] feedback around whitespaces --- Sources/JWTKit/Vendor/FirebaseAuthIdentityToken.swift | 3 --- Tests/JWTKitTests/VendorTokenTests.swift | 2 -- 2 files changed, 5 deletions(-) diff --git a/Sources/JWTKit/Vendor/FirebaseAuthIdentityToken.swift b/Sources/JWTKit/Vendor/FirebaseAuthIdentityToken.swift index 7471dd71..efea3b54 100644 --- a/Sources/JWTKit/Vendor/FirebaseAuthIdentityToken.swift +++ b/Sources/JWTKit/Vendor/FirebaseAuthIdentityToken.swift @@ -5,10 +5,8 @@ #endif public struct FirebaseAuthIdentityToken: JWTPayload { - /// Additional Firebase-specific claims public struct Firebase: Codable, Sendable { - enum CodingKeys: String, CodingKey { case identities case signInProvider = "sign_in_provider" @@ -122,7 +120,6 @@ public struct FirebaseAuthIdentityToken: JWTPayload { } public func verify(using _: some JWTAlgorithm) throws { - guard let projectId = self.audience.value.first else { throw JWTError.claimVerificationFailure(failedClaim: audience, reason: "Token not provided by Firebase") } diff --git a/Tests/JWTKitTests/VendorTokenTests.swift b/Tests/JWTKitTests/VendorTokenTests.swift index c94837e9..2d7c5d20 100644 --- a/Tests/JWTKitTests/VendorTokenTests.swift +++ b/Tests/JWTKitTests/VendorTokenTests.swift @@ -265,7 +265,6 @@ struct VendorTokenTests { @Test("Test Firebase ID Token") func testFirebaseIDToken() async throws { - let token = FirebaseAuthIdentityToken( issuer: "https://securetoken.google.com/firprojectname-12345", subject: "1234567890", @@ -290,7 +289,6 @@ struct VendorTokenTests { @Test("Test Firebase ID Token that is not from Google (invalid issuer)") func testFirebaseIDTokenNotFromGoogle() async throws { - let token = FirebaseAuthIdentityToken( issuer: "https://example.com", subject: "1234567890",