Adding an extra level of authentication with Step Up MFA
Klee Thomas
Posted on April 5, 2021
The problem
Multi-Factor Authentication (MFA) is an important part of a secure authentication process, but it's painful for users. One option to lower the friction on the user is to ask the user to step up their authentication when the user goes to perform a protected action.
In this post I'll go through how to a small application that uses step up MFA to add extra protection to an endpoint. To do this I'll bring together a client written in React and Typescript, an API server built on Express and Typescript and Auth0 actions written in JavaScript.
Server
First lets build the server. This will have three endpoints. one public, one private and one requiring step up authentication.
To handle the JWT authentication lets use express-jwt
and to configure it to work with Auth0 jwks-rsa
import express, { NextFunction, Request, Response } from "express";
import jwks from "jwks-rsa";
import jwtMiddleware from "express-jwt";
const server = express();
var jwtCheck = jwtMiddleware({
secret: jwks.expressJwtSecret({
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 5,
jwksUri: "https://your-domain.your-region.auth0.com/.well-known/jwks.json",
}),
audience: "Your api name",
issuer: "https://your-domain.your-region.auth0.com/",
algorithms: ["RS256"],
});
That will provide a middleware that can be used to check JWT access tokens and extract their claims into the Request object.
For the public endpoint that's not needed. Just take the request and return a hello world value.
server.get("/public", (req: Request, res: Response): void => {
res.json({ hello: "world" });
});
Lets add it to the private endpoint.
server.get(
"/private",
jwtCheck,
(req: Request, res: Response): void => {
res.json({ authenticated: true });
}
);
This will mean that a request will be rejected if the requires doesn't have a valid, non-expired bearer token signed by the Auth0 tenant.
The MFA protected endpoint
The final endpoint needs to check that a token has been generated by a user that has used MFA. There is not a standard claim in the Auth0 token to say when the last MFA event was. Custom claims can be added by Auth0 Rules or Actions and need to be prefixed with a url like prefix to distinguish them from standard claims and avoid clashes (see Auth0 docs).
For this case lets use [https://kleeut.com:mfaTime](https://kleeut.com:mfaTime)
as our custom claim in this case. Once the token has been validated run an extra function that checks if the user has that property and that the last MFA was within one minute of when this action happened. In this case one minute seems like a good option, it's long enough to test the both valid and invalid scenarios.
For the /stepUpMfaEndpoint
endpoint the code looks like this:
server.get(
"/stepUpMfaEndpoint",
jwtCheck,
mfaRequired,
(req: Request, res: Response): void => {
res.json({
authenticated: true,
mfa: true,
followMe: { onTwitter: "https://twiter.com/kleeut" },
});
}
);
The heavy lifting here is done by the mfaRequired
middleware. This middleware runs after the jwtCheck
which extracts the claims from the token and adds them into the request object as a user
property. The Request
type can be extended to include the values from the claims. Making user
an optional property avoids complaints from the Express
typing.
type TokenCliams = {
user?: {
"https://kleeut.com:mfaTime"?: string;
iss: string;
sub: string;
aud: string[];
iat: number;
exp: number;
azp: string;
scope: string;
};
};
function mfaRequired(
req: Request & TokenClaims,
res: Response,
next: NextFunction
) {
...
}
Inside this middleware extract the mfaTime
custom claim and assign it to a variable to make it easier to read and check if it exists and if it does is the time within the last minute.
const mfaTime = req.user?.["https://kleeut.com:mfaTime"];
if (mfaTime && itHasBeenLessThanAMinuteSince(new Date(mfaTime))) {
console.log(`MFA time is good ${mfaTime}`);
next();
return;
}
To work out if the MFA time was within a minute, use date-fns
to compare between now and now minus 1 minute.
import isBefore from "date-fns/isBefore";
import subMinutes from "date-fns/subMinutes";
...
function itHasBeenLessThanAMinuteSince(date: Date): boolean {
return isBefore(subMinutes(Date.now(), 1), date);
}
If the last MFA time was more than a minute ago let's return a 401 response with a body the informs the consumer of the API why they've been forbidden.
console.log(`MFA rejected, ${mfaTime}`);
res.status(401).json({
code: "mfaRequired",
message: "Recent MFA is required to access this endpoint",
});
return;
The idea behind returning a code and a message is that the message should be able to change it's content without breaking the API. Where the code should stay the same to provide a consistent value that the consumers can build their functionality around.
With these functions the server has a public endpoint, a private endpoint and a step up endpoint.
Auth0 Actions
To take the input from the client and present the Guardian MFA dialogue lets make use of Auth0 Actions which is currently in Beta. The alternative would be to use Auth0 Rules which are a more established and stable product with a less pleasant editing experience and less extensibility options. There is some more information on using Actions in my previous post on setting up conditional MFA with Actions.
This new Action will need to assess the scopes passed in during authentication. If the mfa:required
scope is provided it will need to push the user to do a MFA check and set the MFA time in the access token.
The first task is to find out if the scope is present on the the request. The scope and other query string properties available as part of the actor object. The scope object is an optional property, if it's present it's a space separated list. To check that the mfa:required
scope is being passed in the scope string needs to be split to avoid accidentally matching sub string of a different scope.
const scopes = event.actor.query.scope || "";
const splitScopes = scopes.split(" ");
if(splitScopes.includes("mfa:required")){
// Do the thing
}
return {}
To trigger the the Guardian MFA check the returned object needs to contain a command object telling Auth0 to push the user to Guardian before returning to the client with the requested tokens. The return object looks like this:
// the thing
return {
... // other properties
command: {
type: "multifactor",
provider: "guardian"
}
}
The final think that the action needs to do is to include the time that the MFA check was done so that the server can make an informed decision about if the request should be accepted.
This was a little different to what I expected. The authentication
property on the event includes a methods
property that includes the last time that an MFA check was completed. The only thing is this is this value includes the last time that an MFA check was conducted which means using it would leave users having to authenticate twice to get an up to date token. Instead of using the time of the last successful MFA lets write the current timestamp into the token as [https://kleeut.com:mfatime](https://kleeut.com:mfatime)
when the mfa:required
scope is present. This will mean that if the user has gone through a successful MFA event the token that is created will include the current time. If the scope is not present set the [https://kleeut.com:mfatime](https://kleeut.com:mfatime)
to the MFA timestamp from the authentication.methods
.
In the end the end the Action code looks like this:
module.exports = async (event, context) => {
const scopes = event.actor.query.scope || "";
const splitScopes = scopes.split(" ");
if(splitScopes.includes("mfa:required")){
return {
accessToken: {
customClaims:{
"https://kleeut.com:mfaTime": Date.now(),
}
},
command: {
type: "multifactor",
provider: "guardian"
}
};
}
const authMethods = (event.authentication || {}).methods || [];
return {
accessToken:{
customClaims:{
"https://kleeut.com:mfaTime": (authMethods.filter(x => x.name === "mfa")[0] || {}).timestamp
}
}
};
};
Client
The final part is to have a client that knows that when the server rejects a request with the correct code to call back to Auth0 in order to get the user to do MFA and get a token that will be allowed to call the API.
This example uses TypeScript, React and the Auth0 React SDK.
To make the code re-usable let's split out a custom action that can take care of fetching data and requiring the user to perform an MFA action in the case that they're not authorised to connect to an end point.
The hook needs to take a URL and return the data, error object and a function to initiate a call.
function useAuthenticatedCall({
url,
}: {
url: string;
}): { data: unknown; error: string | null; startRequest: () => void } {
...
}
The hook is going to need to be able to track some data.
const { loginWithRedirect, getAccessTokenSilently } = useAuth0(); // get needed actions from the Auth0 sdk
const [data, setData] = useState<unknown>({}); // return value from the request
const [error, setError] = useState<string | null>(null); // an error message for failed requests
const [shouldFetch, setShouldFetch] = useState(false); // an indicator of if a fetch should be started
const [fetching, setFetching] = useState(false); // an indication of if a fetch is in progress
To perform the side effect lets use a use a useEffect
hook. useEffect
needs to be synchronous so add an additional async
function inside the hook and call it when the state is not fetching
and it shouldFetch
.
useEffect((): void => {
const makeAsyncFetchCall = async (): Promise<void> => {
... // fetch and response handling logic
};
if (!fetching && shouldFetch) {
makeAsyncFetchCall();
}
}, [
... // all the variables used in the useEffect
]);
The first thing to do is to prevent fetch being called while the previous fetch is running.
Use the getAccessTokenSilently()
function from the Auth0 SDK, to get the access token to use in the request and set it as a Bearer Token Authorization header.
Take the response, parse it into an object and exit.
Finally set the state back so that it's ready for the next fetch.
setFetching(true);
setShouldFetch(false);
const accessToken = await getAccessTokenSilently();
const headers: HeadersInit = {};
try {
if (accessToken) {
// if there is an access token send it as a Bearer token in the Authorization header.
headers["Authorization"] = `Bearer ${accessToken}`;
}
const response = await fetch(url, { headers });
if (!response.ok) {
// handle the successful response
const data = await response.json();
setData(data);
return;
}
... // stuff for unhappy paths
} catch (e) {
console.error(e);
setError(e.message)
} finally {
setShouldFetch(false);
setFetching(false);
}
The interesting part in this is how to handle the unauthorised cases. In the case that the response is not ok. Lets check the status.
If it's a 401 status then parse the body and check the error code.
If the code is mfaRequired
then redirect the user to login with the additional scope mfa:required
this scope will then be picked up by the Auth0 Action above and the user will be asked to do an MFA check. Presuming they go through this check next time the getAccessTokenSilently
method is called the user will be given an access token with a recent mfaTime
and will be allowed to call the MFA protected method.
In the case that parsing the response fails assume that the user needs to log in again and redirect them to log in.
if (response.status === 401) {
// iF the response is unauthorized execute the auth failure callback.
try {
const body = (await response.json()) as {
code: string;
message: string;
};
if (body.code === "mfaRequired") {
loginWithRedirect({ scope: "mfa:required" }); // add additional scope
return;
}
} catch (e) {}
loginWithRedirect(); // use scope from initial login
return;
}
Summary
While this post has been longer than I intended it to the combination of Auth0 Actions and the React Auth0 SDK makes setting up Step Up authentication a relatively simple matter.
- The server restricts endpoints based on the presence and value of custom claims in the access token.
- Auth0 actions look for an additional scope and use that to force a standard login into an MFA event.
- The client looks at the response from the server and passes the additional scope if the user can be authorised by doing an MFA check.
When implementing this in a production application the biggest challenges will be what factors should you use on initial login and what factors do you want to use to ensure that your sensitive endpoints have additional protection?
Sample code can be found for this on GitHub
Posted on April 5, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.