diff --git a/Package.resolved b/Package.resolved index 0e43ca0a..80fe2253 100644 --- a/Package.resolved +++ b/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-crypto.git", "state" : { - "revision" : "46072478ca365fe48370993833cb22de9b41567f", - "version" : "3.5.2" + "revision" : "8dafe0fdce623f65178689f2d6dea7304f7fbe75", + "version" : "3.6.0" } }, { @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-snapshot-testing", "state" : { - "revision" : "e883fc9ea51e76dc9ed13fd4a92b0ee258a1e8c9", - "version" : "1.17.3" + "revision" : "6d932a79e7173b275b96c600c86c603cf84f153c", + "version" : "1.17.4" } }, { @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "82a453c2dfa335c7e778695762438dfe72b328d2", - "version" : "600.0.0-prerelease-2024-07-24" + "revision" : "06b5cdc432e93b60e3bdf53aff2857c6b312991a", + "version" : "600.0.0-prerelease-2024-07-30" } }, { diff --git a/Sources/Auth/AuthMFA.swift b/Sources/Auth/AuthMFA.swift index 30fd86cb..701b3159 100644 --- a/Sources/Auth/AuthMFA.swift +++ b/Sources/Auth/AuthMFA.swift @@ -23,7 +23,7 @@ public struct AuthMFA: Sendable { /// /// - Parameter params: The parameters for enrolling a new MFA factor. /// - Returns: An authentication response after enrolling the factor. - public func enroll(params: MFAEnrollParams) async throws -> AuthMFAEnrollResponse { + public func enroll(params: any MFAEnrollParamsType) async throws -> AuthMFAEnrollResponse { try await api.authorizedExecute( HTTPRequest( url: configuration.url.appendingPathComponent("factors"), @@ -42,7 +42,8 @@ public struct AuthMFA: Sendable { try await api.authorizedExecute( HTTPRequest( url: configuration.url.appendingPathComponent("factors/\(params.factorId)/challenge"), - method: .post + method: .post, + body: params.channel == nil ? nil : encoder.encode(["channel": params.channel]) ) ) .decoded(decoder: decoder) @@ -112,7 +113,10 @@ public struct AuthMFA: Sendable { let totp = factors.filter { $0.factorType == "totp" && $0.status == .verified } - return AuthMFAListFactorsResponse(all: factors, totp: totp) + let phone = factors.filter { + $0.factorType == "phone" && $0.status == .verified + } + return AuthMFAListFactorsResponse(all: factors, totp: totp, phone: phone) } /// Returns the Authenticator Assurance Level (AAL) for the active session. diff --git a/Sources/Auth/Deprecated.swift b/Sources/Auth/Deprecated.swift index 46c1da90..ac7c1fca 100644 --- a/Sources/Auth/Deprecated.swift +++ b/Sources/Auth/Deprecated.swift @@ -126,3 +126,6 @@ extension AuthClient { ) } } + +@available(*, deprecated, message: "Use MFATotpEnrollParams or MFAPhoneEnrollParams instead.") +public typealias MFAEnrollParams = MFATotpEnrollParams diff --git a/Sources/Auth/Types.swift b/Sources/Auth/Types.swift index 2429d33a..8fac62ac 100644 --- a/Sources/Auth/Types.swift +++ b/Sources/Auth/Types.swift @@ -531,7 +531,7 @@ public struct Factor: Identifiable, Codable, Hashable, Sendable { /// Friendly name of the factor, useful to disambiguate between multiple factors. public let friendlyName: String? - /// Type of factor. Only `totp` supported with this version but may change in future versions. + /// Type of factor. `totp` and `phone` supported with this version. public let factorType: FactorType /// Factor's status. @@ -541,7 +541,11 @@ public struct Factor: Identifiable, Codable, Hashable, Sendable { public let updatedAt: Date } -public struct MFAEnrollParams: Encodable, Hashable, Sendable { +public protocol MFAEnrollParamsType: Encodable, Hashable, Sendable { + var factorType: FactorType { get } +} + +public struct MFATotpEnrollParams: MFAEnrollParamsType { public let factorType: FactorType = "totp" /// Domain which the user is enrolled with. public let issuer: String? @@ -554,16 +558,49 @@ public struct MFAEnrollParams: Encodable, Hashable, Sendable { } } +extension MFAEnrollParamsType where Self == MFATotpEnrollParams { + public static func totp(issuer: String? = nil, friendlyName: String? = nil) -> Self { + MFATotpEnrollParams(issuer: issuer, friendlyName: friendlyName) + } +} + +public struct MFAPhoneEnrollParams: MFAEnrollParamsType { + public let factorType: FactorType = "phone" + + /// Human readable name assigned to the factor. + public let friendlyName: String? + + /// Phone number to be enrolled. Number should conform to E.164 standard. + public let phone: String + + public init(friendlyName: String? = nil, phone: String) { + self.friendlyName = friendlyName + self.phone = phone + } +} + +extension MFAEnrollParamsType where Self == MFAPhoneEnrollParams { + public static func phone(friendlyName: String? = nil, phone: String) -> Self { + MFAPhoneEnrollParams(friendlyName: friendlyName, phone: phone) + } +} + public struct AuthMFAEnrollResponse: Decodable, Hashable, Sendable { /// ID of the factor that was just enrolled (in an unverified state). public let id: String - /// Type of MFA factor. Only `totp` supported for now. + /// Type of MFA factor. public let type: FactorType - /// TOTP enrollment information. + /// TOTP enrollment information. Available only if the ``type`` is `totp`. public var totp: TOTP? + /// Friendly name of the factor, useful to disambiguate between multiple factors. + public var friendlyName: String? + + /// Phone number of the MFA factor in E.164 format. Used to send messages. Available only if the ``type`` is `phone`. + public var phone: String? + public struct TOTP: Decodable, Hashable, Sendable { /// Contains a QR code encoding the authenticator URI. You can convert it to a URL by prepending /// `data:image/svg+xml;utf-8,` to the value. Avoid logging this value to the console. @@ -584,8 +621,12 @@ public struct MFAChallengeParams: Encodable, Hashable { /// ID of the factor to be challenged. Returned in ``AuthMFA/enroll(params:)``. public let factorId: String - public init(factorId: String) { + /// Messaging channel to use (e.g. `whatsapp` or `sms`). Only relevant for phone factors. + public let channel: MessagingChannel? + + public init(factorId: String, channel: MessagingChannel? = nil) { self.factorId = factorId + self.channel = channel } } @@ -632,6 +673,9 @@ public struct AuthMFAChallengeResponse: Decodable, Hashable, Sendable { /// ID of the newly created challenge. public let id: String + /// Factor type which generated the challenge. + public let type: FactorType + /// Timestamp in UNIX seconds when this challenge will no longer be usable. public let expiresAt: TimeInterval } @@ -649,6 +693,9 @@ public struct AuthMFAListFactorsResponse: Decodable, Hashable, Sendable { /// Only verified TOTP factors. (A subset of `all`.) public let totp: [Factor] + + /// Only verified phone factors. (A subset of `all`.) + public let phone: [Factor] } public typealias AuthenticatorAssuranceLevels = String diff --git a/Sources/Realtime/V2/RealtimeChannelV2.swift b/Sources/Realtime/V2/RealtimeChannelV2.swift index 73ec61f4..d9714bf2 100644 --- a/Sources/Realtime/V2/RealtimeChannelV2.swift +++ b/Sources/Realtime/V2/RealtimeChannelV2.swift @@ -12,16 +12,16 @@ import Helpers #if canImport(FoundationNetworking) import FoundationNetworking -extension HTTPURLResponse { - convenience init() { - self.init( - url: URL(string: "http://127.0.0.1")!, - statusCode: 200, - httpVersion: nil, - headerFields: nil - )! + extension HTTPURLResponse { + convenience init() { + self.init( + url: URL(string: "http://127.0.0.1")!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + } } -} #endif public struct RealtimeChannelConfig: Sendable { diff --git a/Tests/AuthTests/RequestsTests.swift b/Tests/AuthTests/RequestsTests.swift index 3ba5defc..ef7b0483 100644 --- a/Tests/AuthTests/RequestsTests.swift +++ b/Tests/AuthTests/RequestsTests.swift @@ -418,6 +418,76 @@ final class RequestsTests: XCTestCase { } } + func testMFAEnrollLegacy() async throws { + let sut = makeSUT() + + try Dependencies[sut.clientID].sessionStorage.store(.validSession) + + await assert { + _ = try await sut.mfa.enroll(params: MFAEnrollParams(issuer: "supabase.com", friendlyName: "test")) + } + } + + func testMFAEnrollTotp() async throws { + let sut = makeSUT() + + try Dependencies[sut.clientID].sessionStorage.store(.validSession) + + await assert { + _ = try await sut.mfa.enroll(params: .totp(issuer: "supabase.com", friendlyName: "test")) + } + } + + func testMFAEnrollPhone() async throws { + let sut = makeSUT() + + try Dependencies[sut.clientID].sessionStorage.store(.validSession) + + await assert { + _ = try await sut.mfa.enroll(params: .phone(friendlyName: "test", phone: "+1 202-918-2132")) + } + } + + func testMFAChallenge() async throws { + let sut = makeSUT() + + try Dependencies[sut.clientID].sessionStorage.store(.validSession) + + await assert { + _ = try await sut.mfa.challenge(params: .init(factorId: "123")) + } + } + + func testMFAChallengePhone() async throws { + let sut = makeSUT() + + try Dependencies[sut.clientID].sessionStorage.store(.validSession) + + await assert { + _ = try await sut.mfa.challenge(params: .init(factorId: "123", channel: .whatsapp)) + } + } + + func testMFAVerify() async throws { + let sut = makeSUT() + + try Dependencies[sut.clientID].sessionStorage.store(.validSession) + + await assert { + _ = try await sut.mfa.verify(params: .init(factorId: "123", challengeId: "123", code: "123456")) + } + } + + func testMFAUnenroll() async throws { + let sut = makeSUT() + + try Dependencies[sut.clientID].sessionStorage.store(.validSession) + + await assert { + _ = try await sut.mfa.unenroll(params: .init(factorId: "123")) + } + } + private func assert(_ block: () async throws -> Void) async { do { try await block() diff --git a/Tests/AuthTests/__Snapshots__/RequestsTests/testMFAChallenge.1.txt b/Tests/AuthTests/__Snapshots__/RequestsTests/testMFAChallenge.1.txt new file mode 100644 index 00000000..da75a782 --- /dev/null +++ b/Tests/AuthTests/__Snapshots__/RequestsTests/testMFAChallenge.1.txt @@ -0,0 +1,6 @@ +curl \ + --request POST \ + --header "Apikey: dummy.api.key" \ + --header "Authorization: Bearer accesstoken" \ + --header "X-Client-Info: gotrue-swift/x.y.z" \ + "http://localhost:54321/auth/v1/factors/123/challenge" \ No newline at end of file diff --git a/Tests/AuthTests/__Snapshots__/RequestsTests/testMFAChallengePhone.1.txt b/Tests/AuthTests/__Snapshots__/RequestsTests/testMFAChallengePhone.1.txt new file mode 100644 index 00000000..d41a5107 --- /dev/null +++ b/Tests/AuthTests/__Snapshots__/RequestsTests/testMFAChallengePhone.1.txt @@ -0,0 +1,8 @@ +curl \ + --request POST \ + --header "Apikey: dummy.api.key" \ + --header "Authorization: Bearer accesstoken" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: gotrue-swift/x.y.z" \ + --data "{\"channel\":\"whatsapp\"}" \ + "http://localhost:54321/auth/v1/factors/123/challenge" \ No newline at end of file diff --git a/Tests/AuthTests/__Snapshots__/RequestsTests/testMFAEnrollLegacy.1.txt b/Tests/AuthTests/__Snapshots__/RequestsTests/testMFAEnrollLegacy.1.txt new file mode 100644 index 00000000..9a6d6aff --- /dev/null +++ b/Tests/AuthTests/__Snapshots__/RequestsTests/testMFAEnrollLegacy.1.txt @@ -0,0 +1,8 @@ +curl \ + --request POST \ + --header "Apikey: dummy.api.key" \ + --header "Authorization: Bearer accesstoken" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: gotrue-swift/x.y.z" \ + --data "{\"factor_type\":\"totp\",\"friendly_name\":\"test\",\"issuer\":\"supabase.com\"}" \ + "http://localhost:54321/auth/v1/factors" \ No newline at end of file diff --git a/Tests/AuthTests/__Snapshots__/RequestsTests/testMFAEnrollPhone.1.txt b/Tests/AuthTests/__Snapshots__/RequestsTests/testMFAEnrollPhone.1.txt new file mode 100644 index 00000000..a23176e6 --- /dev/null +++ b/Tests/AuthTests/__Snapshots__/RequestsTests/testMFAEnrollPhone.1.txt @@ -0,0 +1,8 @@ +curl \ + --request POST \ + --header "Apikey: dummy.api.key" \ + --header "Authorization: Bearer accesstoken" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: gotrue-swift/x.y.z" \ + --data "{\"factor_type\":\"phone\",\"friendly_name\":\"test\",\"phone\":\"+1 202-918-2132\"}" \ + "http://localhost:54321/auth/v1/factors" \ No newline at end of file diff --git a/Tests/AuthTests/__Snapshots__/RequestsTests/testMFAEnrollTotp.1.txt b/Tests/AuthTests/__Snapshots__/RequestsTests/testMFAEnrollTotp.1.txt new file mode 100644 index 00000000..9a6d6aff --- /dev/null +++ b/Tests/AuthTests/__Snapshots__/RequestsTests/testMFAEnrollTotp.1.txt @@ -0,0 +1,8 @@ +curl \ + --request POST \ + --header "Apikey: dummy.api.key" \ + --header "Authorization: Bearer accesstoken" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: gotrue-swift/x.y.z" \ + --data "{\"factor_type\":\"totp\",\"friendly_name\":\"test\",\"issuer\":\"supabase.com\"}" \ + "http://localhost:54321/auth/v1/factors" \ No newline at end of file diff --git a/Tests/AuthTests/__Snapshots__/RequestsTests/testMFAUnenroll.1.txt b/Tests/AuthTests/__Snapshots__/RequestsTests/testMFAUnenroll.1.txt new file mode 100644 index 00000000..b3f057ce --- /dev/null +++ b/Tests/AuthTests/__Snapshots__/RequestsTests/testMFAUnenroll.1.txt @@ -0,0 +1,6 @@ +curl \ + --request DELETE \ + --header "Apikey: dummy.api.key" \ + --header "Authorization: Bearer accesstoken" \ + --header "X-Client-Info: gotrue-swift/x.y.z" \ + "http://localhost:54321/auth/v1/factors/123" \ No newline at end of file diff --git a/Tests/AuthTests/__Snapshots__/RequestsTests/testMFAVerify.1.txt b/Tests/AuthTests/__Snapshots__/RequestsTests/testMFAVerify.1.txt new file mode 100644 index 00000000..f4db83cc --- /dev/null +++ b/Tests/AuthTests/__Snapshots__/RequestsTests/testMFAVerify.1.txt @@ -0,0 +1,8 @@ +curl \ + --request POST \ + --header "Apikey: dummy.api.key" \ + --header "Authorization: Bearer accesstoken" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: gotrue-swift/x.y.z" \ + --data "{\"challenge_id\":\"123\",\"code\":\"123456\",\"factor_id\":\"123\"}" \ + "http://localhost:54321/auth/v1/factors/123/verify" \ No newline at end of file