Skip to content

Commit

Permalink
Add FirebaseAuth identity token (#207)
Browse files Browse the repository at this point in the history
* Add FirebaseIdentityToken

* tests and fixes

* Refactor FirebaseIdentityToken struct and add support for custom claims

* fix

* swift format and give vendor tests human readable names

* feedback around whitespaces
  • Loading branch information
petrpavlik authored Oct 18, 2024
1 parent b0b58ec commit 02a0fa6
Show file tree
Hide file tree
Showing 2 changed files with 225 additions and 0 deletions.
137 changes: 137 additions & 0 deletions Sources/JWTKit/Vendor/FirebaseAuthIdentityToken.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
#if !canImport(Darwin)
import FoundationEssentials
#else
import Foundation
#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"
case signInSecondFactor = "sign_in_second_factor"
case secondFactorIdentifier = "second_factor_identifier"
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?
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/<projectId>", where <projectId> 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
) {
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 {
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")
}

guard self.subject.value.count <= 255 else {
throw JWTError.claimVerificationFailure(failedClaim: subject, reason: "Subject claim beyond 255 ASCII characters long.")
}

try self.expires.verifyNotExpired()
}
}
88 changes: 88 additions & 0 deletions Tests/JWTKitTests/VendorTokenTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ struct VendorTokenTests {
}
}

@Test("Test Google ID Token that is not from Google (invalid issuer)")
func testGoogleIDTokenNotFromGoogle() async throws {
let token = GoogleIdentityToken(
issuer: "https://example.com",
Expand Down Expand Up @@ -72,6 +73,7 @@ struct VendorTokenTests {
}
}

@Test("Test Google ID Token with subject claim that's too big")
func testGoogleIDTokenWithBigSubjectClaim() async throws {
let token = GoogleIdentityToken(
issuer: "https://accounts.google.com",
Expand Down Expand Up @@ -106,6 +108,7 @@ struct VendorTokenTests {
}
}

@Test("Test Apple ID Token")
func testAppleIDToken() async throws {
let token = AppleIdentityToken(
issuer: "https://appleid.apple.com",
Expand All @@ -130,6 +133,7 @@ struct VendorTokenTests {
}
}

@Test("Test Apple ID Token that is not from Apple (invalid issuer)")
func testAppleIDTokenNotFromApple() async throws {
let token = AppleIdentityToken(
issuer: "https://example.com",
Expand Down Expand Up @@ -158,6 +162,7 @@ struct VendorTokenTests {
}
}

@Test("Test Microsoft ID Token")
func testMicrosoftIDToken() async throws {
let tenantID = "some-id"

Expand Down Expand Up @@ -190,6 +195,7 @@ struct VendorTokenTests {
}
}

@Test("Test Microsoft ID Token that is not from Microsoft (invalid issuer)")
func testMicrosoftIDTokenNotFromMicrosoft() async throws {
let token = MicrosoftIdentityToken(
audience: "your-app-client-id",
Expand Down Expand Up @@ -222,6 +228,7 @@ struct VendorTokenTests {
}
}

@Test("Test Microsoft ID Token with missing tenant ID claim")
func testMicrosoftIDTokenWithMissingTenantIDClaim() async throws {
let token = MicrosoftIdentityToken(
audience: "your-app-client-id",
Expand Down Expand Up @@ -255,4 +262,85 @@ struct VendorTokenTests {
try await collection.verify(jwt, as: MicrosoftIdentityToken.self)
}
}

@Test("Test Firebase ID Token")
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: "[email protected]",
emailVerified: true,
phoneNumber: nil,
name: "John Doe",
picture: "https://example.com/johndoe.png",
firebase: .init(identities: ["google.com": ["9876543210"], "email": ["[email protected]"]], 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("Test Firebase ID Token that is not from Google (invalid issuer)")
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: "[email protected]",
emailVerified: true,
phoneNumber: nil,
name: "John Doe",
picture: "https://example.com/johndoe.png",
firebase: .init(identities: ["google.com": ["9876543210"], "email": ["[email protected]"]], 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("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",
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: "[email protected]",
emailVerified: true,
phoneNumber: nil,
name: "John Doe",
picture: "https://example.com/johndoe.png",
firebase: .init(identities: ["google.com": ["9876543210"], "email": ["[email protected]"]], 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)
}
}
}

0 comments on commit 02a0fa6

Please sign in to comment.