Skip to content

feat(auth): add MFA phone #496

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

Merged
merged 1 commit into from
Aug 12, 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
12 changes: 6 additions & 6 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 7 additions & 3 deletions Sources/Auth/AuthMFA.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions Sources/Auth/Deprecated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,6 @@ extension AuthClient {
)
}
}

@available(*, deprecated, message: "Use MFATotpEnrollParams or MFAPhoneEnrollParams instead.")
public typealias MFAEnrollParams = MFATotpEnrollParams
57 changes: 52 additions & 5 deletions Sources/Auth/Types.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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?
Expand All @@ -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.
Expand All @@ -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
}
}

Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand Down
18 changes: 9 additions & 9 deletions Sources/Realtime/V2/RealtimeChannelV2.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
70 changes: 70 additions & 0 deletions Tests/AuthTests/RequestsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
@@ -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"
Loading