<!DOCTYPE html>
<html>
<head>
<script type="text/javascript" src="/silent-callback.js"></script>
</head>
<body></body>
</html>
In this walkthrough, we’ll be examining how we handle authentication and authorization in the commerce starter frontend. This walkthrough will look at the components used in the starter as well as the common API calls.
First, we need to know how we are going to handle getting authorization for the app to hit microservices APIs. We need to get an OAuth access token after the user authenticates, either immediately after logging in or using an existing session. In this section, we’ll cover the additional setup for two ways of getting an access token:
In the commerce app, we use the standard OAuth Authorization Code Grant Flow with Proof-Key-for-Code-Exchange and renew tokens using Silent Auth—see Authorization docs for details on what that looks like. Silent Auth means that the authorization requests take place in an iframe so that the user is not forced to redirect back to auth every time their access token expires.
To support Silent Auth, we define a silent-callback.html
page that the Auth Server can redirect to inside an iframe.
This loads a simple JavaScript file to post the authorization code to the iframe’s parent window.
Tip
|
Also make sure to add silent-callback.html to you Authorized Client’s redirect URIs, which should be handled: https://www.my-app.com/silent-callback.html .
|
<!DOCTYPE html>
<html>
<head>
<script type="text/javascript" src="/silent-callback.js"></script>
</head>
<body></body>
</html>
parent.postMessage(
{ type: 'authorization_code', search: window.location.search },
window.location.origin
);
Once done, we can move on to Establishing an Authentication Context.
Silent Authorization is not always feasible such as when the commerce app is hosted on a different domain than the microservices since most modern browsers block cookies with separate domains. This means that using an iframe to redirect to the Auth Server to get a new token will not work—it requires a cookie set by Auth after login to be present, but Auth is on a separate domain. In this case, we need to enable Refresh Token Rotation. In this case, we use a second token to refresh the access token rather than doing a redirect in an iframe. When the new access token is returned, we get a new refresh token, and the old one is invalidated, hence rotation.
With this, there is no need for a silent-callback.html
page, but we do have to update the configuration of the Auth Server as discussed in Configuring Refresh Tokens.
Once done, we can move on to Establishing an Authentication Context.
Before we can access the microservice APIs, we should determine whether there is an authenticated session ongoing for the current user. Then, we need an access token to interact with APIs. It will also be helpful to components to know some additional state information about whether the authentication check is ongoing or has been attempted already.
Important
|
Before anything else, however, we do need to resolve the tenant and application, which is covered in [Tenant & Application Walkthrough]. Make sure to review that first. |
The first step is to familiarize ourselves with the Auth JavaScript SDK that Broadleaf provides to facilitate a frontend application interacting with the Auth Microservice.
There are two modules in the Auth SDK:
@broadleaf/auth-web
: A JavaScript library built with TypeScript that can be used with any Node.js app regardless of framework, e.g., with React, Angular, Vue, etc.
Also see the Auth Web SDK reference docs.
@broadleaf/auth-react
: A React-specific library that sets up a common React context to handle core authentication concerns for React apps, focused on client-side interactions.
Also see the Auth React SDK reference docs.
The commerce starter that Broadleaf provides to new users already includes these and can be referenced by those implementing their own apps.
Check under node_modules/@broadleaf/auth-[web|react]/src
.
node_modules
The @broadleaf/auth-web
library provides types for requests and response payloads and a "client" for making requests against Broadleaf’s Auth Service s APIs to log in, register, log out, or fetch an access token according to the OAuth 2.0 specification for the Authorization Code Grant Flow.
It can be configured to use an iframe for silent auth, enable the Proof-Key-for-Code-Exchange (PKCE) extension to the Authorization Code Grant Flow, or use refresh token rotation.
It also provides a basic implementation of a token cache to store OAuth access tokens for use in API requests that relies on either an in-memory map or browser storage if so configured.
This can be used as-is or replaced with your own implementation of TokenCache
by passing in a new instance to the constructor of AuthClient
.
Note
|
It is not recommended to use browser-storage unless using refresh token rotation. |
The Auth React SDK reference docs cover more details about the AuthClient
.
It is highly recommended to review these.
Tip
|
If using React, jump to Using Auth React SDK. |
Set up the AuthClient
instance
See for the AuthClient
configuration options.
import { AuthClient } from '@broadleaf/auth-web';
function initializeClient(clientOptions = {}) { (1)
const defaultOptions = {
baseURL: '/auth',
clientId: 'my-authorized-client-id'
};
return new AuthClient({ ...defaultOptions, ...clientOptions });
}
export const AuthClient = initializeClient();
clientOptions
of type AuthClientOptions.
Check session and initialize auth state for the app.
Check for OAuth redirect parameters in case we have just been redirected from a successful log in
These params are code
, state
, and error
.
If present, then handle post-login actions such as redirecting to the homepage or the last page viewed, e.g., My Account Dashboard.
This should use AuthClient#handleRedirectCallback
handleRedirectCallback
assumes that login was initiated using #loginWithRedirect
.
It will return any appState
properties cached when #loginWithRedirect
was called.
In the commerce starter for instance, we cache a returnTo
URI that records where the user was before redirecting to log in.
Thus, appState
is any arbitrary data.
If no params, check for an existing session by checking for an access token.
This should use AuthClient#checkSession
checkSession
will check for a cached token or else try to fetch one.
If not in the cache and silent-auth is enabled (the default), then an iframe will be created, and it’s src
set to the authorization endpoint URL:
GET '{baseUrl}/oauth/authorize?' \ (1)
-d accountId={accountId} \ (2)
-d client_id={clientId} \ (3)
-d code_challenge={codeChallenge} \ (4)
-d prompt='none' \ (5)
-d redirect_uri={redirectUri} \ (6)
-d response_type='code' \ (7)
-d scope={scope} \ (8)
-d state={state} (9)
baseUrl
will be the path and/or host to the Auth Service, e.g., /auth
accountId
is the ID of the customer’s selected B2B Account if any.
This can affect a user’s roles and permissions in the commerce context.
clientId
is the ID of the commerce app’s Authorized Client
codeChallenge
will be generated by the AuthClient
as part of PKCE.
prompt='none'
since the call is made from a hidden iframe.
redirectUri
will be the URI that Auth should redirect back to on success.
Likely to just be the current origin.
Must be whitelisted by the Authorized Client.
response_type='code'
indicates we want an authorization code in the response that we exchange for an access token.
scope
is the set of security scopes for which the user is requested access, e.g., USER CUSTOMER
.
state
is a random string used to help verify the response from Auth is actually the one the app is expecting.
Once Auth redirects back to silent-callback.html
and the code is posted to the parent window, checkSession
will exchange the authorization code for an access token using the token endpoint.
POST '{baseUrl}/oauth/token?' \
-H "Content-Type: application/x-www-form-urlencoded"
-d accountId={accountId}
-d client_id={clientId}
-d code={authorizationCode}
-d code_verifier={codeVerifier} (1)
-d grant_type=authorization_code
-d purpose= (2)
-d redirect_uri={redirectUri}
-d refresh_token= (3)
-d scope={scope}
-d username= (4)
code_verifier
is auto-generated by the Auth Client as part of PKCE.
purpose
is used with embedded login, in which case it would be OTP
for one-time-password.
More on embedded login later.
username
is used with embedded login, in which case it would be the user’s username.
Then the token is cached.
If auth responds with a login_required
error, checkSession
will swallow it as we will just set our app’s isAuthenticated
state to false.
It’s not necessary to force users to redirect to log in, we are simply checking if the session exists already.
import qs from 'query-string';
import { AuthClient } from './auth-client';
function hasLoginRedirectParams(
search = window.location.search
) {
const { code, state, error } = qs.parse(search);
return (!!code && !!state) || !!error;
}
function onRedirectCallback(appState) {
window.history.replaceState(
{},
document.title,
appState?.returnTo || window.location.pathname
);
}
export async function getInitialAuthState() {
if (hasLoginRedirectParams()) {
// type appState will be any data supplied originally to the AuthClient#loginWithRedirect call
// in this case, it will have a `returnTo` URI so the app knows where to redirect back to if it
// came from a different page before logging in like a My Account page.
const { appState } = await AuthClient.handleRedirectCallback(); (1)
onRedirectCallback(appState);
} else {
// fetches a token if possible
// swallows the login_required error, will handle that below instead
await AuthClient.checkSession(); (2)
}
let initState;
if (AuthClient.isAuthenticated()) {
initState = {
isAuthenticated: true,
session: AuthClient.getSessionExpiry(), (3)
user: AuthClient.getIdentityClaims() (4)
};
} else {
initState = {
isAuthenticated: false
};
}
return initState;
}
Now we know if the user is authenticated and have even refreshed their access token. We can use this initialized state in our app.
In the @broadleaf/auth-react
SDK, we have set up an AuthProvider
component that sets up a common React context to handle core authentication concerns.
It instantiates and manages an AuthClient
with the provided configuration options from the caller.
Then it exposes a number of callbacks that wrap methods of the AuthClient
instance, so that the client itself is not exposed while still being usable by the app.
We want to avoid unnecessary mutations and ensure there is only a single instance for the app.
It also manages some basic state like whether the authentication check has occurred, whether the user is authenticated, or whether the check is ongoing.
To initialize state, the AuthProvider
will check for URL params indicating that the app has just been redirected from a successful login attempt in the Auth Service or else check for a pre-existing session.
AuthProvider
internals.useEffect(
() => {
// whether the request was cancelled due to the component unmounting
let isCancelled = false;
(async () => {
try {
// hasLoginRedirectParams() described in next code snippet
if (hasLoginRedirectParams() && !skipRedirectCallback) {
// Handles parsing the callback parameters as part of the OAuth2
// Authorization Code Grant flow. This flow is typically first initiated
// with a call to `AuthClient#loginWithRedirect`.
const { appState } = await client.handleRedirectCallback();
onRedirectCallback(appState);
} else {
// Checks if the user has a session and refreshes the session by silently
// retrieving a token that pre-fills the token cache.
await client.checkSession();
}
let initOptions;
// check the token cache in the AuthClient to see if there's a token after the calls above
if (client.isAuthenticated()) {
initOptions = {
isAuthenticated: true,
session: client.getSessionExpiry(),
user: client.getIdentityClaims()
};
} else {
initOptions = {
isAuthenticated: false
};
}
!isCancelled && dispatch(initAction(initOptions));
} catch (e) {
!isCancelled && dispatch(errorAction(e));
}
})();
return () => {
isCancelled = true;
};
},
// eslint-disable-next-line
[]
);
import qs from 'query-string';
function hasLoginRedirectParams(
search: string = window.location.search
): boolean {
const { code, state, error } = qs.parse(search);
return (!!code && !!state) || !!error;
}
Tip
|
More details on using the Auth React SDK can be found in the Auth React SDK reference docs.
Additionally, the code can be viewed by adding @broadleaf/auth-react to your project.
|
In the commerce starter, the AuthProvider
component from the Auth React SDK is further augmented for the app’s specific needs, and can be seen in app/auth/contexts/auth-context.tsx
.
The primary points of interest in this component are how we are determining the values of various AuthClient
configuration options by using environment variables to allow users of the starter to connect with their Auth microservice, use refresh token rotation, etc. without getting into the code to make changes.
This is particularly useful for changing the configuration when the app is deployed to different environments.
AuthProvider
const ProviderWrapper = ({
accountId,
baseUrl,
silentRedirectUri,
clientId,
router,
useRefreshTokens,
usePkce,
userScope,
useRefreshTokensWithLocalStorage,
useRefreshTokensWithSessionStorage,
fetchUserOperationsWithAccessToken
}) => (
<AuthProvider
key={clientId}
accountId={accountId} // customer B2B account if any
baseURL={baseUrl} // e.g., /auth
redirectUri={baseUrl.href} // e.g., https://www.my-app.com/
silentRedirectUri={silentRedirectUri.href} // e.g., https://www.my-app.com/silent-callback.html
clientId={clientId} // authorized client id
credentials // indicates whether cross-site Access-Control requests should be made using credentials
onRedirectCallback={appState => {
router.replace(appState?.returnTo || window.location.pathname);
}}
scope={userScope} // security scopes to use when requesting an access token, e.g., USER CUSTOMER
useRefreshTokens={useRefreshTokens} // whether the app should use refresh tokens
usePkce={usePkce} // whether to use Proof-Key-for-Code-Exchange (highly recommended)
useIFrameAsFallbackForRefreshToken={
!useRefreshTokensWithLocalStorage &&
!useRefreshTokensWithSessionStorage
}
useSessionStorageForTokenCache={useRefreshTokensWithSessionStorage}
useLocalStorageForTokenCache={useRefreshTokensWithLocalStorage}
/*
* If enabled, then user operations will be fetched using the 'resource API' user operations
* endpoint that accepts a bearer token. Otherwise, user operations will be fetched using the
* endpoint that relies on session cookie authentication. The access-token approach is useful
* when in a cross-domain environment that can't pass session cookies.
*/
fetchUserOperationsWithAccessToken={fetchUserOperationsWithAccessToken}
>
{/* component that handles keeping the session alive or else logging the user out when session expires */}
<AuthSessionManager>{children}</AuthSessionManager>
</AuthProvider>
);
After establishing an Auth Context, we can look at some examples of how to use it to provide access to Broadleaf APIs.
To access the context we will use the useAuth
from @broadleaf/auth-react
.
The following logout button provides a simple example of the hooks use.
Tip
|
See AuthContext in the Auth React SDK reference docs for details on what is available from this hook. |
import { useAuth } from '@broadleaf/auth-react';
export const LogoutButton = () => {
const { logoutWithRedirect } = useAuth();
return (
<button
onClick={() => {
logoutWithRedirect();
}}
type="button"
>
Log Out
</button>
);
}
One useful hook in the starter that users will likely use or copy is the useGetCustomerAccessToken
hook.
This produces a callback method to fetch a new or cached access token through the AuthClient
on demand.
It will handle passing in the appropriate scopes for a customer or CSR user as configured for the app.
The hook is provided below and can be used as a basis for similar functions in frameworks other than React.
app/auth/hooks/use-get-customer-access-token.ts
import { useCallback } from 'react';
import { useAuth } from '@broadleaf/auth-react';
import { useCsrContext } from '@src/csr/context';
import { CSR_SCOPE, isCsr } from '@src/csr/utils';
export const useGetCustomerAccessToken = () => {
const { isAuthenticated, getAccessToken, user } = useAuth();
const { csrAnonymous } = useCsrContext();
return useCallback(
async (options = {}) => { (1)
try {
if (!isAuthenticated || csrAnonymous) {
// guest users will use the Commerce Gateway's token rather than their own
// Commerce Gateway will detect that it should substitute its own when proxying requests
return null;
}
const scopes = options.scope;
if (isCsr(user) && (!scopes || scopes?.indexOf(CSR_SCOPE) === -1)) {
const scope = `${CSR_SCOPE} ${options.scope || ''}`.trimEnd();
options = { ...options, scope };
}
return await getAccessToken(options);
} catch (error) {
return null;
}
},
[isAuthenticated]
);
};
options are of type GetAccessTokenOptions
The starter uses this when making any request from the client that may depend on user-specific data.
import { ItemListClient } from '@broadleaf/commerce-cart';
import { useGetCustomerAccessToken } from '@app/auth/hooks';
import { useItemListClient } from '@src/common/contexts';
const useGetItemListsByName = (itemListName, options) => { (1)
const getCustomerAccessToken = useGetCustomerAccessToken();
const itemListClient = useItemListClient();
return async () => {
const accessToken = await getCustomerAccessToken();
return itemListClient.listItemLists(`name==${itemListName}`, {accessToken});
};
}
options
of type ClientOptions
The starter itself manages the user’s selected B2B Account and passes that into the AuthProvider
so that it is included in token requests.
This can impact what roles and permissions the user has specific to that Account.
Tip
|
See Shared Concepts: Accounts for more information on them. |
The management of the selected Account is abstracted out of the Auth SDK since it can be handled in a number of different ways by the app. In the starter, we have a selector component in the header that lists all the accounts the user is a part of, allowing them to select which context they should shop or act within.
Tip
|
Checkout app/layout/components/header/account-selector-drop-down.tsx for its details.
|
Accounts can be retrieved using the AccountClient#getCustomersAccounts
from the Customer JS SDK after the user logs in.
// if using the commerce starter
import { useGetCustomerAccessToken } from '@src/auth/hooks';
import { useAccountClient } from '@src/common/contexts';
const useGetCustomerAccounts = () => {
const getCustomerToken = useGetCustomerAccessToken();
const accountClient = useAccountClient();
return async () => {
const accessToken = await getCustomerToken();
return await accountClient.getCustomersAccounts({ accessToken });
}
}
Once selected, we then cache the account ID in the app (either using browser storage or a call to the node server for a server-side cache) and trigger the app to reload.
The ApplicationAuthProvider
that wraps AuthProvider
checks the cache and passes in the selected account ID to AuthProvider
.
Afterwards, the account ID can be found as a claim on the access token:
const { user } = useAuth();
const accountId = user?.acct_id;
Some components should only be visible to authenticated users such as my-account pages. Once an Auth Context is set up, we simply need to consult it to know whether the user is authenticated.
const Dashboard = () => {
const { isAuthenticated, didAuthenticationCheck } = useAuth();
if (!didAuthenticationCheck) {
return <div>Loading...</div>;
}
if (!isAuthenticated) {
window.location.replace('/login');
return null;
}
return <div>Welcome to Your Account</div>
}
Broadleaf supports customer login and registration by two methods:
Universal Login: The user will be redirected to the Auth Server which serves its own login form. This is a more secure option.
[Embedded Login with One-Time Passcode]: The commerce app hosts the login form so there is no redirect.
Tip
|
Broadleaf recommends Universal Registration (and Login) as the more secure option. Details can be found in Embedded Login with One-Time Password. |
This section will cover both methods from the perspective of the commerce app.
When the login and registration forms are hosted directly by the Auth Server, the user will need to be redirected there, and the commerce app will need to handle when the user is redirected back to acquire an access token for API requests.
Using the Auth Web SDK makes this quite simple for implementors. To redirect to auth, call AuthClient#loginWithRedirect.
This method will build a URL to hit the authorization endpoint:
GET '{baseUrl}/oauth/authorize?' \ (1)
-d accountId={accountId} \ (2)
-d client_id={clientId} \ (3)
-d code_challenge={codeChallenge} \ (4)
-d code_challenge_method=S256
-d redirect_uri={redirectUri} \ (5)
-d response_type=code \ (6)
-d scope={scope} \ (7)
-d state={state} (8)
baseUrl
will be the path and/or host to the Auth Service, e.g., /auth
accountId
is the ID of the customer’s selected B2B Account if any.
This can affect a user’s roles and permissions in the commerce context.
clientId
is the ID of the commerce app’s Authorized Client
codeChallenge
will be generated by the AuthClient
as part of PKCE.
redirectUri
will be the URI that Auth should redirect back to on success.
Likely to just be the current origin.
Must be whitelisted by the Authorized Client.
response_type='code'
indicates we want an authorization code in the response that we exchange for an access token.
scope
is the set of security scopes for which the user is requested access, e.g., USER CUSTOMER
.
state
is a random string used to help verify the response from Auth is actually the one the app is expecting.
It will cache an auth transaction with the data included in the request in order to verify the response.
Then it will redirect the browser to the authorization endpoint.
Once Auth redirects back, the AuthProvider
will check for the redirect params in the URL
AuthProvider
will either be from the Auth React SDK, or, if not using React, a similar component of your own make.
This will also result in AuthClient#handleRedirectCallback being called to handle checking the cached auth transaction and exchanging the authorization code in the redirect params for an access token.
AuthClient
instance if using another framework as shown above.import { useAuth } from '@broadleaf/auth-react';
const Login = () => {
const { loginWithRedirect } = useAuth();
return (
<button
onClick={() => {
loginWithRedirect();
}}
type="button"
>
Log In
</button>
);
};
Similarly to login, the Auth SDK makes registration simple for users with AuthClient#registerWithRedirect. This will simply redirect the browser to the Auth-hosted registration page providing the Authorized Client’s ID and a URI to redirect to.
Successful registration may automatically log in the user, depending on how the Authorization Server is configured.
In which case, the AuthProvider
will be able to get a new access token automatically.
Otherwise, the user should be redirected to the login form.
In this section we will cover setting up registration and login forms hosted by the commerce app. We will cover the payloads, endpoints to use, and responses.
Tip
|
On the backend, broadleaf.auth.login.embedded.enabled (global property) must be true and the auth server being submitted to must have embeddedLoginEnabled also set to true .
|
Tip
|
In the commerce starter, you can view an example registration form in app/auth/components/embedded-register.tsx .
|
The registration form should have the following fields
userType
: Hidden field that can default to CUSTOMER
(as opposed to ADMIN
) since this is the commerce app not admin.
fullName
: The user’s full name in one field.
email
: The user’s email.
username
: Optionally the user’s chosen username if different from the email.
password
: The user’s password.
Must conform to whatever the business requirements for format are.
passwordConfirmation
: The confirmation for verifying the password is not mistyped. Optional.
preview
: Whether this is in a preview-on-site context and thus not a production user registration.
attributes.*
: Client implementations may choose to utilize this field to accept and pass additional custom information as part of the user registration process.
This data should then be passed to AuthClient#registerWithCredentials.
registerWithCredentials
will submit the data to {baseUrl}/register/embedded/submit?client_id={clientId}
.
If successful, the user details will be returned in the response.
If not, validation errors will be returned.
The login form should have fields for username
and password
.
These should then be passed to AuthClient#loginWithCredentials.
This method will then submit to {baseUrl}/embedded/login?client_id={clientId}
with Content-Type: application/x-www-form-urlencoded
.
The response will include a one-time-password to use in exchange for an access token.
The AuthClient
will automatically do so for you, submitting the token to the OAuth token endpoint similarly to non-embedded, but with some slight changes shown below.
POST '{baseUrl}/oauth/token?' \
-H "Content-Type: application/x-www-form-urlencoded"
-d accountId={accountId}
-d client_id={clientId}
-d code={token}
-d grant_type=authorization_code
-d purpose=OTP
-d redirect_uri={redirectUri}
-d scope={scope}
-d username={form.username}
If successful, the access token will then be cached. If not, validation errors will be returned related to the login form.