Skip to content
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
33 changes: 33 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,39 @@ This SDK uses **Universal Login** for MFA step-up flows. When an MFA-required er

See [Auth0.swift documentation](https://github.com/auth0/Auth0.swift) for detailed setup.

## MyAccount API Client — Required Pattern

**All My Account API calls MUST use `MyAccountClientFactory.create()` instead of calling `Auth0.myAccount()` directly.**

This factory attaches the `Auth0-Client` HTTP header that identifies requests as originating from the UI Components SDK. The library name and version are defined centrally in `Auth0UniversalComponents/Version.swift`:

```swift
// Version.swift
let version = "1.0.0"
let libraryName = "universal-components-ios"
```

### Correct Usage

```swift
// In any UseCase
let client = MyAccountClientFactory.create(
token: apiCredentials.accessToken,
domain: dependencies.domain,
session: session
)
let result = try await client.authenticationMethods.enrollTOTP().start()
```

### Incorrect Usage (will trigger SwiftLint error)

```swift
// DO NOT use Auth0.myAccount() directly — this bypasses telemetry headers
let client = Auth0.myAccount(token: token, domain: domain, session: session)
```

---

## Key Technical Decisions

### UI Framework Support
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,12 +142,6 @@ public actor Auth0UniversalComponentsSDKInitializer {
tokenProvider: tokenProvider)
}

/// Reset the SDK to an uninitialized state.
///
/// This is primarily used for testing purposes to clear the singleton instance.
static func reset() {
_shared = nil
}
}

/// Ensures a URL string has an HTTPS scheme.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ struct GetAuthMethodsUseCase: GetAuthMethodsUseCaseable {
/// - Returns: Array of enrolled authentication methods
/// - Throws: Auth0APIError if the request fails
func execute(request: GetAuthMethodsRequest) async throws -> [AuthenticationMethod] {
try await Auth0.myAccount(token: request.token, domain: request.domain, session: session)
try await MyAccountClientFactory.create(token: request.token, domain: request.domain, session: session)
.authenticationMethods
.getAuthenticationMethods()
.start()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ struct GetFactorsUseCase: GetFactorsUseCaseable {
/// - Returns: Array of available authentication factors
/// - Throws: Auth0APIError if the request fails
func execute(request: GetFactorsRequest) async throws -> [Factor] {
try await Auth0.myAccount(token: request.token, domain: request.domain, session: session)
try await MyAccountClientFactory.create(token: request.token, domain: request.domain, session: session)
.authenticationMethods
.getFactors()
.start()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Combine
import Foundation
import SwiftUI
import Auth0

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Auth0
import Foundation

/// Factory for creating MyAccount API clients with telemetry pre-applied.
///
/// All UseCases should use this factory instead of calling `Auth0.myAccount()` directly.
/// This ensures the `Auth0-Client` header identifies requests as originating from
/// the UI Components SDK.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update the Agents.md file with this instruction for creating MyAccount client . If possible have a lint check to enforce this

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

struct MyAccountClientFactory {
static func create(token: String, domain: String, session: URLSession = .shared) -> MyAccount {
var client = Auth0.myAccount(token: token, domain: domain, session: session)
client.using(inLibrary: libraryName, version: version)
return client
}

static func create(token: String, domain: String) -> MyAccount {
var client = Auth0.myAccount(token: token, domain: domain)
client.using(inLibrary: libraryName, version: version)
return client
}
}
1 change: 1 addition & 0 deletions Auth0UniversalComponents/Core/Utils/ErrorHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -197,4 +197,5 @@ struct ErrorHandler {
retryCallback()
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ struct ConfirmEmailEnrollmentUseCase: ConfirmEmailEnrollmentUseCaseable {
/// - Returns: The newly created email authentication method
/// - Throws: Auth0APIError if the OTP is invalid or the request fails
func execute(request: ConfirmEmailEnrollmentRequest) async throws -> AuthenticationMethod {
try await Auth0.myAccount(token: request.token, domain: request.domain, session: session)
try await MyAccountClientFactory.create(token: request.token, domain: request.domain, session: session)
.authenticationMethods
.confirmEmailEnrollment(id: request.id, authSession: request.authSession, otpCode: request.otpCode)
.start()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ struct ConfirmPhoneEnrollmentUseCase: ConfirmPhoneEnrollmentUseCaseable {
/// - Returns: The newly created phone authentication method
/// - Throws: Auth0APIError if the OTP is invalid or the request fails
func execute(request: ConfirmPhoneEnrollmentRequest) async throws -> AuthenticationMethod {
try await Auth0.myAccount(token: request.token, domain: request.domain, session: session)
try await MyAccountClientFactory.create(token: request.token, domain: request.domain, session: session)
.authenticationMethods
.confirmPhoneEnrollment(id: request.id, authSession: request.authSession, otpCode: request.otpCode)
.start()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ struct StartEmailEnrollmentUseCase: StartEmailEnrollmentUseCaseable {
/// - Returns: Email enrollment challenge with verification details
/// - Throws: Auth0APIError if the request fails
func execute(request: StartEmailEnrollmentRequest) async throws -> EmailEnrollmentChallenge {
try await Auth0.myAccount(token: request.token, domain: request.domain, session: session)
try await MyAccountClientFactory.create(token: request.token, domain: request.domain, session: session)
.authenticationMethods
.enrollEmail(emailAddress: request.email)
.start()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ struct StartPhoneEnrollmentUseCase: StartPhoneEnrollmentUseCaseable {
/// - Returns: Phone enrollment challenge with verification details
/// - Throws: Auth0APIError if the request fails
func execute(request: StartPhoneEnrollmentRequest) async throws -> PhoneEnrollmentChallenge {
try await Auth0.myAccount(token: request.token, domain: request.domain, session: session)
try await MyAccountClientFactory.create(token: request.token, domain: request.domain, session: session)
.authenticationMethods
.enrollPhone(phoneNumber: request.phoneNumber, preferredAuthenticationMethod: request.preferredAuthenticationMethod)
.start()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ struct ConfirmPasskeyEnrollmentUseCase: ConfirmPasskeyEnrollmentUseCaseable {
/// - Returns: The enrolled passkey authentication method
/// - Throws: Auth0APIError if the passkey creation or enrollment fails
func execute(request: ConfirmPasskeyEnrollmentRequest) async throws -> PasskeyAuthenticationMethod {
try await Auth0.myAccount(token: request.token, domain: request.domain)
try await MyAccountClientFactory.create(token: request.token, domain: request.domain)
.authenticationMethods
.enroll(passkey: request.passkey, challenge: request.challenge)
.start()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ struct StartPasskeyEnrollmentUseCase: StartPasskeyEnrollmentUseCaseable {
/// - Returns: Passkey enrollment challenge
/// - Throws: Auth0APIError if the request fails
func execute(request: StartPasskeyEnrollmentRequest) async throws -> PasskeyEnrollmentChallenge {
try await Auth0.myAccount(token: request.token, domain: request.domain)
try await MyAccountClientFactory.create(token: request.token, domain: request.domain)
.authenticationMethods
.passkeyEnrollmentChallenge()
.start()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ struct ConfirmRecoveryCodeEnrollmentUseCase: ConfirmRecoveryCodeEnrollmentUseCas
/// - Returns: The newly created recovery code authentication method
/// - Throws: Auth0APIError if the request fails
func execute(request: ConfirmRecoveryCodeEnrollmentRequest) async throws -> AuthenticationMethod {
try await Auth0.myAccount(token: request.token, domain: request.domain, session: session)
try await MyAccountClientFactory.create(token: request.token, domain: request.domain, session: session)
.authenticationMethods
.confirmRecoveryCodeEnrollment(id: request.id, authSession: request.authSession)
.start()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ struct StartRecoveryCodeEnrollmentUseCase: StartRecoveryCodeEnrollmentUseCaseabl
/// - Returns: Recovery code enrollment challenge with generated codes
/// - Throws: Auth0APIError if the request fails
func execute(request: StartRecoveryCodeEnrollmentRequest) async throws -> RecoveryCodeEnrollmentChallenge {
try await Auth0.myAccount(token: request.token, domain: request.domain, session: session)
try await MyAccountClientFactory.create(token: request.token, domain: request.domain, session: session)
.authenticationMethods
.enrollRecoveryCode()
.start()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Combine
import Foundation
import Auth0

/// View model for recovery code enrollment.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ struct DeleteAuthMethodUseCase: DeleteAuthMethodUseCaseable {
/// - Parameter request: Request containing the method ID to delete
/// - Throws: Auth0APIError if the method cannot be deleted
func execute(request: DeleteAuthMethodRequest) async throws {
try await Auth0.myAccount(token: request.token, domain: request.domain, session: session)
try await MyAccountClientFactory.create(token: request.token, domain: request.domain, session: session)
.authenticationMethods
.deleteAuthenticationMethod(by: request.id)
.start()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ struct ConfirmPushEnrollmentUseCase: ConfirmPushEnrollmentUseCaseable {
/// - Returns: The newly created push notification authentication method
/// - Throws: Auth0APIError if the request fails
func execute(request: ConfirmPushEnrollmentRequest) async throws -> AuthenticationMethod {
try await Auth0.myAccount(token: request.token, domain: request.domain, session: session)
try await MyAccountClientFactory.create(token: request.token, domain: request.domain, session: session)
.authenticationMethods
.confirmPushNotificationEnrollment(id: request.id, authSession: request.authSession)
.start()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ struct ConfirmTOTPEnrollmentUseCase: ConfirmTOTPEnrollmentUseCaseable {
/// - Returns: The newly created TOTP authentication method
/// - Throws: Auth0APIError if the OTP is invalid or the request fails
func execute(request: ConfirmTOTPEnrollmentRequest) async throws -> AuthenticationMethod {
try await Auth0.myAccount(token: request.token, domain: request.domain, session: session)
try await MyAccountClientFactory.create(token: request.token, domain: request.domain, session: session)
.authenticationMethods
.confirmTOTPEnrollment(id: request.id, authSession: request.authSession, otpCode: request.otpCode)
.start()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ struct StartPushEnrollmentUseCase: StartPushEnrollmentUseCaseable {
/// - Returns: Push notification enrollment challenge
/// - Throws: Auth0APIError if the request fails
func execute(request: StartPushEnrollmentRequest) async throws -> PushEnrollmentChallenge {
try await Auth0.myAccount(token: request.token, domain: request.domain, session: session)
try await MyAccountClientFactory.create(token: request.token, domain: request.domain, session: session)
.authenticationMethods
.enrollPushNotification()
.start()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ struct StartTOTPEnrollmentUseCase: StartTOTPEnrollmentUseCaseable {
/// - Returns: TOTP enrollment challenge with QR code and secret
/// - Throws: Auth0APIError if the request fails
func execute(request: StartTOTPEnrollmentRequest) async throws -> TOTPEnrollmentChallenge {
try await Auth0.myAccount(token: request.token, domain: request.domain, session: session)
try await MyAccountClientFactory.create(token: request.token, domain: request.domain, session: session)
.authenticationMethods
.enrollTOTP()
.start()
Expand Down
4 changes: 4 additions & 0 deletions Auth0UniversalComponents/Version.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@
/// This version string follows semantic versioning and is used to track
/// the SDK's release version.
let version = "1.0.0"

/// The library name used in the `Auth0-Client` HTTP header to identify
/// requests originating from the UI Components SDK.
let libraryName = "universal-components-ios"
14 changes: 11 additions & 3 deletions Auth0UniversalComponentsTests/Mocks/MockURLProtocol.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import Foundation

final class MockURLProtocol: URLProtocol {
static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data?))?
nonisolated(unsafe) static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data?))?

private var capturedHandler: ((URLRequest) throws -> (HTTPURLResponse, Data?))?

override init(request: URLRequest, cachedResponse: CachedURLResponse?, client: (any URLProtocolClient)?) {
capturedHandler = Self.requestHandler
super.init(request: request, cachedResponse: cachedResponse, client: client)
}

override static func canInit(with request: URLRequest) -> Bool {
return true
Expand All @@ -12,8 +19,9 @@ final class MockURLProtocol: URLProtocol {
}

override func startLoading() {
guard let handler = MockURLProtocol.requestHandler else {
fatalError("MockURLProtocol requires a requestHandler.")
guard let handler = capturedHandler else {
client?.urlProtocol(self, didFailWithError: URLError(.unknown))
return
}

do {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,10 @@
// MARK: - Tests
@available(iOS 16.6, macOS 13.5, visionOS 1.0, *)
@Test func testInit_initialState() async {
guard #available(iOS 16.6, macOS 13.5, visionOS 1.0, *) else { return }

Check warning on line 84 in Auth0UniversalComponentsTests/Passkeys/PasskeysEnrollmentViewModelTests.swift

View workflow job for this annotation

GitHub Actions / Test on visionOS using Xcode 26.2

unnecessary check for 'visionOS'; enclosing scope ensures guard will always be true

Check warning on line 84 in Auth0UniversalComponentsTests/Passkeys/PasskeysEnrollmentViewModelTests.swift

View workflow job for this annotation

GitHub Actions / Test on iOS using Xcode 26.2

unnecessary check for 'iOS'; enclosing scope ensures guard will always be true

Check warning on line 84 in Auth0UniversalComponentsTests/Passkeys/PasskeysEnrollmentViewModelTests.swift

View workflow job for this annotation

GitHub Actions / Test on macOS using Xcode 26.2

unnecessary check for 'macOS'; enclosing scope ensures guard will always be true

let mockTokenProvider = MockTokenProvider()

Auth0UniversalComponentsSDKInitializer.reset()
Auth0UniversalComponentsSDKInitializer.initialize(
session: makeMockSession(),
bundle: .main,
Expand All @@ -105,12 +104,11 @@

@available(iOS 16.6, macOS 13.5, visionOS 1.0, *)
@Test func testInit_withDelegate() async {
guard #available(iOS 16.6, macOS 13.5, visionOS 1.0, *) else { return }

Check warning on line 107 in Auth0UniversalComponentsTests/Passkeys/PasskeysEnrollmentViewModelTests.swift

View workflow job for this annotation

GitHub Actions / Test on visionOS using Xcode 26.2

unnecessary check for 'visionOS'; enclosing scope ensures guard will always be true

Check warning on line 107 in Auth0UniversalComponentsTests/Passkeys/PasskeysEnrollmentViewModelTests.swift

View workflow job for this annotation

GitHub Actions / Test on iOS using Xcode 26.2

unnecessary check for 'iOS'; enclosing scope ensures guard will always be true

Check warning on line 107 in Auth0UniversalComponentsTests/Passkeys/PasskeysEnrollmentViewModelTests.swift

View workflow job for this annotation

GitHub Actions / Test on macOS using Xcode 26.2

unnecessary check for 'macOS'; enclosing scope ensures guard will always be true

let mockTokenProvider = MockTokenProvider()
let mockDelegate = MockRefreshDelegate()

Auth0UniversalComponentsSDKInitializer.reset()
Auth0UniversalComponentsSDKInitializer.initialize(
session: makeMockSession(),
bundle: .main,
Expand All @@ -130,12 +128,11 @@

@available(iOS 16.6, macOS 13.5, visionOS 1.0, *)
@Test func testStartEnrollment_callsUseCase() async throws {
guard #available(iOS 16.6, macOS 13.5, visionOS 1.0, *) else { return }

Check warning on line 131 in Auth0UniversalComponentsTests/Passkeys/PasskeysEnrollmentViewModelTests.swift

View workflow job for this annotation

GitHub Actions / Test on iOS using Xcode 26.2

unnecessary check for 'iOS'; enclosing scope ensures guard will always be true

Check warning on line 131 in Auth0UniversalComponentsTests/Passkeys/PasskeysEnrollmentViewModelTests.swift

View workflow job for this annotation

GitHub Actions / Test on macOS using Xcode 26.2

unnecessary check for 'macOS'; enclosing scope ensures guard will always be true

let mockTokenProvider = MockTokenProvider()
await NavigationStore.shared.reset()

Auth0UniversalComponentsSDKInitializer.reset()
Auth0UniversalComponentsSDKInitializer.initialize(
session: makeMockSession(),
bundle: .main,
Expand All @@ -158,12 +155,11 @@

@available(iOS 16.6, macOS 13.5, visionOS 1.0, *)
@Test func testStartEnrollment_withMockUseCase() async throws {
guard #available(iOS 16.6, macOS 13.5, visionOS 1.0, *) else { return }

Check warning on line 158 in Auth0UniversalComponentsTests/Passkeys/PasskeysEnrollmentViewModelTests.swift

View workflow job for this annotation

GitHub Actions / Test on visionOS using Xcode 26.2

unnecessary check for 'visionOS'; enclosing scope ensures guard will always be true

Check warning on line 158 in Auth0UniversalComponentsTests/Passkeys/PasskeysEnrollmentViewModelTests.swift

View workflow job for this annotation

GitHub Actions / Test on iOS using Xcode 26.2

unnecessary check for 'iOS'; enclosing scope ensures guard will always be true

Check warning on line 158 in Auth0UniversalComponentsTests/Passkeys/PasskeysEnrollmentViewModelTests.swift

View workflow job for this annotation

GitHub Actions / Test on iOS using Xcode 26.2

unnecessary check for 'iOS'; enclosing scope ensures guard will always be true

Check warning on line 158 in Auth0UniversalComponentsTests/Passkeys/PasskeysEnrollmentViewModelTests.swift

View workflow job for this annotation

GitHub Actions / Test on macOS using Xcode 26.2

unnecessary check for 'macOS'; enclosing scope ensures guard will always be true

Check warning on line 158 in Auth0UniversalComponentsTests/Passkeys/PasskeysEnrollmentViewModelTests.swift

View workflow job for this annotation

GitHub Actions / Test on macOS using Xcode 26.2

unnecessary check for 'macOS'; enclosing scope ensures guard will always be true

let mockTokenProvider = MockTokenProvider()
await NavigationStore.shared.reset()

Auth0UniversalComponentsSDKInitializer.reset()
Auth0UniversalComponentsSDKInitializer.initialize(
session: makeMockSession(),
bundle: .main,
Expand Down Expand Up @@ -197,12 +193,11 @@

@available(iOS 16.6, macOS 13.5, visionOS 1.0, *)
@Test func testAuthorizationController_delegateMethods() async {
guard #available(iOS 16.6, macOS 13.5, visionOS 1.0, *) else { return }

Check warning on line 196 in Auth0UniversalComponentsTests/Passkeys/PasskeysEnrollmentViewModelTests.swift

View workflow job for this annotation

GitHub Actions / Test on iOS using Xcode 26.2

unnecessary check for 'iOS'; enclosing scope ensures guard will always be true

Check warning on line 196 in Auth0UniversalComponentsTests/Passkeys/PasskeysEnrollmentViewModelTests.swift

View workflow job for this annotation

GitHub Actions / Test on macOS using Xcode 26.2

unnecessary check for 'macOS'; enclosing scope ensures guard will always be true

let mockTokenProvider = MockTokenProvider()
await NavigationStore.shared.reset()

Auth0UniversalComponentsSDKInitializer.reset()
Auth0UniversalComponentsSDKInitializer.initialize(
session: makeMockSession(),
bundle: .main,
Expand All @@ -226,11 +221,10 @@

@available(iOS 16.6, macOS 13.5, visionOS 1.0, *)
@Test func testHandle_setsLoaderToFalse() async {
guard #available(iOS 16.6, macOS 13.5, visionOS 1.0, *) else { return }

Check warning on line 224 in Auth0UniversalComponentsTests/Passkeys/PasskeysEnrollmentViewModelTests.swift

View workflow job for this annotation

GitHub Actions / Test on iOS using Xcode 26.2

unnecessary check for 'iOS'; enclosing scope ensures guard will always be true

Check warning on line 224 in Auth0UniversalComponentsTests/Passkeys/PasskeysEnrollmentViewModelTests.swift

View workflow job for this annotation

GitHub Actions / Test on macOS using Xcode 26.2

unnecessary check for 'macOS'; enclosing scope ensures guard will always be true

let mockTokenProvider = MockTokenProvider()

Auth0UniversalComponentsSDKInitializer.reset()
Auth0UniversalComponentsSDKInitializer.initialize(
session: makeMockSession(),
bundle: .main,
Expand Down Expand Up @@ -262,11 +256,10 @@

@available(iOS 16.6, macOS 13.5, visionOS 1.0, *)
@Test func testHandle_errorHandling() async {
guard #available(iOS 16.6, macOS 13.5, visionOS 1.0, *) else { return }

Check warning on line 259 in Auth0UniversalComponentsTests/Passkeys/PasskeysEnrollmentViewModelTests.swift

View workflow job for this annotation

GitHub Actions / Test on iOS using Xcode 26.2

unnecessary check for 'iOS'; enclosing scope ensures guard will always be true

Check warning on line 259 in Auth0UniversalComponentsTests/Passkeys/PasskeysEnrollmentViewModelTests.swift

View workflow job for this annotation

GitHub Actions / Test on macOS using Xcode 26.2

unnecessary check for 'macOS'; enclosing scope ensures guard will always be true

let mockTokenProvider = MockTokenProvider()

Auth0UniversalComponentsSDKInitializer.reset()
Auth0UniversalComponentsSDKInitializer.initialize(
session: makeMockSession(),
bundle: .main,
Expand Down
Loading
Loading