npm.io
6.3.3 • Published 1 month ago

@reapit/connect-session

Licence
ISC
Version
6.3.3
Deps
9
Size
4.0 MB
Vulns
0
Weekly
0

Connect Session

A thin wrapper around the Reapit Connect OAuth API.

Managing OAuth flows can be tricky, especially redirecting, keeping sessions refreshed and cached in memory. To make this process easier, we have built the Connect Session module for any JavaScript app.

To get started run

$ yarn add @reapit/connect-session

Then follow the steps for either browsers or React below. For full documentation see here.

Basic Browser Usage

The module is intended to be browser framework agnostic although we ship a React Hook for React users (see below).

For all users, in a file at the root of the source of your project, first instantiate and export the ReapitConnectBrowserSession class.

The constructor accepts a single options object. Only connectClientId and connectOAuthUrl are required, see comments below:

import { ReapitConnectBrowserSession } from '@reapit/connect-session'

// You should instantiate the class once only as a singleton as the module manages it's own state
export const reapitConnectBrowserSession = new ReapitConnectBrowserSession({
  // The client id of your application, obtained from Reapit Developer Portal
  connectClientId: 'SOME_CLIENT_ID',
  // The url to the Reapit Connect instance.
  connectOAuthUrl: 'https://connect.reapit.cloud',
  // The relative path you want to re-direct in your application after a successful login. You will have supplied this when you registered your app.
  // Defaults to '' or the root of your project if not supplied
  connectLoginRedirectPath: '/some-redirect-path',
  // The relative path you want to re-direct in your application after a successful logout. You will have supplied this when you registered your app.
  // Defaults to '/login' if not supplied
  connectLogoutRedirectPath: '/some-login-path',
  // Optional in ms - default is 3hrs(10800000 ms) - after this time if your application is idle, the app will log you out and require a fresh login.
  // Web only - the idle timeout is not applied in desktop mode (see Idle timeout below)
  connectApplicationTimeout: 10800000,
  // Optional in seconds - default is 300 seconds (5 minutes) - allows for clock skew when verifying tokens where the server and client clocks are not exactly in sync - this is occassionaly an issue with older Windows machines especially
  connectClockSkewAllowance: 300,
  // Optional - default is 3 - the number of consecutive times the module will redirect to the authorize endpoint without
  // obtaining a session before it gives up and logs the user out, to prevent infinite redirect loops (see Authorize retries below).
  // Must be a finite number of 0 or more - invalid values (eg NaN, Infinity or negative numbers) log a warning and use the default
  connectAuthorizeRetries: 3,
  // Optional - default is true - use [Proof Key for Code Exchange](https://oauth.net/2/pkce/) when exchanging an authorization code.
  // Set to false to disable PKCE.
  usePKCE: true,
  // Deprecated - connectUserPoolId is no longer used and can safely be removed
})

The instantiated class can then be used in your code. It exports the following methods:


import { reapitConnectBrowserSession } from './path-to-your-module'

// The definition of the reapitConnectBrowserSession
interface ReapitConnectSession {
  // The accessToken is the `Bearer <<token>>` you need to authenticate against the platform API.
  accessToken: string
  // Refresh token is provided as a convenience - in practice the module handle's refreshing and caching of your session out the box
  refreshToken: string
  // Id token is provided as a convenience - the parsed output is below in the loginIdentity object below
  idToken: string
  // The identity token has been verified as valid before decoding so that you can trust it's claims
  loginIdentity: {
    email: string
    name: string
    developerId: string | null
    clientId: string | null
    adminId: string | null
    userCode: string | null
    groups: string[]
    orgName: string | null
    orgId: string | null
    offGroupIds: string | null
    offGrouping: boolean
    offGroupName: string | null
    officeId: string | null
  }
}

// Returns a promise containing your reapitSession object as per the interface above. Resolves to undefined if a request
// for a session is already in flight, if the module is redirecting to Reapit Connect, or if an error occurred.
// See Session lifecycle below
reapitConnectBrowserSession.connectSession().then((reapitSession: ReapitConnectSession | void) => reapitSession)

// Handles redirect to authorization endpoint - in most cases, I don't need to call in my app as handled by the module
// but made public in case I want to override the redirect URI I specified in the constructor (a full URI, not a path).
// The URI used is stored and sent with the code exchange when I am redirected back, as Reapit Connect requires them to match.
// Calling this directly does not count towards, or check, the authorize retry limit (see Authorize retries below)
reapitConnectBrowserSession.connectAuthorizeRedirect(redirectUri?: string)

// Handles redirect to logout - defaults to constructor logout uri but I can override if I like (a full URI, not a path).
// Clears the stored refresh token, user name and authorize retry count before redirecting
reapitConnectBrowserSession.connectLogoutRedirect(redirectUri?: string)

// Handles redirect to login - defaults to constructor redirect uri but I can override if I like (a full URI, not a path).
// As with connectAuthorizeRedirect, the URI used is stored and sent with the code exchange when I am redirected back.
// Clears the stored refresh token, user name and authorize retry count before redirecting
reapitConnectBrowserSession.connectLoginRedirect(redirectUri?: string)

// Clears the in memory session only. Stored refresh tokens are kept, so the next call to connectSession will silently
// refresh rather than requiring a login. Use connectLogoutRedirect to fully log out
reapitConnectBrowserSession.connectClearSession()

// String representation of the original path and query (eg '/some-path?someQuery=true'), of the page I was on before I
// started the OAuth flow. We cache this in the state object before redirecting to the authorize endpoint as a convenience
// so that I can preserve the state of my app on page refresh. Defaults to null if no state param exists in my current URI.
// You may not find this behavior desireable so the module does not perform the redirect by default, however if you do
// you will need to use this value in your code appropriate to your front end stack eg using a redirect component in React Router
reapitConnectBrowserSession.connectInternalRedirect

// A convenience getter to check if my app has been loaded inside RPS / Desktop / Agency Cloud. True when
// window.__REAPIT_MARKETPLACE_GLOBALS__ is present
reapitConnectBrowserSession.connectIsDesktop

// A convenience getter to check if my app has a valid session (one that does not expire within the next 5 minutes)
reapitConnectBrowserSession.connectHasSession

Session lifecycle

When you call connectSession() the module works through the following in order:

  1. If a session is cached in memory and the access token does not expire within the next 5 minutes, it is returned without a network request.
  2. If a request to the token endpoint is already in flight, the call resolves to undefined. The in-flight request will populate the cached session, so call connectSession() again once it has resolved. Once a redirect to Reapit Connect has started, further calls also resolve to undefined until the page unloads, so multiple callers cannot start multiple redirects. If an unexpected error occurs, the lock is released so a later call can try again.
  3. If there is a refresh token (in memory, or in storage from a previous page load), it is exchanged for a new session.
  4. Otherwise, if there is an authorization code in the URL (you have just been redirected back from Reapit Connect), it is exchanged for a session, with a PKCE code verifier when usePKCE is enabled.
  5. If there is neither, the user is redirected to the Reapit Connect authorize endpoint. The current path and query string are cached against the OAuth state parameter so you can restore them after login using connectInternalRedirect.

If the token endpoint returns an error, or the ID token cannot be verified, the stored refresh token is cleared, the error is logged to the console and the user is redirected to the authorize endpoint again, subject to the retry limit below.

A successful exchange caches the session in memory and stores the refresh token and the user's email address so the session survives a page reload.

Authorize retries and logout failover

Each automatic redirect to the authorize endpoint made by connectSession() counts as a retry. This covers both "no refresh token or code" and "the token exchange failed". The count is kept in storage so it survives the full page navigation each redirect causes. Calling connectAuthorizeRedirect() yourself, directly or through the React hook, is treated as a deliberate action: it neither counts towards the limit nor checks it.

  • Once the count reaches connectAuthorizeRetries (default 3), the module stops redirecting to authorize and calls connectLogoutRedirect() instead. This breaks what would otherwise be an infinite redirect loop, for example when a code exchange keeps failing because of a misconfigured redirect URI or a client clock that is badly out of sync.
  • With the default of 3, the user is redirected to authorize up to 3 times and logged out on the 4th attempt. Setting connectAuthorizeRetries: 0 disables automatic authorize redirects entirely, so the user is logged out and must log in manually.
  • The count is reset to 0 after any successful token exchange, and by connectLoginRedirect() and connectLogoutRedirect(). So a user who clicks a login button gets a fresh set of retries.
  • In desktop mode the count is held in localStorage and is therefore shared by all tabs for the same client id. A successful login in any tab resets it.

Your logout redirect path (connectLogoutRedirectPath, default /login) must not require a session. If the page you land on after logout calls connectSession(), the user will cycle between authorize redirects and logout indefinitely.

Storage

The module persists a small amount of state so that sessions survive page reloads and the OAuth redirect flow. In a web browser this is sessionStorage, so it is scoped to the tab and cleared when the tab is closed. In desktop mode (connectIsDesktop) it is localStorage, because the desktop host does not preserve tabs.

Key Contents Cleared
REAPIT_REFRESH_TOKEN_<email>_<clientId> Refresh token On login / logout redirect, or a token endpoint error
REAPIT_LAST_AUTH_USER_<clientId> Email address of the last authenticated user On login / logout redirect
REAPIT_AUTHORIZE_RETRIES_<clientId> Consecutive authorize redirect count Reset to 0 on a successful exchange, or a login / logout redirect
<state nonce>-REAPIT_CODE_VERIFIER PKCE code verifier for an in-progress login Not cleared
<state nonce>-REAPIT_REDIRECT_URI Redirect URI sent to /authorize When the code is exchanged
REAPIT_REDIRECT_URI_<clientId> Redirect URI sent to /login When the code is exchanged
<state nonce> Encoded path and query to return to after login Not cleared

The access and ID tokens are only ever held in memory.

Idle timeout

In a web browser the user is logged out with connectLogoutRedirect() after connectApplicationTimeout ms (default 3 hours) without a mousemove, keypress or touchstart event on the document. The idle timeout is not applied in desktop mode.

Note that the module listens by assigning document.onmousemove, document.onkeypress and document.ontouchstart, so it will replace any handlers your app has assigned to those properties. Use addEventListener in your app instead.

React Usage

In addition to the basic browser API, we export a React Hook to use in your React Components.

To leverage the Hook, first instantiate the class as per above. Then, around the Routes you wish to protect with Reapit Connect authentication, simply return when the session is not present as below:

// import the instantiated class, the hook and the provider
import { useReapitConnect } from '@reapit/connect-session'
import { reapitConnectBrowserSession } from './connect-session'

export const PrivateRouteWrapper: React.FC = ({ children }) => {
  // Call the hook to retun a session object
  const { connectSession } = useReapitConnect(reapitConnectBrowserSession)

  // The session object implements the same interface as the browser class with the exception that the connectSession promise is handled wrapped in a useEffect hook and so is just an objecy or null. Here I return null from the component while I am fetching a session
  if (!connectSession) {
    return null
  }

  // I now have a session I can render my App
  return (
    <AppNavContainer>
      <Menu />
    </AppNavContainer>
  )
}

The hook calls connectSession() once on mount and re-renders with the session when it resolves. It does not retry, so if a component using the hook mounts while another request for a session is still in flight, it will receive undefined and keep connectSession as null. Call the hook once near the top of your tree (as in the wrapper above) and pass the session down, or ensure child components mount only after the wrapper has a session.

Then in my React child components, I have access to the session values and methods eg:

import { useReapitConnect } from '@reapit/connect-session'
import { reapitConnectBrowserSession } from './connect-session'

export const SomeComponent: React.FC = () => {
  const { connectSession, connectLogoutRedirect } = useReapitConnect(reapitConnectBrowserSession)
  // Optional full URI param - defaults to the logout URI from the constructor if not supplied
  const handleLogout = () => connectLogoutRedirect('https://some-app.reapit.cloud/custom-login-path')

  return (
    <CustomFetchComponent connectSession={connectSession}>
      <Button onClick={handleLogout}>I am a logout button!</Button>
    </CustomFetchComponent>
  )
}