Repository: vapor/jwt-kit (swift)

Issue:
Can't create `GoogleIdentityToken` without a Decoder
**Is your feature request related to a problem? Please describe.**
Often times we provide properties of `GoogleIdentityToken` manually, as we get them through Google Docs etc, not from a JSON body. But because it extends `Decodable`, the automatic initializer isn't available, so the only way to create it (AFAIK) is to create a custom `Decodable` object, which is awkward and bad in performance. We can't even extend it with our own initializer because it contains `let` properties.

**Describe the solution you'd like**
An initializer could be added manually, if no less-boilerplate solution is found.

**Describe alternatives you've considered**
`GoogleIdentityTokenFactory` object (with the same properties as `GoogleIdentityToken`) that doesn't conform to `Decodable` (thus has the automatic memberwise initializer) and has a method that returns `GoogleIdentityToken` (or `GoogleIdentityToken` could have an initializer taking `GoogleIdentityTokenFactory`, for discoverability). I think this is overengineering it though, and doesn't provide less boilerplate.

**Additional context**
I may be missing something, because this looks like a very commonly used thing.

Pull request:
Fixes #131. Also adds tests to the vendor token payloads to get some coverage up

Patch:
diff --git a/Sources/JWTKit/Claims/TenantIDClaim.swift b/Sources/JWTKit/Claims/TenantIDClaim.swift
index 6421886e..1a8ab42b 100644
--- a/Sources/JWTKit/Claims/TenantIDClaim.swift
+++ b/Sources/JWTKit/Claims/TenantIDClaim.swift
@@ -7,12 +7,20 @@
 /// logging or auditing the tenant context of the authenticated user. The value of "tid" is a
 /// case-sensitive string representing a GUID. The presence of this claim and its proper validation
 /// are critical for the security of multi-tenant applications.
-public struct TenantIDClaim: JWTClaim, Equatable {
-    /// See ``JWTClaim``.
+public struct TenantIDClaim: JWTClaim, Equatable, ExpressibleByStringLiteral, ExpressibleByNilLiteral {
+    // See `JWTClaim.value`.
     public var value: String?
 
-    /// See ``JWTClaim``.
+    // See `JWTClaim.init(value:)`.
     public init(value: String?) {
         self.value = value
     }
+
+    public init(stringLiteral value: String) {
+        self.init(value: value)
+    }
+
+    public init(nilLiteral: ()) {
+        self.init(value: nil)
+    }
 }
diff --git a/Sources/JWTKit/Vendor/AppleIdentityToken.swift b/Sources/JWTKit/Vendor/AppleIdentityToken.swift
index bace6044..51414693 100644
--- a/Sources/JWTKit/Vendor/AppleIdentityToken.swift
+++ b/Sources/JWTKit/Vendor/AppleIdentityToken.swift
@@ -54,6 +54,34 @@ public struct AppleIdentityToken: JWTPayload {
     /// A value that indicates whether the user appears to be a real person.
     public let realUserStatus: UserDetectionStatus?
 
+    public init(
+        issuer: IssuerClaim,
+        audience: AudienceClaim,
+        expires: ExpirationClaim,
+        issuedAt: IssuedAtClaim,
+        subject: SubjectClaim,
+        nonceSupported: BoolClaim? = nil,
+        nonce: String? = nil,
+        email: String? = nil,
+        orgId: String? = nil,
+        emailVerified: BoolClaim? = nil,
+        isPrivateEmail: BoolClaim? = nil,
+        realUserStatus: UserDetectionStatus? = nil
+    ) {
+        self.issuer = issuer
+        self.audience = audience
+        self.expires = expires
+        self.issuedAt = issuedAt
+        self.subject = subject
+        self.nonceSupported = nonceSupported
+        self.nonce = nonce
+        self.email = email
+        self.orgId = orgId
+        self.emailVerified = emailVerified
+        self.isPrivateEmail = isPrivateEmail
+        self.realUserStatus = realUserStatus
+    }
+
     public func verify(using _: JWTAlgorithm) throws {
         guard self.issuer.value == "https://appleid.apple.com" else {
             throw JWTError.claimVerificationFailure(failedClaim: issuer, reason: "Token not provided by Apple")
diff --git a/Sources/JWTKit/Vendor/GoogleIdentityToken.swift b/Sources/JWTKit/Vendor/GoogleIdentityToken.swift
index 0aec1bee..f51d1910 100644
--- a/Sources/JWTKit/Vendor/GoogleIdentityToken.swift
+++ b/Sources/JWTKit/Vendor/GoogleIdentityToken.swift
@@ -98,6 +98,44 @@ public struct GoogleIdentityToken: JWTPayload {
     /// The value of the nonce supplied by your app in the authentication request. You should enforce protection against replay attacks by ensuring it is presented only once.
     public let nonce: String?
 
+    public init(
+        issuer: IssuerClaim,
+        subject: SubjectClaim,
+        audience: AudienceClaim,
+        authorizedPresenter: String,
+        issuedAt: IssuedAtClaim,
+        expires: ExpirationClaim,
+        atHash: String? = nil,
+        hostedDomain: GoogleHostedDomainClaim? = nil,
+        email: String? = nil,
+        emailVerified: BoolClaim? = nil,
+        name: String? = nil,
+        picture: String? = nil,
+        profile: String? = nil,
+        givenName: String? = nil,
+        familyName: String? = nil,
+        locale: LocaleClaim? = nil,
+        nonce: String? = nil
+    ) {
+        self.issuer = issuer
+        self.subject = subject
+        self.audience = audience
+        self.authorizedPresenter = authorizedPresenter
+        self.issuedAt = issuedAt
+        self.expires = expires
+        self.atHash = atHash
+        self.hostedDomain = hostedDomain
+        self.email = email
+        self.emailVerified = emailVerified
+        self.name = name
+        self.picture = picture
+        self.profile = profile
+        self.givenName = givenName
+        self.familyName = familyName
+        self.locale = locale
+        self.nonce = nonce
+    }
+
     public func verify(using _: JWTAlgorithm) throws {
         guard ["accounts.google.com", "https://accounts.google.com"].contains(self.issuer.value) else {
             throw JWTError.claimVerificationFailure(failedClaim: issuer, reason: "Token not provided by Google")
diff --git a/Sources/JWTKit/Vendor/MicrosoftIdentityToken.swift b/Sources/JWTKit/Vendor/MicrosoftIdentityToken.swift
index 63ab936e..50489e1a 100644
--- a/Sources/JWTKit/Vendor/MicrosoftIdentityToken.swift
+++ b/Sources/JWTKit/Vendor/MicrosoftIdentityToken.swift
@@ -106,6 +106,46 @@ public struct MicrosoftIdentityToken: JWTPayload {
     /// Indicates the version of the id_token.
     public let version: String?
 
+    public init(
+        audience: AudienceClaim,
+        issuer: IssuerClaim,
+        issuedAt: IssuedAtClaim,
+        identityProvider: String?,
+        notBefore: NotBeforeClaim,
+        expires: ExpirationClaim,
+        codeHash: String?,
+        accessTokenHash: String?,
+        preferredUsername: String?,
+        email: String?,
+        name: String?,
+        nonce: String?,
+        objectId: String,
+        roles: [String]?,
+        subject: SubjectClaim,
+        tenantId: TenantIDClaim,
+        uniqueName: String?,
+        version: String?
+    ) {
+        self.audience = audience
+        self.issuer = issuer
+        self.issuedAt = issuedAt
+        self.identityProvider = identityProvider
+        self.notBefore = notBefore
+        self.expires = expires
+        self.codeHash = codeHash
+        self.accessTokenHash = accessTokenHash
+        self.preferredUsername = preferredUsername
+        self.email = email
+        self.name = name
+        self.nonce = nonce
+        self.objectId = objectId
+        self.roles = roles
+        self.subject = subject
+        self.tenantId = tenantId
+        self.uniqueName = uniqueName
+        self.version = version
+    }
+
     public func verify(using _: JWTAlgorithm) throws {
         guard let tenantId = self.tenantId.value else {
             throw JWTError.claimVerificationFailure(failedClaim: tenantId, reason: "Token must contain tenant Id")


Test patch:
diff --git a/Tests/JWTKitTests/VendorTokenTests.swift b/Tests/JWTKitTests/VendorTokenTests.swift
new file mode 100644
index 00000000..0c1c953c
--- /dev/null
+++ b/Tests/JWTKitTests/VendorTokenTests.swift
@@ -0,0 +1,245 @@
+import JWTKit
+import XCTest
+
+final class VendorTokenTests: XCTestCase {
+    func testGoogleIDToken() async throws {
+        let token = GoogleIdentityToken(
+            issuer: "https://accounts.google.com",
+            subject: "1234567890",
+            audience: "your-client-id.apps.googleusercontent.com",
+            authorizedPresenter: "another-client-id.apps.googleusercontent.com",
+            issuedAt: .init(value: .now),
+            expires: .init(value: .now + 3600),
+            atHash: "XYZ123",
+            hostedDomain: "example.com",
+            email: "user@example.com",
+            emailVerified: true,
+            name: "John Doe",
+            picture: "https://example.com/johndoe.png",
+            profile: "https://example.com/johndoe",
+            givenName: "John",
+            familyName: "Doe",
+            locale: "en",
+            nonce: "nonceValue"
+        )
+
+        let collection = await JWTKeyCollection().addHS256(key: "secret")
+        let jwt = try await collection.sign(token)
+
+        await XCTAssertNoThrowAsync(try await collection.verify(jwt, as: GoogleIdentityToken.self))
+    }
+
+    func testGoogleIDTokenNotFromGoogle() async throws {
+        let token = GoogleIdentityToken(
+            issuer: "https://example.com",
+            subject: "1234567890",
+            audience: "your-client-id.apps.googleusercontent.com",
+            authorizedPresenter: "another-client-id.apps.googleusercontent.com",
+            issuedAt: .init(value: .now),
+            expires: .init(value: .now + 3600),
+            atHash: "XYZ123",
+            hostedDomain: "example.com",
+            email: "user@example.com",
+            emailVerified: true,
+            name: "John Doe",
+            picture: "https://example.com/johndoe.png",
+            profile: "https://example.com/johndoe",
+            givenName: "John",
+            familyName: "Doe",
+            locale: "en",
+            nonce: "nonceValue"
+        )
+
+        let collection = await JWTKeyCollection().addHS256(key: "secret")
+        let jwt = try await collection.sign(token)
+
+        await XCTAssertThrowsErrorAsync(try await collection.verify(jwt, as: GoogleIdentityToken.self)) { error in
+            guard let error = error as? JWTError else {
+                return XCTFail("Unexpected error: \(error)")
+            }
+            XCTAssertEqual(error.errorType, .claimVerificationFailure)
+            XCTAssertEqual(error.reason, "Token not provided by Google")
+        }
+    }
+
+    func testGoogleIDTokenWithBigSubjectClaim() async throws {
+        let token = GoogleIdentityToken(
+            issuer: "https://accounts.google.com",
+            subject: .init(stringLiteral: String(repeating: "A", count: 1000)),
+            audience: "your-client-id.apps.googleusercontent.com",
+            authorizedPresenter: "another-client-id.apps.googleusercontent.com",
+            issuedAt: .init(value: .now),
+            expires: .init(value: .now + 3600),
+            atHash: "XYZ123",
+            hostedDomain: "example.com",
+            email: "user@example.com",
+            emailVerified: true,
+            name: "John Doe",
+            picture: "https://example.com/johndoe.png",
+            profile: "https://example.com/johndoe",
+            givenName: "John",
+            familyName: "Doe",
+            locale: "en",
+            nonce: "nonceValue"
+        )
+
+        let collection = await JWTKeyCollection().addHS256(key: "secret")
+        let jwt = try await collection.sign(token)
+
+        await XCTAssertThrowsErrorAsync(try await collection.verify(jwt, as: GoogleIdentityToken.self)) { error in
+            guard let error = error as? JWTError else {
+                return XCTFail("Unexpected error: \(error)")
+            }
+            XCTAssertEqual(error.errorType, .claimVerificationFailure)
+            XCTAssertEqual(error.reason, "Subject claim beyond 255 ASCII characters long.")
+        }
+    }
+
+    func testAppleIDToken() async throws {
+        let token = AppleIdentityToken(
+            issuer: "https://appleid.apple.com",
+            audience: "your-client-id",
+            expires: .init(value: .now + 3600),
+            issuedAt: .init(value: .now),
+            subject: "user1234567890",
+            nonceSupported: true,
+            nonce: "nonce123",
+            email: "user@example.com",
+            orgId: "org123",
+            emailVerified: true,
+            isPrivateEmail: false,
+            realUserStatus: .likelyReal
+        )
+
+        let collection = await JWTKeyCollection().addHS256(key: "secret")
+        let jwt = try await collection.sign(token)
+
+        await XCTAssertNoThrowAsync(try await collection.verify(jwt, as: AppleIdentityToken.self))
+    }
+
+    func testAppleIDTokenNotFromApple() async throws {
+        let token = AppleIdentityToken(
+            issuer: "https://example.com",
+            audience: "your-client-id",
+            expires: .init(value: .now + 3600),
+            issuedAt: .init(value: .now),
+            subject: "user1234567890",
+            nonceSupported: true,
+            nonce: "nonce123",
+            email: "user@example.com",
+            orgId: "org123",
+            emailVerified: true,
+            isPrivateEmail: false,
+            realUserStatus: .likelyReal
+        )
+
+        let collection = await JWTKeyCollection().addHS256(key: "secret")
+        let jwt = try await collection.sign(token)
+
+        await XCTAssertThrowsErrorAsync(try await collection.verify(jwt, as: AppleIdentityToken.self)) { error in
+            guard let error = error as? JWTError else {
+                return XCTFail("Unexpected error: \(error)")
+            }
+            XCTAssertEqual(error.errorType, .claimVerificationFailure)
+            XCTAssertEqual(error.reason, "Token not provided by Apple")
+        }
+    }
+
+    func testMicrosoftIDToken() async throws {
+        let tenantID = "some-id"
+
+        let token = MicrosoftIdentityToken(
+            audience: "your-app-client-id",
+            issuer: .init(value: "https://login.microsoftonline.com/\(tenantID)/v2.0"),
+            issuedAt: .init(value: .now),
+            identityProvider: "https://login.microsoftonline.com/\(tenantID)/v2.0",
+            notBefore: .init(value: .now),
+            expires: .init(value: .now + 3600),
+            codeHash: "codeHashValue",
+            accessTokenHash: "accessTokenHashValue",
+            preferredUsername: "user@example.com",
+            email: "user@example.com",
+            name: "John Doe",
+            nonce: "nonceValue",
+            objectId: "objectIdValue",
+            roles: ["user", "admin"],
+            subject: "subjectValue",
+            tenantId: .init(value: tenantID),
+            uniqueName: "uniqueNameValue",
+            version: "2.0"
+        )
+
+        let collection = await JWTKeyCollection().addHS256(key: "secret")
+        let jwt = try await collection.sign(token)
+
+        await XCTAssertNoThrowAsync(try await collection.verify(jwt, as: MicrosoftIdentityToken.self))
+    }
+
+    func testMicrosoftIDTokenNotFromMicrosoft() async throws {
+        let token = MicrosoftIdentityToken(
+            audience: "your-app-client-id",
+            issuer: "https://example.com",
+            issuedAt: .init(value: .now),
+            identityProvider: "https://login.microsoftonline.com/{tenantId}/v2.0",
+            notBefore: .init(value: .now),
+            expires: .init(value: .now + 3600),
+            codeHash: "codeHashValue",
+            accessTokenHash: "accessTokenHashValue",
+            preferredUsername: "user@example.com",
+            email: "user@example.com",
+            name: "John Doe",
+            nonce: "nonceValue",
+            objectId: "objectIdValue",
+            roles: ["user", "admin"],
+            subject: "subjectValue",
+            tenantId: "tenantIdValue",
+            uniqueName: "uniqueNameValue",
+            version: "2.0"
+        )
+
+        let collection = await JWTKeyCollection().addHS256(key: "secret")
+        let jwt = try await collection.sign(token)
+
+        await XCTAssertThrowsErrorAsync(try await collection.verify(jwt, as: MicrosoftIdentityToken.self)) { error in
+            guard let error = error as? JWTError else {
+                return XCTFail("Unexpected error: \(error)")
+            }
+            XCTAssertEqual(error.errorType, .claimVerificationFailure)
+            XCTAssertEqual(error.reason, "Token not provided by Microsoft")
+        }
+    }
+
+    func testMicrosoftIDTokenWithMissingTenantIDClaim() async throws {
+        let token = MicrosoftIdentityToken(
+            audience: "your-app-client-id",
+            issuer: "https://example.com",
+            issuedAt: .init(value: .now),
+            identityProvider: "https://login.microsoftonline.com/{tenantId}/v2.0",
+            notBefore: .init(value: .now),
+            expires: .init(value: .now + 3600),
+            codeHash: "codeHashValue",
+            accessTokenHash: "accessTokenHashValue",
+            preferredUsername: "user@example.com",
+            email: "user@example.com",
+            name: "John Doe",
+            nonce: "nonceValue",
+            objectId: "objectIdValue",
+            roles: ["user", "admin"],
+            subject: "subjectValue",
+            tenantId: nil,
+            uniqueName: "uniqueNameValue",
+            version: "2.0"
+        )
+
+        let collection = await JWTKeyCollection().addHS256(key: "secret")
+        let jwt = try await collection.sign(token)
+
+        await XCTAssertThrowsErrorAsync(try await collection.verify(jwt, as: MicrosoftIdentityToken.self)) { error in
+            guard let error = error as? JWTError else {
+                return XCTFail("Unexpected error: \(error)")
+            }
+            XCTAssertEqual(error.errorType, .claimVerificationFailure)
+            XCTAssertEqual(error.reason, "Token must contain tenant Id")
+        }
+    }
+}