Skip to content

Commit 2e445f2

Browse files
authored
feat(auth): add MFA phone (#496)
1 parent 42a2235 commit 2e445f2

13 files changed

+199
-23
lines changed

Package.resolved

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Sources/Auth/AuthMFA.swift

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ public struct AuthMFA: Sendable {
2323
///
2424
/// - Parameter params: The parameters for enrolling a new MFA factor.
2525
/// - Returns: An authentication response after enrolling the factor.
26-
public func enroll(params: MFAEnrollParams) async throws -> AuthMFAEnrollResponse {
26+
public func enroll(params: any MFAEnrollParamsType) async throws -> AuthMFAEnrollResponse {
2727
try await api.authorizedExecute(
2828
HTTPRequest(
2929
url: configuration.url.appendingPathComponent("factors"),
@@ -42,7 +42,8 @@ public struct AuthMFA: Sendable {
4242
try await api.authorizedExecute(
4343
HTTPRequest(
4444
url: configuration.url.appendingPathComponent("factors/\(params.factorId)/challenge"),
45-
method: .post
45+
method: .post,
46+
body: params.channel == nil ? nil : encoder.encode(["channel": params.channel])
4647
)
4748
)
4849
.decoded(decoder: decoder)
@@ -112,7 +113,10 @@ public struct AuthMFA: Sendable {
112113
let totp = factors.filter {
113114
$0.factorType == "totp" && $0.status == .verified
114115
}
115-
return AuthMFAListFactorsResponse(all: factors, totp: totp)
116+
let phone = factors.filter {
117+
$0.factorType == "phone" && $0.status == .verified
118+
}
119+
return AuthMFAListFactorsResponse(all: factors, totp: totp, phone: phone)
116120
}
117121

118122
/// Returns the Authenticator Assurance Level (AAL) for the active session.

Sources/Auth/Deprecated.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,6 @@ extension AuthClient {
126126
)
127127
}
128128
}
129+
130+
@available(*, deprecated, message: "Use MFATotpEnrollParams or MFAPhoneEnrollParams instead.")
131+
public typealias MFAEnrollParams = MFATotpEnrollParams

Sources/Auth/Types.swift

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -531,7 +531,7 @@ public struct Factor: Identifiable, Codable, Hashable, Sendable {
531531
/// Friendly name of the factor, useful to disambiguate between multiple factors.
532532
public let friendlyName: String?
533533

534-
/// Type of factor. Only `totp` supported with this version but may change in future versions.
534+
/// Type of factor. `totp` and `phone` supported with this version.
535535
public let factorType: FactorType
536536

537537
/// Factor's status.
@@ -541,7 +541,11 @@ public struct Factor: Identifiable, Codable, Hashable, Sendable {
541541
public let updatedAt: Date
542542
}
543543

544-
public struct MFAEnrollParams: Encodable, Hashable, Sendable {
544+
public protocol MFAEnrollParamsType: Encodable, Hashable, Sendable {
545+
var factorType: FactorType { get }
546+
}
547+
548+
public struct MFATotpEnrollParams: MFAEnrollParamsType {
545549
public let factorType: FactorType = "totp"
546550
/// Domain which the user is enrolled with.
547551
public let issuer: String?
@@ -554,16 +558,49 @@ public struct MFAEnrollParams: Encodable, Hashable, Sendable {
554558
}
555559
}
556560

561+
extension MFAEnrollParamsType where Self == MFATotpEnrollParams {
562+
public static func totp(issuer: String? = nil, friendlyName: String? = nil) -> Self {
563+
MFATotpEnrollParams(issuer: issuer, friendlyName: friendlyName)
564+
}
565+
}
566+
567+
public struct MFAPhoneEnrollParams: MFAEnrollParamsType {
568+
public let factorType: FactorType = "phone"
569+
570+
/// Human readable name assigned to the factor.
571+
public let friendlyName: String?
572+
573+
/// Phone number to be enrolled. Number should conform to E.164 standard.
574+
public let phone: String
575+
576+
public init(friendlyName: String? = nil, phone: String) {
577+
self.friendlyName = friendlyName
578+
self.phone = phone
579+
}
580+
}
581+
582+
extension MFAEnrollParamsType where Self == MFAPhoneEnrollParams {
583+
public static func phone(friendlyName: String? = nil, phone: String) -> Self {
584+
MFAPhoneEnrollParams(friendlyName: friendlyName, phone: phone)
585+
}
586+
}
587+
557588
public struct AuthMFAEnrollResponse: Decodable, Hashable, Sendable {
558589
/// ID of the factor that was just enrolled (in an unverified state).
559590
public let id: String
560591

561-
/// Type of MFA factor. Only `totp` supported for now.
592+
/// Type of MFA factor.
562593
public let type: FactorType
563594

564-
/// TOTP enrollment information.
595+
/// TOTP enrollment information. Available only if the ``type`` is `totp`.
565596
public var totp: TOTP?
566597

598+
/// Friendly name of the factor, useful to disambiguate between multiple factors.
599+
public var friendlyName: String?
600+
601+
/// Phone number of the MFA factor in E.164 format. Used to send messages. Available only if the ``type`` is `phone`.
602+
public var phone: String?
603+
567604
public struct TOTP: Decodable, Hashable, Sendable {
568605
/// Contains a QR code encoding the authenticator URI. You can convert it to a URL by prepending
569606
/// `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 {
584621
/// ID of the factor to be challenged. Returned in ``AuthMFA/enroll(params:)``.
585622
public let factorId: String
586623

587-
public init(factorId: String) {
624+
/// Messaging channel to use (e.g. `whatsapp` or `sms`). Only relevant for phone factors.
625+
public let channel: MessagingChannel?
626+
627+
public init(factorId: String, channel: MessagingChannel? = nil) {
588628
self.factorId = factorId
629+
self.channel = channel
589630
}
590631
}
591632

@@ -632,6 +673,9 @@ public struct AuthMFAChallengeResponse: Decodable, Hashable, Sendable {
632673
/// ID of the newly created challenge.
633674
public let id: String
634675

676+
/// Factor type which generated the challenge.
677+
public let type: FactorType
678+
635679
/// Timestamp in UNIX seconds when this challenge will no longer be usable.
636680
public let expiresAt: TimeInterval
637681
}
@@ -649,6 +693,9 @@ public struct AuthMFAListFactorsResponse: Decodable, Hashable, Sendable {
649693

650694
/// Only verified TOTP factors. (A subset of `all`.)
651695
public let totp: [Factor]
696+
697+
/// Only verified phone factors. (A subset of `all`.)
698+
public let phone: [Factor]
652699
}
653700

654701
public typealias AuthenticatorAssuranceLevels = String

Sources/Realtime/V2/RealtimeChannelV2.swift

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,16 @@ import Helpers
1212
#if canImport(FoundationNetworking)
1313
import FoundationNetworking
1414

15-
extension HTTPURLResponse {
16-
convenience init() {
17-
self.init(
18-
url: URL(string: "http://127.0.0.1")!,
19-
statusCode: 200,
20-
httpVersion: nil,
21-
headerFields: nil
22-
)!
15+
extension HTTPURLResponse {
16+
convenience init() {
17+
self.init(
18+
url: URL(string: "http://127.0.0.1")!,
19+
statusCode: 200,
20+
httpVersion: nil,
21+
headerFields: nil
22+
)!
23+
}
2324
}
24-
}
2525
#endif
2626

2727
public struct RealtimeChannelConfig: Sendable {

Tests/AuthTests/RequestsTests.swift

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,76 @@ final class RequestsTests: XCTestCase {
418418
}
419419
}
420420

421+
func testMFAEnrollLegacy() async throws {
422+
let sut = makeSUT()
423+
424+
try Dependencies[sut.clientID].sessionStorage.store(.validSession)
425+
426+
await assert {
427+
_ = try await sut.mfa.enroll(params: MFAEnrollParams(issuer: "supabase.com", friendlyName: "test"))
428+
}
429+
}
430+
431+
func testMFAEnrollTotp() async throws {
432+
let sut = makeSUT()
433+
434+
try Dependencies[sut.clientID].sessionStorage.store(.validSession)
435+
436+
await assert {
437+
_ = try await sut.mfa.enroll(params: .totp(issuer: "supabase.com", friendlyName: "test"))
438+
}
439+
}
440+
441+
func testMFAEnrollPhone() async throws {
442+
let sut = makeSUT()
443+
444+
try Dependencies[sut.clientID].sessionStorage.store(.validSession)
445+
446+
await assert {
447+
_ = try await sut.mfa.enroll(params: .phone(friendlyName: "test", phone: "+1 202-918-2132"))
448+
}
449+
}
450+
451+
func testMFAChallenge() async throws {
452+
let sut = makeSUT()
453+
454+
try Dependencies[sut.clientID].sessionStorage.store(.validSession)
455+
456+
await assert {
457+
_ = try await sut.mfa.challenge(params: .init(factorId: "123"))
458+
}
459+
}
460+
461+
func testMFAChallengePhone() async throws {
462+
let sut = makeSUT()
463+
464+
try Dependencies[sut.clientID].sessionStorage.store(.validSession)
465+
466+
await assert {
467+
_ = try await sut.mfa.challenge(params: .init(factorId: "123", channel: .whatsapp))
468+
}
469+
}
470+
471+
func testMFAVerify() async throws {
472+
let sut = makeSUT()
473+
474+
try Dependencies[sut.clientID].sessionStorage.store(.validSession)
475+
476+
await assert {
477+
_ = try await sut.mfa.verify(params: .init(factorId: "123", challengeId: "123", code: "123456"))
478+
}
479+
}
480+
481+
func testMFAUnenroll() async throws {
482+
let sut = makeSUT()
483+
484+
try Dependencies[sut.clientID].sessionStorage.store(.validSession)
485+
486+
await assert {
487+
_ = try await sut.mfa.unenroll(params: .init(factorId: "123"))
488+
}
489+
}
490+
421491
private func assert(_ block: () async throws -> Void) async {
422492
do {
423493
try await block()
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
curl \
2+
--request POST \
3+
--header "Apikey: dummy.api.key" \
4+
--header "Authorization: Bearer accesstoken" \
5+
--header "X-Client-Info: gotrue-swift/x.y.z" \
6+
"http://localhost:54321/auth/v1/factors/123/challenge"
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
curl \
2+
--request POST \
3+
--header "Apikey: dummy.api.key" \
4+
--header "Authorization: Bearer accesstoken" \
5+
--header "Content-Type: application/json" \
6+
--header "X-Client-Info: gotrue-swift/x.y.z" \
7+
--data "{\"channel\":\"whatsapp\"}" \
8+
"http://localhost:54321/auth/v1/factors/123/challenge"
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
curl \
2+
--request POST \
3+
--header "Apikey: dummy.api.key" \
4+
--header "Authorization: Bearer accesstoken" \
5+
--header "Content-Type: application/json" \
6+
--header "X-Client-Info: gotrue-swift/x.y.z" \
7+
--data "{\"factor_type\":\"totp\",\"friendly_name\":\"test\",\"issuer\":\"supabase.com\"}" \
8+
"http://localhost:54321/auth/v1/factors"
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
curl \
2+
--request POST \
3+
--header "Apikey: dummy.api.key" \
4+
--header "Authorization: Bearer accesstoken" \
5+
--header "Content-Type: application/json" \
6+
--header "X-Client-Info: gotrue-swift/x.y.z" \
7+
--data "{\"factor_type\":\"phone\",\"friendly_name\":\"test\",\"phone\":\"+1 202-918-2132\"}" \
8+
"http://localhost:54321/auth/v1/factors"

0 commit comments

Comments
 (0)