-
-
Notifications
You must be signed in to change notification settings - Fork 54
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add FirebaseAuth identity token (#207)
* 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
1 parent
b0b58ec
commit 02a0fa6
Showing
2 changed files
with
225 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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", | ||
|
@@ -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", | ||
|
@@ -106,6 +108,7 @@ struct VendorTokenTests { | |
} | ||
} | ||
|
||
@Test("Test Apple ID Token") | ||
func testAppleIDToken() async throws { | ||
let token = AppleIdentityToken( | ||
issuer: "https://appleid.apple.com", | ||
|
@@ -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", | ||
|
@@ -158,6 +162,7 @@ struct VendorTokenTests { | |
} | ||
} | ||
|
||
@Test("Test Microsoft ID Token") | ||
func testMicrosoftIDToken() async throws { | ||
let tenantID = "some-id" | ||
|
||
|
@@ -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", | ||
|
@@ -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", | ||
|
@@ -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) | ||
} | ||
} | ||
} |