• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

supabase / supabase-swift / 17321717294

29 Aug 2025 10:44AM UTC coverage: 78.634% (+1.2%) from 77.386%
17321717294

Pull #781

github

web-flow
Merge 80b20054e into e4d8c3718
Pull Request #781: RFC: Migrate HTTP networking from URLSession to Alamofire

1027 of 1123 new or added lines in 27 files covered. (91.45%)

27 existing lines in 8 files now uncovered.

5156 of 6557 relevant lines covered (78.63%)

29.27 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

87.21
/Sources/Auth/AuthClient.swift
1
import Alamofire
2
import ConcurrencyExtras
3
import Foundation
4

5
#if canImport(AuthenticationServices)
6
  import AuthenticationServices
7
#endif
8

9
#if canImport(FoundationNetworking)
10
  import FoundationNetworking
11
#endif
12

13
#if canImport(WatchKit)
14
  import WatchKit
15
#endif
16

17
#if canImport(ObjectiveC) && canImport(Combine)
18
  import Combine
19
#endif
20

21
typealias AuthClientID = Int
22

23
struct AuthClientLoggerDecorator: SupabaseLogger {
24
  let clientID: AuthClientID
25
  let decoratee: any SupabaseLogger
26

27
  func log(message: SupabaseLogMessage) {
8✔
28
    var message = message
8✔
29
    message.additionalContext["client_id"] = .integer(clientID)
8✔
30
    decoratee.log(message: message)
8✔
31
  }
8✔
32
}
33

34
public actor AuthClient {
35
  static var globalClientID = 0
36
  nonisolated let clientID: AuthClientID
37

38
  nonisolated private var api: APIClient { Dependencies[clientID].api }
34✔
39

40
  nonisolated var configuration: AuthClient.Configuration { Dependencies[clientID].configuration }
91✔
41

42
  nonisolated private var codeVerifierStorage: CodeVerifierStorage {
16✔
43
    Dependencies[clientID].codeVerifierStorage
16✔
44
  }
16✔
45

46
  nonisolated private var date: @Sendable () -> Date { Dependencies[clientID].date }
5✔
47
  nonisolated private var sessionManager: SessionManager { Dependencies[clientID].sessionManager }
47✔
48
  nonisolated private var eventEmitter: AuthStateChangeEventEmitter {
48✔
49
    Dependencies[clientID].eventEmitter
48✔
50
  }
48✔
51
  nonisolated private var logger: (any SupabaseLogger)? {
10✔
52
    Dependencies[clientID].configuration.logger
10✔
53
  }
10✔
54
  nonisolated private var sessionStorage: SessionStorage { Dependencies[clientID].sessionStorage }
9✔
55
  nonisolated private var pkce: PKCE { Dependencies[clientID].pkce }
18✔
56

57
  /// Returns the session, refreshing it if necessary.
58
  ///
59
  /// If no session can be found, a ``AuthError/sessionMissing`` error is thrown.
60
  public var session: Session {
61
    get async throws {
21✔
62
      try await sessionManager.session()
21✔
63
    }
13✔
64
  }
65

66
  /// Returns the current session, if any.
67
  ///
68
  /// The session returned by this property may be expired. Use ``session`` for a session that is guaranteed to be valid.
69
  nonisolated public var currentSession: Session? {
9✔
70
    sessionStorage.get()
9✔
71
  }
9✔
72

73
  /// Returns the current user, if any.
74
  ///
75
  /// The user returned by this property may be outdated. Use ``user(jwt:)`` method to get an up-to-date user instance.
76
  nonisolated public var currentUser: User? {
1✔
77
    currentSession?.user
1✔
78
  }
1✔
79

80
  /// Namespace for accessing multi-factor authentication API.
81
  nonisolated public var mfa: AuthMFA {
10✔
82
    AuthMFA(clientID: clientID)
10✔
83
  }
10✔
84

85
  /// Namespace for the GoTrue admin methods.
86
  /// - Warning: This methods requires `service_role` key, be careful to never expose `service_role`
87
  /// key in the client.
88
  nonisolated public var admin: AuthAdmin {
7✔
89
    AuthAdmin(clientID: clientID)
7✔
90
  }
7✔
91

92
  /// Initializes a AuthClient with a specific configuration.
93
  ///
94
  /// - Parameters:
95
  ///   - configuration: The client configuration.
96
  public init(configuration: Configuration) {
117✔
97
    AuthClient.globalClientID += 1
117✔
98
    clientID = AuthClient.globalClientID
117✔
99

117✔
100
    var configuration = configuration
117✔
101
    var headers = HTTPHeaders(configuration.headers)
117✔
102
    if headers["X-Client-Info"] == nil {
117✔
NEW
103
      headers["X-Client-Info"] = "auth-swift/\(version)"
×
NEW
104
    }
×
105

117✔
106
    headers[apiVersionHeaderNameHeaderKey] = apiVersions[._20240101]!.name.rawValue
117✔
107

117✔
108
    configuration.headers = headers.dictionary
117✔
109

117✔
110
    Dependencies[clientID] = Dependencies(
117✔
111
      configuration: configuration,
117✔
112
      session: configuration.session.newSession(adapters: [
117✔
113
        DefaultHeadersRequestAdapter(headers: headers)
117✔
114
      ]),
117✔
115
      api: APIClient(clientID: clientID),
117✔
116
      codeVerifierStorage: .live(clientID: clientID),
117✔
117
      sessionStorage: .live(clientID: clientID),
117✔
118
      sessionManager: .live(clientID: clientID),
117✔
119
      logger: configuration.logger.map {
117✔
120
        AuthClientLoggerDecorator(clientID: clientID, decoratee: $0)
1✔
121
      }
1✔
122
    )
117✔
123

117✔
124
    Task { @MainActor in observeAppLifecycleChanges() }
117✔
125
  }
117✔
126

127
  #if canImport(ObjectiveC) && canImport(Combine)
128
    @MainActor
129
    private func observeAppLifecycleChanges() {
117✔
130
      var didBecomeActiveNotification: NSNotification.Name?
117✔
131
      var willResignActiveNotification: NSNotification.Name?
117✔
132

117✔
133
      #if canImport(UIKit)
134
        #if canImport(WatchKit)
135
          if #available(watchOS 7.0, *) {
136
            didBecomeActiveNotification = WKExtension.applicationDidBecomeActiveNotification
137
            willResignActiveNotification = WKExtension.applicationWillResignActiveNotification
138
          }
139
        #else
140
          didBecomeActiveNotification = UIApplication.didBecomeActiveNotification
117✔
141
          willResignActiveNotification = UIApplication.willResignActiveNotification
117✔
142
        #endif
143
      #elseif canImport(AppKit)
144
        didBecomeActiveNotification = NSApplication.didBecomeActiveNotification
145
        willResignActiveNotification = NSApplication.willResignActiveNotification
146
      #endif
147

117✔
148
      if let didBecomeActiveNotification, let willResignActiveNotification {
117✔
149
        var cancellables = Set<AnyCancellable>()
117✔
150

117✔
151
        NotificationCenter.default
117✔
152
          .publisher(for: didBecomeActiveNotification)
117✔
153
          .sink(
117✔
154
            receiveCompletion: { _ in
117✔
155
              // hold ref to cancellable until it completes
×
156
              _ = cancellables
×
157
            },
×
158
            receiveValue: { [weak self] _ in
117✔
159
              Task {
×
160
                await self?.handleDidBecomeActive()
×
161
              }
×
162
            }
×
163
          )
117✔
164
          .store(in: &cancellables)
117✔
165

117✔
166
        NotificationCenter.default
117✔
167
          .publisher(for: willResignActiveNotification)
117✔
168
          .sink(
117✔
169
            receiveCompletion: { _ in
117✔
170
              // hold ref to cancellable until it completes
×
171
              _ = cancellables
×
172
            },
×
173
            receiveValue: { [weak self] _ in
117✔
174
              Task {
×
175
                await self?.handleWillResignActive()
×
176
              }
×
177
            }
×
178
          )
117✔
179
          .store(in: &cancellables)
117✔
180
      }
117✔
181

117✔
182
    }
117✔
183

184
    private func handleDidBecomeActive() {
×
185
      if configuration.autoRefreshToken {
×
186
        startAutoRefresh()
×
187
      }
×
188
    }
×
189

190
    private func handleWillResignActive() {
×
191
      if configuration.autoRefreshToken {
×
192
        stopAutoRefresh()
×
193
      }
×
194
    }
×
195
  #else
196
    @MainActor
197
    private func observeAppLifecycleChanges() {
198
      // no-op
199
    }
200
  #endif
201

202
  /// Listen for auth state changes.
203
  /// - Parameter listener: Block that executes when a new event is emitted.
204
  /// - Returns: A handle that can be used to manually unsubscribe.
205
  ///
206
  /// - Note: This method blocks execution until the ``AuthChangeEvent/initialSession`` event is
207
  /// emitted. Although this operation is usually fast, in case of the current stored session being
208
  /// invalid, a call to the endpoint is necessary for refreshing the session.
209
  @discardableResult
210
  public func onAuthStateChange(
211
    _ listener: @escaping AuthStateChangeListener
212
  ) async -> some AuthStateChangeListenerRegistration {
12✔
213
    let token = eventEmitter.attach(listener)
12✔
214
    await emitInitialSession(forToken: token)
12✔
215
    return token
12✔
216
  }
12✔
217

218
  /// Listen for auth state changes.
219
  ///
220
  /// An `.initialSession` is always emitted when this method is called.
221
  nonisolated public var authStateChanges:
222
    AsyncStream<
223
      (
224
        event: AuthChangeEvent,
225
        session: Session?
226
      )
227
    >
228
  {
11✔
229
    let (stream, continuation) = AsyncStream<
11✔
230
      (
11✔
231
        event: AuthChangeEvent,
11✔
232
        session: Session?
11✔
233
      )
11✔
234
    >.makeStream()
11✔
235

11✔
236
    Task {
11✔
237
      let handle = await onAuthStateChange { event, session in
19✔
238
        continuation.yield((event, session))
19✔
239
      }
19✔
240

11✔
241
      continuation.onTermination = { _ in
11✔
242
        handle.remove()
9✔
243
      }
9✔
244
    }
11✔
245

11✔
246
    return stream
11✔
247
  }
11✔
248

249
  /// Creates a new user.
250
  /// - Parameters:
251
  ///   - email: User's email address.
252
  ///   - password: Password for the user.
253
  ///   - data: Custom data object to store additional user metadata.
254
  ///   - redirectTo: The redirect URL embedded in the email link, defaults to ``Configuration/redirectToURL`` if not provided.
255
  ///   - captchaToken: Optional captcha token for securing this endpoint.
256
  @discardableResult
257
  public func signUp(
258
    email: String,
259
    password: String,
260
    data: [String: AnyJSON]? = nil,
261
    redirectTo: URL? = nil,
262
    captchaToken: String? = nil
263
  ) async throws(AuthError) -> AuthResponse {
1✔
264
    let (codeChallenge, codeChallengeMethod) = prepareForPKCE()
1✔
265

1✔
266
    return try await _signUp(
1✔
267
      body: SignUpRequest(
1✔
268
        email: email,
1✔
269
        password: password,
1✔
270
        data: data,
1✔
271
        gotrueMetaSecurity: captchaToken.map(AuthMetaSecurity.init(captchaToken:)),
1✔
272
        codeChallenge: codeChallenge,
1✔
273
        codeChallengeMethod: codeChallengeMethod
1✔
274
      ),
1✔
275
      query: (redirectTo ?? configuration.redirectToURL).map {
1✔
276
        ["redirect_to": $0.absoluteString]
1✔
277
      }
1✔
278
    )
1✔
279
  }
1✔
280

281
  /// Creates a new user.
282
  /// - Parameters:
283
  ///   - phone: User's phone number with international prefix.
284
  ///   - password: Password for the user.
285
  ///   - channel: Messaging channel to use (e.g. whatsapp or sms).
286
  ///   - data: Custom data object to store additional user metadata.
287
  ///   - captchaToken: Optional captcha token for securing this endpoint.
288
  @discardableResult
289
  public func signUp(
290
    phone: String,
291
    password: String,
292
    channel: MessagingChannel = .sms,
293
    data: [String: AnyJSON]? = nil,
294
    captchaToken: String? = nil
295
  ) async throws(AuthError) -> AuthResponse {
1✔
296
    try await _signUp(
1✔
297
      body: SignUpRequest(
1✔
298
        password: password,
1✔
299
        phone: phone,
1✔
300
        channel: channel,
1✔
301
        data: data,
1✔
302
        gotrueMetaSecurity: captchaToken.map(AuthMetaSecurity.init(captchaToken:))
1✔
303
      )
1✔
304
    )
1✔
305
  }
1✔
306

307
  private func _signUp(body: SignUpRequest, query: Parameters? = nil) async throws(AuthError)
308
    -> AuthResponse
309
  {
3✔
310
    let response = try await wrappingError(or: mapToAuthError) {
3✔
311
      try await self.api.execute(
3✔
312
        self.configuration.url.appendingPathComponent("signup"),
3✔
313
        method: .post,
3✔
314
        query: query,
3✔
315
        body: body
3✔
316
      )
3✔
317
      .serializingDecodable(AuthResponse.self, decoder: self.configuration.decoder)
3✔
318
      .value
3✔
319
    }
3✔
320

3✔
321
    if let session = response.session {
3✔
322
      await sessionManager.update(session)
3✔
323
      eventEmitter.emit(.signedIn, session: session)
3✔
324
    }
3✔
325

3✔
326
    return response
3✔
327
  }
3✔
328

329
  /// Log in an existing user with an email and password.
330
  /// - Parameters:
331
  ///   - email: User's email address.
332
  ///   - password: User's password.
333
  ///   - captchaToken: Optional captcha token for securing this endpoint.
334
  @discardableResult
335
  public func signIn(
336
    email: String,
337
    password: String,
338
    captchaToken: String? = nil
339
  ) async throws(AuthError) -> Session {
2✔
340
    try await _signIn(
2✔
341
      grantType: "password",
2✔
342
      credentials: UserCredentials(
2✔
343
        email: email,
2✔
344
        password: password,
2✔
345
        gotrueMetaSecurity: captchaToken.map(AuthMetaSecurity.init(captchaToken:))
2✔
346
      )
2✔
347
    )
2✔
348
  }
2✔
349

350
  /// Log in an existing user with a phone and password.
351
  /// - Parameters:
352
  ///   - email: User's phone number.
353
  ///   - password: User's password.
354
  ///   - captchaToken: Optional captcha token for securing this endpoint.
355
  @discardableResult
356
  public func signIn(
357
    phone: String,
358
    password: String,
359
    captchaToken: String? = nil
360
  ) async throws(AuthError) -> Session {
1✔
361
    try await _signIn(
1✔
362
      grantType: "password",
1✔
363
      credentials: UserCredentials(
1✔
364
        password: password,
1✔
365
        phone: phone,
1✔
366
        gotrueMetaSecurity: captchaToken.map(AuthMetaSecurity.init(captchaToken:))
1✔
367
      )
1✔
368
    )
1✔
369
  }
1✔
370

371
  /// Allows signing in with an ID token issued by certain supported providers.
372
  /// The ID token is verified for validity and a new session is established.
373
  @discardableResult
374
  public func signInWithIdToken(credentials: OpenIDConnectCredentials) async throws(AuthError)
375
    -> Session
376
  {
1✔
377
    try await _signIn(
1✔
378
      grantType: "id_token",
1✔
379
      credentials: credentials
1✔
380
    )
1✔
381
  }
1✔
382

383
  /// Creates a new anonymous user.
384
  /// - Parameters:
385
  ///   - data: A custom data object to store the user's metadata. This maps to the
386
  /// `auth.users.raw_user_meta_data` column. The `data` should be a JSON object that includes
387
  /// user-specific info, such as their first and last name.
388
  ///   - captchaToken: Verification token received when the user completes the captcha.
389
  @discardableResult
390
  public func signInAnonymously(
391
    data: [String: AnyJSON]? = nil,
392
    captchaToken: String? = nil
393
  ) async throws(AuthError) -> Session {
1✔
394
    try await _signUp(
1✔
395
      body: SignUpRequest(
1✔
396
        data: data,
1✔
397
        gotrueMetaSecurity: captchaToken.map { AuthMetaSecurity(captchaToken: $0) }
1✔
398
      )
1✔
399
    ).session!  // anonymous sign in will always return a session
1✔
400
  }
1✔
401

402
  private func _signIn<Credentials: Encodable & Sendable>(
403
    grantType: String,
404
    credentials: Credentials
405
  ) async throws(AuthError) -> Session {
4✔
406
    let session = try await wrappingError(or: mapToAuthError) {
4✔
407
      try await self.api.execute(
4✔
408
        self.configuration.url.appendingPathComponent("token"),
4✔
409
        method: .post,
4✔
410
        query: ["grant_type": grantType],
4✔
411
        body: credentials
4✔
412
      )
4✔
413
      .serializingDecodable(Session.self, decoder: self.configuration.decoder)
4✔
414
      .value
4✔
415
    }
4✔
416

4✔
417
    await sessionManager.update(session)
4✔
418
    eventEmitter.emit(.signedIn, session: session)
4✔
419

4✔
420
    return session
4✔
421
  }
4✔
422

423
  /// Log in user using magic link.
424
  ///
425
  /// If the `{{ .ConfirmationURL }}` variable is specified in the email template, a magic link will
426
  /// be sent.
427
  /// If the `{{ .Token }}` variable is specified in the email template, an OTP will be sent.
428
  /// - Parameters:
429
  ///   - email: User's email address.
430
  ///   - redirectTo: Redirect URL embedded in the email link.
431
  ///   - shouldCreateUser: Creates a new user, defaults to `true`.
432
  ///   - data: User's metadata.
433
  ///   - captchaToken: Captcha verification token.
434
  public func signInWithOTP(
435
    email: String,
436
    redirectTo: URL? = nil,
437
    shouldCreateUser: Bool = true,
438
    data: [String: AnyJSON]? = nil,
439
    captchaToken: String? = nil
440
  ) async throws(AuthError) {
1✔
441
    let (codeChallenge, codeChallengeMethod) = prepareForPKCE()
1✔
442

1✔
443
    _ = try await wrappingError(or: mapToAuthError) {
1✔
444
      try await self.api.execute(
1✔
445
        self.configuration.url.appendingPathComponent("otp"),
1✔
446
        method: .post,
1✔
447
        query: (redirectTo ?? self.configuration.redirectToURL).map {
1✔
448
          ["redirect_to": $0.absoluteString]
1✔
449
        },
1✔
450
        body:
1✔
451
          OTPParams(
1✔
452
            email: email,
1✔
453
            createUser: shouldCreateUser,
1✔
454
            data: data,
1✔
455
            gotrueMetaSecurity: captchaToken.map(AuthMetaSecurity.init(captchaToken:)),
1✔
456
            codeChallenge: codeChallenge,
1✔
457
            codeChallengeMethod: codeChallengeMethod
1✔
458
          )
1✔
459
      )
1✔
460
      .serializingData()
1✔
461
      .value
1✔
462
    }
1✔
463
  }
1✔
464

465
  /// Log in user using a one-time password (OTP)..
466
  ///
467
  /// - Parameters:
468
  ///   - phone: User's phone with international prefix.
469
  ///   - channel: Messaging channel to use (e.g `whatsapp` or `sms`), defaults to `sms`.
470
  ///   - shouldCreateUser: Creates a new user, defaults to `true`.
471
  ///   - data: User's metadata.
472
  ///   - captchaToken: Captcha verification token.
473
  ///
474
  /// - Note: You need to configure a WhatsApp sender on Twillo if you are using phone sign in with the `whatsapp` channel.
475
  public func signInWithOTP(
476
    phone: String,
477
    channel: MessagingChannel = .sms,
478
    shouldCreateUser: Bool = true,
479
    data: [String: AnyJSON]? = nil,
480
    captchaToken: String? = nil
481
  ) async throws(AuthError) {
1✔
482
    _ = try await wrappingError(or: mapToAuthError) {
1✔
483
      try await self.api.execute(
1✔
484
        self.configuration.url.appendingPathComponent("otp"),
1✔
485
        method: .post,
1✔
486
        body: OTPParams(
1✔
487
          phone: phone,
1✔
488
          createUser: shouldCreateUser,
1✔
489
          channel: channel,
1✔
490
          data: data,
1✔
491
          gotrueMetaSecurity: captchaToken.map(AuthMetaSecurity.init(captchaToken:))
1✔
492
        )
1✔
493
      )
1✔
494
      .serializingData()
1✔
495
      .value
1✔
496
    }
1✔
497
  }
1✔
498

499
  /// Attempts a single-sign on using an enterprise Identity Provider.
500
  /// - Parameters:
501
  ///   - domain: The email domain to use for signing in.
502
  ///   - redirectTo: The URL to redirect the user to after they sign in with the third-party provider.
503
  ///   - captchaToken: The captcha token to be used for captcha verification.
504
  /// - Returns: A URL that you can use to initiate the provider's authentication flow.
505
  public func signInWithSSO(
506
    domain: String,
507
    redirectTo: URL? = nil,
508
    captchaToken: String? = nil
509
  ) async throws(AuthError) -> SSOResponse {
1✔
510
    let (codeChallenge, codeChallengeMethod) = prepareForPKCE()
1✔
511

1✔
512
    return try await wrappingError(or: mapToAuthError) {
1✔
513
      try await self.api.execute(
1✔
514
        self.configuration.url.appendingPathComponent("sso"),
1✔
515
        method: .post,
1✔
516
        body: SignInWithSSORequest(
1✔
517
          providerId: nil,
1✔
518
          domain: domain,
1✔
519
          redirectTo: redirectTo ?? self.configuration.redirectToURL,
1✔
520
          gotrueMetaSecurity: captchaToken.map { AuthMetaSecurity(captchaToken: $0) },
1✔
521
          codeChallenge: codeChallenge,
1✔
522
          codeChallengeMethod: codeChallengeMethod
1✔
523
        )
1✔
524
      )
1✔
525
      .serializingDecodable(SSOResponse.self, decoder: self.configuration.decoder)
1✔
526
      .value
1✔
527
    }
1✔
528
  }
1✔
529

530
  /// Attempts a single-sign on using an enterprise Identity Provider.
531
  /// - Parameters:
532
  ///   - providerId: The ID of the SSO provider to use for signing in.
533
  ///   - redirectTo: The URL to redirect the user to after they sign in with the third-party
534
  /// provider.
535
  ///   - captchaToken: The captcha token to be used for captcha verification.
536
  /// - Returns: A URL that you can use to initiate the provider's authentication flow.
537
  public func signInWithSSO(
538
    providerId: String,
539
    redirectTo: URL? = nil,
540
    captchaToken: String? = nil
541
  ) async throws(AuthError) -> SSOResponse {
1✔
542
    let (codeChallenge, codeChallengeMethod) = prepareForPKCE()
1✔
543

1✔
544
    return try await wrappingError(or: mapToAuthError) {
1✔
545
      try await self.api.execute(
1✔
546
        self.configuration.url.appendingPathComponent("sso"),
1✔
547
        method: .post,
1✔
548
        body: SignInWithSSORequest(
1✔
549
          providerId: providerId,
1✔
550
          domain: nil,
1✔
551
          redirectTo: redirectTo ?? self.configuration.redirectToURL,
1✔
552
          gotrueMetaSecurity: captchaToken.map { AuthMetaSecurity(captchaToken: $0) },
1✔
553
          codeChallenge: codeChallenge,
1✔
554
          codeChallengeMethod: codeChallengeMethod
1✔
555
        )
1✔
556
      )
1✔
557
      .serializingDecodable(SSOResponse.self, decoder: self.configuration.decoder)
1✔
558
      .value
1✔
559
    }
1✔
560
  }
1✔
561

562
  /// Log in an existing user by exchanging an Auth Code issued during the PKCE flow.
563
  public func exchangeCodeForSession(authCode: String) async throws(AuthError) -> Session {
1✔
564
    let codeVerifier = codeVerifierStorage.get()
1✔
565

1✔
566
    if codeVerifier == nil {
1✔
567
      logger?.error(
×
NEW
568
        "code verifier not found, a code verifier should exist when calling this method."
×
NEW
569
      )
×
UNCOV
570
    }
×
571

1✔
572
    let session = try await wrappingError(or: mapToAuthError) {
1✔
573
      try await self.api.execute(
1✔
574
        self.configuration.url.appendingPathComponent("token"),
1✔
575
        method: .post,
1✔
576
        query: ["grant_type": "pkce"],
1✔
577
        body: ["auth_code": authCode, "code_verifier": codeVerifier]
1✔
578
      )
1✔
579
      .serializingDecodable(Session.self, decoder: self.configuration.decoder)
1✔
580
      .value
1✔
581
    }
1✔
582

1✔
583
    codeVerifierStorage.set(nil)
1✔
584

1✔
585
    await sessionManager.update(session)
1✔
586
    eventEmitter.emit(.signedIn, session: session)
1✔
587

1✔
588
    return session
1✔
589
  }
1✔
590

591
  /// Get a URL which you can use to start an OAuth flow for a third-party provider.
592
  ///
593
  /// Use this method if you want to have full control over the OAuth flow implementation, once you
594
  /// have result URL with a OAuth token, use method ``session(from:)`` to load the session
595
  /// into the client.
596
  ///
597
  /// If that isn't the case, you should consider using
598
  /// ``signInWithOAuth(provider:redirectTo:scopes:queryParams:launchFlow:)`` or
599
  /// ``signInWithOAuth(provider:redirectTo:scopes:queryParams:configure:)``.
600
  nonisolated public func getOAuthSignInURL(
601
    provider: Provider,
602
    scopes: String? = nil,
603
    redirectTo: URL? = nil,
604
    queryParams: [(name: String, value: String?)] = []
605
  ) throws(AuthError) -> URL {
2✔
606
    try wrappingError(or: mapToAuthError) {
2✔
607
      try self.getURLForProvider(
2✔
608
        url: self.configuration.url.appendingPathComponent("authorize"),
2✔
609
        provider: provider,
2✔
610
        scopes: scopes,
2✔
611
        redirectTo: redirectTo,
2✔
612
        queryParams: queryParams
2✔
613
      )
2✔
614
    }
2✔
615
  }
2✔
616

617
  /// Sign-in an existing user via a third-party provider.
618
  ///
619
  /// - Parameters:
620
  ///   - provider: The third-party provider.
621
  ///   - redirectTo: A URL to send the user to after they are confirmed.
622
  ///   - scopes: A space-separated list of scopes granted to the OAuth application.
623
  ///   - queryParams: Additional query params.
624
  ///   - launchFlow: A launch closure that you can use to implement the authentication flow. Use
625
  /// the `url` to initiate the flow and return a `URL` that contains the OAuth result.
626
  ///
627
  /// - Note: This method support the PKCE flow.
628
  @discardableResult
629
  public func signInWithOAuth(
630
    provider: Provider,
631
    redirectTo: URL? = nil,
632
    scopes: String? = nil,
633
    queryParams: [(name: String, value: String?)] = [],
634
    launchFlow: @MainActor @Sendable (_ url: URL) async throws -> URL
635
  ) async throws(AuthError) -> Session {
1✔
636
    let url = try getOAuthSignInURL(
1✔
637
      provider: provider,
1✔
638
      scopes: scopes,
1✔
639
      redirectTo: redirectTo ?? configuration.redirectToURL,
1✔
640
      queryParams: queryParams
1✔
641
    )
1✔
642

1✔
643
    do {
1✔
644
      let resultURL = try await launchFlow(url)
1✔
645
      return try await session(from: resultURL)
1✔
646
    } catch {
1✔
NEW
647
      throw mapToAuthError(error)
×
NEW
648
    }
×
649
  }
1✔
650

651
  #if canImport(AuthenticationServices)
652
    /// Sign-in an existing user via a third-party provider using ``ASWebAuthenticationSession``.
653
    ///
654
    /// - Parameters:
655
    ///   - provider: The third-party provider.
656
    ///   - redirectTo: A URL to send the user to after they are confirmed.
657
    ///   - scopes: A space-separated list of scopes granted to the OAuth application.
658
    ///   - queryParams: Additional query params.
659
    ///   - configure: A configuration closure that you can use to customize the internal
660
    /// ``ASWebAuthenticationSession`` object.
661
    ///
662
    /// - Note: This method support the PKCE flow.
663
    /// - Warning: Do not call `start()` on the `ASWebAuthenticationSession` object inside the
664
    /// `configure` closure, as the method implementation calls it already.
665
    @available(watchOS 6.2, tvOS 16.0, *)
666
    @discardableResult
667
    public func signInWithOAuth(
668
      provider: Provider,
669
      redirectTo: URL? = nil,
670
      scopes: String? = nil,
671
      queryParams: [(name: String, value: String?)] = [],
672
      configure: @Sendable (_ session: ASWebAuthenticationSession) -> Void = { _ in }
×
NEW
673
    ) async throws(AuthError) -> Session {
×
674
      try await signInWithOAuth(
×
675
        provider: provider,
×
676
        redirectTo: redirectTo,
×
677
        scopes: scopes,
×
678
        queryParams: queryParams
×
679
      ) { @MainActor url in
×
680
        try await withCheckedThrowingContinuation { [configuration] continuation in
×
681
          guard let callbackScheme = (configuration.redirectToURL ?? redirectTo)?.scheme else {
×
682
            preconditionFailure(
×
683
              "Please, provide a valid redirect URL, either thorugh `redirectTo` param, or globally thorugh `AuthClient.Configuration.redirectToURL`."
×
684
            )
×
685
          }
×
686

×
687
          #if !os(tvOS) && !os(watchOS)
688
            var presentationContextProvider: DefaultPresentationContextProvider?
×
689
          #endif
690

×
691
          let session = ASWebAuthenticationSession(
×
692
            url: url,
×
693
            callbackURLScheme: callbackScheme
×
694
          ) { url, error in
×
695
            if let error {
×
696
              continuation.resume(throwing: error)
×
697
            } else if let url {
×
698
              continuation.resume(returning: url)
×
699
            } else {
×
700
              fatalError("Expected url or error, but got none.")
×
701
            }
×
702

×
703
            #if !os(tvOS) && !os(watchOS)
704
              // Keep a strong reference to presentationContextProvider until the flow completes.
×
705
              _ = presentationContextProvider
×
706
            #endif
707
          }
×
708

×
709
          configure(session)
×
710

×
711
          #if !os(tvOS) && !os(watchOS)
712
            if session.presentationContextProvider == nil {
×
713
              presentationContextProvider = DefaultPresentationContextProvider()
×
714
              session.presentationContextProvider = presentationContextProvider
×
715
            }
×
716
          #endif
717

×
718
          session.start()
×
719
        }
×
720
      }
×
721
    }
×
722
  #endif
723

724
  /// Handles an incoming URL received by the app.
725
  ///
726
  /// ## Usage example:
727
  ///
728
  /// ### UIKit app lifecycle
729
  ///
730
  /// In your `AppDelegate.swift`:
731
  ///
732
  /// ```swift
733
  /// public func application(
734
  ///   _ application: UIApplication,
735
  ///   didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
736
  /// ) -> Bool {
737
  ///   if let url = launchOptions?[.url] as? URL {
738
  ///     supabase.auth.handle(url)
739
  ///   }
740
  ///
741
  ///   return true
742
  /// }
743
  ///
744
  /// func application(
745
  ///   _ app: UIApplication,
746
  ///   open url: URL,
747
  ///   options: [UIApplication.OpenURLOptionsKey: Any]
748
  /// ) -> Bool {
749
  ///   supabase.auth.handle(url)
750
  ///   return true
751
  /// }
752
  /// ```
753
  ///
754
  /// ### UIKit app lifecycle with scenes
755
  ///
756
  /// In your `SceneDelegate.swift`:
757
  ///
758
  /// ```swift
759
  /// func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
760
  ///   guard let url = URLContexts.first?.url else { return }
761
  ///   supabase.auth.handle(url)
762
  /// }
763
  /// ```
764
  ///
765
  /// ### SwiftUI app lifecycle
766
  ///
767
  /// In your `AppDelegate.swift`:
768
  ///
769
  /// ```swift
770
  /// SomeView()
771
  ///   .onOpenURL { url in
772
  ///     supabase.auth.handle(url)
773
  ///   }
774
  /// ```
775
  nonisolated public func handle(_ url: URL) {
×
776
    Task {
×
777
      do {
×
778
        try await session(from: url)
×
779
      } catch {
×
780
        logger?.error("Failure loading session from url '\(url)' error: \(error)")
×
781
      }
×
782
    }
×
783
  }
×
784

785
  /// Gets the session data from a OAuth2 callback URL.
786
  @discardableResult
787
  public func session(from url: URL) async throws(AuthError) -> Session {
10✔
788
    logger?.debug("Received URL: \(url)")
10✔
789

10✔
790
    let params = extractParams(from: url)
10✔
791

10✔
792
    return try await wrappingError(or: mapToAuthError) {
10✔
793
      switch self.configuration.flowType {
10✔
794
      case .implicit:
10✔
795
        guard self.isImplicitGrantFlow(params: params) else {
5✔
796
          throw AuthError.implicitGrantRedirect(
1✔
797
            message: "Not a valid implicit grant flow URL: \(url)"
1✔
798
          )
1✔
799
        }
4✔
800
        return try await self.handleImplicitGrantFlow(params: params)
4✔
801

10✔
802
      case .pkce:
10✔
803
        guard self.isPKCEFlow(params: params) else {
5✔
804
          throw AuthError.pkceGrantCodeExchange(message: "Not a valid PKCE flow URL: \(url)")
1✔
805
        }
4✔
806
        return try await self.handlePKCEFlow(params: params)
4✔
807
      }
10✔
808
    }
10✔
809
  }
10✔
810

811
  private func handleImplicitGrantFlow(params: [String: String]) async throws -> Session {
4✔
812
    precondition(configuration.flowType == .implicit, "Method only allowed for implicit flow.")
4✔
813

4✔
814
    if let errorDescription = params["error_description"] {
4✔
815
      throw AuthError.implicitGrantRedirect(
1✔
816
        message: errorDescription.replacingOccurrences(of: "+", with: " ")
1✔
817
      )
1✔
818
    }
3✔
819

3✔
820
    guard
3✔
821
      let accessToken = params["access_token"],
3✔
822
      let expiresIn = params["expires_in"].flatMap(TimeInterval.init),
3✔
823
      let refreshToken = params["refresh_token"],
3✔
824
      let tokenType = params["token_type"]
3✔
825
    else {
3✔
UNCOV
826
      throw AuthError.implicitGrantRedirect(message: "No session defined in URL")
×
827
    }
3✔
828

3✔
829
    let expiresAt = params["expires_at"].flatMap(TimeInterval.init)
3✔
830
    let providerToken = params["provider_token"]
3✔
831
    let providerRefreshToken = params["provider_refresh_token"]
3✔
832

3✔
833
    let user = try await api.execute(
3✔
834
      configuration.url.appendingPathComponent("user"),
3✔
835
      method: .get,
3✔
836
      headers: [.authorization(bearerToken: accessToken)]
3✔
837
    )
3✔
838
    .serializingDecodable(User.self, decoder: configuration.decoder)
3✔
839
    .value
3✔
840

3✔
841
    let session = Session(
3✔
842
      providerToken: providerToken,
3✔
843
      providerRefreshToken: providerRefreshToken,
3✔
844
      accessToken: accessToken,
3✔
845
      tokenType: tokenType,
3✔
846
      expiresIn: expiresIn,
3✔
847
      expiresAt: expiresAt ?? date().addingTimeInterval(expiresIn).timeIntervalSince1970,
3✔
848
      refreshToken: refreshToken,
3✔
849
      user: user
3✔
850
    )
3✔
851

3✔
852
    await sessionManager.update(session)
3✔
853
    eventEmitter.emit(.signedIn, session: session)
3✔
854

3✔
855
    if let type = params["type"], type == "recovery" {
3✔
856
      eventEmitter.emit(.passwordRecovery, session: session)
1✔
857
    }
1✔
858

3✔
859
    return session
3✔
860
  }
4✔
861

862
  private func handlePKCEFlow(params: [String: String]) async throws -> Session {
4✔
863
    precondition(configuration.flowType == .pkce, "Method only allowed for PKCE flow.")
4✔
864

4✔
865
    if params["error"] != nil || params["error_description"] != nil || params["error_code"] != nil {
4✔
866
      throw AuthError.pkceGrantCodeExchange(
3✔
867
        message: params["error_description"]?.replacingOccurrences(of: "+", with: " ")
3✔
868
          ?? "Error in URL with unspecified error_description.",
3✔
869
        error: params["error"] ?? "unspecified_error",
3✔
870
        code: params["error_code"] ?? "unspecified_code"
3✔
871
      )
3✔
872
    }
3✔
873

1✔
874
    guard let code = params["code"] else {
1✔
875
      throw AuthError.pkceGrantCodeExchange(message: "No code detected.")
×
876
    }
1✔
877

1✔
878
    return try await exchangeCodeForSession(authCode: code)
1✔
879
  }
4✔
880

881
  /// Sets the session data from the current session. If the current session is expired, setSession
882
  /// will take care of refreshing it to obtain a new session.
883
  ///
884
  /// If the refresh token is invalid and the current session has expired, an error will be thrown.
885
  /// This method will use the exp claim defined in the access token.
886
  /// - Parameters:
887
  ///   - accessToken: The current access token.
888
  ///   - refreshToken: The current refresh token.
889
  /// - Returns: A new valid session.
890
  @discardableResult
891
  public func setSession(accessToken: String, refreshToken: String) async throws(AuthError)
892
    -> Session
893
  {
2✔
894
    let now = date()
2✔
895
    var expiresAt = now
2✔
896
    var hasExpired = true
2✔
897
    var session: Session
2✔
898

2✔
899
    let jwt = JWT.decodePayload(accessToken)
2✔
900
    if let exp = jwt?["exp"] as? TimeInterval {
2✔
901
      expiresAt = Date(timeIntervalSince1970: exp)
2✔
902
      hasExpired = expiresAt <= now
2✔
903
    }
2✔
904

2✔
905
    if hasExpired {
2✔
906
      session = try await refreshSession(refreshToken: refreshToken)
1✔
907
    } else {
1✔
908
      let user = try await user(jwt: accessToken)
1✔
909
      session = Session(
1✔
910
        accessToken: accessToken,
1✔
911
        tokenType: "bearer",
1✔
912
        expiresIn: expiresAt.timeIntervalSince(now),
1✔
913
        expiresAt: expiresAt.timeIntervalSince1970,
1✔
914
        refreshToken: refreshToken,
1✔
915
        user: user
1✔
916
      )
1✔
917
    }
2✔
918

2✔
919
    await sessionManager.update(session)
2✔
920
    eventEmitter.emit(.signedIn, session: session)
2✔
921
    return session
2✔
922
  }
2✔
923

924
  /// Signs out the current user, if there is a logged in user.
925
  ///
926
  /// If using ``SignOutScope/others`` scope, no ``AuthChangeEvent/signedOut`` event is fired.
927
  /// - Parameter scope: Specifies which sessions should be logged out.
928
  public func signOut(scope: SignOutScope = .global) async throws(AuthError) {
7✔
929
    guard let accessToken = currentSession?.accessToken else {
7✔
930
      configuration.logger?.warning("signOut called without a session")
×
931
      return
×
932
    }
7✔
933

7✔
934
    if scope != .others {
7✔
935
      await sessionManager.remove()
6✔
936
      eventEmitter.emit(.signedOut, session: nil)
6✔
937
    }
6✔
938

7✔
939
    do {
7✔
940
      try await wrappingError(or: mapToAuthError) {
7✔
941
        _ = try await self.api.execute(
7✔
942
          self.configuration.url.appendingPathComponent("logout"),
7✔
943
          method: .post,
7✔
944
          headers: [.authorization(bearerToken: accessToken)],
7✔
945
          query: ["scope": scope.rawValue]
7✔
946
        )
7✔
947
        .serializingData()
7✔
948
        .value
7✔
949
      }
7✔
950
    } catch let AuthError.api(_, _, _, response)
4✔
951
      where [404, 403, 401].contains(response.statusCode)
7✔
952
    {
7✔
953
      // ignore 404s since user might not exist anymore
3✔
954
      // ignore 401s, and 403s since an invalid or expired JWT should sign out the current session.
3✔
955
    }
7✔
956
  }
7✔
957

958
  /// Log in an user given a User supplied OTP received via email.
959
  @discardableResult
960
  public func verifyOTP(
961
    email: String,
962
    token: String,
963
    type: EmailOTPType,
964
    redirectTo: URL? = nil,
965
    captchaToken: String? = nil
966
  ) async throws(AuthError) -> AuthResponse {
1✔
967
    try await _verifyOTP(
1✔
968
      query: (redirectTo ?? configuration.redirectToURL).map {
1✔
969
        ["redirect_to": $0.absoluteString]
1✔
970
      },
1✔
971
      body: .email(
1✔
972
        VerifyEmailOTPParams(
1✔
973
          email: email,
1✔
974
          token: token,
1✔
975
          type: type,
1✔
976
          gotrueMetaSecurity: captchaToken.map(AuthMetaSecurity.init(captchaToken:))
1✔
977
        )
1✔
978
      )
1✔
979
    )
1✔
980
  }
1✔
981

982
  /// Log in an user given a User supplied OTP received via mobile.
983
  @discardableResult
984
  public func verifyOTP(
985
    phone: String,
986
    token: String,
987
    type: MobileOTPType,
988
    captchaToken: String? = nil
989
  ) async throws(AuthError) -> AuthResponse {
1✔
990
    try await _verifyOTP(
1✔
991
      body: .mobile(
1✔
992
        VerifyMobileOTPParams(
1✔
993
          phone: phone,
1✔
994
          token: token,
1✔
995
          type: type,
1✔
996
          gotrueMetaSecurity: captchaToken.map(AuthMetaSecurity.init(captchaToken:))
1✔
997
        )
1✔
998
      )
1✔
999
    )
1✔
1000
  }
1✔
1001

1002
  /// Log in an user given a token hash received via email.
1003
  @discardableResult
1004
  public func verifyOTP(
1005
    tokenHash: String,
1006
    type: EmailOTPType
1007
  ) async throws(AuthError) -> AuthResponse {
1✔
1008
    try await _verifyOTP(
1✔
1009
      body: .tokenHash(VerifyTokenHashParams(tokenHash: tokenHash, type: type))
1✔
1010
    )
1✔
1011
  }
1✔
1012

1013
  private func _verifyOTP(
1014
    query: Parameters? = nil,
1015
    body: VerifyOTPParams
1016
  ) async throws(AuthError) -> AuthResponse {
3✔
1017
    let response = try await wrappingError(or: mapToAuthError) {
3✔
1018
      try await self.api.execute(
3✔
1019
        self.configuration.url.appendingPathComponent("verify"),
3✔
1020
        method: .post,
3✔
1021
        query: query,
3✔
1022
        body: body
3✔
1023
      )
3✔
1024
      .serializingDecodable(AuthResponse.self, decoder: self.configuration.decoder)
3✔
1025
      .value
3✔
1026
    }
3✔
1027

3✔
1028
    if let session = response.session {
3✔
1029
      await sessionManager.update(session)
3✔
1030
      eventEmitter.emit(.signedIn, session: session)
3✔
1031
    }
3✔
1032

3✔
1033
    return response
3✔
1034
  }
3✔
1035

1036
  /// Resends an existing signup confirmation email or email change email.
1037
  ///
1038
  /// To obfuscate whether such the email already exists in the system this method succeeds in both
1039
  /// cases.
1040
  public func resend(
1041
    email: String,
1042
    type: ResendEmailType,
1043
    emailRedirectTo: URL? = nil,
1044
    captchaToken: String? = nil
1045
  ) async throws(AuthError) {
1✔
1046
    _ = try await wrappingError(or: mapToAuthError) {
1✔
1047
      try await self.api.execute(
1✔
1048
        self.configuration.url.appendingPathComponent("resend"),
1✔
1049
        method: .post,
1✔
1050
        query: (emailRedirectTo ?? self.configuration.redirectToURL).map {
1✔
1051
          ["redirect_to": $0.absoluteString]
1✔
1052
        },
1✔
1053
        body: ResendEmailParams(
1✔
1054
          type: type,
1✔
1055
          email: email,
1✔
1056
          gotrueMetaSecurity: captchaToken.map(AuthMetaSecurity.init(captchaToken:))
1✔
1057
        )
1✔
1058
      )
1✔
1059
      .serializingData()
1✔
1060
      .value
1✔
1061
    }
1✔
1062
  }
1✔
1063

1064
  /// Resends an existing SMS OTP or phone change OTP.
1065
  /// - Returns: An object containing the unique ID of the message as reported by the SMS sending
1066
  /// provider. Useful for tracking deliverability problems.
1067
  ///
1068
  /// To obfuscate whether such the phone number already exists in the system this method succeeds
1069
  /// in both cases.
1070
  @discardableResult
1071
  public func resend(
1072
    phone: String,
1073
    type: ResendMobileType,
1074
    captchaToken: String? = nil
1075
  ) async throws(AuthError) -> ResendMobileResponse {
1✔
1076
    return try await wrappingError(or: mapToAuthError) {
1✔
1077
      try await self.api.execute(
1✔
1078
        self.configuration.url.appendingPathComponent("resend"),
1✔
1079
        method: .post,
1✔
1080
        body: ResendMobileParams(
1✔
1081
          type: type,
1✔
1082
          phone: phone,
1✔
1083
          gotrueMetaSecurity: captchaToken.map(AuthMetaSecurity.init(captchaToken:))
1✔
1084
        )
1✔
1085
      )
1✔
1086
      .serializingDecodable(ResendMobileResponse.self, decoder: self.configuration.decoder)
1✔
1087
      .value
1✔
1088
    }
1✔
1089
  }
1✔
1090

1091
  /// Sends a re-authentication OTP to the user's email or phone number.
1092
  public func reauthenticate() async throws(AuthError) {
1✔
1093
    _ = try await wrappingError(or: mapToAuthError) {
1✔
1094
      try await self.api.execute(
1✔
1095
        self.configuration.url.appendingPathComponent("reauthenticate"),
1✔
1096
        method: .get,
1✔
1097
        headers: [
1✔
1098
          .authorization(bearerToken: try await self.session.accessToken)
1✔
1099
        ]
1✔
1100
      )
1✔
1101
      .serializingData()
1✔
1102
      .value
1✔
1103
    }
1✔
1104
  }
1✔
1105

1106
  /// Gets the current user details if there is an existing session.
1107
  /// - Parameter jwt: Takes in an optional access token jwt. If no jwt is provided, user() will
1108
  /// attempt to get the jwt from the current session.
1109
  ///
1110
  /// Should be used only when you require the most current user data. For faster results, ``currentUser`` is recommended.
1111
  public func user(jwt: String? = nil) async throws(AuthError) -> User {
1✔
1112
    return try await wrappingError(or: mapToAuthError) {
1✔
1113
      if let jwt {
1✔
1114
        return try await self.api.execute(
1✔
1115
          self.configuration.url.appendingPathComponent("user"),
1✔
1116
          headers: [
1✔
1117
            .authorization(bearerToken: jwt)
1✔
1118
          ]
1✔
1119
        )
1✔
1120
        .serializingDecodable(User.self, decoder: self.configuration.decoder)
1✔
1121
        .value
1✔
1122

1✔
1123
      }
1✔
UNCOV
1124

×
NEW
1125
      return try await self.api.execute(
×
NEW
1126
        self.configuration.url.appendingPathComponent("user"),
×
NEW
1127
        headers: [
×
NEW
1128
          .authorization(bearerToken: try await self.session.accessToken)
×
NEW
1129
        ]
×
NEW
1130
      )
×
NEW
1131
      .serializingDecodable(User.self, decoder: self.configuration.decoder)
×
NEW
1132
      .value
×
1133
    }
1✔
1134
  }
1✔
1135

1136
  /// Updates user data, if there is a logged in user.
1137
  @discardableResult
1138
  public func update(user: UserAttributes, redirectTo: URL? = nil) async throws(AuthError) -> User {
1✔
1139
    var user = user
1✔
1140

1✔
1141
    if user.email != nil {
1✔
1142
      let (codeChallenge, codeChallengeMethod) = prepareForPKCE()
1✔
1143
      user.codeChallenge = codeChallenge
1✔
1144
      user.codeChallengeMethod = codeChallengeMethod
1✔
1145
    }
1✔
1146

1✔
1147
    return try await wrappingError(or: mapToAuthError) {
1✔
1148
      var session = try await self.sessionManager.session()
1✔
1149
      let updatedUser = try await self.api.execute(
1✔
1150
        self.configuration.url.appendingPathComponent("user"),
1✔
1151
        method: .put,
1✔
1152
        query: (redirectTo ?? self.configuration.redirectToURL).map {
1✔
NEW
1153
          ["redirect_to": $0.absoluteString]
×
NEW
1154
        },
×
1155
        body: user
1✔
1156
      )
1✔
1157
      .serializingDecodable(User.self, decoder: self.configuration.decoder)
1✔
1158
      .value
1✔
1159

1✔
1160
      session.user = updatedUser
1✔
1161
      await self.sessionManager.update(session)
1✔
1162
      self.eventEmitter.emit(.userUpdated, session: session)
1✔
1163
      return updatedUser
1✔
1164
    }
1✔
1165
  }
1✔
1166

1167
  /// Gets all the identities linked to a user.
NEW
1168
  public func userIdentities() async throws(AuthError) -> [UserIdentity] {
×
1169
    try await user().identities ?? []
×
1170
  }
×
1171

1172
  /// Links an OAuth identity to an existing user.
1173
  ///
1174
  /// This method supports the PKCE flow.
1175
  ///
1176
  /// - Parameters:
1177
  ///   - provider: The provider you want to link the user with.
1178
  ///   - scopes: A space-separated list of scopes granted to the OAuth application.
1179
  ///   - redirectTo: A URL to send the user to after they are confirmed.
1180
  ///   - queryParams: Additional query parameters to use.
1181
  ///   - launchURL: Custom launch URL logic.
1182
  public func linkIdentity(
1183
    provider: Provider,
1184
    scopes: String? = nil,
1185
    redirectTo: URL? = nil,
1186
    queryParams: [(name: String, value: String?)] = [],
1187
    launchURL: @MainActor (_ url: URL) -> Void
1188
  ) async throws(AuthError) {
1✔
1189
    let response = try await getLinkIdentityURL(
1✔
1190
      provider: provider,
1✔
1191
      scopes: scopes,
1✔
1192
      redirectTo: redirectTo,
1✔
1193
      queryParams: queryParams
1✔
1194
    )
1✔
1195

1✔
1196
    await launchURL(response.url)
1✔
1197
  }
1✔
1198

1199
  /// Links an OAuth identity to an existing user.
1200
  ///
1201
  /// This method supports the PKCE flow.
1202
  ///
1203
  /// - Parameters:
1204
  ///   - provider: The provider you want to link the user with.
1205
  ///   - scopes: A space-separated list of scopes granted to the OAuth application.
1206
  ///   - redirectTo: A URL to send the user to after they are confirmed.
1207
  ///   - queryParams: Additional query parameters to use.
1208
  ///
1209
  /// - Note: This method opens the URL using the default URL opening mechanism for the platform, if you with to provide your own URL opening logic use ``linkIdentity(provider:scopes:redirectTo:queryParams:launchURL:)``.
1210
  public func linkIdentity(
1211
    provider: Provider,
1212
    scopes: String? = nil,
1213
    redirectTo: URL? = nil,
1214
    queryParams: [(name: String, value: String?)] = []
1215
  ) async throws(AuthError) {
1✔
1216
    try await linkIdentity(
1✔
1217
      provider: provider,
1✔
1218
      scopes: scopes,
1✔
1219
      redirectTo: redirectTo,
1✔
1220
      queryParams: queryParams,
1✔
1221
      launchURL: { Dependencies[clientID].urlOpener.open($0) }
1✔
1222
    )
1✔
1223
  }
1✔
1224

1225
  /// Returns the URL to link the user's identity with an OAuth provider.
1226
  ///
1227
  /// This method supports the PKCE flow.
1228
  ///
1229
  /// - Parameters:
1230
  ///   - provider: The provider you want to link the user with.
1231
  ///   - scopes: A space-separated list of scopes granted to the OAuth application.
1232
  ///   - redirectTo: A URL to send the user to after they are confirmed.
1233
  ///   - queryParams: Additional query parameters to use.
1234
  public func getLinkIdentityURL(
1235
    provider: Provider,
1236
    scopes: String? = nil,
1237
    redirectTo: URL? = nil,
1238
    queryParams: [(name: String, value: String?)] = []
1239
  ) async throws(AuthError) -> OAuthResponse {
2✔
1240
    try await wrappingError(or: mapToAuthError) {
2✔
1241
      let url = try self.getURLForProvider(
2✔
1242
        url: self.configuration.url.appendingPathComponent("user/identities/authorize"),
2✔
1243
        provider: provider,
2✔
1244
        scopes: scopes,
2✔
1245
        redirectTo: redirectTo,
2✔
1246
        queryParams: queryParams,
2✔
1247
        skipBrowserRedirect: true
2✔
1248
      )
2✔
1249

2✔
1250
      struct Response: Codable {
2✔
1251
        let url: URL
2✔
1252
      }
2✔
1253

2✔
1254
      let response = try await self.api.execute(
2✔
1255
        url,
2✔
1256
        method: .get,
2✔
1257
        headers: [
2✔
1258
          .authorization(bearerToken: try await self.session.accessToken)
2✔
1259
        ]
2✔
1260
      )
2✔
1261
      .serializingDecodable(Response.self, decoder: self.configuration.decoder)
2✔
1262
      .value
2✔
1263

2✔
1264
      return OAuthResponse(provider: provider, url: response.url)
2✔
1265
    }
2✔
1266
  }
2✔
1267

1268
  /// Unlinks an identity from a user by deleting it. The user will no longer be able to sign in
1269
  /// with that identity once it's unlinked.
1270
  public func unlinkIdentity(_ identity: UserIdentity) async throws(AuthError) {
1✔
1271
    _ = try await wrappingError(or: mapToAuthError) {
1✔
1272
      try await self.api.execute(
1✔
1273
        self.configuration.url.appendingPathComponent("user/identities/\(identity.identityId)"),
1✔
1274
        method: .delete,
1✔
1275
        headers: [
1✔
1276
          .authorization(bearerToken: try await self.session.accessToken)
1✔
1277
        ]
1✔
1278
      )
1✔
1279
      .serializingData()
1✔
1280
      .value
1✔
1281
    }
1✔
1282
  }
1✔
1283

1284
  /// Sends a reset request to an email address.
1285
  public func resetPasswordForEmail(
1286
    _ email: String,
1287
    redirectTo: URL? = nil,
1288
    captchaToken: String? = nil
1289
  ) async throws(AuthError) {
1✔
1290
    let (codeChallenge, codeChallengeMethod) = prepareForPKCE()
1✔
1291

1✔
1292
    _ = try await wrappingError(or: mapToAuthError) {
1✔
1293
      try await self.api.execute(
1✔
1294
        self.configuration.url.appendingPathComponent("recover"),
1✔
1295
        method: .post,
1✔
1296
        query: (redirectTo ?? self.configuration.redirectToURL).map {
1✔
1297
          ["redirect_to": $0.absoluteString]
1✔
1298
        },
1✔
1299
        body: RecoverParams(
1✔
1300
          email: email,
1✔
1301
          gotrueMetaSecurity: captchaToken.map(AuthMetaSecurity.init(captchaToken:)),
1✔
1302
          codeChallenge: codeChallenge,
1✔
1303
          codeChallengeMethod: codeChallengeMethod
1✔
1304
        )
1✔
1305
      )
1✔
1306
      .serializingData()
1✔
1307
      .value
1✔
1308
    }
1✔
1309
  }
1✔
1310

1311
  /// Refresh and return a new session, regardless of expiry status.
1312
  /// - Parameter refreshToken: The optional refresh token to use for refreshing the session. If
1313
  /// none is provided then this method tries to load the refresh token from the current session.
1314
  /// - Returns: A new session.
1315
  @discardableResult
1316
  public func refreshSession(refreshToken: String? = nil) async throws(AuthError) -> Session {
2✔
1317
    guard let refreshToken = refreshToken ?? currentSession?.refreshToken else {
2✔
1318
      throw AuthError.sessionMissing
×
1319
    }
2✔
1320

2✔
1321
    return try await wrappingError(or: mapToAuthError) {
2✔
1322
      try await self.sessionManager.refreshSession(refreshToken)
2✔
1323
    }
2✔
1324
  }
2✔
1325

1326
  /// Starts an auto-refresh process in the background. The session is checked every few seconds. Close to the time of expiration a process is started to refresh the session. If refreshing fails it will be retried for as long as necessary.
1327
  ///
1328
  /// If you set ``Configuration/autoRefreshToken`` you don't need to call this function, it will be called for you.
1329
  public func startAutoRefresh() {
×
1330
    Task { await sessionManager.startAutoRefresh() }
×
1331
  }
×
1332

1333
  /// Stops an active auto refresh process running in the background (if any).
1334
  public func stopAutoRefresh() {
×
1335
    Task { await sessionManager.stopAutoRefresh() }
×
1336
  }
×
1337

1338
  private func emitInitialSession(forToken token: ObservationToken) async {
12✔
1339
    let session = try? await session
12✔
1340
    eventEmitter.emit(.initialSession, session: session, token: token)
12✔
1341
  }
12✔
1342

1343
  nonisolated private func prepareForPKCE() -> (
1344
    codeChallenge: String?, codeChallengeMethod: String?
1345
  ) {
10✔
1346
    guard configuration.flowType == .pkce else {
10✔
1347
      return (nil, nil)
1✔
1348
    }
9✔
1349

9✔
1350
    let codeVerifier = pkce.generateCodeVerifier()
9✔
1351
    codeVerifierStorage.set(codeVerifier)
9✔
1352

9✔
1353
    let codeChallenge = pkce.generateCodeChallenge(codeVerifier)
9✔
1354
    let codeChallengeMethod = codeVerifier == codeChallenge ? "plain" : "s256"
9✔
1355

9✔
1356
    return (codeChallenge, codeChallengeMethod)
9✔
1357
  }
10✔
1358

1359
  private func isImplicitGrantFlow(params: [String: String]) -> Bool {
5✔
1360
    params["access_token"] != nil || params["error_description"] != nil
5✔
1361
  }
5✔
1362

1363
  private func isPKCEFlow(params: [String: String]) -> Bool {
5✔
1364
    let currentCodeVerifier = codeVerifierStorage.get()
5✔
1365
    return params["code"] != nil || params["error_description"] != nil || params["error"] != nil
5✔
1366
      || params["error_code"] != nil && currentCodeVerifier != nil
5✔
1367
  }
5✔
1368

1369
  nonisolated private func getURLForProvider(
1370
    url: URL,
1371
    provider: Provider,
1372
    scopes: String? = nil,
1373
    redirectTo: URL? = nil,
1374
    queryParams: [(name: String, value: String?)] = [],
1375
    skipBrowserRedirect: Bool? = nil
1376
  ) throws -> URL {
4✔
1377
    guard
4✔
1378
      var components = URLComponents(
4✔
1379
        url: url,
4✔
1380
        resolvingAgainstBaseURL: false
4✔
1381
      )
4✔
1382
    else {
4✔
1383
      throw URLError(.badURL)
×
1384
    }
4✔
1385

4✔
1386
    var queryItems: [URLQueryItem] = [
4✔
1387
      URLQueryItem(name: "provider", value: provider.rawValue)
4✔
1388
    ]
4✔
1389

4✔
1390
    if let scopes {
4✔
1391
      queryItems.append(URLQueryItem(name: "scopes", value: scopes))
1✔
1392
    }
1✔
1393

4✔
1394
    if let redirectTo = redirectTo ?? configuration.redirectToURL {
4✔
1395
      queryItems.append(URLQueryItem(name: "redirect_to", value: redirectTo.absoluteString))
2✔
1396
    }
2✔
1397

4✔
1398
    let (codeChallenge, codeChallengeMethod) = prepareForPKCE()
4✔
1399

4✔
1400
    if let codeChallenge {
4✔
1401
      queryItems.append(URLQueryItem(name: "code_challenge", value: codeChallenge))
3✔
1402
    }
3✔
1403

4✔
1404
    if let codeChallengeMethod {
4✔
1405
      queryItems.append(URLQueryItem(name: "code_challenge_method", value: codeChallengeMethod))
3✔
1406
    }
3✔
1407

4✔
1408
    if let skipBrowserRedirect {
4✔
1409
      queryItems.append(URLQueryItem(name: "skip_http_redirect", value: "\(skipBrowserRedirect)"))
2✔
1410
    }
2✔
1411

4✔
1412
    queryItems.append(contentsOf: queryParams.map(URLQueryItem.init))
4✔
1413

4✔
1414
    components.queryItems = queryItems
4✔
1415

4✔
1416
    guard let url = components.url else {
4✔
1417
      throw URLError(.badURL)
×
1418
    }
4✔
1419

4✔
1420
    return url
4✔
1421
  }
4✔
1422
}
1423

1424
extension AuthClient {
1425
  /// Notification posted when an auth state event is triggered.
1426
  public static let didChangeAuthStateNotification = Notification.Name(
1427
    "AuthClient.didChangeAuthStateNotification"
1428
  )
1429

1430
  /// A user info key to retrieve the ``AuthChangeEvent`` value for a
1431
  /// ``AuthClient/didChangeAuthStateNotification`` notification.
1432
  public static let authChangeEventInfoKey = "AuthClient.authChangeEvent"
1433

1434
  /// A user info key to retrieve the ``Session`` value for a
1435
  /// ``AuthClient/didChangeAuthStateNotification`` notification.
1436
  public static let authChangeSessionInfoKey = "AuthClient.authChangeSession"
1437
}
1438

1439
#if canImport(AuthenticationServices) && !os(tvOS) && !os(watchOS)
1440
  @MainActor
1441
  final class DefaultPresentationContextProvider: NSObject,
1442
    ASWebAuthenticationPresentationContextProviding
1443
  {
1444
    func presentationAnchor(for _: ASWebAuthenticationSession) -> ASPresentationAnchor {
×
1445
      ASPresentationAnchor()
×
1446
    }
×
1447
  }
1448
#endif
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc