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

Add FirebaseAuth identity token #207

Merged
merged 6 commits into from
Oct 18, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
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: "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("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: "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("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: "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)
}
}
}
Loading