auth0 / auth0-spa-js

Auth0 authentication for Single Page Applications (SPA) with PKCE
MIT License
903 stars 357 forks source link

Extra redirect on silent login after upgrade to v2 #1277

Closed tibotiber closed 2 months ago

tibotiber commented 2 months ago

Checklist

Description

Hi,

I create this report on the recommendation of tyf from the community forum. I’m using @auth0/auth0-spa-js and I’ve just upgraded from 1.22.3 to 2.1.3. The documentation to guide the upgrade was great, thanks.

One issue i’m observing is if I refresh the page, it does a silent login. On v1, it’s pretty fast and doesn’t leave my domain. On v2, it seems to take one extra step, and redirects to ?code=xxx&state=xxx temporarily before coming back. It also takes longer.

Is there a new option to get the previous behaviour?

Thanks :pray:

Reproduction

  1. take an app that works as expected using on v1.22.3
  2. upgrade to v2.1.3
  3. restart app and refresh page

Here is a before/after video recording which I hope can help.

On v1.22.3

https://github.com/auth0/auth0-spa-js/assets/5635553/a5832993-074d-4f25-962f-558a1888cf17

On v2.1.3

https://github.com/auth0/auth0-spa-js/assets/5635553/8acf32b8-2274-49ad-b290-0287bc9631be

Additional context

Here is my Auth0.tsx provider

import createAuth0Client, {
  Auth0Client,
  Auth0ClientOptions,
  GetTokenSilentlyOptions,
  LogoutOptions,
  RedirectLoginOptions,
} from '@auth0/auth0-spa-js'
import { datadogLogs } from '@datadog/browser-logs'
import * as Sentry from '@sentry/react'
import { message } from 'antd'
import { mergeDeepRight } from 'ramda'
import React, { EffectCallback, FC, useContext, useEffect, useState } from 'react'
import { useHistory } from 'react-router-dom'

import { Auth0UserWithClaims, User } from 'types/User'

const localStorageKey = 'smplrspace-auth0'

// login message in loader
const setLoginLoader = (active: boolean): void => {
  const loader = document.getElementById('loader-login')
  if (active) {
    loader && loader.classList.add('active')
  } else {
    loader && loader.classList.remove('active')
  }
}

export interface Auth0ContextValue {
  isAuthenticated: boolean
  user: User | null
  loading: boolean
  offline: boolean
  logout: (options?: LogoutOptions) => void
  loggingOut: boolean
  loginWithRedirect: (options?: RedirectLoginOptions) => Promise<void>
  getTokenSilently: (options?: GetTokenSilentlyOptions) => Promise<string>
  updateUser: (updates: Partial<User>) => void
}
export const Auth0Context = React.createContext<Auth0ContextValue | undefined>(undefined)

export const useAuth0Dangerously = () => useContext(Auth0Context)
export const useAuth0 = (): Auth0ContextValue => {
  const auth0Context = useContext(Auth0Context)
  if (!auth0Context) {
    throw new Error('Auth0Context was called without a provider')
  }
  return auth0Context
}

interface AuthenticatedAuth0ContextValue
  extends Pick<Auth0ContextValue, 'loading' | 'logout' | 'loggingOut' | 'getTokenSilently' | 'updateUser'> {
  user: User
}
export const useAuthenticatedAuth0 = (): AuthenticatedAuth0ContextValue => {
  const { user, loading, logout, loggingOut, getTokenSilently, updateUser, offline } = useAuth0()
  if (offline || !user) {
    throw new Error('auth0Context was expected a user to be logged in, but did not find one')
  }
  return {
    user,
    loading,
    logout,
    loggingOut,
    getTokenSilently,
    updateUser,
  }
}

export const Auth0Provider: FC<Auth0ClientOptions> = ({ children, ...initOptions }) => {
  const history = useHistory()
  const [isAuthenticated, setIsAuthenticated] = useState(false)
  const [user, setUser] = useState<User | null>(null)
  const [loading, setLoading] = useState(true)
  const [offline, setOffline] = useState(!navigator.onLine)
  const [loggingOut, setLoggingOut] = useState(false)

  const [auth0Client, setAuth0Client] = useState<Auth0Client>()
  const [localDevToken, setLocalDevToken] = useState<string>()

  // set user on sentry events and log login
  const onLogin = () => {
    if (user) {
      const { email, name } = user
      Sentry.configureScope((scope) => {
        scope.setUser({ email, name })
      })
      datadogLogs.logger.info(`Login`, { email, user })
    } else {
      Sentry.configureScope((scope) => {
        scope.setUser(null)
      })
    }
  }
  useEffect(onLogin, [user && user.email])

  const postLogin = (user: Auth0UserWithClaims): void => {
    const { sub, name, email } = user
    if (!sub || !name || !email) {
      throw new Error('user.sub, name, or email are not defined')
    }

    setLoginLoader(false)
    // set user in state
    const userForContext = {
      ...user,
      sub,
      name,
      email,
      emailVerified: user['https://smplrspace.com/jwt/claims'].emailVerified,
      admin: user['https://smplrspace.com/jwt/claims'].admin,
      personalOrganizationId: user['https://smplrspace.com/jwt/claims'].personalOrganizationId,
      missiveDigest: user['https://smplrspace.com/jwt/claims'].missiveDigest,
    }
    setUser(userForContext)
  }

  const setupAuth0: EffectCallback = () => {
    // auth
    const initAuth0 = async () => {
      setLoginLoader(true)

      const auth0FromHook = await createAuth0Client({
        ...initOptions,
        // use rotating refresh tokens
        // https://auth0.com/blog/securing-single-page-applications-with-refresh-token-rotation/
        // https://auth0.com/docs/secure/tokens/refresh-tokens/refresh-token-rotation
        useRefreshTokens: true,
      })
      setAuth0Client(auth0FromHook)

      if (window.location.search.includes('code=')) {
        const { appState } = await auth0FromHook.handleRedirectCallback()
        history.push(appState && appState.targetUrl, appState)
      }

      const isAuthenticated = await auth0FromHook.isAuthenticated()

      if (isAuthenticated) {
        // already authenticated via cookie
        const user = await auth0FromHook.getUser<Auth0UserWithClaims>()
        if (!user) {
          throw new Error('Authenticated user should not be null')
        }
        postLogin(user)
        localStorage.setItem(localStorageKey, JSON.stringify(user))
      }

      setIsAuthenticated(isAuthenticated)

      setLoading(false)
    }

    const initOffline = (user: Auth0UserWithClaims): void => {
      postLogin(user)
      setIsAuthenticated(true)
      setLoading(false)
    }

    if (offline) {
      // use persisted data
      const storedUserItem = localStorage.getItem(localStorageKey)
      if (storedUserItem) {
        const storedUser: Auth0UserWithClaims = JSON.parse(storedUserItem)
        initOffline(storedUser)
      }
    } else {
      initAuth0()
    }
  }
  useEffect(setupAuth0, [])

  const setupOfflineListener = () => {
    // add offline tracking
    const goOnline = () => setOffline(false)
    const goOffline = () => setOffline(true)
    window.addEventListener('online', goOnline)
    window.addEventListener('offline', goOffline)

    // dispose event listeners
    return () => {
      window.removeEventListener('online', goOnline)
      window.removeEventListener('offline', goOffline)
    }
  }
  useEffect(setupOfflineListener, [])

  const updateUser = (updates: Partial<User>): void => {
    if (!user) {
      return
    }
    const newUser = mergeDeepRight(user, updates)
    setUser(newUser)
    localStorage.setItem(localStorageKey, JSON.stringify(newUser))
  }

  const logout = async (options?: LogoutOptions): Promise<void> => {
    if (offline) {
      message.error(`You cannot log out while offline`)
      return
    }
    if (!auth0Client) {
      throw new Error('Auth0Client is not ready')
    }
    if (!user) {
      throw new Error('User cannot be null during logout')
    }
    datadogLogs.logger.info(`Logout`, { email: user.email, user })
    setLoggingOut(true)
    setUser(null)
    localStorage.removeItem(localStorageKey)
    await auth0Client.logout(options)
  }

  const loginWithRedirect = async (options?: RedirectLoginOptions): Promise<void> => {
    if (offline) {
      throw new Error(`You cannot login while offline`)
    }
    if (!auth0Client) {
      throw new Error('Auth0Client is not ready')
    }
    return auth0Client.loginWithRedirect(options)
  }

  const getTokenSilently = async (options?: GetTokenSilentlyOptions): Promise<string> => {
    if (offline) {
      throw new Error('You cannot make API calls while offline')
    }
    if (!auth0Client) {
      throw new Error('Auth0Client is not ready')
    }
    return auth0Client.getTokenSilently(options)
  }

  return (
    <Auth0Context.Provider
      value={{
        isAuthenticated,
        user,
        loading,
        offline,
        logout,
        loggingOut,
        loginWithRedirect,
        getTokenSilently,
        updateUser,
      }}
    >
      {children}
    </Auth0Context.Provider>
  )
}

and here is PrivateRoute.tsx

import React, { FC, useEffect } from 'react'
import { Redirect, Route, RouteProps } from 'react-router-dom'

import { ErrorComponent } from 'src/app/components/ErrorComponent'
import { useAuth0 } from 'src/app/contexts/Auth0'
import useQueryParams from 'src/utils-web/useQueryParams'

interface PrivateRouteProps extends RouteProps {
  adminOnly?: boolean
}
const PrivateRoute: FC<PrivateRouteProps> = ({ component: Component, path, adminOnly = false, ...rest }) => {
  const { loading, isAuthenticated, loginWithRedirect, user, loggingOut } = useAuth0()
  // process auth0 callback errors
  const queryParams = useQueryParams()

  useEffect(() => {
    if (loading || isAuthenticated || queryParams.get('error')) {
      return
    }
    const fn = async () => {
      await loginWithRedirect({
        appState: { targetUrl: window.location.pathname },
      })
    }
    fn()
  }, [loading, isAuthenticated, loginWithRedirect, path, queryParams])

  if (queryParams.get('error')) {
    smplrprint.error('Auth0 error', queryParams.get('error_description'))
    return <ErrorComponent message="Sorry, we could not log you in." />
  }

  if (loggingOut) {
    return null // show index.html
  }

  if (!isAuthenticated) {
    return null // keep showing index.html
  }

  if (adminOnly && (!user || !user.admin)) {
    return <Redirect to="/" />
  }

  return <Route path={path} component={Component} {...rest} />
}

export default PrivateRoute

auth0-spa-js version

2.1.3

Which framework are you using (React, Angular, Vue...)?

React

Framework version

16.14.0

Which browsers have you tested in?

Chrome

nandan-bhat commented 2 months ago

Hi @tibotiber ,

Besides domain and clientId, could you provide the other configuration options you're passing to createAuth0Client ?. Additionally, can you try reproducing the same issue on our Javascript Sample App ?

tibotiber commented 2 months ago

Hi @nandan-bhat, below are the options used in createAuth0Client:

I'll give a shot to the Javascript Sample App and revert as well.

Any idea what could have changed between the 2 versions that produce this difference in behaviour?

tibotiber commented 2 months ago

PS: the sample app is using static files. That's very different from a react app which i'm setup with. I don't know how relevant would a repro be in that case. I'll still try, but do you have a sample react app with auth0-spa-js somewhere?

nandan-bhat commented 2 months ago

Hi @tibotiber,

What is the structure of initOptions ?

After the upgrade (v2.1.3) createAuth0Client should look like this.

Reference createAuth0Client: https://auth0.github.io/auth0-spa-js/interfaces/Auth0ClientOptions.html Reference AuthorizationParams: https://auth0.github.io/auth0-spa-js/interfaces/AuthorizationParams.html

When did this change ? Here https://github.com/auth0/auth0-spa-js/releases/tag/v2.0.0

await createAuth0Client ({
    domain: YOUR_DOMAIN,
    clientId: YOUR_CLIENT_ID,
    authorizationParams: {
        audience: YOUR_AUDIENCE,
        redirect_uri = window.location.origin
    },
    useRefreshTokens: true | false
});

If the current configuration differs from this configuration, feel free to attempt it and share the outcome here.

Question: Is there any specific reason behind your decision not to use the auth0-react SDK ? It's designed for React and still relies on auth0-spa-js.

tibotiber commented 2 months ago

Thanks for pointing that out @nandan-bhat. Yes, I've got the good format for the options. My codebase is in Typescript so that was quick to pick on and well documented on your side ✌️. Here is the code sample:

  <Auth0Provider
    domain={import.meta.env.VITE_AUTH0_DOMAIN}
    clientId={import.meta.env.VITE_AUTH0_CLIENTID}
    authorizationParams={{
      audience: import.meta.env.VITE_AUTH0_AUDIENCE,
      redirect_uri: window.location.origin,
    }}
  >

with useRefreshTokens set directly in the createAuth0Client call.

it's been a few years since we made the choice between auth0-react and auth0-spa-js so i don't have the full context anymore. But i think it boiled down to auth0-react being a tiny wrapper around auth0-spa-js (at least at the time) and the fact that it introduced one layer of potential delay when a new version (bugfix in particular) is available (e.g. spa-js updated but react package not yet).

I'm back on this now, will test a repro with the sample app.

tibotiber commented 2 months ago

I think I managed to reproduce the regression in the sample app. Check out https://github.com/tibotiber/auth0-javascript-samples/pull/1

The last commit shows how it used to work in v1.22 (no extra redirect)

https://github.com/auth0/auth0-spa-js/assets/5635553/47d28e57-accb-4e3b-a40b-19a86f47227c

while the previous commit shows the extra redirect in v2

https://github.com/auth0/auth0-spa-js/assets/5635553/6c08da80-84cc-4a53-85e9-da5f93855918

also it seems this only happens with useRefreshTokens: true

Hopefully this helps

nandan-bhat commented 2 months ago

Hello @tibotiber,

Thank you for the comprehensive details about the issue; it really helps. We will take some time to investigate and provide an update once we have more information.

nandan-bhat commented 2 months ago

Hello @tibotiber,

I have an update. Please try setting useRefreshTokensFallback: true alongside useRefreshTokens: true. As you mentioned earlier, the use-case is related to useRefreshTokens: true. In V1, useRefreshTokensFallback defaulted to true, but in V2, this default has been changed to false. You need to explicitly set it to true.

You can refer to the migration guide here (search for useRefreshTokensFallback) for more details about this change. Also go through the documentation.

Here's how your V2 code should look:

// This configuration is for JavaScript. Adjust your TypeScript setup accordingly.

await createAuth0Client({
    domain: YOUR_DOMAIN,
    clientId: YOUR_CLIENT_ID,
    authorizationParams: {
        audience: YOUR_AUDIENCE,
        redirect_uri: window.location.origin
    },
    useRefreshTokens: true,
    useRefreshTokensFallback: true // Include this.
});

Let me know if this resolves the issue.

tibotiber commented 2 months ago

Hi @nandan-bhat, you nailed it! 🙂 This solved the issue, thanks for the help. I have definitely missed this point out when I went through the migration guide. I think I was too confident with the Typescript warnings being solved 😓.

Edit: v1 -> v2 migration shaved my bundle size, noiiiice :)