Will Access/ID Tokens Received Via Refresh Token Contain the Custom Claims

Overview

When using the Auth0 post-login flow to set custom claims in the token, data is received from metadata and set in the token as a custom claim. Will the custom claim be available even if the access_token is acquired via refresh_token?

Applies To
  • Custom Claims
  • Refresh Token
Solution

The access tokens received via a refresh token flow will usually have the same custom claims. This is because the same extensibility points (Actions) will run in this flow too. Any custom claims added to the access or ID token during the login step will also be added in a refresh token flow as long as the same Action code fully executes.

However, some use cases that require user interaction (e.g., an Auth0 Form collecting information that is then inserted as a custom claim in an Action) will not execute similarly during the refresh token flow, as this flow cannot support interactive events that require a browser. In those cases, the custom claims will be missing.

 

If those claims have to be retained during the refresh token call, a previously issued access or ID token with those custom claims can be passed with a custom parameter from the application side while making the refresh token call, and a special action can be used to verify and insert the claims as a workaround.

This Action must decode the received token, validate its signature, ensure that the token was issued to the user for whom the refresh token flow is running, and only then set the same custom claims in the Action. Using a token not issued to the user and not properly validating it before inserting the claims may cause a security vulnerability and requires careful implementation and testing.

 

Below is a sample Action that inserts the claims in an access token if the previously issued access token has those claims, and can be a good starting point. The Action assumes the access token is passed to the /oauth/token endpoint with a custom attribute named previous_access_token. This custom attribute can be named freely as long as it does not collide with a reserved name used in the flow. Best practice would be to namespace it with your company name.

The sample Action ensures that claims from a previous, expired access token are only carried over to the new token if specific security conditions are met.

  1. Distinguishes Between Login and Refresh: The script first checks if the event is an initial login or a refresh token exchange.

    • On initial login, it embeds the current session ID into a custom claim (https://your-app.com/claims/sid) on the access token.

    • On refresh token exchange, it proceeds to the validation steps.

  2. Requires Previous Access Token: The Action expects the client application to include the expired or old token in the body of the refresh token request under the key previous_access_token.

  3. Performs Some Security Validation: Before retaining any claims, the Action validates the previous_access_token to prevent misuse:

    • Signature & Issuer: It verifies the token's signature using your Auth0 tenant's public key and confirms it was issued by the correct tenant.

    • User Identity: It ensures the user ID (sub) in the old token matches the user currently exchanging the refresh token.

    • Client ID: It checks that the client ID (azp) in the old token matches the client making the current request.

    • Session ID: It verifies that the session ID stored in the old token matches the session ID of the refresh token being used. This is critical to ensure the old token belongs to the same login session.

    • Custom Expiration: It implements a custom check to ensure the old token is not too old (in this example, it must have been issued within the last 24 hours). 

exports.onExecutePostLogin = async (event, api) => {
  // Import required libraries for JWT validation.
  const jwt = require('jsonwebtoken');
  const jwksRsa = require('jwks-rsa');

  // --- 1. Handle initial login vs. Refresh Token flow ---
  if (event.transaction?.protocol !== 'oauth2-refresh-token') {
    // This is not a refresh token flow, so we are in an initial login.
    
    // Set a test claim that can be retained later.
    api.accessToken.setCustomClaim("https://your-app.com/claims/test", "XYZ");

    // Capture the session ID and store it in the access token.
    const sessionID = event.session.id;
    if (sessionID) {
      api.accessToken.setCustomClaim("https://your-app.com/claims/sid", sessionID);
    }
    return;
  }

  // --- 2. Check for the previous token in the request body ---
  const previousToken = event.request.body.previous_access_token;
  if (!previousToken) {
    console.log('No previous_access_token found in refresh token request.');
    return;
  }

  // --- 3. Decode and Validate the previous token ---
  const jwksUri = `https://[YOUR-LOGIN-DOMAIN]/.well-known/jwks.json`;
  const jwksClient = jwksRsa({
    jwksUri: jwksUri,
    cache: true,
    rateLimit: true,
  });

  const getKey = (header, callback) => {
    jwksClient.getSigningKey(header.kid, (err, key) => {
      const signingKey = key.publicKey || key.rsaPublicKey;
      callback(null, signingKey);
    });
  };

  let decodedToken;
  try {
    decodedToken = await new Promise((resolve, reject) => {
      jwt.verify(
        previousToken,
        getKey,
        {
          issuer: `https://[YOUR-LOGIN-DOMAIN]/`,
          audience: event.refresh_token?.resource_servers[0]?.audience||"audience-is-missing",// verify audience
          algorithms: ['RS256'],
          ignoreExpiration: true, // We ignore the original 'exp' claim...
        },
        (err, decoded) => {
          if (err) {
            return reject(err);
          }
          resolve(decoded);
        }
      );
    });

  // --- 4. Verify that the token belongs to the current user ---
    if (decodedToken.sub !== event.user.user_id) {
      console.error(
        `Security violation: previous_access_token subject ('${decodedToken.sub}') does not match the current user ('${event.user.user_id}').`
      );
      api.access.deny('Token mismatch: The provided token does not belong to the current user.');
      return;
    }

    // --- 5. Verify the Client ID matches ---
    // The 'azp' claim in the token represents the OAuth2 client ID.
    if (decodedToken.azp !== event.client.client_id) {
       console.error(
        `Security violation: previous_access_token client ID ('${decodedToken.azp}') does not match the current client ID ('${event.client.client_id}').`
      );
      api.access.deny('Client ID mismatch: The provided token was not issued to this client.');
      return;
    }

    // --- 6. Custom expiration check: Token must be issued recently. Ideally, it can be set 
    // close to the absolute or inactivity session timeout setting in the tenant. In this 
    // sample, it is within the last 24 hours ---
    const issuedAt = decodedToken.iat; // 'iat' is a Unix timestamp in seconds
    const now = Math.floor(Date.now() / 1000); // current time in seconds
    const oneDayInSeconds = 24 * 60 * 60; // 86400

    if ((now - issuedAt) > oneDayInSeconds) {
      console.error(
        `Custom expiration failed: Token was issued at ${new Date(issuedAt * 1000).toISOString()} which is more than 24 hours ago.`
      );
      api.access.deny('The provided token is too old to be used for retaining claims.');
      return;
    }

    // --- 7. Verify the session ID matches the latest session ---
    const tokenSid = decodedToken["https://your-app.com/claims/sid"];
    const refreshTokenSid = event.refresh_token.session_id;

    if (!tokenSid || tokenSid !== refreshTokenSid) {
      console.error(
        `Security violation: Session ID mismatch. Token SID: '${tokenSid}', refreshTokenSid SID: '${refreshTokenSid}'.`
      );
      api.access.deny('Session mismatch: The provided token is from an outdated session.');
      return;
    }  

  } catch (error) {
    console.error('Validation of previous_access_token failed:', error.message);
    api.access.deny('Invalid or expired previous_access_token provided.');
    return;
  }

   // --- 8. Add any additional checks specific to your use case that help to 
   // verify that the claims can be applied to the user. E.g. call an external service 
   // that holds information on users and can be cross-checked to ensure that the claims
   // are safe to add. ---

   // This is a TODO for our customers. 

  // --- 9. Extract and Set the Custom Claims ---
  console.log('Successfully decoded previous token payload:', JSON.stringify(decodedToken, null, 2));

  const CLAIM_NAMESPACE = 'https://your-app.com/claims';
  let claimsFound = false;
  // Iterate over all keys in the token to find the ones that start with our namespace.
  for (const key in decodedToken) {
    if (key.startsWith(CLAIM_NAMESPACE)) {
      claimsFound = true;
      const value = decodedToken[key];
      // Set the claim on the new access token using its full, original key.
      api.accessToken.setCustomClaim(key, value);
      console.log(`Retained claim: '${key}'`);
    }
  }

  if (!claimsFound) {
    console.log(`No claims starting with the namespace '${CLAIM_NAMESPACE}' were found in the previous token.`);
  }
};


 

Recommended content

No recommended content found...