@supabase/gotrue-js 2.8.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,6 +27,8 @@ import {
27
27
  resolveFetch,
28
28
  setItemAsync,
29
29
  uuid,
30
+ retryable,
31
+ sleep,
30
32
  } from './lib/helpers'
31
33
  import localStorageAdapter from './lib/local-storage'
32
34
  import { polyfillGlobalThis } from './lib/polyfills'
@@ -79,6 +81,13 @@ const DEFAULT_OPTIONS: Omit<Required<GoTrueClientOptions>, 'fetch' | 'storage'>
79
81
  headers: DEFAULT_HEADERS,
80
82
  }
81
83
 
84
+ /** Current session will be checked for refresh at this interval. */
85
+ const AUTO_REFRESH_TICK_DURATION = 10 * 1000
86
+
87
+ /**
88
+ * A token refresh will be attempted this many ticks before the current session expires. */
89
+ const AUTO_REFRESH_TICK_THRESHOLD = 3
90
+
82
91
  export default class GoTrueClient {
83
92
  /**
84
93
  * Namespace for the GoTrue admin methods.
@@ -104,8 +113,7 @@ export default class GoTrueClient {
104
113
  protected persistSession: boolean
105
114
  protected storage: SupportedStorage
106
115
  protected stateChangeEmitters: Map<string, Subscription> = new Map()
107
- protected refreshTokenTimer?: ReturnType<typeof setTimeout>
108
- protected networkRetries = 0
116
+ protected autoRefreshTicker: ReturnType<typeof setInterval> | null = null
109
117
  protected refreshingDeferred: Deferred<CallRefreshTokenResult> | null = null
110
118
  /**
111
119
  * Keeps track of the async client initialization.
@@ -142,7 +150,6 @@ export default class GoTrueClient {
142
150
  this.fetch = resolveFetch(settings.fetch)
143
151
  this.detectSessionInUrl = settings.detectSessionInUrl
144
152
 
145
- this.initialize()
146
153
  this.mfa = {
147
154
  verify: this._verify.bind(this),
148
155
  enroll: this._enroll.bind(this),
@@ -152,6 +159,8 @@ export default class GoTrueClient {
152
159
  challengeAndVerify: this._challengeAndVerify.bind(this),
153
160
  getAuthenticatorAssuranceLevel: this._getAuthenticatorAssuranceLevel.bind(this),
154
161
  }
162
+
163
+ this.initialize()
155
164
  }
156
165
 
157
166
  /**
@@ -213,7 +222,7 @@ export default class GoTrueClient {
213
222
  error: new AuthUnknownError('Unexpected error during initialization', error),
214
223
  }
215
224
  } finally {
216
- this._handleVisibilityChange()
225
+ await this._handleVisibilityChange()
217
226
  }
218
227
  }
219
228
 
@@ -352,6 +361,7 @@ export default class GoTrueClient {
352
361
  redirectTo: credentials.options?.redirectTo,
353
362
  scopes: credentials.options?.scopes,
354
363
  queryParams: credentials.options?.queryParams,
364
+ skipBrowserRedirect: credentials.options?.skipBrowserRedirect,
355
365
  })
356
366
  }
357
367
 
@@ -903,11 +913,26 @@ export default class GoTrueClient {
903
913
  */
904
914
  private async _refreshAccessToken(refreshToken: string): Promise<AuthResponse> {
905
915
  try {
906
- return await _request(this.fetch, 'POST', `${this.url}/token?grant_type=refresh_token`, {
907
- body: { refresh_token: refreshToken },
908
- headers: this.headers,
909
- xform: _sessionResponse,
910
- })
916
+ const startedAt = Date.now()
917
+
918
+ // will attempt to refresh the token with exponential backoff
919
+ return await retryable(
920
+ async (attempt) => {
921
+ await sleep(attempt * 200) // 0, 200, 400, 800, ...
922
+
923
+ return await _request(this.fetch, 'POST', `${this.url}/token?grant_type=refresh_token`, {
924
+ body: { refresh_token: refreshToken },
925
+ headers: this.headers,
926
+ xform: _sessionResponse,
927
+ })
928
+ },
929
+ (attempt, _, result) =>
930
+ result &&
931
+ result.error &&
932
+ result.error instanceof AuthRetryableFetchError &&
933
+ // retryable only if the request can be sent before the backoff overflows the tick duration
934
+ Date.now() + (attempt + 1) * 200 - startedAt < AUTO_REFRESH_TICK_DURATION
935
+ )
911
936
  } catch (error) {
912
937
  if (isAuthError(error)) {
913
938
  return { data: { session: null, user: null }, error }
@@ -933,6 +958,7 @@ export default class GoTrueClient {
933
958
  redirectTo?: string
934
959
  scopes?: string
935
960
  queryParams?: { [key: string]: string }
961
+ skipBrowserRedirect?: boolean
936
962
  } = {}
937
963
  ) {
938
964
  const url: string = this._getUrlForProvider(provider, {
@@ -941,7 +967,7 @@ export default class GoTrueClient {
941
967
  queryParams: options.queryParams,
942
968
  })
943
969
  // try to open on the browser
944
- if (isBrowser()) {
970
+ if (isBrowser() && !options.skipBrowserRedirect) {
945
971
  window.location.assign(url)
946
972
  }
947
973
  return { data: { provider, url }, error: null }
@@ -966,24 +992,12 @@ export default class GoTrueClient {
966
992
 
967
993
  if ((currentSession.expires_at ?? Infinity) < timeNow + EXPIRY_MARGIN) {
968
994
  if (this.autoRefreshToken && currentSession.refresh_token) {
969
- this.networkRetries++
970
995
  const { error } = await this._callRefreshToken(currentSession.refresh_token)
996
+
971
997
  if (error) {
972
998
  console.log(error.message)
973
- if (
974
- error instanceof AuthRetryableFetchError &&
975
- this.networkRetries < NETWORK_FAILURE.MAX_RETRIES
976
- ) {
977
- if (this.refreshTokenTimer) clearTimeout(this.refreshTokenTimer)
978
- this.refreshTokenTimer = setTimeout(
979
- () => this._recoverAndRefresh(),
980
- NETWORK_FAILURE.RETRY_INTERVAL ** this.networkRetries * 100 // exponential backoff
981
- )
982
- return
983
- }
984
999
  await this._removeSession()
985
1000
  }
986
- this.networkRetries = 0
987
1001
  } else {
988
1002
  await this._removeSession()
989
1003
  }
@@ -1052,14 +1066,6 @@ export default class GoTrueClient {
1052
1066
  this.inMemorySession = session
1053
1067
  }
1054
1068
 
1055
- const expiresAt = session.expires_at
1056
- if (expiresAt) {
1057
- const timeNow = Math.round(Date.now() / 1000)
1058
- const expiresIn = expiresAt - timeNow
1059
- const refreshDurationBeforeExpires = expiresIn > EXPIRY_MARGIN ? EXPIRY_MARGIN : 0.5
1060
- this._startAutoRefreshToken((expiresIn - refreshDurationBeforeExpires) * 1000)
1061
- }
1062
-
1063
1069
  if (this.persistSession && session.expires_at) {
1064
1070
  await this._persistSession(session)
1065
1071
  }
@@ -1075,57 +1081,133 @@ export default class GoTrueClient {
1075
1081
  } else {
1076
1082
  this.inMemorySession = null
1077
1083
  }
1084
+ }
1085
+
1086
+ /**
1087
+ * Starts an auto-refresh process in the background. The session is checked
1088
+ * every few seconds. Close to the time of expiration a process is started to
1089
+ * refresh the session. If refreshing fails it will be retried for as long as
1090
+ * necessary.
1091
+ *
1092
+ * If you set the {@link GoTrueClientOptions#autoRefreshToken} you don't need
1093
+ * to call this function, it will be called for you.
1094
+ *
1095
+ * On browsers the refresh process works only when the tab/window is in the
1096
+ * foreground to conserve resources as well as prevent race conditions and
1097
+ * flooding auth with requests.
1098
+ *
1099
+ * On non-browser platforms the refresh process works *continuously* in the
1100
+ * background, which may not be desireable. You should hook into your
1101
+ * platform's foreground indication mechanism and call these methods
1102
+ * appropriately to conserve resources.
1103
+ *
1104
+ * {@see #stopAutoRefresh}
1105
+ */
1106
+ async startAutoRefresh() {
1107
+ await this.stopAutoRefresh()
1108
+ this.autoRefreshTicker = setInterval(
1109
+ () => this._autoRefreshTokenTick(),
1110
+ AUTO_REFRESH_TICK_DURATION
1111
+ )
1112
+
1113
+ // run the tick immediately
1114
+ await this._autoRefreshTokenTick()
1115
+ }
1116
+
1117
+ /**
1118
+ * Stops an active auto refresh process running in the background (if any).
1119
+ * See {@link #startAutoRefresh} for more details.
1120
+ */
1121
+ async stopAutoRefresh() {
1122
+ const ticker = this.autoRefreshTicker
1123
+ this.autoRefreshTicker = null
1078
1124
 
1079
- if (this.refreshTokenTimer) {
1080
- clearTimeout(this.refreshTokenTimer)
1125
+ if (ticker) {
1126
+ clearInterval(ticker)
1081
1127
  }
1082
1128
  }
1083
1129
 
1084
1130
  /**
1085
- * Clear and re-create refresh token timer
1086
- * @param value time intervals in milliseconds.
1087
- * @param session The current session.
1131
+ * Runs the auto refresh token tick.
1088
1132
  */
1089
- private _startAutoRefreshToken(value: number) {
1090
- if (this.refreshTokenTimer) clearTimeout(this.refreshTokenTimer)
1091
- if (value <= 0 || !this.autoRefreshToken) return
1133
+ private async _autoRefreshTokenTick() {
1134
+ const now = Date.now()
1092
1135
 
1093
- this.refreshTokenTimer = setTimeout(async () => {
1094
- this.networkRetries++
1136
+ try {
1095
1137
  const {
1096
1138
  data: { session },
1097
- error: sessionError,
1139
+ error,
1098
1140
  } = await this.getSession()
1099
- if (!sessionError && session) {
1100
- const { error } = await this._callRefreshToken(session.refresh_token)
1101
- if (!error) this.networkRetries = 0
1102
- if (
1103
- error instanceof AuthRetryableFetchError &&
1104
- this.networkRetries < NETWORK_FAILURE.MAX_RETRIES
1105
- )
1106
- this._startAutoRefreshToken(NETWORK_FAILURE.RETRY_INTERVAL ** this.networkRetries * 100) // exponential backoff
1141
+
1142
+ if (!session || !session.refresh_token || !session.expires_at) {
1143
+ return
1144
+ }
1145
+
1146
+ // session will expire in this many ticks (or has already expired if <= 0)
1147
+ const expiresInTicks = Math.floor(
1148
+ (session.expires_at * 1000 - now) / AUTO_REFRESH_TICK_DURATION
1149
+ )
1150
+
1151
+ if (expiresInTicks < AUTO_REFRESH_TICK_THRESHOLD) {
1152
+ await this._callRefreshToken(session.refresh_token)
1107
1153
  }
1108
- }, value)
1109
- if (typeof this.refreshTokenTimer.unref === 'function') this.refreshTokenTimer.unref()
1154
+ } catch (e: any) {
1155
+ console.error('Auto refresh tick failed with error. This is likely a transient error.', e)
1156
+ }
1110
1157
  }
1111
1158
 
1112
- private _handleVisibilityChange() {
1159
+ /**
1160
+ * Registers callbacks on the browser / platform, which in-turn run
1161
+ * algorithms when the browser window/tab are in foreground. On non-browser
1162
+ * platforms it assumes always foreground.
1163
+ */
1164
+ private async _handleVisibilityChange() {
1113
1165
  if (!isBrowser() || !window?.addEventListener) {
1166
+ if (this.autoRefreshToken) {
1167
+ // in non-browser environments the refresh token ticker runs always
1168
+ this.startAutoRefresh()
1169
+ }
1170
+
1114
1171
  return false
1115
1172
  }
1116
1173
 
1117
1174
  try {
1118
- window?.addEventListener('visibilitychange', async () => {
1119
- if (document.visibilityState === 'visible') {
1120
- await this.initializePromise
1121
- await this._recoverAndRefresh()
1122
- }
1123
- })
1175
+ window?.addEventListener(
1176
+ 'visibilitychange',
1177
+ async () => await this._onVisibilityChanged(false)
1178
+ )
1179
+
1180
+ // now immediately call the visbility changed callback to setup with the
1181
+ // current visbility state
1182
+ await this._onVisibilityChanged(true) // initial call
1124
1183
  } catch (error) {
1125
1184
  console.error('_handleVisibilityChange', error)
1126
1185
  }
1127
1186
  }
1128
1187
 
1188
+ /**
1189
+ * Callback registered with `window.addEventListener('visibilitychange')`.
1190
+ */
1191
+ private async _onVisibilityChanged(isInitial: boolean) {
1192
+ if (document.visibilityState === 'visible') {
1193
+ if (!isInitial) {
1194
+ // initial visibility change setup is handled in another flow under #initialize()
1195
+ await this.initializePromise
1196
+ await this._recoverAndRefresh()
1197
+ }
1198
+
1199
+ if (this.autoRefreshToken) {
1200
+ // in browser environments the refresh token ticker runs only on focused tabs
1201
+ // which prevents race conditions
1202
+ this.startAutoRefresh()
1203
+ }
1204
+ } else if (document.visibilityState === 'hidden') {
1205
+ if (this.autoRefreshToken) {
1206
+ this.stopAutoRefresh()
1207
+ }
1208
+ }
1209
+ }
1210
+
1129
1211
  /**
1130
1212
  * Generates the relevant login URL for a third-party provider.
1131
1213
  * @param options.redirectTo A URL or mobile address to send the user to after they are confirmed.
@@ -1174,10 +1256,7 @@ export default class GoTrueClient {
1174
1256
  }
1175
1257
 
1176
1258
  /**
1177
- * Enrolls a factor
1178
- * @param friendlyName Human readable name assigned to a device
1179
- * @param factorType device which we're validating against. Can only be TOTP for now.
1180
- * @param issuer domain which the user is enrolling with
1259
+ * {@see GoTrueMFAApi#enroll}
1181
1260
  */
1182
1261
  private async _enroll(params: MFAEnrollParams): Promise<AuthMFAEnrollResponse> {
1183
1262
  try {
@@ -1214,9 +1293,7 @@ export default class GoTrueClient {
1214
1293
  }
1215
1294
 
1216
1295
  /**
1217
- * Validates a device as part of the enrollment step.
1218
- * @param factorId System assigned identifier for authenticator device as returned by enroll
1219
- * @param code Code Generated by an authenticator device
1296
+ * {@see GoTrueMFAApi#verify}
1220
1297
  */
1221
1298
  private async _verify(params: MFAVerifyParams): Promise<AuthMFAVerifyResponse> {
1222
1299
  try {
@@ -1255,8 +1332,7 @@ export default class GoTrueClient {
1255
1332
  }
1256
1333
 
1257
1334
  /**
1258
- * Creates a challenge which a user can verify against
1259
- * @param factorId System assigned identifier for authenticator device as returned by enroll
1335
+ * {@see GoTrueMFAApi#challenge}
1260
1336
  */
1261
1337
  private async _challenge(params: MFAChallengeParams): Promise<AuthMFAChallengeResponse> {
1262
1338
  try {
@@ -1283,9 +1359,7 @@ export default class GoTrueClient {
1283
1359
  }
1284
1360
 
1285
1361
  /**
1286
- * Creates a challenge and immediately verifies it
1287
- * @param factorId System assigned identifier for authenticator device as returned by enroll
1288
- * @param code Code Generated by an authenticator device
1362
+ * {@see GoTrueMFAApi#challengeAndVerify}
1289
1363
  */
1290
1364
  private async _challengeAndVerify(
1291
1365
  params: MFAChallengeAndVerifyParams
@@ -1304,7 +1378,7 @@ export default class GoTrueClient {
1304
1378
  }
1305
1379
 
1306
1380
  /**
1307
- * Displays all devices for a given user
1381
+ * {@see GoTrueMFAApi#listFactors}
1308
1382
  */
1309
1383
  private async _listFactors(): Promise<AuthMFAListFactorsResponse> {
1310
1384
  const {
@@ -1330,8 +1404,7 @@ export default class GoTrueClient {
1330
1404
  }
1331
1405
 
1332
1406
  /**
1333
- * Gets the current and next authenticator assurance level (AAL)
1334
- * and the current authentication methods for the session (AMR)
1407
+ * {@see GoTrueMFAApi#getAuthenticatorAssuranceLevel}
1335
1408
  */
1336
1409
  private async _getAuthenticatorAssuranceLevel(): Promise<AuthMFAGetAuthenticatorAssuranceLevelResponse> {
1337
1410
  const {
@@ -149,3 +149,45 @@ export function decodeJWTPayload(token: string) {
149
149
  const base64Url = parts[1]
150
150
  return JSON.parse(decodeBase64URL(base64Url))
151
151
  }
152
+
153
+ /**
154
+ * Creates a promise that resolves to null after some time.
155
+ */
156
+ export function sleep(time: number): Promise<null> {
157
+ return new Promise((accept) => {
158
+ setTimeout(() => accept(null), time)
159
+ })
160
+ }
161
+
162
+ /**
163
+ * Converts the provided async function into a retryable function. Each result
164
+ * or thrown error is sent to the isRetryable function which should return true
165
+ * if the function should run again.
166
+ */
167
+ export function retryable<T>(
168
+ fn: (attempt: number) => Promise<T>,
169
+ isRetryable: (attempt: number, error: any | null, result?: T) => boolean
170
+ ): Promise<T> {
171
+ const promise = new Promise<T>((accept, reject) => {
172
+ // eslint-disable-next-line @typescript-eslint/no-extra-semi
173
+ ;(async () => {
174
+ for (let attempt = 0; attempt < Infinity; attempt++) {
175
+ try {
176
+ const result = await fn(attempt)
177
+
178
+ if (!isRetryable(attempt, null, result)) {
179
+ accept(result)
180
+ return
181
+ }
182
+ } catch (e: any) {
183
+ if (!isRetryable(attempt, e)) {
184
+ reject(e)
185
+ return
186
+ }
187
+ }
188
+ }
189
+ })()
190
+ })
191
+
192
+ return promise
193
+ }
package/src/lib/types.ts CHANGED
@@ -440,6 +440,8 @@ export type SignInWithOAuthCredentials = {
440
440
  scopes?: string
441
441
  /** An object of query params */
442
442
  queryParams?: { [key: string]: string }
443
+ /** If set to true does not immediately redirect the current browser context to visit the OAuth authorization page for the provider. */
444
+ skipBrowserRedirect?: boolean
443
445
  }
444
446
  }
445
447
 
@@ -1,2 +1,2 @@
1
1
  // Generated by genversion.
2
- export const version = '2.8.0'
2
+ export const version = '2.10.0'