Skip to content

Commit

Permalink
feat(auth): add MFA phone (#496)
Browse files Browse the repository at this point in the history
  • Loading branch information
grdsdev authored Aug 12, 2024
1 parent 42a2235 commit 2e445f2
Show file tree
Hide file tree
Showing 13 changed files with 199 additions and 23 deletions.
12 changes: 6 additions & 6 deletions Package.resolved
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
},
{
Expand All @@ -32,17 +32,17 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-snapshot-testing",
"state" : {
"revision" : "e883fc9ea51e76dc9ed13fd4a92b0ee258a1e8c9",
"version" : "1.17.3"
"revision" : "6d932a79e7173b275b96c600c86c603cf84f153c",
"version" : "1.17.4"
}
},
{
"identity" : "swift-syntax",
"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"
}
},
{
Expand Down
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"

0 comments on commit 2e445f2

Please sign in to comment.