Skip to content

Latest commit

 

History

History
516 lines (411 loc) · 20.6 KB

README.md

File metadata and controls

516 lines (411 loc) · 20.6 KB

use-eazy-auth

Build Status npm version

React components and hooks to deal with token based authentication

This project takes the main concepts and algorithms (but also the name) from the eazy-auth library, and aims at providing equivalent functionality in contexts where the usage of eazy-auth with its strong dependency on redux and redux-saga is just too constraining.

Installation

yarn add use-eazy-auth
npm install --save use-eazy-auth

Api

<Auth /> Component

The top level component where you are able to configure authentication behaviours.

Token based authentication is based on the usage of a token as a proof of identity. As such, the library has to deal with acquiring a token, storing it for later use, validating it, refreshing it when it expires, and deleting it when no refresh is possible or the token is revoked.

Moreover, the token is strictly tied to a user (as it is the proof of his identity), and so it is usually a good idea to keep the user object around while the token is valid.

This concepts are common to the majority of token based authentication system, even if implementation of them can be really different. Given this, use-eazy-auth gives you full customization freedom to integrate with your specific implementation and to tailor its own behaviours by passing props to the <Auth /> component.

The <Auth /> component creates React contexts that are used by any hook, so it is mandatory to make it a common ancestor for all components that need to deal with authentication, and advisable to put it as near as possible to the root of the React application tree.

The following properties are required:

  • loginCall: the login call implements the process of acquiring a valid token, usually by means of some credentials (in the majority of cases, this is just a username, password pair, but it is not required). The signature of this function must be

      (credentials: any) =>
        Promise<{ accessToken: string, refreshToken?: string }, any> |
        Observable<{ accessToken: string, refreshToken?: string }, any>

    Has you can see, the function is expected to return a promise which rejects in case of unsuccessful authentication (the error shape is up to you) or resolves in case of successful authentication. The required accessToken property in the resolution argument must hold the token which will be used to authenticate the user when interacting with the server. The optional refreshToken property, if present, must hold a token which is never used for API calls, but it is used to get a new token when that returned by the login expires without having the user go through the login procedure again. In case the access token expires and no refresh is possible, the user will experience a forced logout

  • meCall: the me call implements the process of validating a previously stored token while gathering information about the owner user. This is used both to read user information from server and make them available throughout the application and to validate a token that has been recalled after some time from storage (see later). The signature of this function must be

      (accessToken: string) =>
        Promise<any, { status: number }> | Observable<any, { status: number }>

    Has you can see, this function is expected to retrieve user information given an access token. In case the process succeeds, it is expected to return the object that describes the user (the shape of this is again completely up to you). In case the process cannot succeed, the promise is expected to be rejected with a status code. In this last situation, the accessToken cannot be considered valid anymore.

    If a refreshToken was provided, refreshTokenCall is set on the <Auth /> object and the error status code is 401, the library will attempt to refresh the token and eventually repeat the me call with the refreshed token. If for any reason the token cannot be refreshed the user will be logged out.

  • refreshTokenCall: some authentication schemes allow the usage of some kind of refresh token to obtain a fresh access token when the currently used one expires. This property allows to pass a function that implements the refresh procedure. As such, its signature is

      (refreshToken: string) =>
        Promise<{ accessToken: string, refreshToken?: string }, any> |
        Observable<{ accessToken: string, refreshToken?: string }, any>

    Considerations about the login call hold just the same for this api, the only difference is that the credentials parameter is replaced by the refreshToken

  • storageBackend: the storage of accessToken and refreshToken allows the website to remember the user identity and to skip the authentication procedure in a subsequent visit. You are free to choose any synchronous or asynchronous storage backend like localStorage, sessionStorage (or AsyncStorage when using ReactNative). A storage object must meet the following signature

      type Storage = {
        getItem: (key: string) => string | Promise<string, any>,
        setItem: (key: string, value: string) => void | Promise<void, any>,
        removeItem: (key: string) => void | Promise<void, any>
      }

    This property defaults to window.localStorage if available, or to no storage otherwise. In case you want to completely disable token storage, set this property to false

  • storageNamespace: in case you did not opt-out token storage, you can customize the key under which the tokens are stored by setting this property (it must be a string). If you don't set this property, it defaults to the string auth

  • onLogout: An optional callback inoked when user explicit logout (calling logout action) or is kicked out from 401 rejection in call api functions.

Here is a usage example

Please note that the login call and the me call are not real life examples: always validate your users against your authentication backend!

import React from 'react'
import Auth from 'use-eazy-auth'

const loginCall = ({ username, password }) => new Promise((resolve, reject) =>
  (username === 'alice' && password === 'my-super-secret-password')
    ? resolve({ accessToken: 'alice-is-allowed-to-access' })
    : reject('Unauthorized!')
)

const meCall = token => new Promise((resolve, reject) =>
  (token === 'alice-is-allowed-to-access')
    ? resolve({ username: 'alice', status: 'Administrator' })
    : reject('Unauthorized!')
)

const App = () => (
  <Auth
    loginCall={loginCall}
    meCall={meCall}
    storageBackend={storageBackend}
    storageNamespace='my-auth'
  >
    {
      /* react-router or in any case the restricted section of
       * your application should be put here
       */
    }
    <Screens />
  </Auth>
)

You can also use the render prop.

function App() {
  return (
    <Auth
      loginCall={loginCall}
      meCall={meCall}
      storageBackend={storageBackend}
      storageNamespace='my-auth'
      render={(authActions, authState, userState) => /* render my children */}
    />
  )
}

useAuthState() hook

This hooks returns the current auth state. The auth state is the operational state of the library, which can tell you if some operation is in progress, like initialization or login. The state object is a plain object with the following properties

  • bootstrappedAuth (bool): this flag tells whether the library has loaded or loading is still in progress. Loading means that the library is fetching stored tokens and validating them with a me call.
  • authenticated (bool): this flag tells whether the user is authenticated (i.e. the library has a valid access token ready for use) or not
  • loginLoading (bool): this flag tells whether a login operation is in progress
  • loginError (any): this property holds the result of the last rejected promise (it is not cleared after a successful login call, you need to clear it explictly by calling clearLoginError - see example)

Usage example

import React, { useState } from 'react'
import { useAuthState, useAuthActions } from 'use-eazy-auth'

const Screens = () => {
  const { authenticated, bootstrappedAuth } = useAuthState()
  if (!bootstrappedAuth) {
    return <div>Please wait, we are logging you in...</div>
  }
  return authenticated ? <Home /> : <Login />
}


const Login = () => {
  const { loginLoading, loginError } = useAuthState()
  const { login, clearLoginError } = useAuthActions()

  const [username, setUsername] = useState('')
  const [password, setPassword] = useState('')

  return (
    <form onSubmit={e => {
      e.preventDefault()
      if (username !== '' && password !== '') {
        login({ username, password })
      }
    }}>
      <div>
        <input
          placeholder='@username'
          type='text'
          value={username}
          onChange={e => {
            clearLoginError()
            setUsername(e.target.value)
          }}
        />
      </div>
      <div>
        <input
          placeholder='password'
          type='password'
          value={password}
          onChange={e => {
            clearLoginError()
            setPassword(e.target.value)
          }}
        />
      </div>
      <button disabled={loginLoading}>{!loginLoading ? 'Login!' : 'Logged in...'}</button>
      {loginError && <div>Bad combination of username and password</div>}
    </form>
  )
}

useAuthActions() hook

This hook allows to invoke some auth related behaviours. It returns a plain JavaScript object whose properties are functions.

  • callAuthApiPromise This function performs an authenticated API call. The first parameter is a factory function (a function which returns a fucntion) that is expected to create the real api call function (i.e. the function that implements the real api call, you can use XHR, Axios, SuperAgent or whatever you like inside this). The factory function is invoked with the access token, and is expected to return again a function - the api call function. Any additional parameter supplied to the callAuthApiPromise will be used as a parameter to invoke the api call function. The api call must return a promise. If all is fine, that promise is expected to resolve. In case it rejects, the rejection value must be an object with a status property carrying the status code of the request. A 401 code will trigger the refresh token operation (if available) and repeat the api call invocation with the new token. If even this second call is rejected, the user will be logged out.

  • callAuthApiObservable This behaves like callAuthApiPromise except that the api call function is expected to return an Observable from RxJS. Promise rejection is replaced by error raising.

  • login This function triggers a login operation. It is expected to be called with a single argument (the credentials object) which is used to invoke the loginCall provided to the <Auth /> component as a property

  • logout This function triggers a logout operation. This means clearing the stored tokens and set the library authenticated state to false. No api call is performed here.

  • clearLoginError This function clear the current login error.

  • updateUser This function update the current auth user with given User object.

  • patchUser This function shallow merge the given User object with current User object.

  • setTokens

      ({ accessToken: string, refreshToken?: string }) => void

    This function explicit set new tokens, this function write new tokens in storage as well.

All these functions are stable across renders, so it is safe to add them as dependencies of some useEffect or useMemo, they will never trigger any unnecessary re-renders.

Here is some example

import React, { useState, useEffect } from 'react'
import { useAuthActions } from 'use-eazy-auth'

const authenticatedGetTodos = (token) => (category) => new Promise((resolve, reject) => {
  return (token === 23)
    ? resolve([
      'Learn React',
      'Prepare the dinner',
    ])
    : reject({ status: 401, error: 'Go out' })
})

const Home = () => {
  const [todos, setTodos] = useState([])
  const { logout, callAuthApiPromise } = useAuthActions()

  useEffect(() => {
    callAuthApiPromise(authenticatedGetTodos, 'all')
      .then(todos => setTodos(todos))
  }, [callAuthApiPromise])

  return (
    <div>
      <h2>Todos</h2>
      <ul>
        {todos.map((todo, i) => (
          <li key={i}>{todo}</li>
        ))}
      </ul>
      <div>
        <button onClick={logout}>Logout</button>
      </div>
    </div>
  )
}

useAuthUser() hook

This hook returns the current user object (in the shape you chose to return from the meCall supplied to the <Auth /> component) and the current token as props of a plain JavaScript object. If user is not logged in, both properties result in null values.

import { useAuthUser } from 'use-eazy-auth'

const Home = () => {
  const { user, token } = useAuthUser()

  return (
    <div>
      Logged in user {user.username} <br />
      identified by token {token}
    </div>
  )
}

Provide initial data

In certain scenarios (Server Side Rendering), you need to provide initial data to your <Auth /> and avoid all the side effects appening during first renders (check tokens, perform meCall ecc).

You can do that using the initialData prop:

const App = () => (
  <Auth
    initialData={{
      user: { id: 23, name: 'Gio Va' },
      accessToken: 'secret',
      refreshToken: 'refreshSecret'
    }}
  >
    {/* ... */}
  </Auth>
)

When both user and token are not null the initial state is authenticated otherwise no.

The initialData typing is:

interface InitialAuthData<A = any, R = any, U = any>  {
  accessToken: A | null
  refreshToken?: R | null
  expires?: number | null
  user: U | null
}

React Router Integration

This library ships with components useful to integrate routing (by react-router) and authentication. You are not forced to do this: you can use any routing library you wish and write the integration yourself, maybe taking our react-router integration as an example

The integration is done by providing three specialized Route components: GuestRoute, AuthRoute and MaybeAuthRoute. A GuestRoute can be accessed only by non authenticated users, and will redirect authenticated users. An AuthRoute can be accessed just by authenticated users, and will redirect any non authenticated visitor. A MaybeAuthRoute will accept authenticated just as non authenticated users. If in some route you don't care about authentication, a vanilla Route can still be used.

You can import those components from use-eazy-auth/routes

<GuestRoute /> component

When the auth is booting render an optional spinner, when the user is authenticated render a <Redirect /> otherwise act as a normal <Route />.

The <GuestRoute /> component accepts the following props

  • redirectTo: the path to redirect authenticated users to
  • redirectToReferrer: if set to true, users that are redirected to this page from an <AuthRoute /> because they are not authenticated will be redirected back after login instead of being redirected to the path set by redirectTo. Note that it is mandatory to set the redirectTo property as unauthenticated users may land directly on a GuestRoute and so they may not have a referrer
  • spinnerComponent: an optional spinner component to render instead of content until the auth initialization is not complete
  • spinner: an optional spinner react element to render instead of content until the auth initialization is not complete
  • any other property accepted by <Route />
type GuestRouteProps = {
  redirectTo?: string | Location<Dictionary>
  redirectToReferrer?: boolean
  spinner?: ReactNode
  spinnerComponent?: ComponentType
} & RouteProps

<AuthRoute /> component

When the auth is booting render an optional spinner, when the user is authenticated act as <Route /> otherwise act as a normal <Route />.

Can also redirect your user by a given redirectTest.

The <AuthRoute /> component accepts the following props

  • redirectTo: the path to redirect a non authenticated user to
  • rememberReferrer: whether to enable the referrer in order to redirect the user back after login
  • redirectTest: a function to test if current authenticated user can access your route, take user as only parameter and if falsy is returned the user can acccess the route, otherwise the return value is expected to be a valid path used to redirect the user.
  • spinnerComponent: an optional spinner component to render instead of content until the auth initialization is not complete
  • spinner: an optional spinner react element to render instead of content until the auth initialization is not complete
  • any other property accepted by <Route />
type AuthRouteProps<U = any> = {
  redirectTest?: null | ((user: U) => string | null | undefined | Location)
  redirectTo?: string | Location
  spinner?: ReactNode
  spinnerComponent?: ComponentType
  rememberReferrer?: boolean
} & RouteProps

<MaybeAuthRoute /> component

When the auth is booting render an optional spinner otherwise act as <Route />.

The <MaybeAuthRoute /> component accepts the following props

  • spinnerComponent: an optional spinner component to render instead of content until the auth initialization is not complete
  • spinner: an optional spinner react element to render instead of content until the auth initialization is not complete
  • any other property accepted by <Route />
export type MaybeAuthRouteProps = {
  spinner?: ReactNode
  spinnerComponent?: ComponentType
} & RouteProps

Fetching libraries integrations

import useSWR, { SWRConfig } from 'swr'
import { useAuthActions } from 'use-eazy-auth'
import { meCall, refreshTokenCall, loginCall } from './authCalls'

function Dashboard() {
  const { data: todos } = useSWR('/api/todos')
  // ...
}

function ConfigureAuthFetch({ children }) {
  const { callAuthApiPromise } = useAuthActions()
  return (
    <SWRConfig
      value={{
        fetcher: (...args) =>
          callAuthApiPromise(
            token => (url, options) =>
              fetch(url, {
                ...options,
                headers: {
                  ...options?.headers,
                  Authorization: `Bearer ${token}`,
                },
              })
                // NOTE: use-eazy-auth needs a Rejection with shape:
                // { status: number }
                .then(res => (res.ok ? res.json() : Promise.reject(res))),
            ...args
          ),
      }}
    >
      {children}
    </SWRConfig>
  )
}

function App() {
  return (
    <Auth loginCall={login} meCall={me} refreshTokenCall={refresh}>
      <ConfigureAuthFetch>
        <Dashboard />
      </ConfigureAuthFetch>
    </Auth>
  )
}
import { useQuery } from 'react-query'
import { useAuthActions } from 'use-eazy-auth'

export default function Dashboard() {
  const { callAuthApiPromise } = useAuthActions()
  const { data: todos } = useQuery(['todos'], () =>
    callAuthApiPromise((token) => () =>
      fetch(`/api/todos`, {
        headers: {
          Authorization: `Bearer ${token}`,
        },
      }).then((res) => (res.ok ? res.json() : Promise.reject(res)))
    )
  )
  // ...
}
import { ConfigureRj, rj, useRunRj } from 'react-rocketjump'
import { useAuthActions } from 'use-eazy-auth'

const Todos = rj({
  effectCaller: rj.configured(),
  effect: (token) => () =>
    fetch(`/api/todos/`, {
      headers: {
        Authorization: `Bearer ${token}`,
      },
    }).then((res) => (res.ok ? res.json() : Promise.reject(res))),
})

export default function Dashboard() {
  const [{ data: todos }] = useRunRj(Todos)
  // ...
}

function ConfigureAuthFetch({ children }) {
  const { callAuthApiObservable } = useAuthActions()
  // NOTE: react-rocketjump supports RxJs Observables
  return (
    <ConfigureRj effectCaller={callAuthApiObservable}>
      {children}
    </ConfigureRj>
  )
}

function App() {
  return (
    <Auth loginCall={login} meCall={me} refreshTokenCall={refresh}>
      <ConfgureAuthFetch>
        <Dashboard />
      </ConfgureAuthFetch>
    </Auth>
  )
}

Run example

This repository contains a runnable basic example of the main functionalities of the library

git clone https://github.com/inmagik/use-eazy-auth.git
cd use-eazy-auth
yarn install
yarn dev