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

supabase / auth-js / 12896193447

21 Jan 2025 09:27PM UTC coverage: 50.022% (+0.02%) from 50.0%
12896193447

push

github

web-flow
feat: consider session expired with margin on getSession() without auto refresh (#1027)

When `autoRefreshToken` is off (or when a tab is in the background) but
`getSession()` is called -- such as in an active Realtime channel,
`getSession()` might return a JWT which will expire while the message is
travelling over the internet. There is one confirmed case of this
happening.

This PR adjusts this using the established `EXPIRY_MARGIN_MS` constant
(which only applies on initial initialization of the client). The
constant's value is brought in line with the `autoRefreshToken` ticks
which run every 30 seconds and refreshing is attempted 3 ticks prior to
the session expiring.

This means that JWTs with an expiry value **less than 90s** will always
refresh the session; which is acceptable.

426 of 985 branches covered (43.25%)

Branch coverage included in aggregate %.

5 of 7 new or added lines in 2 files covered. (71.43%)

724 of 1314 relevant lines covered (55.1%)

38.28 hits per line

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

41.82
/src/GoTrueClient.ts
1
import GoTrueAdminApi from './GoTrueAdminApi'
6✔
2
import {
6✔
3
  DEFAULT_HEADERS,
4
  EXPIRY_MARGIN_MS,
5
  AUTO_REFRESH_TICK_DURATION_MS,
6
  AUTO_REFRESH_TICK_THRESHOLD,
7
  GOTRUE_URL,
8
  STORAGE_KEY,
9
} from './lib/constants'
10
import {
6✔
11
  AuthError,
12
  AuthImplicitGrantRedirectError,
13
  AuthPKCEGrantCodeExchangeError,
14
  AuthInvalidCredentialsError,
15
  AuthSessionMissingError,
16
  AuthInvalidTokenResponseError,
17
  AuthUnknownError,
18
  isAuthApiError,
19
  isAuthError,
20
  isAuthRetryableFetchError,
21
  isAuthSessionMissingError,
22
  isAuthImplicitGrantRedirectError,
23
} from './lib/errors'
24
import {
6✔
25
  Fetch,
26
  _request,
27
  _sessionResponse,
28
  _sessionResponsePassword,
29
  _userResponse,
30
  _ssoResponse,
31
} from './lib/fetch'
32
import {
6✔
33
  decodeJWTPayload,
34
  Deferred,
35
  getItemAsync,
36
  isBrowser,
37
  removeItemAsync,
38
  resolveFetch,
39
  setItemAsync,
40
  uuid,
41
  retryable,
42
  sleep,
43
  supportsLocalStorage,
44
  parseParametersFromURL,
45
  getCodeChallengeAndMethod,
46
} from './lib/helpers'
47
import { localStorageAdapter, memoryLocalStorageAdapter } from './lib/local-storage'
6✔
48
import { polyfillGlobalThis } from './lib/polyfills'
6✔
49
import { version } from './lib/version'
6✔
50
import { LockAcquireTimeoutError, navigatorLock } from './lib/locks'
6✔
51

52
import type {
53
  AuthChangeEvent,
54
  AuthResponse,
55
  AuthResponsePassword,
56
  AuthTokenResponse,
57
  AuthTokenResponsePassword,
58
  AuthOtpResponse,
59
  CallRefreshTokenResult,
60
  GoTrueClientOptions,
61
  InitializeResult,
62
  OAuthResponse,
63
  SSOResponse,
64
  Provider,
65
  Session,
66
  SignInWithIdTokenCredentials,
67
  SignInWithOAuthCredentials,
68
  SignInWithPasswordCredentials,
69
  SignInWithPasswordlessCredentials,
70
  SignUpWithPasswordCredentials,
71
  SignInWithSSO,
72
  SignOut,
73
  Subscription,
74
  SupportedStorage,
75
  User,
76
  UserAttributes,
77
  UserResponse,
78
  VerifyOtpParams,
79
  GoTrueMFAApi,
80
  MFAEnrollParams,
81
  AuthMFAEnrollResponse,
82
  MFAChallengeParams,
83
  AuthMFAChallengeResponse,
84
  MFAUnenrollParams,
85
  AuthMFAUnenrollResponse,
86
  MFAVerifyParams,
87
  AuthMFAVerifyResponse,
88
  AuthMFAListFactorsResponse,
89
  AMREntry,
90
  AuthMFAGetAuthenticatorAssuranceLevelResponse,
91
  AuthenticatorAssuranceLevels,
92
  Factor,
93
  MFAChallengeAndVerifyParams,
94
  ResendParams,
95
  AuthFlowType,
96
  LockFunc,
97
  UserIdentity,
98
  SignInAnonymouslyCredentials,
99
  MFAEnrollTOTPParams,
100
  MFAEnrollPhoneParams,
101
  AuthMFAEnrollTOTPResponse,
102
  AuthMFAEnrollPhoneResponse,
103
} from './lib/types'
104

105
polyfillGlobalThis() // Make "globalThis" available
6✔
106

107
const DEFAULT_OPTIONS: Omit<Required<GoTrueClientOptions>, 'fetch' | 'storage' | 'lock'> = {
6✔
108
  url: GOTRUE_URL,
109
  storageKey: STORAGE_KEY,
110
  autoRefreshToken: true,
111
  persistSession: true,
112
  detectSessionInUrl: true,
113
  headers: DEFAULT_HEADERS,
114
  flowType: 'implicit',
115
  debug: false,
116
  hasCustomAuthorizationHeader: false,
117
}
118

119
async function lockNoOp<R>(name: string, acquireTimeout: number, fn: () => Promise<R>): Promise<R> {
120
  return await fn()
170✔
121
}
122

123
export default class GoTrueClient {
6✔
124
  private static nextInstanceID = 0
6✔
125

126
  private instanceID: number
127

128
  /**
129
   * Namespace for the GoTrue admin methods.
130
   * These methods should only be used in a trusted server-side environment.
131
   */
132
  admin: GoTrueAdminApi
133
  /**
134
   * Namespace for the MFA methods.
135
   */
136
  mfa: GoTrueMFAApi
137
  /**
138
   * The storage key used to identify the values saved in localStorage
139
   */
140
  protected storageKey: string
141

142
  protected flowType: AuthFlowType
143

144
  protected autoRefreshToken: boolean
145
  protected persistSession: boolean
146
  protected storage: SupportedStorage
147
  protected memoryStorage: { [key: string]: string } | null = null
44✔
148
  protected stateChangeEmitters: Map<string, Subscription> = new Map()
44✔
149
  protected autoRefreshTicker: ReturnType<typeof setInterval> | null = null
44✔
150
  protected visibilityChangedCallback: (() => Promise<any>) | null = null
44✔
151
  protected refreshingDeferred: Deferred<CallRefreshTokenResult> | null = null
44✔
152
  /**
153
   * Keeps track of the async client initialization.
154
   * When null or not yet resolved the auth state is `unknown`
155
   * Once resolved the the auth state is known and it's save to call any further client methods.
156
   * Keep extra care to never reject or throw uncaught errors
157
   */
158
  protected initializePromise: Promise<InitializeResult> | null = null
44✔
159
  protected detectSessionInUrl = true
44✔
160
  protected url: string
161
  protected headers: {
162
    [key: string]: string
163
  }
164
  protected hasCustomAuthorizationHeader = false
44✔
165
  protected suppressGetSessionWarning = false
44✔
166
  protected fetch: Fetch
167
  protected lock: LockFunc
168
  protected lockAcquired = false
44✔
169
  protected pendingInLock: Promise<any>[] = []
44✔
170

171
  /**
172
   * Used to broadcast state change events to other tabs listening.
173
   */
174
  protected broadcastChannel: BroadcastChannel | null = null
44✔
175

176
  protected logDebugMessages: boolean
177
  protected logger: (message: string, ...args: any[]) => void = console.log
44✔
178

179
  /**
180
   * Create a new client for use in the browser.
181
   */
182
  constructor(options: GoTrueClientOptions) {
183
    this.instanceID = GoTrueClient.nextInstanceID
44✔
184
    GoTrueClient.nextInstanceID += 1
44✔
185

186
    if (this.instanceID > 0 && isBrowser()) {
44!
187
      console.warn(
×
188
        'Multiple GoTrueClient instances detected in the same browser context. It is not an error, but this should be avoided as it may produce undefined behavior when used concurrently under the same storage key.'
189
      )
190
    }
191

192
    const settings = { ...DEFAULT_OPTIONS, ...options }
44✔
193

194
    this.logDebugMessages = !!settings.debug
44✔
195
    if (typeof settings.debug === 'function') {
44!
196
      this.logger = settings.debug
×
197
    }
198

199
    this.persistSession = settings.persistSession
44✔
200
    this.storageKey = settings.storageKey
44✔
201
    this.autoRefreshToken = settings.autoRefreshToken
44✔
202
    this.admin = new GoTrueAdminApi({
44✔
203
      url: settings.url,
204
      headers: settings.headers,
205
      fetch: settings.fetch,
206
    })
207

208
    this.url = settings.url
44✔
209
    this.headers = settings.headers
44✔
210
    this.fetch = resolveFetch(settings.fetch)
44✔
211
    this.lock = settings.lock || lockNoOp
44✔
212
    this.detectSessionInUrl = settings.detectSessionInUrl
44✔
213
    this.flowType = settings.flowType
44✔
214
    this.hasCustomAuthorizationHeader = settings.hasCustomAuthorizationHeader
44✔
215

216
    if (settings.lock) {
44!
217
      this.lock = settings.lock
×
218
    } else if (isBrowser() && globalThis?.navigator?.locks) {
44!
219
      this.lock = navigatorLock
×
220
    } else {
221
      this.lock = lockNoOp
44✔
222
    }
223

224
    this.mfa = {
44✔
225
      verify: this._verify.bind(this),
226
      enroll: this._enroll.bind(this),
227
      unenroll: this._unenroll.bind(this),
228
      challenge: this._challenge.bind(this),
229
      listFactors: this._listFactors.bind(this),
230
      challengeAndVerify: this._challengeAndVerify.bind(this),
231
      getAuthenticatorAssuranceLevel: this._getAuthenticatorAssuranceLevel.bind(this),
232
    }
233

234
    if (this.persistSession) {
44!
235
      if (settings.storage) {
44!
236
        this.storage = settings.storage
44✔
237
      } else {
238
        if (supportsLocalStorage()) {
×
239
          this.storage = localStorageAdapter
×
240
        } else {
241
          this.memoryStorage = {}
×
242
          this.storage = memoryLocalStorageAdapter(this.memoryStorage)
×
243
        }
244
      }
245
    } else {
246
      this.memoryStorage = {}
×
247
      this.storage = memoryLocalStorageAdapter(this.memoryStorage)
×
248
    }
249

250
    if (isBrowser() && globalThis.BroadcastChannel && this.persistSession && this.storageKey) {
44!
251
      try {
×
252
        this.broadcastChannel = new globalThis.BroadcastChannel(this.storageKey)
×
253
      } catch (e: any) {
254
        console.error(
×
255
          'Failed to create a new BroadcastChannel, multi-tab state changes will not be available',
256
          e
257
        )
258
      }
259

260
      this.broadcastChannel?.addEventListener('message', async (event) => {
×
261
        this._debug('received broadcast notification from other tab or client', event)
×
262

263
        await this._notifyAllSubscribers(event.data.event, event.data.session, false) // broadcast = false so we don't get an endless loop of messages
×
264
      })
265
    }
266

267
    this.initialize()
44✔
268
  }
269

270
  private _debug(...args: any[]): GoTrueClient {
271
    if (this.logDebugMessages) {
2,082!
272
      this.logger(
×
273
        `GoTrueClient@${this.instanceID} (${version}) ${new Date().toISOString()}`,
274
        ...args
275
      )
276
    }
277

278
    return this
2,082✔
279
  }
280

281
  /**
282
   * Initializes the client session either from the url or from storage.
283
   * This method is automatically called when instantiating the client, but should also be called
284
   * manually when checking for an error from an auth redirect (oauth, magiclink, password recovery, etc).
285
   */
286
  async initialize(): Promise<InitializeResult> {
287
    if (this.initializePromise) {
44!
288
      return await this.initializePromise
×
289
    }
290

291
    this.initializePromise = (async () => {
44✔
292
      return await this._acquireLock(-1, async () => {
44✔
293
        return await this._initialize()
44✔
294
      })
295
    })()
296

297
    return await this.initializePromise
44✔
298
  }
299

300
  /**
301
   * IMPORTANT:
302
   * 1. Never throw in this method, as it is called from the constructor
303
   * 2. Never return a session from this method as it would be cached over
304
   *    the whole lifetime of the client
305
   */
306
  private async _initialize(): Promise<InitializeResult> {
307
    try {
44✔
308
      const params = parseParametersFromURL(window.location.href)
44✔
309
      let callbackUrlType = 'none'
×
310
      if (this._isImplicitGrantCallback(params)) {
×
311
        callbackUrlType = 'implicit'
×
312
      } else if (await this._isPKCECallback(params)) {
×
313
        callbackUrlType = 'pkce'
×
314
      }
315

316
      /**
317
       * Attempt to get the session from the URL only if these conditions are fulfilled
318
       *
319
       * Note: If the URL isn't one of the callback url types (implicit or pkce),
320
       * then there could be an existing session so we don't want to prematurely remove it
321
       */
322
      if (isBrowser() && this.detectSessionInUrl && callbackUrlType !== 'none') {
×
323
        const { data, error } = await this._getSessionFromURL(params, callbackUrlType)
×
324
        if (error) {
×
325
          this._debug('#_initialize()', 'error detecting session from URL', error)
×
326

327
          if (isAuthImplicitGrantRedirectError(error)) {
×
328
            const errorCode = error.details?.code
×
329
            if (
×
330
              errorCode === 'identity_already_exists' ||
×
331
              errorCode === 'identity_not_found' ||
332
              errorCode === 'single_identity_not_deletable'
333
            ) {
334
              return { error }
×
335
            }
336
          }
337

338
          // failed login attempt via url,
339
          // remove old session as in verifyOtp, signUp and signInWith*
340
          await this._removeSession()
×
341

342
          return { error }
×
343
        }
344

345
        const { session, redirectType } = data
×
346

347
        this._debug(
×
348
          '#_initialize()',
349
          'detected session in URL',
350
          session,
351
          'redirect type',
352
          redirectType
353
        )
354

355
        await this._saveSession(session)
×
356

357
        setTimeout(async () => {
×
358
          if (redirectType === 'recovery') {
×
359
            await this._notifyAllSubscribers('PASSWORD_RECOVERY', session)
×
360
          } else {
361
            await this._notifyAllSubscribers('SIGNED_IN', session)
×
362
          }
363
        }, 0)
364

365
        return { error: null }
×
366
      }
367
      // no login attempt via callback url try to recover session from storage
368
      await this._recoverAndRefresh()
×
369
      return { error: null }
×
370
    } catch (error) {
371
      if (isAuthError(error)) {
44!
372
        return { error }
×
373
      }
374

375
      return {
44✔
376
        error: new AuthUnknownError('Unexpected error during initialization', error),
377
      }
378
    } finally {
379
      await this._handleVisibilityChange()
44✔
380
      this._debug('#_initialize()', 'end')
44✔
381
    }
382
  }
383

384
  /**
385
   * Creates a new anonymous user.
386
   *
387
   * @returns A session where the is_anonymous claim in the access token JWT set to true
388
   */
389
  async signInAnonymously(credentials?: SignInAnonymouslyCredentials): Promise<AuthResponse> {
390
    try {
×
391
      const res = await _request(this.fetch, 'POST', `${this.url}/signup`, {
×
392
        headers: this.headers,
393
        body: {
394
          data: credentials?.options?.data ?? {},
×
395
          gotrue_meta_security: { captcha_token: credentials?.options?.captchaToken },
×
396
        },
397
        xform: _sessionResponse,
398
      })
399
      const { data, error } = res
×
400

401
      if (error || !data) {
×
402
        return { data: { user: null, session: null }, error: error }
×
403
      }
404
      const session: Session | null = data.session
×
405
      const user: User | null = data.user
×
406

407
      if (data.session) {
×
408
        await this._saveSession(data.session)
×
409
        await this._notifyAllSubscribers('SIGNED_IN', session)
×
410
      }
411

412
      return { data: { user, session }, error: null }
×
413
    } catch (error) {
414
      if (isAuthError(error)) {
×
415
        return { data: { user: null, session: null }, error }
×
416
      }
417

418
      throw error
×
419
    }
420
  }
421

422
  /**
423
   * Creates a new user.
424
   *
425
   * Be aware that if a user account exists in the system you may get back an
426
   * error message that attempts to hide this information from the user.
427
   * This method has support for PKCE via email signups. The PKCE flow cannot be used when autoconfirm is enabled.
428
   *
429
   * @returns A logged-in session if the server has "autoconfirm" ON
430
   * @returns A user if the server has "autoconfirm" OFF
431
   */
432
  async signUp(credentials: SignUpWithPasswordCredentials): Promise<AuthResponse> {
433
    try {
60✔
434
      let res: AuthResponse
435
      if ('email' in credentials) {
60✔
436
        const { email, password, options } = credentials
54✔
437
        let codeChallenge: string | null = null
54✔
438
        let codeChallengeMethod: string | null = null
54✔
439
        if (this.flowType === 'pkce') {
54!
440
          ;[codeChallenge, codeChallengeMethod] = await getCodeChallengeAndMethod(
×
441
            this.storage,
442
            this.storageKey
443
          )
444
        }
445
        res = await _request(this.fetch, 'POST', `${this.url}/signup`, {
54✔
446
          headers: this.headers,
447
          redirectTo: options?.emailRedirectTo,
162!
448
          body: {
449
            email,
450
            password,
451
            data: options?.data ?? {},
324!
452
            gotrue_meta_security: { captcha_token: options?.captchaToken },
162!
453
            code_challenge: codeChallenge,
454
            code_challenge_method: codeChallengeMethod,
455
          },
456
          xform: _sessionResponse,
457
        })
458
      } else if ('phone' in credentials) {
6!
459
        const { phone, password, options } = credentials
6✔
460
        res = await _request(this.fetch, 'POST', `${this.url}/signup`, {
6✔
461
          headers: this.headers,
462
          body: {
463
            phone,
464
            password,
465
            data: options?.data ?? {},
36!
466
            channel: options?.channel ?? 'sms',
36!
467
            gotrue_meta_security: { captcha_token: options?.captchaToken },
18!
468
          },
469
          xform: _sessionResponse,
470
        })
471
      } else {
472
        throw new AuthInvalidCredentialsError(
×
473
          'You must provide either an email or phone number and a password'
474
        )
475
      }
476

477
      const { data, error } = res
52✔
478

479
      if (error || !data) {
52!
480
        return { data: { user: null, session: null }, error: error }
×
481
      }
482

483
      const session: Session | null = data.session
52✔
484
      const user: User | null = data.user
52✔
485

486
      if (data.session) {
52✔
487
        await this._saveSession(data.session)
50✔
488
        await this._notifyAllSubscribers('SIGNED_IN', session)
50✔
489
      }
490

491
      return { data: { user, session }, error: null }
52✔
492
    } catch (error) {
493
      if (isAuthError(error)) {
8✔
494
        return { data: { user: null, session: null }, error }
8✔
495
      }
496

497
      throw error
×
498
    }
499
  }
500

501
  /**
502
   * Log in an existing user with an email and password or phone and password.
503
   *
504
   * Be aware that you may get back an error message that will not distinguish
505
   * between the cases where the account does not exist or that the
506
   * email/phone and password combination is wrong or that the account can only
507
   * be accessed via social login.
508
   */
509
  async signInWithPassword(
510
    credentials: SignInWithPasswordCredentials
511
  ): Promise<AuthTokenResponsePassword> {
512
    try {
12✔
513
      let res: AuthResponsePassword
514
      if ('email' in credentials) {
12✔
515
        const { email, password, options } = credentials
10✔
516
        res = await _request(this.fetch, 'POST', `${this.url}/token?grant_type=password`, {
10✔
517
          headers: this.headers,
518
          body: {
519
            email,
520
            password,
521
            gotrue_meta_security: { captcha_token: options?.captchaToken },
30!
522
          },
523
          xform: _sessionResponsePassword,
524
        })
525
      } else if ('phone' in credentials) {
2!
526
        const { phone, password, options } = credentials
2✔
527
        res = await _request(this.fetch, 'POST', `${this.url}/token?grant_type=password`, {
2✔
528
          headers: this.headers,
529
          body: {
530
            phone,
531
            password,
532
            gotrue_meta_security: { captcha_token: options?.captchaToken },
6!
533
          },
534
          xform: _sessionResponsePassword,
535
        })
536
      } else {
537
        throw new AuthInvalidCredentialsError(
×
538
          'You must provide either an email or phone number and a password'
539
        )
540
      }
541
      const { data, error } = res
10✔
542

543
      if (error) {
10!
544
        return { data: { user: null, session: null }, error }
×
545
      } else if (!data || !data.session || !data.user) {
10!
546
        return { data: { user: null, session: null }, error: new AuthInvalidTokenResponseError() }
×
547
      }
548
      if (data.session) {
10✔
549
        await this._saveSession(data.session)
10✔
550
        await this._notifyAllSubscribers('SIGNED_IN', data.session)
10✔
551
      }
552
      return {
10✔
553
        data: {
554
          user: data.user,
555
          session: data.session,
556
          ...(data.weak_password ? { weakPassword: data.weak_password } : null),
10!
557
        },
558
        error,
559
      }
560
    } catch (error) {
561
      if (isAuthError(error)) {
2✔
562
        return { data: { user: null, session: null }, error }
2✔
563
      }
564
      throw error
×
565
    }
566
  }
567

568
  /**
569
   * Log in an existing user via a third-party provider.
570
   * This method supports the PKCE flow.
571
   */
572
  async signInWithOAuth(credentials: SignInWithOAuthCredentials): Promise<OAuthResponse> {
573
    return await this._handleProviderSignIn(credentials.provider, {
8✔
574
      redirectTo: credentials.options?.redirectTo,
24✔
575
      scopes: credentials.options?.scopes,
24✔
576
      queryParams: credentials.options?.queryParams,
24✔
577
      skipBrowserRedirect: credentials.options?.skipBrowserRedirect,
24✔
578
    })
579
  }
580

581
  /**
582
   * Log in an existing user by exchanging an Auth Code issued during the PKCE flow.
583
   */
584
  async exchangeCodeForSession(authCode: string): Promise<AuthTokenResponse> {
585
    await this.initializePromise
×
586

587
    return this._acquireLock(-1, async () => {
×
588
      return this._exchangeCodeForSession(authCode)
×
589
    })
590
  }
591

592
  private async _exchangeCodeForSession(authCode: string): Promise<
593
    | {
594
        data: { session: Session; user: User; redirectType: string | null }
595
        error: null
596
      }
597
    | { data: { session: null; user: null; redirectType: null }; error: AuthError }
598
  > {
599
    const storageItem = await getItemAsync(this.storage, `${this.storageKey}-code-verifier`)
×
600
    const [codeVerifier, redirectType] = ((storageItem ?? '') as string).split('/')
×
601

602
    try {
×
603
      const { data, error } = await _request(
×
604
        this.fetch,
605
        'POST',
606
        `${this.url}/token?grant_type=pkce`,
607
        {
608
          headers: this.headers,
609
          body: {
610
            auth_code: authCode,
611
            code_verifier: codeVerifier,
612
          },
613
          xform: _sessionResponse,
614
        }
615
      )
616
      await removeItemAsync(this.storage, `${this.storageKey}-code-verifier`)
×
617
      if (error) {
×
618
        throw error
×
619
      }
620
      if (!data || !data.session || !data.user) {
×
621
        return {
×
622
          data: { user: null, session: null, redirectType: null },
623
          error: new AuthInvalidTokenResponseError(),
624
        }
625
      }
626
      if (data.session) {
×
627
        await this._saveSession(data.session)
×
628
        await this._notifyAllSubscribers('SIGNED_IN', data.session)
×
629
      }
630
      return { data: { ...data, redirectType: redirectType ?? null }, error }
×
631
    } catch (error) {
632
      if (isAuthError(error)) {
×
633
        return { data: { user: null, session: null, redirectType: null }, error }
×
634
      }
635

636
      throw error
×
637
    }
638
  }
639

640
  /**
641
   * Allows signing in with an OIDC ID token. The authentication provider used
642
   * should be enabled and configured.
643
   */
644
  async signInWithIdToken(credentials: SignInWithIdTokenCredentials): Promise<AuthTokenResponse> {
645
    try {
×
646
      const { options, provider, token, access_token, nonce } = credentials
×
647

648
      const res = await _request(this.fetch, 'POST', `${this.url}/token?grant_type=id_token`, {
×
649
        headers: this.headers,
650
        body: {
651
          provider,
652
          id_token: token,
653
          access_token,
654
          nonce,
655
          gotrue_meta_security: { captcha_token: options?.captchaToken },
×
656
        },
657
        xform: _sessionResponse,
658
      })
659

660
      const { data, error } = res
×
661
      if (error) {
×
662
        return { data: { user: null, session: null }, error }
×
663
      } else if (!data || !data.session || !data.user) {
×
664
        return {
×
665
          data: { user: null, session: null },
666
          error: new AuthInvalidTokenResponseError(),
667
        }
668
      }
669
      if (data.session) {
×
670
        await this._saveSession(data.session)
×
671
        await this._notifyAllSubscribers('SIGNED_IN', data.session)
×
672
      }
673
      return { data, error }
×
674
    } catch (error) {
675
      if (isAuthError(error)) {
×
676
        return { data: { user: null, session: null }, error }
×
677
      }
678
      throw error
×
679
    }
680
  }
681

682
  /**
683
   * Log in a user using magiclink or a one-time password (OTP).
684
   *
685
   * If the `{{ .ConfirmationURL }}` variable is specified in the email template, a magiclink will be sent.
686
   * If the `{{ .Token }}` variable is specified in the email template, an OTP will be sent.
687
   * If you're using phone sign-ins, only an OTP will be sent. You won't be able to send a magiclink for phone sign-ins.
688
   *
689
   * Be aware that you may get back an error message that will not distinguish
690
   * between the cases where the account does not exist or, that the account
691
   * can only be accessed via social login.
692
   *
693
   * Do note that you will need to configure a Whatsapp sender on Twilio
694
   * if you are using phone sign in with the 'whatsapp' channel. The whatsapp
695
   * channel is not supported on other providers
696
   * at this time.
697
   * This method supports PKCE when an email is passed.
698
   */
699
  async signInWithOtp(credentials: SignInWithPasswordlessCredentials): Promise<AuthOtpResponse> {
700
    try {
4✔
701
      if ('email' in credentials) {
4✔
702
        const { email, options } = credentials
2✔
703
        let codeChallenge: string | null = null
2✔
704
        let codeChallengeMethod: string | null = null
2✔
705
        if (this.flowType === 'pkce') {
2!
706
          ;[codeChallenge, codeChallengeMethod] = await getCodeChallengeAndMethod(
×
707
            this.storage,
708
            this.storageKey
709
          )
710
        }
711
        const { error } = await _request(this.fetch, 'POST', `${this.url}/otp`, {
2✔
712
          headers: this.headers,
713
          body: {
714
            email,
715
            data: options?.data ?? {},
12!
716
            create_user: options?.shouldCreateUser ?? true,
12!
717
            gotrue_meta_security: { captcha_token: options?.captchaToken },
6!
718
            code_challenge: codeChallenge,
719
            code_challenge_method: codeChallengeMethod,
720
          },
721
          redirectTo: options?.emailRedirectTo,
6!
722
        })
723
        return { data: { user: null, session: null }, error }
2✔
724
      }
725
      if ('phone' in credentials) {
2✔
726
        const { phone, options } = credentials
2✔
727
        const { data, error } = await _request(this.fetch, 'POST', `${this.url}/otp`, {
2✔
728
          headers: this.headers,
729
          body: {
730
            phone,
731
            data: options?.data ?? {},
12!
732
            create_user: options?.shouldCreateUser ?? true,
12!
733
            gotrue_meta_security: { captcha_token: options?.captchaToken },
6!
734
            channel: options?.channel ?? 'sms',
12!
735
          },
736
        })
737
        return { data: { user: null, session: null, messageId: data?.message_id }, error }
×
738
      }
739
      throw new AuthInvalidCredentialsError('You must provide either an email or phone number.')
×
740
    } catch (error) {
741
      if (isAuthError(error)) {
2✔
742
        return { data: { user: null, session: null }, error }
2✔
743
      }
744

745
      throw error
×
746
    }
747
  }
748

749
  /**
750
   * Log in a user given a User supplied OTP or TokenHash received through mobile or email.
751
   */
752
  async verifyOtp(params: VerifyOtpParams): Promise<AuthResponse> {
753
    try {
4✔
754
      let redirectTo: string | undefined = undefined
4✔
755
      let captchaToken: string | undefined = undefined
4✔
756
      if ('options' in params) {
4!
757
        redirectTo = params.options?.redirectTo
×
758
        captchaToken = params.options?.captchaToken
×
759
      }
760
      const { data, error } = await _request(this.fetch, 'POST', `${this.url}/verify`, {
4✔
761
        headers: this.headers,
762
        body: {
763
          ...params,
764
          gotrue_meta_security: { captcha_token: captchaToken },
765
        },
766
        redirectTo,
767
        xform: _sessionResponse,
768
      })
769

770
      if (error) {
×
771
        throw error
×
772
      }
773

774
      if (!data) {
×
775
        throw new Error('An error occurred on token verification.')
×
776
      }
777

778
      const session: Session | null = data.session
×
779
      const user: User = data.user
×
780

781
      if (session?.access_token) {
×
782
        await this._saveSession(session as Session)
×
783
        await this._notifyAllSubscribers(
×
784
          params.type == 'recovery' ? 'PASSWORD_RECOVERY' : 'SIGNED_IN',
×
785
          session
786
        )
787
      }
788

789
      return { data: { user, session }, error: null }
×
790
    } catch (error) {
791
      if (isAuthError(error)) {
4✔
792
        return { data: { user: null, session: null }, error }
4✔
793
      }
794

795
      throw error
×
796
    }
797
  }
798

799
  /**
800
   * Attempts a single-sign on using an enterprise Identity Provider. A
801
   * successful SSO attempt will redirect the current page to the identity
802
   * provider authorization page. The redirect URL is implementation and SSO
803
   * protocol specific.
804
   *
805
   * You can use it by providing a SSO domain. Typically you can extract this
806
   * domain by asking users for their email address. If this domain is
807
   * registered on the Auth instance the redirect will use that organization's
808
   * currently active SSO Identity Provider for the login.
809
   *
810
   * If you have built an organization-specific login page, you can use the
811
   * organization's SSO Identity Provider UUID directly instead.
812
   */
813
  async signInWithSSO(params: SignInWithSSO): Promise<SSOResponse> {
814
    try {
×
815
      let codeChallenge: string | null = null
×
816
      let codeChallengeMethod: string | null = null
×
817
      if (this.flowType === 'pkce') {
×
818
        ;[codeChallenge, codeChallengeMethod] = await getCodeChallengeAndMethod(
×
819
          this.storage,
820
          this.storageKey
821
        )
822
      }
823

824
      return await _request(this.fetch, 'POST', `${this.url}/sso`, {
×
825
        body: {
826
          ...('providerId' in params ? { provider_id: params.providerId } : null),
×
827
          ...('domain' in params ? { domain: params.domain } : null),
×
828
          redirect_to: params.options?.redirectTo ?? undefined,
×
829
          ...(params?.options?.captchaToken
×
830
            ? { gotrue_meta_security: { captcha_token: params.options.captchaToken } }
831
            : null),
832
          skip_http_redirect: true, // fetch does not handle redirects
833
          code_challenge: codeChallenge,
834
          code_challenge_method: codeChallengeMethod,
835
        },
836
        headers: this.headers,
837
        xform: _ssoResponse,
838
      })
839
    } catch (error) {
840
      if (isAuthError(error)) {
×
841
        return { data: null, error }
×
842
      }
843
      throw error
×
844
    }
845
  }
846

847
  /**
848
   * Sends a reauthentication OTP to the user's email or phone number.
849
   * Requires the user to be signed-in.
850
   */
851
  async reauthenticate(): Promise<AuthResponse> {
852
    await this.initializePromise
×
853

854
    return await this._acquireLock(-1, async () => {
×
855
      return await this._reauthenticate()
×
856
    })
857
  }
858

859
  private async _reauthenticate(): Promise<AuthResponse> {
860
    try {
×
861
      return await this._useSession(async (result) => {
×
862
        const {
863
          data: { session },
864
          error: sessionError,
865
        } = result
×
866
        if (sessionError) throw sessionError
×
867
        if (!session) throw new AuthSessionMissingError()
×
868

869
        const { error } = await _request(this.fetch, 'GET', `${this.url}/reauthenticate`, {
×
870
          headers: this.headers,
871
          jwt: session.access_token,
872
        })
873
        return { data: { user: null, session: null }, error }
×
874
      })
875
    } catch (error) {
876
      if (isAuthError(error)) {
×
877
        return { data: { user: null, session: null }, error }
×
878
      }
879
      throw error
×
880
    }
881
  }
882

883
  /**
884
   * Resends an existing signup confirmation email, email change email, SMS OTP or phone change OTP.
885
   */
886
  async resend(credentials: ResendParams): Promise<AuthOtpResponse> {
887
    try {
×
888
      const endpoint = `${this.url}/resend`
×
889
      if ('email' in credentials) {
×
890
        const { email, type, options } = credentials
×
891
        const { error } = await _request(this.fetch, 'POST', endpoint, {
×
892
          headers: this.headers,
893
          body: {
894
            email,
895
            type,
896
            gotrue_meta_security: { captcha_token: options?.captchaToken },
×
897
          },
898
          redirectTo: options?.emailRedirectTo,
×
899
        })
900
        return { data: { user: null, session: null }, error }
×
901
      } else if ('phone' in credentials) {
×
902
        const { phone, type, options } = credentials
×
903
        const { data, error } = await _request(this.fetch, 'POST', endpoint, {
×
904
          headers: this.headers,
905
          body: {
906
            phone,
907
            type,
908
            gotrue_meta_security: { captcha_token: options?.captchaToken },
×
909
          },
910
        })
911
        return { data: { user: null, session: null, messageId: data?.message_id }, error }
×
912
      }
913
      throw new AuthInvalidCredentialsError(
×
914
        'You must provide either an email or phone number and a type'
915
      )
916
    } catch (error) {
917
      if (isAuthError(error)) {
×
918
        return { data: { user: null, session: null }, error }
×
919
      }
920
      throw error
×
921
    }
922
  }
923

924
  /**
925
   * Returns the session, refreshing it if necessary.
926
   *
927
   * The session returned can be null if the session is not detected which can happen in the event a user is not signed-in or has logged out.
928
   *
929
   * **IMPORTANT:** This method loads values directly from the storage attached
930
   * to the client. If that storage is based on request cookies for example,
931
   * the values in it may not be authentic and therefore it's strongly advised
932
   * against using this method and its results in such circumstances. A warning
933
   * will be emitted if this is detected. Use {@link #getUser()} instead.
934
   */
935
  async getSession() {
936
    await this.initializePromise
20✔
937

938
    const result = await this._acquireLock(-1, async () => {
20✔
939
      return this._useSession(async (result) => {
20✔
940
        return result
20✔
941
      })
942
    })
943

944
    return result
20✔
945
  }
946

947
  /**
948
   * Acquires a global lock based on the storage key.
949
   */
950
  private async _acquireLock<R>(acquireTimeout: number, fn: () => Promise<R>): Promise<R> {
951
    this._debug('#_acquireLock', 'begin', acquireTimeout)
170✔
952

953
    try {
170✔
954
      if (this.lockAcquired) {
170!
955
        const last = this.pendingInLock.length
×
956
          ? this.pendingInLock[this.pendingInLock.length - 1]
957
          : Promise.resolve()
958

959
        const result = (async () => {
×
960
          await last
×
961
          return await fn()
×
962
        })()
963

964
        this.pendingInLock.push(
×
965
          (async () => {
966
            try {
×
967
              await result
×
968
            } catch (e: any) {
969
              // we just care if it finished
970
            }
971
          })()
972
        )
973

974
        return result
×
975
      }
976

977
      return await this.lock(`lock:${this.storageKey}`, acquireTimeout, async () => {
170✔
978
        this._debug('#_acquireLock', 'lock acquired for storage key', this.storageKey)
170✔
979

980
        try {
170✔
981
          this.lockAcquired = true
170✔
982

983
          const result = fn()
170✔
984

985
          this.pendingInLock.push(
170✔
986
            (async () => {
987
              try {
170✔
988
                await result
170✔
989
              } catch (e: any) {
990
                // we just care if it finished
991
              }
992
            })()
993
          )
994

995
          await result
170✔
996

997
          // keep draining the queue until there's nothing to wait on
998
          while (this.pendingInLock.length) {
170✔
999
            const waitOn = [...this.pendingInLock]
170✔
1000

1001
            await Promise.all(waitOn)
170✔
1002

1003
            this.pendingInLock.splice(0, waitOn.length)
170✔
1004
          }
1005

1006
          return await result
170✔
1007
        } finally {
1008
          this._debug('#_acquireLock', 'lock released for storage key', this.storageKey)
170✔
1009

1010
          this.lockAcquired = false
170✔
1011
        }
1012
      })
1013
    } finally {
1014
      this._debug('#_acquireLock', 'end')
170✔
1015
    }
1016
  }
1017

1018
  /**
1019
   * Use instead of {@link #getSession} inside the library. It is
1020
   * semantically usually what you want, as getting a session involves some
1021
   * processing afterwards that requires only one client operating on the
1022
   * session at once across multiple tabs or processes.
1023
   */
1024
  private async _useSession<R>(
1025
    fn: (
1026
      result:
1027
        | {
1028
            data: {
1029
              session: Session
1030
            }
1031
            error: null
1032
          }
1033
        | {
1034
            data: {
1035
              session: null
1036
            }
1037
            error: AuthError
1038
          }
1039
        | {
1040
            data: {
1041
              session: null
1042
            }
1043
            error: null
1044
          }
1045
    ) => Promise<R>
1046
  ): Promise<R> {
1047
    this._debug('#_useSession', 'begin')
126✔
1048

1049
    try {
126✔
1050
      // the use of __loadSession here is the only correct use of the function!
1051
      const result = await this.__loadSession()
126✔
1052

1053
      return await fn(result)
126✔
1054
    } finally {
1055
      this._debug('#_useSession', 'end')
126✔
1056
    }
1057
  }
1058

1059
  /**
1060
   * NEVER USE DIRECTLY!
1061
   *
1062
   * Always use {@link #_useSession}.
1063
   */
1064
  private async __loadSession(): Promise<
1065
    | {
1066
        data: {
1067
          session: Session
1068
        }
1069
        error: null
1070
      }
1071
    | {
1072
        data: {
1073
          session: null
1074
        }
1075
        error: AuthError
1076
      }
1077
    | {
1078
        data: {
1079
          session: null
1080
        }
1081
        error: null
1082
      }
1083
  > {
1084
    this._debug('#__loadSession()', 'begin')
126✔
1085

1086
    if (!this.lockAcquired) {
126✔
1087
      this._debug('#__loadSession()', 'used outside of an acquired lock!', new Error().stack)
2✔
1088
    }
1089

1090
    try {
126✔
1091
      let currentSession: Session | null = null
126✔
1092

1093
      const maybeSession = await getItemAsync(this.storage, this.storageKey)
126✔
1094

1095
      this._debug('#getSession()', 'session from storage', maybeSession)
126✔
1096

1097
      if (maybeSession !== null) {
126✔
1098
        if (this._isValidSession(maybeSession)) {
70!
1099
          currentSession = maybeSession
70✔
1100
        } else {
1101
          this._debug('#getSession()', 'session from storage is not valid')
×
1102
          await this._removeSession()
×
1103
        }
1104
      }
1105

1106
      if (!currentSession) {
126✔
1107
        return { data: { session: null }, error: null }
56✔
1108
      }
1109

1110
      // A session is considered expired before the access token _actually_
1111
      // expires. When the autoRefreshToken option is off (or when the tab is
1112
      // in the background), very eager users of getSession() -- like
1113
      // realtime-js -- might send a valid JWT which will expire by the time it
1114
      // reaches the server.
1115
      const hasExpired = currentSession.expires_at
70!
1116
        ? currentSession.expires_at * 1000 - Date.now() < EXPIRY_MARGIN_MS
1117
        : false
1118

1119
      this._debug(
70✔
1120
        '#__loadSession()',
1121
        `session has${hasExpired ? '' : ' not'} expired`,
70✔
1122
        'expires_at',
1123
        currentSession.expires_at
1124
      )
1125

1126
      if (!hasExpired) {
70✔
1127
        if (this.storage.isServer) {
68✔
1128
          let suppressWarning = this.suppressGetSessionWarning
14✔
1129
          const proxySession: Session = new Proxy(currentSession, {
14✔
1130
            get: (target: any, prop: string, receiver: any) => {
1131
              if (!suppressWarning && prop === 'user') {
24✔
1132
                // only show warning when the user object is being accessed from the server
1133
                console.warn(
2✔
1134
                  'Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.'
1135
                )
1136
                suppressWarning = true // keeps this proxy instance from logging additional warnings
2✔
1137
                this.suppressGetSessionWarning = true // keeps this client's future proxy instances from warning
2✔
1138
              }
1139
              return Reflect.get(target, prop, receiver)
24✔
1140
            },
1141
          })
1142
          currentSession = proxySession
14✔
1143
        }
1144

1145
        return { data: { session: currentSession }, error: null }
68✔
1146
      }
1147

1148
      const { session, error } = await this._callRefreshToken(currentSession.refresh_token)
2✔
1149
      if (error) {
2!
1150
        return { data: { session: null }, error }
×
1151
      }
1152

1153
      return { data: { session }, error: null }
2✔
1154
    } finally {
1155
      this._debug('#__loadSession()', 'end')
126✔
1156
    }
1157
  }
1158

1159
  /**
1160
   * Gets the current user details if there is an existing session. This method
1161
   * performs a network request to the Supabase Auth server, so the returned
1162
   * value is authentic and can be used to base authorization rules on.
1163
   *
1164
   * @param jwt Takes in an optional access token JWT. If no JWT is provided, the JWT from the current session is used.
1165
   */
1166
  async getUser(jwt?: string): Promise<UserResponse> {
1167
    if (jwt) {
6!
1168
      return await this._getUser(jwt)
×
1169
    }
1170

1171
    await this.initializePromise
6✔
1172

1173
    const result = await this._acquireLock(-1, async () => {
6✔
1174
      return await this._getUser()
6✔
1175
    })
1176

1177
    return result
6✔
1178
  }
1179

1180
  private async _getUser(jwt?: string): Promise<UserResponse> {
1181
    try {
8✔
1182
      if (jwt) {
8✔
1183
        return await _request(this.fetch, 'GET', `${this.url}/user`, {
2✔
1184
          headers: this.headers,
1185
          jwt: jwt,
1186
          xform: _userResponse,
1187
        })
1188
      }
1189

1190
      return await this._useSession(async (result) => {
6✔
1191
        const { data, error } = result
6✔
1192
        if (error) {
6!
1193
          throw error
×
1194
        }
1195

1196
        // returns an error if there is no access_token or custom authorization header
1197
        if (!data.session?.access_token && !this.hasCustomAuthorizationHeader) {
6✔
1198
          return { data: { user: null }, error: new AuthSessionMissingError() }
2✔
1199
        }
1200

1201
        return await _request(this.fetch, 'GET', `${this.url}/user`, {
4✔
1202
          headers: this.headers,
1203
          jwt: data.session?.access_token ?? undefined,
24!
1204
          xform: _userResponse,
1205
        })
1206
      })
1207
    } catch (error) {
1208
      if (isAuthError(error)) {
×
1209
        if (isAuthSessionMissingError(error)) {
×
1210
          // JWT contains a `session_id` which does not correspond to an active
1211
          // session in the database, indicating the user is signed out.
1212

1213
          await this._removeSession()
×
1214
          await removeItemAsync(this.storage, `${this.storageKey}-code-verifier`)
×
1215
        }
1216

1217
        return { data: { user: null }, error }
×
1218
      }
1219

1220
      throw error
×
1221
    }
1222
  }
1223

1224
  /**
1225
   * Updates user data for a logged in user.
1226
   */
1227
  async updateUser(
1228
    attributes: UserAttributes,
1229
    options: {
6✔
1230
      emailRedirectTo?: string | undefined
1231
    } = {}
1232
  ): Promise<UserResponse> {
1233
    await this.initializePromise
6✔
1234

1235
    return await this._acquireLock(-1, async () => {
6✔
1236
      return await this._updateUser(attributes, options)
6✔
1237
    })
1238
  }
1239

1240
  protected async _updateUser(
1241
    attributes: UserAttributes,
1242
    options: {
×
1243
      emailRedirectTo?: string | undefined
1244
    } = {}
1245
  ): Promise<UserResponse> {
1246
    try {
6✔
1247
      return await this._useSession(async (result) => {
6✔
1248
        const { data: sessionData, error: sessionError } = result
6✔
1249
        if (sessionError) {
6!
1250
          throw sessionError
×
1251
        }
1252
        if (!sessionData.session) {
6!
1253
          throw new AuthSessionMissingError()
×
1254
        }
1255
        const session: Session = sessionData.session
6✔
1256
        let codeChallenge: string | null = null
6✔
1257
        let codeChallengeMethod: string | null = null
6✔
1258
        if (this.flowType === 'pkce' && attributes.email != null) {
6!
1259
          ;[codeChallenge, codeChallengeMethod] = await getCodeChallengeAndMethod(
×
1260
            this.storage,
1261
            this.storageKey
1262
          )
1263
        }
1264

1265
        const { data, error: userError } = await _request(this.fetch, 'PUT', `${this.url}/user`, {
6✔
1266
          headers: this.headers,
1267
          redirectTo: options?.emailRedirectTo,
18!
1268
          body: {
1269
            ...attributes,
1270
            code_challenge: codeChallenge,
1271
            code_challenge_method: codeChallengeMethod,
1272
          },
1273
          jwt: session.access_token,
1274
          xform: _userResponse,
1275
        })
1276
        if (userError) throw userError
6!
1277
        session.user = data.user as User
6✔
1278
        await this._saveSession(session)
6✔
1279
        await this._notifyAllSubscribers('USER_UPDATED', session)
6✔
1280
        return { data: { user: session.user }, error: null }
6✔
1281
      })
1282
    } catch (error) {
1283
      if (isAuthError(error)) {
×
1284
        return { data: { user: null }, error }
×
1285
      }
1286

1287
      throw error
×
1288
    }
1289
  }
1290

1291
  /**
1292
   * Decodes a JWT (without performing any validation).
1293
   */
1294
  private _decodeJWT(jwt: string): {
1295
    exp?: number
1296
    aal?: AuthenticatorAssuranceLevels | null
1297
    amr?: AMREntry[] | null
1298
  } {
1299
    return decodeJWTPayload(jwt)
×
1300
  }
1301

1302
  /**
1303
   * Sets the session data from the current session. If the current session is expired, setSession will take care of refreshing it to obtain a new session.
1304
   * If the refresh token or access token in the current session is invalid, an error will be thrown.
1305
   * @param currentSession The current session that minimally contains an access token and refresh token.
1306
   */
1307
  async setSession(currentSession: {
1308
    access_token: string
1309
    refresh_token: string
1310
  }): Promise<AuthResponse> {
1311
    await this.initializePromise
2✔
1312

1313
    return await this._acquireLock(-1, async () => {
2✔
1314
      return await this._setSession(currentSession)
2✔
1315
    })
1316
  }
1317

1318
  protected async _setSession(currentSession: {
1319
    access_token: string
1320
    refresh_token: string
1321
  }): Promise<AuthResponse> {
1322
    try {
2✔
1323
      if (!currentSession.access_token || !currentSession.refresh_token) {
2!
1324
        throw new AuthSessionMissingError()
×
1325
      }
1326

1327
      const timeNow = Date.now() / 1000
2✔
1328
      let expiresAt = timeNow
2✔
1329
      let hasExpired = true
2✔
1330
      let session: Session | null = null
2✔
1331
      const payload = decodeJWTPayload(currentSession.access_token)
2✔
1332
      if (payload.exp) {
2✔
1333
        expiresAt = payload.exp
2✔
1334
        hasExpired = expiresAt <= timeNow
2✔
1335
      }
1336

1337
      if (hasExpired) {
2!
1338
        const { session: refreshedSession, error } = await this._callRefreshToken(
×
1339
          currentSession.refresh_token
1340
        )
1341
        if (error) {
×
1342
          return { data: { user: null, session: null }, error: error }
×
1343
        }
1344

1345
        if (!refreshedSession) {
×
1346
          return { data: { user: null, session: null }, error: null }
×
1347
        }
1348
        session = refreshedSession
×
1349
      } else {
1350
        const { data, error } = await this._getUser(currentSession.access_token)
2✔
1351
        if (error) {
2!
1352
          throw error
×
1353
        }
1354
        session = {
2✔
1355
          access_token: currentSession.access_token,
1356
          refresh_token: currentSession.refresh_token,
1357
          user: data.user,
1358
          token_type: 'bearer',
1359
          expires_in: expiresAt - timeNow,
1360
          expires_at: expiresAt,
1361
        }
1362
        await this._saveSession(session)
2✔
1363
        await this._notifyAllSubscribers('SIGNED_IN', session)
2✔
1364
      }
1365

1366
      return { data: { user: session.user, session }, error: null }
2✔
1367
    } catch (error) {
1368
      if (isAuthError(error)) {
×
1369
        return { data: { session: null, user: null }, error }
×
1370
      }
1371

1372
      throw error
×
1373
    }
1374
  }
1375

1376
  /**
1377
   * Returns a new session, regardless of expiry status.
1378
   * Takes in an optional current session. If not passed in, then refreshSession() will attempt to retrieve it from getSession().
1379
   * If the current session's refresh token is invalid, an error will be thrown.
1380
   * @param currentSession The current session. If passed in, it must contain a refresh token.
1381
   */
1382
  async refreshSession(currentSession?: { refresh_token: string }): Promise<AuthResponse> {
1383
    await this.initializePromise
4✔
1384

1385
    return await this._acquireLock(-1, async () => {
4✔
1386
      return await this._refreshSession(currentSession)
4✔
1387
    })
1388
  }
1389

1390
  protected async _refreshSession(currentSession?: {
1391
    refresh_token: string
1392
  }): Promise<AuthResponse> {
1393
    try {
4✔
1394
      return await this._useSession(async (result) => {
4✔
1395
        if (!currentSession) {
4✔
1396
          const { data, error } = result
2✔
1397
          if (error) {
2!
1398
            throw error
×
1399
          }
1400

1401
          currentSession = data.session ?? undefined
2!
1402
        }
1403

1404
        if (!currentSession?.refresh_token) {
4!
1405
          throw new AuthSessionMissingError()
×
1406
        }
1407

1408
        const { session, error } = await this._callRefreshToken(currentSession.refresh_token)
4✔
1409
        if (error) {
4!
1410
          return { data: { user: null, session: null }, error: error }
×
1411
        }
1412

1413
        if (!session) {
4!
1414
          return { data: { user: null, session: null }, error: null }
×
1415
        }
1416

1417
        return { data: { user: session.user, session }, error: null }
4✔
1418
      })
1419
    } catch (error) {
1420
      if (isAuthError(error)) {
×
1421
        return { data: { user: null, session: null }, error }
×
1422
      }
1423

1424
      throw error
×
1425
    }
1426
  }
1427

1428
  /**
1429
   * Gets the session data from a URL string
1430
   */
1431
  private async _getSessionFromURL(
1432
    params: { [parameter: string]: string },
1433
    callbackUrlType: string
1434
  ): Promise<
1435
    | {
1436
        data: { session: Session; redirectType: string | null }
1437
        error: null
1438
      }
1439
    | { data: { session: null; redirectType: null }; error: AuthError }
1440
  > {
1441
    try {
2✔
1442
      if (!isBrowser()) throw new AuthImplicitGrantRedirectError('No browser detected.')
2✔
1443

1444
      // If there's an error in the URL, it doesn't matter what flow it is, we just return the error.
1445
      if (params.error || params.error_description || params.error_code) {
×
1446
        // The error class returned implies that the redirect is from an implicit grant flow
1447
        // but it could also be from a redirect error from a PKCE flow.
1448
        throw new AuthImplicitGrantRedirectError(
×
1449
          params.error_description || 'Error in URL with unspecified error_description',
×
1450
          {
1451
            error: params.error || 'unspecified_error',
×
1452
            code: params.error_code || 'unspecified_code',
×
1453
          }
1454
        )
1455
      }
1456

1457
      // Checks for mismatches between the flowType initialised in the client and the URL parameters
1458
      switch (callbackUrlType) {
×
1459
        case 'implicit':
1460
          if (this.flowType === 'pkce') {
×
1461
            throw new AuthPKCEGrantCodeExchangeError('Not a valid PKCE flow url.')
×
1462
          }
1463
          break
×
1464
        case 'pkce':
1465
          if (this.flowType === 'implicit') {
×
1466
            throw new AuthImplicitGrantRedirectError('Not a valid implicit grant flow url.')
×
1467
          }
1468
          break
×
1469
        default:
1470
        // there's no mismatch so we continue
1471
      }
1472

1473
      // Since this is a redirect for PKCE, we attempt to retrieve the code from the URL for the code exchange
1474
      if (callbackUrlType === 'pkce') {
×
1475
        this._debug('#_initialize()', 'begin', 'is PKCE flow', true)
×
1476
        if (!params.code) throw new AuthPKCEGrantCodeExchangeError('No code detected.')
×
1477
        const { data, error } = await this._exchangeCodeForSession(params.code)
×
1478
        if (error) throw error
×
1479

1480
        const url = new URL(window.location.href)
×
1481
        url.searchParams.delete('code')
×
1482

1483
        window.history.replaceState(window.history.state, '', url.toString())
×
1484

1485
        return { data: { session: data.session, redirectType: null }, error: null }
×
1486
      }
1487

1488
      const {
1489
        provider_token,
1490
        provider_refresh_token,
1491
        access_token,
1492
        refresh_token,
1493
        expires_in,
1494
        expires_at,
1495
        token_type,
1496
      } = params
×
1497

1498
      if (!access_token || !expires_in || !refresh_token || !token_type) {
×
1499
        throw new AuthImplicitGrantRedirectError('No session defined in URL')
×
1500
      }
1501

1502
      const timeNow = Math.round(Date.now() / 1000)
×
1503
      const expiresIn = parseInt(expires_in)
×
1504
      let expiresAt = timeNow + expiresIn
×
1505

1506
      if (expires_at) {
×
1507
        expiresAt = parseInt(expires_at)
×
1508
      }
1509

1510
      const actuallyExpiresIn = expiresAt - timeNow
×
NEW
1511
      if (actuallyExpiresIn * 1000 <= AUTO_REFRESH_TICK_DURATION_MS) {
×
1512
        console.warn(
×
1513
          `@supabase/gotrue-js: Session as retrieved from URL expires in ${actuallyExpiresIn}s, should have been closer to ${expiresIn}s`
1514
        )
1515
      }
1516

1517
      const issuedAt = expiresAt - expiresIn
×
1518
      if (timeNow - issuedAt >= 120) {
×
1519
        console.warn(
×
1520
          '@supabase/gotrue-js: Session as retrieved from URL was issued over 120s ago, URL could be stale',
1521
          issuedAt,
1522
          expiresAt,
1523
          timeNow
1524
        )
1525
      } else if (timeNow - issuedAt < 0) {
×
1526
        console.warn(
×
1527
          '@supabase/gotrue-js: Session as retrieved from URL was issued in the future? Check the device clock for skew',
1528
          issuedAt,
1529
          expiresAt,
1530
          timeNow
1531
        )
1532
      }
1533

1534
      const { data, error } = await this._getUser(access_token)
×
1535
      if (error) throw error
×
1536

1537
      const session: Session = {
×
1538
        provider_token,
1539
        provider_refresh_token,
1540
        access_token,
1541
        expires_in: expiresIn,
1542
        expires_at: expiresAt,
1543
        refresh_token,
1544
        token_type,
1545
        user: data.user,
1546
      }
1547

1548
      // Remove tokens from URL
1549
      window.location.hash = ''
×
1550
      this._debug('#_getSessionFromURL()', 'clearing window.location.hash')
×
1551

1552
      return { data: { session, redirectType: params.type }, error: null }
×
1553
    } catch (error) {
1554
      if (isAuthError(error)) {
2✔
1555
        return { data: { session: null, redirectType: null }, error }
2✔
1556
      }
1557

1558
      throw error
×
1559
    }
1560
  }
1561

1562
  /**
1563
   * Checks if the current URL contains parameters given by an implicit oauth grant flow (https://www.rfc-editor.org/rfc/rfc6749.html#section-4.2)
1564
   */
1565
  private _isImplicitGrantCallback(params: { [parameter: string]: string }): boolean {
1566
    return Boolean(params.access_token || params.error_description)
×
1567
  }
1568

1569
  /**
1570
   * Checks if the current URL and backing storage contain parameters given by a PKCE flow
1571
   */
1572
  private async _isPKCECallback(params: { [parameter: string]: string }): Promise<boolean> {
1573
    const currentStorageContent = await getItemAsync(
×
1574
      this.storage,
1575
      `${this.storageKey}-code-verifier`
1576
    )
1577

1578
    return !!(params.code && currentStorageContent)
×
1579
  }
1580

1581
  /**
1582
   * Inside a browser context, `signOut()` will remove the logged in user from the browser session and log them out - removing all items from localstorage and then trigger a `"SIGNED_OUT"` event.
1583
   *
1584
   * For server-side management, you can revoke all refresh tokens for a user by passing a user's JWT through to `auth.api.signOut(JWT: string)`.
1585
   * There is no way to revoke a user's access token jwt until it expires. It is recommended to set a shorter expiry on the jwt for this reason.
1586
   *
1587
   * If using `others` scope, no `SIGNED_OUT` event is fired!
1588
   */
1589
  async signOut(options: SignOut = { scope: 'global' }): Promise<{ error: AuthError | null }> {
82✔
1590
    await this.initializePromise
82✔
1591

1592
    return await this._acquireLock(-1, async () => {
82✔
1593
      return await this._signOut(options)
82✔
1594
    })
1595
  }
1596

1597
  protected async _signOut(
1598
    { scope }: SignOut = { scope: 'global' }
×
1599
  ): Promise<{ error: AuthError | null }> {
1600
    return await this._useSession(async (result) => {
82✔
1601
      const { data, error: sessionError } = result
82✔
1602
      if (sessionError) {
82!
1603
        return { error: sessionError }
×
1604
      }
1605
      const accessToken = data.session?.access_token
82✔
1606
      if (accessToken) {
82✔
1607
        const { error } = await this.admin.signOut(accessToken, scope)
32✔
1608
        if (error) {
32✔
1609
          // ignore 404s since user might not exist anymore
1610
          // ignore 401s since an invalid or expired JWT should sign out the current session
1611
          if (
2!
1612
            !(
1613
              isAuthApiError(error) &&
8✔
1614
              (error.status === 404 || error.status === 401 || error.status === 403)
1615
            )
1616
          ) {
1617
            return { error }
×
1618
          }
1619
        }
1620
      }
1621
      if (scope !== 'others') {
82✔
1622
        await this._removeSession()
82✔
1623
        await removeItemAsync(this.storage, `${this.storageKey}-code-verifier`)
82✔
1624
      }
1625
      return { error: null }
82✔
1626
    })
1627
  }
1628

1629
  /**
1630
   * Receive a notification every time an auth event happens.
1631
   * @param callback A callback function to be invoked when an auth event happens.
1632
   */
1633
  onAuthStateChange(
1634
    callback: (event: AuthChangeEvent, session: Session | null) => void | Promise<void>
1635
  ): {
1636
    data: { subscription: Subscription }
1637
  } {
1638
    const id: string = uuid()
2✔
1639
    const subscription: Subscription = {
2✔
1640
      id,
1641
      callback,
1642
      unsubscribe: () => {
1643
        this._debug('#unsubscribe()', 'state change callback with id removed', id)
2✔
1644

1645
        this.stateChangeEmitters.delete(id)
2✔
1646
      },
1647
    }
1648

1649
    this._debug('#onAuthStateChange()', 'registered callback with id', id)
2✔
1650

1651
    this.stateChangeEmitters.set(id, subscription)
2✔
1652
    ;(async () => {
2✔
1653
      await this.initializePromise
2✔
1654

1655
      await this._acquireLock(-1, async () => {
2✔
1656
        this._emitInitialSession(id)
2✔
1657
      })
1658
    })()
1659

1660
    return { data: { subscription } }
2✔
1661
  }
1662

1663
  private async _emitInitialSession(id: string): Promise<void> {
1664
    return await this._useSession(async (result) => {
2✔
1665
      try {
2✔
1666
        const {
1667
          data: { session },
1668
          error,
1669
        } = result
2✔
1670
        if (error) throw error
2!
1671

1672
        await this.stateChangeEmitters.get(id)?.callback('INITIAL_SESSION', session)
2!
1673
        this._debug('INITIAL_SESSION', 'callback id', id, 'session', session)
2✔
1674
      } catch (err) {
1675
        await this.stateChangeEmitters.get(id)?.callback('INITIAL_SESSION', null)
×
1676
        this._debug('INITIAL_SESSION', 'callback id', id, 'error', err)
×
1677
        console.error(err)
×
1678
      }
1679
    })
1680
  }
1681

1682
  /**
1683
   * Sends a password reset request to an email address. This method supports the PKCE flow.
1684
   *
1685
   * @param email The email address of the user.
1686
   * @param options.redirectTo The URL to send the user to after they click the password reset link.
1687
   * @param options.captchaToken Verification token received when the user completes the captcha on the site.
1688
   */
1689
  async resetPasswordForEmail(
1690
    email: string,
1691
    options: {
×
1692
      redirectTo?: string
1693
      captchaToken?: string
1694
    } = {}
1695
  ): Promise<
1696
    | {
1697
        data: {}
1698
        error: null
1699
      }
1700
    | { data: null; error: AuthError }
1701
  > {
1702
    let codeChallenge: string | null = null
4✔
1703
    let codeChallengeMethod: string | null = null
4✔
1704

1705
    if (this.flowType === 'pkce') {
4!
1706
      ;[codeChallenge, codeChallengeMethod] = await getCodeChallengeAndMethod(
×
1707
        this.storage,
1708
        this.storageKey,
1709
        true // isPasswordRecovery
1710
      )
1711
    }
1712
    try {
4✔
1713
      return await _request(this.fetch, 'POST', `${this.url}/recover`, {
4✔
1714
        body: {
1715
          email,
1716
          code_challenge: codeChallenge,
1717
          code_challenge_method: codeChallengeMethod,
1718
          gotrue_meta_security: { captcha_token: options.captchaToken },
1719
        },
1720
        headers: this.headers,
1721
        redirectTo: options.redirectTo,
1722
      })
1723
    } catch (error) {
1724
      if (isAuthError(error)) {
×
1725
        return { data: null, error }
×
1726
      }
1727

1728
      throw error
×
1729
    }
1730
  }
1731

1732
  /**
1733
   * Gets all the identities linked to a user.
1734
   */
1735
  async getUserIdentities(): Promise<
1736
    | {
1737
        data: {
1738
          identities: UserIdentity[]
1739
        }
1740
        error: null
1741
      }
1742
    | { data: null; error: AuthError }
1743
  > {
1744
    try {
×
1745
      const { data, error } = await this.getUser()
×
1746
      if (error) throw error
×
1747
      return { data: { identities: data.user.identities ?? [] }, error: null }
×
1748
    } catch (error) {
1749
      if (isAuthError(error)) {
×
1750
        return { data: null, error }
×
1751
      }
1752
      throw error
×
1753
    }
1754
  }
1755
  /**
1756
   * Links an oauth identity to an existing user.
1757
   * This method supports the PKCE flow.
1758
   */
1759
  async linkIdentity(credentials: SignInWithOAuthCredentials): Promise<OAuthResponse> {
1760
    try {
×
1761
      const { data, error } = await this._useSession(async (result) => {
×
1762
        const { data, error } = result
×
1763
        if (error) throw error
×
1764
        const url: string = await this._getUrlForProvider(
×
1765
          `${this.url}/user/identities/authorize`,
1766
          credentials.provider,
1767
          {
1768
            redirectTo: credentials.options?.redirectTo,
×
1769
            scopes: credentials.options?.scopes,
×
1770
            queryParams: credentials.options?.queryParams,
×
1771
            skipBrowserRedirect: true,
1772
          }
1773
        )
1774
        return await _request(this.fetch, 'GET', url, {
×
1775
          headers: this.headers,
1776
          jwt: data.session?.access_token ?? undefined,
×
1777
        })
1778
      })
1779
      if (error) throw error
×
1780
      if (isBrowser() && !credentials.options?.skipBrowserRedirect) {
×
1781
        window.location.assign(data?.url)
×
1782
      }
1783
      return { data: { provider: credentials.provider, url: data?.url }, error: null }
×
1784
    } catch (error) {
1785
      if (isAuthError(error)) {
×
1786
        return { data: { provider: credentials.provider, url: null }, error }
×
1787
      }
1788
      throw error
×
1789
    }
1790
  }
1791

1792
  /**
1793
   * Unlinks an identity from a user by deleting it. The user will no longer be able to sign in with that identity once it's unlinked.
1794
   */
1795
  async unlinkIdentity(identity: UserIdentity): Promise<
1796
    | {
1797
        data: {}
1798
        error: null
1799
      }
1800
    | { data: null; error: AuthError }
1801
  > {
1802
    try {
×
1803
      return await this._useSession(async (result) => {
×
1804
        const { data, error } = result
×
1805
        if (error) {
×
1806
          throw error
×
1807
        }
1808
        return await _request(
×
1809
          this.fetch,
1810
          'DELETE',
1811
          `${this.url}/user/identities/${identity.identity_id}`,
1812
          {
1813
            headers: this.headers,
1814
            jwt: data.session?.access_token ?? undefined,
×
1815
          }
1816
        )
1817
      })
1818
    } catch (error) {
1819
      if (isAuthError(error)) {
×
1820
        return { data: null, error }
×
1821
      }
1822
      throw error
×
1823
    }
1824
  }
1825

1826
  /**
1827
   * Generates a new JWT.
1828
   * @param refreshToken A valid refresh token that was returned on login.
1829
   */
1830
  private async _refreshAccessToken(refreshToken: string): Promise<AuthResponse> {
1831
    const debugName = `#_refreshAccessToken(${refreshToken.substring(0, 5)}...)`
14✔
1832
    this._debug(debugName, 'begin')
14✔
1833

1834
    try {
14✔
1835
      const startedAt = Date.now()
14✔
1836

1837
      // will attempt to refresh the token with exponential backoff
1838
      return await retryable(
14✔
1839
        async (attempt) => {
1840
          if (attempt > 0) {
14!
1841
            await sleep(200 * Math.pow(2, attempt - 1)) // 200, 400, 800, ...
×
1842
          }
1843

1844
          this._debug(debugName, 'refreshing attempt', attempt)
14✔
1845

1846
          return await _request(this.fetch, 'POST', `${this.url}/token?grant_type=refresh_token`, {
14✔
1847
            body: { refresh_token: refreshToken },
1848
            headers: this.headers,
1849
            xform: _sessionResponse,
1850
          })
1851
        },
1852
        (attempt, error) => {
1853
          const nextBackOffInterval = 200 * Math.pow(2, attempt)
14✔
1854
          return (
14✔
1855
            error &&
14!
1856
            isAuthRetryableFetchError(error) &&
1857
            // retryable only if the request can be sent before the backoff overflows the tick duration
1858
            Date.now() + nextBackOffInterval - startedAt < AUTO_REFRESH_TICK_DURATION_MS
1859
          )
1860
        }
1861
      )
1862
    } catch (error) {
1863
      this._debug(debugName, 'error', error)
×
1864

1865
      if (isAuthError(error)) {
×
1866
        return { data: { session: null, user: null }, error }
×
1867
      }
1868
      throw error
×
1869
    } finally {
1870
      this._debug(debugName, 'end')
14✔
1871
    }
1872
  }
1873

1874
  private _isValidSession(maybeSession: unknown): maybeSession is Session {
1875
    const isValidSession =
1876
      typeof maybeSession === 'object' &&
70✔
1877
      maybeSession !== null &&
1878
      'access_token' in maybeSession &&
1879
      'refresh_token' in maybeSession &&
1880
      'expires_at' in maybeSession
1881

1882
    return isValidSession
70✔
1883
  }
1884

1885
  private async _handleProviderSignIn(
1886
    provider: Provider,
1887
    options: {
1888
      redirectTo?: string
1889
      scopes?: string
1890
      queryParams?: { [key: string]: string }
1891
      skipBrowserRedirect?: boolean
1892
    }
1893
  ) {
1894
    const url: string = await this._getUrlForProvider(`${this.url}/authorize`, provider, {
8✔
1895
      redirectTo: options.redirectTo,
1896
      scopes: options.scopes,
1897
      queryParams: options.queryParams,
1898
    })
1899

1900
    this._debug('#_handleProviderSignIn()', 'provider', provider, 'options', options, 'url', url)
8✔
1901

1902
    // try to open on the browser
1903
    if (isBrowser() && !options.skipBrowserRedirect) {
8!
1904
      window.location.assign(url)
×
1905
    }
1906

1907
    return { data: { provider, url }, error: null }
8✔
1908
  }
1909

1910
  /**
1911
   * Recovers the session from LocalStorage and refreshes the token
1912
   * Note: this method is async to accommodate for AsyncStorage e.g. in React native.
1913
   */
1914
  private async _recoverAndRefresh() {
1915
    const debugName = '#_recoverAndRefresh()'
×
1916
    this._debug(debugName, 'begin')
×
1917

1918
    try {
×
1919
      const currentSession = await getItemAsync(this.storage, this.storageKey)
×
1920
      this._debug(debugName, 'session from storage', currentSession)
×
1921

1922
      if (!this._isValidSession(currentSession)) {
×
1923
        this._debug(debugName, 'session is not valid')
×
1924
        if (currentSession !== null) {
×
1925
          await this._removeSession()
×
1926
        }
1927

1928
        return
×
1929
      }
1930

1931
      const expiresWithMargin =
NEW
1932
        (currentSession.expires_at ?? Infinity) * 1000 - Date.now() < EXPIRY_MARGIN_MS
×
1933

1934
      this._debug(
×
1935
        debugName,
1936
        `session has${expiresWithMargin ? '' : ' not'} expired with margin of ${EXPIRY_MARGIN_MS}s`
×
1937
      )
1938

1939
      if (expiresWithMargin) {
×
1940
        if (this.autoRefreshToken && currentSession.refresh_token) {
×
1941
          const { error } = await this._callRefreshToken(currentSession.refresh_token)
×
1942

1943
          if (error) {
×
1944
            console.error(error)
×
1945

1946
            if (!isAuthRetryableFetchError(error)) {
×
1947
              this._debug(
×
1948
                debugName,
1949
                'refresh failed with a non-retryable error, removing the session',
1950
                error
1951
              )
1952
              await this._removeSession()
×
1953
            }
1954
          }
1955
        }
1956
      } else {
1957
        // no need to persist currentSession again, as we just loaded it from
1958
        // local storage; persisting it again may overwrite a value saved by
1959
        // another client with access to the same local storage
1960
        await this._notifyAllSubscribers('SIGNED_IN', currentSession)
×
1961
      }
1962
    } catch (err) {
1963
      this._debug(debugName, 'error', err)
×
1964

1965
      console.error(err)
×
1966
      return
×
1967
    } finally {
1968
      this._debug(debugName, 'end')
×
1969
    }
1970
  }
1971

1972
  private async _callRefreshToken(refreshToken: string): Promise<CallRefreshTokenResult> {
1973
    if (!refreshToken) {
22!
1974
      throw new AuthSessionMissingError()
×
1975
    }
1976

1977
    // refreshing is already in progress
1978
    if (this.refreshingDeferred) {
22✔
1979
      return this.refreshingDeferred.promise
6✔
1980
    }
1981

1982
    const debugName = `#_callRefreshToken(${refreshToken.substring(0, 5)}...)`
16✔
1983

1984
    this._debug(debugName, 'begin')
16✔
1985

1986
    try {
16✔
1987
      this.refreshingDeferred = new Deferred<CallRefreshTokenResult>()
16✔
1988

1989
      const { data, error } = await this._refreshAccessToken(refreshToken)
16✔
1990
      if (error) throw error
14✔
1991
      if (!data.session) throw new AuthSessionMissingError()
12!
1992

1993
      await this._saveSession(data.session)
12✔
1994
      await this._notifyAllSubscribers('TOKEN_REFRESHED', data.session)
12✔
1995

1996
      const result = { session: data.session, error: null }
12✔
1997

1998
      this.refreshingDeferred.resolve(result)
12✔
1999

2000
      return result
12✔
2001
    } catch (error) {
2002
      this._debug(debugName, 'error', error)
4✔
2003

2004
      if (isAuthError(error)) {
4✔
2005
        const result = { session: null, error }
2✔
2006

2007
        if (!isAuthRetryableFetchError(error)) {
2✔
2008
          await this._removeSession()
2✔
2009
        }
2010

2011
        this.refreshingDeferred?.resolve(result)
2!
2012

2013
        return result
2✔
2014
      }
2015

2016
      this.refreshingDeferred?.reject(error)
2!
2017
      throw error
2✔
2018
    } finally {
2019
      this.refreshingDeferred = null
16✔
2020
      this._debug(debugName, 'end')
16✔
2021
    }
2022
  }
2023

2024
  private async _notifyAllSubscribers(
2025
    event: AuthChangeEvent,
2026
    session: Session | null,
2027
    broadcast = true
164✔
2028
  ) {
2029
    const debugName = `#_notifyAllSubscribers(${event})`
164✔
2030
    this._debug(debugName, 'begin', session, `broadcast = ${broadcast}`)
164✔
2031

2032
    try {
164✔
2033
      if (this.broadcastChannel && broadcast) {
164!
2034
        this.broadcastChannel.postMessage({ event, session })
×
2035
      }
2036

2037
      const errors: any[] = []
164✔
2038
      const promises = Array.from(this.stateChangeEmitters.values()).map(async (x) => {
164✔
2039
        try {
×
2040
          await x.callback(event, session)
×
2041
        } catch (e: any) {
2042
          errors.push(e)
×
2043
        }
2044
      })
2045

2046
      await Promise.all(promises)
164✔
2047

2048
      if (errors.length > 0) {
164!
2049
        for (let i = 0; i < errors.length; i += 1) {
×
2050
          console.error(errors[i])
×
2051
        }
2052

2053
        throw errors[0]
×
2054
      }
2055
    } finally {
2056
      this._debug(debugName, 'end')
164✔
2057
    }
2058
  }
2059

2060
  /**
2061
   * set currentSession and currentUser
2062
   * process to _startAutoRefreshToken if possible
2063
   */
2064
  private async _saveSession(session: Session) {
2065
    this._debug('#_saveSession()', session)
84✔
2066
    // _saveSession is always called whenever a new session has been acquired
2067
    // so we can safely suppress the warning returned by future getSession calls
2068
    this.suppressGetSessionWarning = true
84✔
2069
    await setItemAsync(this.storage, this.storageKey, session)
84✔
2070
  }
2071

2072
  private async _removeSession() {
2073
    this._debug('#_removeSession()')
84✔
2074

2075
    await removeItemAsync(this.storage, this.storageKey)
84✔
2076
    await this._notifyAllSubscribers('SIGNED_OUT', null)
84✔
2077
  }
2078

2079
  /**
2080
   * Removes any registered visibilitychange callback.
2081
   *
2082
   * {@see #startAutoRefresh}
2083
   * {@see #stopAutoRefresh}
2084
   */
2085
  private _removeVisibilityChangedCallback() {
2086
    this._debug('#_removeVisibilityChangedCallback()')
4✔
2087

2088
    const callback = this.visibilityChangedCallback
4✔
2089
    this.visibilityChangedCallback = null
4✔
2090

2091
    try {
4✔
2092
      if (callback && isBrowser() && window?.removeEventListener) {
4!
2093
        window.removeEventListener('visibilitychange', callback)
×
2094
      }
2095
    } catch (e) {
2096
      console.error('removing visibilitychange callback failed', e)
×
2097
    }
2098
  }
2099

2100
  /**
2101
   * This is the private implementation of {@link #startAutoRefresh}. Use this
2102
   * within the library.
2103
   */
2104
  private async _startAutoRefresh() {
2105
    await this._stopAutoRefresh()
4✔
2106

2107
    this._debug('#_startAutoRefresh()')
4✔
2108

2109
    const ticker = setInterval(() => this._autoRefreshTokenTick(), AUTO_REFRESH_TICK_DURATION_MS)
4✔
2110
    this.autoRefreshTicker = ticker
4✔
2111

2112
    if (ticker && typeof ticker === 'object' && typeof ticker.unref === 'function') {
4!
2113
      // ticker is a NodeJS Timeout object that has an `unref` method
2114
      // https://nodejs.org/api/timers.html#timeoutunref
2115
      // When auto refresh is used in NodeJS (like for testing) the
2116
      // `setInterval` is preventing the process from being marked as
2117
      // finished and tests run endlessly. This can be prevented by calling
2118
      // `unref()` on the returned object.
2119
      ticker.unref()
4✔
2120
      // @ts-expect-error TS has no context of Deno
2121
    } else if (typeof Deno !== 'undefined' && typeof Deno.unrefTimer === 'function') {
×
2122
      // similar like for NodeJS, but with the Deno API
2123
      // https://deno.land/api@latest?unstable&s=Deno.unrefTimer
2124
      // @ts-expect-error TS has no context of Deno
2125
      Deno.unrefTimer(ticker)
×
2126
    }
2127

2128
    // run the tick immediately, but in the next pass of the event loop so that
2129
    // #_initialize can be allowed to complete without recursively waiting on
2130
    // itself
2131
    setTimeout(async () => {
4✔
2132
      await this.initializePromise
4✔
2133
      await this._autoRefreshTokenTick()
4✔
2134
    }, 0)
2135
  }
2136

2137
  /**
2138
   * This is the private implementation of {@link #stopAutoRefresh}. Use this
2139
   * within the library.
2140
   */
2141
  private async _stopAutoRefresh() {
2142
    this._debug('#_stopAutoRefresh()')
4✔
2143

2144
    const ticker = this.autoRefreshTicker
4✔
2145
    this.autoRefreshTicker = null
4✔
2146

2147
    if (ticker) {
4!
2148
      clearInterval(ticker)
×
2149
    }
2150
  }
2151

2152
  /**
2153
   * Starts an auto-refresh process in the background. The session is checked
2154
   * every few seconds. Close to the time of expiration a process is started to
2155
   * refresh the session. If refreshing fails it will be retried for as long as
2156
   * necessary.
2157
   *
2158
   * If you set the {@link GoTrueClientOptions#autoRefreshToken} you don't need
2159
   * to call this function, it will be called for you.
2160
   *
2161
   * On browsers the refresh process works only when the tab/window is in the
2162
   * foreground to conserve resources as well as prevent race conditions and
2163
   * flooding auth with requests. If you call this method any managed
2164
   * visibility change callback will be removed and you must manage visibility
2165
   * changes on your own.
2166
   *
2167
   * On non-browser platforms the refresh process works *continuously* in the
2168
   * background, which may not be desirable. You should hook into your
2169
   * platform's foreground indication mechanism and call these methods
2170
   * appropriately to conserve resources.
2171
   *
2172
   * {@see #stopAutoRefresh}
2173
   */
2174
  async startAutoRefresh() {
2175
    this._removeVisibilityChangedCallback()
4✔
2176
    await this._startAutoRefresh()
4✔
2177
  }
2178

2179
  /**
2180
   * Stops an active auto refresh process running in the background (if any).
2181
   *
2182
   * If you call this method any managed visibility change callback will be
2183
   * removed and you must manage visibility changes on your own.
2184
   *
2185
   * See {@link #startAutoRefresh} for more details.
2186
   */
2187
  async stopAutoRefresh() {
2188
    this._removeVisibilityChangedCallback()
×
2189
    await this._stopAutoRefresh()
×
2190
  }
2191

2192
  /**
2193
   * Runs the auto refresh token tick.
2194
   */
2195
  private async _autoRefreshTokenTick() {
2196
    this._debug('#_autoRefreshTokenTick()', 'begin')
4✔
2197

2198
    try {
4✔
2199
      await this._acquireLock(0, async () => {
4✔
2200
        try {
4✔
2201
          const now = Date.now()
4✔
2202

2203
          try {
4✔
2204
            return await this._useSession(async (result) => {
4✔
2205
              const {
2206
                data: { session },
2207
              } = result
4✔
2208

2209
              if (!session || !session.refresh_token || !session.expires_at) {
4!
2210
                this._debug('#_autoRefreshTokenTick()', 'no session')
×
2211
                return
×
2212
              }
2213

2214
              // session will expire in this many ticks (or has already expired if <= 0)
2215
              const expiresInTicks = Math.floor(
4✔
2216
                (session.expires_at * 1000 - now) / AUTO_REFRESH_TICK_DURATION_MS
2217
              )
2218

2219
              this._debug(
4✔
2220
                '#_autoRefreshTokenTick()',
2221
                `access token expires in ${expiresInTicks} ticks, a tick lasts ${AUTO_REFRESH_TICK_DURATION_MS}ms, refresh threshold is ${AUTO_REFRESH_TICK_THRESHOLD} ticks`
2222
              )
2223

2224
              if (expiresInTicks <= AUTO_REFRESH_TICK_THRESHOLD) {
4!
2225
                await this._callRefreshToken(session.refresh_token)
×
2226
              }
2227
            })
2228
          } catch (e: any) {
2229
            console.error(
×
2230
              'Auto refresh tick failed with error. This is likely a transient error.',
2231
              e
2232
            )
2233
          }
2234
        } finally {
2235
          this._debug('#_autoRefreshTokenTick()', 'end')
4✔
2236
        }
2237
      })
2238
    } catch (e: any) {
2239
      if (e.isAcquireTimeout || e instanceof LockAcquireTimeoutError) {
×
2240
        this._debug('auto refresh token tick lock not available')
×
2241
      } else {
2242
        throw e
×
2243
      }
2244
    }
2245
  }
2246

2247
  /**
2248
   * Registers callbacks on the browser / platform, which in-turn run
2249
   * algorithms when the browser window/tab are in foreground. On non-browser
2250
   * platforms it assumes always foreground.
2251
   */
2252
  private async _handleVisibilityChange() {
2253
    this._debug('#_handleVisibilityChange()')
44✔
2254

2255
    if (!isBrowser() || !window?.addEventListener) {
44!
2256
      if (this.autoRefreshToken) {
44✔
2257
        // in non-browser environments the refresh token ticker runs always
2258
        this.startAutoRefresh()
4✔
2259
      }
2260

2261
      return false
44✔
2262
    }
2263

2264
    try {
×
2265
      this.visibilityChangedCallback = async () => await this._onVisibilityChanged(false)
×
2266

2267
      window?.addEventListener('visibilitychange', this.visibilityChangedCallback)
×
2268

2269
      // now immediately call the visbility changed callback to setup with the
2270
      // current visbility state
2271
      await this._onVisibilityChanged(true) // initial call
×
2272
    } catch (error) {
2273
      console.error('_handleVisibilityChange', error)
×
2274
    }
2275
  }
2276

2277
  /**
2278
   * Callback registered with `window.addEventListener('visibilitychange')`.
2279
   */
2280
  private async _onVisibilityChanged(calledFromInitialize: boolean) {
2281
    const methodName = `#_onVisibilityChanged(${calledFromInitialize})`
×
2282
    this._debug(methodName, 'visibilityState', document.visibilityState)
×
2283

2284
    if (document.visibilityState === 'visible') {
×
2285
      if (this.autoRefreshToken) {
×
2286
        // in browser environments the refresh token ticker runs only on focused tabs
2287
        // which prevents race conditions
2288
        this._startAutoRefresh()
×
2289
      }
2290

2291
      if (!calledFromInitialize) {
×
2292
        // called when the visibility has changed, i.e. the browser
2293
        // transitioned from hidden -> visible so we need to see if the session
2294
        // should be recovered immediately... but to do that we need to acquire
2295
        // the lock first asynchronously
2296
        await this.initializePromise
×
2297

2298
        await this._acquireLock(-1, async () => {
×
2299
          if (document.visibilityState !== 'visible') {
×
2300
            this._debug(
×
2301
              methodName,
2302
              'acquired the lock to recover the session, but the browser visibilityState is no longer visible, aborting'
2303
            )
2304

2305
            // visibility has changed while waiting for the lock, abort
2306
            return
×
2307
          }
2308

2309
          // recover the session
2310
          await this._recoverAndRefresh()
×
2311
        })
2312
      }
2313
    } else if (document.visibilityState === 'hidden') {
×
2314
      if (this.autoRefreshToken) {
×
2315
        this._stopAutoRefresh()
×
2316
      }
2317
    }
2318
  }
2319

2320
  /**
2321
   * Generates the relevant login URL for a third-party provider.
2322
   * @param options.redirectTo A URL or mobile address to send the user to after they are confirmed.
2323
   * @param options.scopes A space-separated list of scopes granted to the OAuth application.
2324
   * @param options.queryParams An object of key-value pairs containing query parameters granted to the OAuth application.
2325
   */
2326
  private async _getUrlForProvider(
2327
    url: string,
2328
    provider: Provider,
2329
    options: {
2330
      redirectTo?: string
2331
      scopes?: string
2332
      queryParams?: { [key: string]: string }
2333
      skipBrowserRedirect?: boolean
2334
    }
2335
  ) {
2336
    const urlParams: string[] = [`provider=${encodeURIComponent(provider)}`]
8✔
2337
    if (options?.redirectTo) {
8!
2338
      urlParams.push(`redirect_to=${encodeURIComponent(options.redirectTo)}`)
4✔
2339
    }
2340
    if (options?.scopes) {
8!
2341
      urlParams.push(`scopes=${encodeURIComponent(options.scopes)}`)
4✔
2342
    }
2343
    if (this.flowType === 'pkce') {
8!
2344
      const [codeChallenge, codeChallengeMethod] = await getCodeChallengeAndMethod(
×
2345
        this.storage,
2346
        this.storageKey
2347
      )
2348

2349
      const flowParams = new URLSearchParams({
×
2350
        code_challenge: `${encodeURIComponent(codeChallenge)}`,
2351
        code_challenge_method: `${encodeURIComponent(codeChallengeMethod)}`,
2352
      })
2353
      urlParams.push(flowParams.toString())
×
2354
    }
2355
    if (options?.queryParams) {
8!
2356
      const query = new URLSearchParams(options.queryParams)
×
2357
      urlParams.push(query.toString())
×
2358
    }
2359
    if (options?.skipBrowserRedirect) {
8!
2360
      urlParams.push(`skip_http_redirect=${options.skipBrowserRedirect}`)
×
2361
    }
2362

2363
    return `${url}?${urlParams.join('&')}`
8✔
2364
  }
2365

2366
  private async _unenroll(params: MFAUnenrollParams): Promise<AuthMFAUnenrollResponse> {
2367
    try {
×
2368
      return await this._useSession(async (result) => {
×
2369
        const { data: sessionData, error: sessionError } = result
×
2370
        if (sessionError) {
×
2371
          return { data: null, error: sessionError }
×
2372
        }
2373

2374
        return await _request(this.fetch, 'DELETE', `${this.url}/factors/${params.factorId}`, {
×
2375
          headers: this.headers,
2376
          jwt: sessionData?.session?.access_token,
×
2377
        })
2378
      })
2379
    } catch (error) {
2380
      if (isAuthError(error)) {
×
2381
        return { data: null, error }
×
2382
      }
2383
      throw error
×
2384
    }
2385
  }
2386

2387
  /**
2388
   * {@see GoTrueMFAApi#enroll}
2389
   */
2390
  private async _enroll(params: MFAEnrollTOTPParams): Promise<AuthMFAEnrollTOTPResponse>
2391
  private async _enroll(params: MFAEnrollPhoneParams): Promise<AuthMFAEnrollPhoneResponse>
2392
  private async _enroll(params: MFAEnrollParams): Promise<AuthMFAEnrollResponse> {
2393
    try {
2✔
2394
      return await this._useSession(async (result) => {
2✔
2395
        const { data: sessionData, error: sessionError } = result
2✔
2396
        if (sessionError) {
2!
2397
          return { data: null, error: sessionError }
×
2398
        }
2399

2400
        const body = {
2✔
2401
          friendly_name: params.friendlyName,
2402
          factor_type: params.factorType,
2403
          ...(params.factorType === 'phone' ? { phone: params.phone } : { issuer: params.issuer }),
2!
2404
        }
2405

2406
        const { data, error } = await _request(this.fetch, 'POST', `${this.url}/factors`, {
2✔
2407
          body,
2408
          headers: this.headers,
2409
          jwt: sessionData?.session?.access_token,
12!
2410
        })
2411

2412
        if (error) {
2!
2413
          return { data: null, error }
×
2414
        }
2415

2416
        if (params.factorType === 'totp' && data?.totp?.qr_code) {
2!
2417
          data.totp.qr_code = `data:image/svg+xml;utf-8,${data.totp.qr_code}`
2✔
2418
        }
2419

2420
        return { data, error: null }
2✔
2421
      })
2422
    } catch (error) {
2423
      if (isAuthError(error)) {
×
2424
        return { data: null, error }
×
2425
      }
2426
      throw error
×
2427
    }
2428
  }
2429

2430
  /**
2431
   * {@see GoTrueMFAApi#verify}
2432
   */
2433
  private async _verify(params: MFAVerifyParams): Promise<AuthMFAVerifyResponse> {
2434
    return this._acquireLock(-1, async () => {
×
2435
      try {
×
2436
        return await this._useSession(async (result) => {
×
2437
          const { data: sessionData, error: sessionError } = result
×
2438
          if (sessionError) {
×
2439
            return { data: null, error: sessionError }
×
2440
          }
2441

2442
          const { data, error } = await _request(
×
2443
            this.fetch,
2444
            'POST',
2445
            `${this.url}/factors/${params.factorId}/verify`,
2446
            {
2447
              body: { code: params.code, challenge_id: params.challengeId },
2448
              headers: this.headers,
2449
              jwt: sessionData?.session?.access_token,
×
2450
            }
2451
          )
2452
          if (error) {
×
2453
            return { data: null, error }
×
2454
          }
2455

2456
          await this._saveSession({
×
2457
            expires_at: Math.round(Date.now() / 1000) + data.expires_in,
2458
            ...data,
2459
          })
2460
          await this._notifyAllSubscribers('MFA_CHALLENGE_VERIFIED', data)
×
2461

2462
          return { data, error }
×
2463
        })
2464
      } catch (error) {
2465
        if (isAuthError(error)) {
×
2466
          return { data: null, error }
×
2467
        }
2468
        throw error
×
2469
      }
2470
    })
2471
  }
2472

2473
  /**
2474
   * {@see GoTrueMFAApi#challenge}
2475
   */
2476
  private async _challenge(params: MFAChallengeParams): Promise<AuthMFAChallengeResponse> {
2477
    return this._acquireLock(-1, async () => {
×
2478
      try {
×
2479
        return await this._useSession(async (result) => {
×
2480
          const { data: sessionData, error: sessionError } = result
×
2481
          if (sessionError) {
×
2482
            return { data: null, error: sessionError }
×
2483
          }
2484

2485
          return await _request(
×
2486
            this.fetch,
2487
            'POST',
2488
            `${this.url}/factors/${params.factorId}/challenge`,
2489
            {
2490
              body: { channel: params.channel },
2491
              headers: this.headers,
2492
              jwt: sessionData?.session?.access_token,
×
2493
            }
2494
          )
2495
        })
2496
      } catch (error) {
2497
        if (isAuthError(error)) {
×
2498
          return { data: null, error }
×
2499
        }
2500
        throw error
×
2501
      }
2502
    })
2503
  }
2504

2505
  /**
2506
   * {@see GoTrueMFAApi#challengeAndVerify}
2507
   */
2508
  private async _challengeAndVerify(
2509
    params: MFAChallengeAndVerifyParams
2510
  ): Promise<AuthMFAVerifyResponse> {
2511
    // both _challenge and _verify independently acquire the lock, so no need
2512
    // to acquire it here
2513

2514
    const { data: challengeData, error: challengeError } = await this._challenge({
×
2515
      factorId: params.factorId,
2516
    })
2517
    if (challengeError) {
×
2518
      return { data: null, error: challengeError }
×
2519
    }
2520

2521
    return await this._verify({
×
2522
      factorId: params.factorId,
2523
      challengeId: challengeData.id,
2524
      code: params.code,
2525
    })
2526
  }
2527

2528
  /**
2529
   * {@see GoTrueMFAApi#listFactors}
2530
   */
2531
  private async _listFactors(): Promise<AuthMFAListFactorsResponse> {
2532
    // use #getUser instead of #_getUser as the former acquires a lock
2533
    const {
2534
      data: { user },
2535
      error: userError,
2536
    } = await this.getUser()
×
2537
    if (userError) {
×
2538
      return { data: null, error: userError }
×
2539
    }
2540

2541
    const factors = user?.factors || []
×
2542
    const totp = factors.filter(
×
2543
      (factor) => factor.factor_type === 'totp' && factor.status === 'verified'
×
2544
    )
2545
    const phone = factors.filter(
×
2546
      (factor) => factor.factor_type === 'phone' && factor.status === 'verified'
×
2547
    )
2548

2549
    return {
×
2550
      data: {
2551
        all: factors,
2552
        totp,
2553
        phone,
2554
      },
2555
      error: null,
2556
    }
2557
  }
2558

2559
  /**
2560
   * {@see GoTrueMFAApi#getAuthenticatorAssuranceLevel}
2561
   */
2562
  private async _getAuthenticatorAssuranceLevel(): Promise<AuthMFAGetAuthenticatorAssuranceLevelResponse> {
2563
    return this._acquireLock(-1, async () => {
×
2564
      return await this._useSession(async (result) => {
×
2565
        const {
2566
          data: { session },
2567
          error: sessionError,
2568
        } = result
×
2569
        if (sessionError) {
×
2570
          return { data: null, error: sessionError }
×
2571
        }
2572
        if (!session) {
×
2573
          return {
×
2574
            data: { currentLevel: null, nextLevel: null, currentAuthenticationMethods: [] },
2575
            error: null,
2576
          }
2577
        }
2578

2579
        const payload = this._decodeJWT(session.access_token)
×
2580

2581
        let currentLevel: AuthenticatorAssuranceLevels | null = null
×
2582

2583
        if (payload.aal) {
×
2584
          currentLevel = payload.aal
×
2585
        }
2586

2587
        let nextLevel: AuthenticatorAssuranceLevels | null = currentLevel
×
2588

2589
        const verifiedFactors =
2590
          session.user.factors?.filter((factor: Factor) => factor.status === 'verified') ?? []
×
2591

2592
        if (verifiedFactors.length > 0) {
×
2593
          nextLevel = 'aal2'
×
2594
        }
2595

2596
        const currentAuthenticationMethods = payload.amr || []
×
2597

2598
        return { data: { currentLevel, nextLevel, currentAuthenticationMethods }, error: null }
×
2599
      })
2600
    })
2601
  }
2602
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc