Skip to content

feat: expose MFA context on token error cause with isMfaRequiredError type guard#169

Closed
subhankarmaiti wants to merge 2 commits into
mainfrom
feat/add-mfa-required-error-class
Closed

feat: expose MFA context on token error cause with isMfaRequiredError type guard#169
subhankarmaiti wants to merge 2 commits into
mainfrom
feat/add-mfa-required-error-class

Conversation

@subhankarmaiti
Copy link
Copy Markdown
Contributor

@subhankarmaiti subhankarmaiti commented May 7, 2026

Summary

  • Extends OAuth2Error interface with optional mfa_token and mfa_requirements fields
  • Adds MfaRequirements interface describing which factors the user must challenge or enroll
  • Adds isMfaRequiredError(error) type guard that narrows any caught error to one with cause.mfa_token (string) and cause.mfa_requirements guaranteed present
  • Propagates MFA fields from the server response body into the cause of existing error classes via a toOAuth2Error helper
  • Updates EXAMPLES.md with documentation and usage examples

Motivation

When the Auth0 server requires MFA, it returns a 403 with error: "mfa_required", mfa_token, and optionally mfa_requirements. Previously these fields were
discarded when the response was wrapped into TokenByPasswordError, TokenByRefreshTokenError, etc.

This change is non-breaking:

  • Existing instanceof TokenByPasswordError catches still work identically
  • cause gains two new optional fields (mfa_token, mfa_requirements) that are undefined for non-MFA errors
  • The isMfaRequiredError type guard is additive — consumers opt-in to the new behavior

How it works

Server returns 403:
{ error: "mfa_required", error_description: "...", mfa_token: "Fe26...", mfa_requirements: {...} }

openid-client throws ResponseBodyError with full response body in .cause

auth-client.ts catch block calls toOAuth2Error(e):
→ detects mfa_required, extracts mfa_token and mfa_requirements from response body
→ returns OAuth2Error with all fields populated

Existing error class (e.g. TokenByPasswordError) is thrown with enriched cause

Consumer catches error and uses isMfaRequiredError(error) to detect + narrow

Examples

Basic: detect MFA and start challenge

import { AuthClient, isMfaRequiredError } from '@auth0/auth0-auth-js';
                                                                                                                                                                       
const authClient = new AuthClient({ domain, clientId, clientSecret });                                                                                                 
                                                                                                                                                                       
try {                                                                                                                                                                  
  await authClient.getTokenByPassword({ username: 'user@example.com', password: 'p4ssw0rd' });                                                                       
} catch (error) {                                                                                                                                                      
  if (isMfaRequiredError(error)) {                                                                                                                                     
    // error.cause.mfa_token is narrowed to `string`                                                                                                                   
    const challenge = await authClient.mfa.challengeAuthenticator({                                                                                                    
      mfaToken: error.cause.mfa_token,                                                                                                                                 
      challengeType: 'otp',                                                                                                                                            
    });                                                                                                                                                                
  }                                                                                                                                                                    
}                                                                                                                                                                      
                                                                                                                                                                     
Refresh token step-up MFA                                                                                                                                              
                                                                     
import { isMfaRequiredError } from '@auth0/auth0-auth-js';                                                                                                             
                                                                                                                                                                       
try {                                                                                                                                                                
  await authClient.getTokenByRefreshToken({ refreshToken: 'rt_...' });                                                                                                 
} catch (error) {                                                                                                                                                      
  if (isMfaRequiredError(error)) {                                                                                                                                     
    const challenge = await authClient.mfa.challengeAuthenticator({                                                                                                    
      mfaToken: error.cause.mfa_token,                                                                                                                                 
      challengeType: 'otp',                                                                                                                                            
    });                                                                                                                                                                
  }                                                                                                                                                                    
}                                                                                                                                                                    
                                                                                                                                                                       
Enrollment vs. challenge branching                                                                                                                                     
                                                                                                                                                                     
import { isMfaRequiredError } from '@auth0/auth0-auth-js';                                                                                                             
                                                                                                                                                                       
try {                                                                                                                                                                
  await authClient.getTokenByPassword({ username, password });                                                                                                         
} catch (error) {                                                                                                                                                      
  if (isMfaRequiredError(error)) {                                                                                                                                     
    const { mfa_token, mfa_requirements } = error.cause;                                                                                                               
                                                                                                                                                                       
    if (mfa_requirements?.enroll?.length) {                                                                                                                            
      // User must enroll a new factor                                                                                                                                 
      const enrollment = await authClient.mfa.enrollAuthenticator({                                                                                                    
        mfaToken: mfa_token,                                                                                                                                           
        authenticatorTypes: ['otp'],                                                                                                                                   
      });                                                                                                                                                              
      // Show QR code via enrollment.barcodeUri                                                                                                                        
    } else if (mfa_requirements?.challenge?.length) {                                                                                                                  
      // User has factors enrolled, initiate challenge                                                                                                                 
      await authClient.mfa.challengeAuthenticator({                                                                                                                    
        mfaToken: mfa_token,                                                                                                                                           
        challengeType: mfa_requirements.challenge[0].type as 'otp' | 'oob',                                                                                            
      });                                                                                                                                                              
    }                                                                                                                                                                  
  }                                                                                                                                                                    
}                                                                                                                                                                    
                                                                                                                                                                       
Existing error handling still works (no breaking change)                                                                                                               
                                                                                                                                                                     
import { TokenByPasswordError } from '@auth0/auth0-auth-js';                                                                                                           
                                                                                                                                                                       
try {                                                                                                                                                                
  await authClient.getTokenByPassword({ username, password });                                                                                                         
} catch (error) {                                                                                                                                                      
  if (error instanceof TokenByPasswordError) {                                                                                                                         
    // This still catches mfa_required errors — nothing changes for existing consumers                                                                                 
    console.log(error.cause?.error); // 'mfa_required' | 'invalid_grant' | ...                                                                                         
  }                                                                                                                                                                    
}  

@subhankarmaiti subhankarmaiti changed the title feat: add MfaRequiredError class for detecting MFA challenges from token endpoints feat: expose MFA context on token error cause with isMfaRequiredError type guard May 8, 2026
error_description: err.error_description ?? '',
message: (e as { message?: string }).message,
};
if (err.error === 'mfa_required' && err.cause) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we are just checking if err.cause exists or not.

Should this function validate that err.cause is actually an object (not just truthy) before accessing properties on it ?

What happens if cause is a string or number, wouldn't err.cause.mfa_token throw or return unexpected values ?

Since e is unknown from the catch block, the as cast on line 143 doesn't provide runtime safety.

Reference: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause#description

message: (e as { message?: string }).message,
};
if (err.error === 'mfa_required' && err.cause) {
base.mfa_token = err.cause.mfa_token as string | undefined;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit:
Can we add a typeof check here instead of casting ? Something like typeof err.cause.mfa_token === 'string' ? err.cause.mfa_token : undefined ?

This way we don't silently assign a non-string value if the server response shape changes.

* Describes which MFA factors the user must challenge or enroll.
*/
export interface MfaRequirements {
challenge?: Array<{ type: string }>;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should type be a string literal union (e.g. otp' | 'oob' | 'webauthn-roaming' | 'webauthn-platform' | 'push-notification' | 'phone' | 'email') instead of plain string ?

That way consumers get autocomplete and don't need to cast when branching on the type.

return (
error instanceof Error &&
(error as { cause?: OAuth2Error }).cause?.error === 'mfa_required' &&
typeof (error as { cause?: OAuth2Error }).cause?.mfa_token === 'string'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The return type says { cause: OAuth2Error & { error: 'mfa_required'; mfa_token: string } } & Error but should this also include mfa_requirements as optional ?

Without it, consumers who destructure error.cause won't see mfa_requirements in the narrowed type unless they separately cast it.


return TokenResponse.fromTokenEndpointResponse(tokenEndpointResponse);
} catch (e) {
throw new TokenByCodeError('There was an error while trying to request a token.', e as OAuth2Error);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getTokenByCode still uses e as OAuth2Error instead of toOAuth2Error(e). Is there a reason this method is excluded ? If a user hits step-up MFA after a code exchange, the MFA context on the error would be lost. Even if it's not expected today, using toOAuth2Error here would keep all catch blocks consistent.


return TokenResponse.fromTokenEndpointResponse(tokenEndpointResponse);
} catch (e) {
throw new TokenByClientCredentialsError('There was an error while trying to request a token.', e as OAuth2Error);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

*/
export function isMfaRequiredError(
error: unknown
): error is { cause: OAuth2Error & { error: 'mfa_required'; mfa_token: string } } & Error {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The return type narrows to { cause: OAuth2Error & { ... } } & Error. But ApiError declares cause as public cause?: OAuth2Error (optional).

After the guard passes, TypeScript will believe cause is definitely defined but the narrowed type is & Error, not & ApiError. This means consumers lose access to .code, .name, and other ApiError properties after narrowing.

Was this intentional ? Would narrowing to & ApiError (or a union of the known error classes) be better for DX ?

@nandan-bhat
Copy link
Copy Markdown
Contributor

We may need to add coverage for:

  • toOAuth2Error with an mfa_required error (does it extract the fields correctly?)
  • toOAuth2Error with a non-MFA error (does it leave mfa_token undefined?)
  • isMfaRequiredError returning true for a valid MFA error
  • isMfaRequiredError returning false for a regular error, or when mfa_token is missing
    End-to-end: calling getTokenByPassword when the server returns 403 mfa_required, and confirming the caught error passes the type guard

@subhankarmaiti
Copy link
Copy Markdown
Contributor Author

closing this as we have all change sin a single PR #164

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants