Search code examples
reactjsoauth-2.0microsoft-graph-apimicrosoft-teamsrefresh-token

How do I get a refresh token in microsoft OAuth for my app in Microsoft Teams when the user needs to consent?


I have an app for Microsoft teams that uses Azure SSO login.

The authentication flow is as follows:

  1. When a user opens the app in a channel in Teams, the front-end fetches the sso-token from Microsoft graph API and sends it to back-end.
  2. On receiving the soo-token, the back-end makes a call to /token route of Microsoft graph API with the sso-token and scopes (including offline_access) to fetch the access_token and refresh_token.
  3. If the user has already consented or the admin has consented on the user's behalf, then there is no issue and the back-end is getting both refresh_token as well as access_token and the back-end returns 200 status. But if consent is required, then the back-end sends a 403 status back to the front-end.
  4. Upon receiving 403 status, the front-end now starts the consent flow and makes a call to /authorize route with client-id and scopes (including offline_access). In this case, after the user has consented, I am not getting the refresh_token from Microsoft, instead only access_token is returned.

Now, I have a feature to send Microsoft teams activity notifications to users from the app and for that, I need to refresh the access token from time to time without user interaction as the process of sending activity notifications is happening from the back-end. And since the back-end is not getting the refresh_token in the scenario where user consent was required, after some time the access_token saved in the back-end is expiring and all the API calls for sending activity notifications result in an error.

The Back-end code for the step 2

  1. Utility function for fetching the access_token and refresh_token using sso-token in the back-end.
// other import statements
//...
const {
  MS_CLIENT_ID,
  MS_CLIENT_SECRET,
  MS_GRAPH_SCOPES,
} = require('../config/env');

const graphScopes = `https://graph.microsoft.com/User.Read https://graph.microsoft.com/${MS_GRAPH_SCOPES} offline_access`;

// receives the sso-token as parameter from front-end
const getAccessToken = async (ssoToken) => {
  try {
    const { tid } = jwt_decode(ssoToken);
    if (!tid) throw new Error('sso token is malformed');

    const accessTokenQueryParams = new URLSearchParams({
      grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
      client_id: MS_CLIENT_ID,
      client_secret: MS_CLIENT_SECRET,
      assertion: ssoToken,
      scope: graphScopes,
      requested_token_use: 'on_behalf_of',
    }).toString();

    const { data, status } = await axios({
      method: 'POST',
      url: `https://login.microsoftonline.com/${tid}/oauth2/v2.0/token`,
      headers: {
        Accept: 'application/json',
        'Content-Type': 'application/x-www-form-urlencoded',
      },
      data: accessTokenQueryParams,
    });
    if (status != 200) throw new Error('could not exchange access token');

    return data;
  } catch (error) {
    console.error({
      function: 'getAccessToken',
      dir: __dirname,
      file: __filename,
      error:
        error.response && error.response.data ? error.response.data : error,
    });
    return null;
  }
};
  1. the API route for ms teams authentication that is calling the utility function with the sso-token sent from front-end.
router.get('/auth', async (req, res) => {
  try {
    const {
      ssoToken,
      teamId,
      teamName = '',
      channelId,
      channelName = '',
    } = req.query;

    const { tid } = jwt_decode(ssoToken);
    if (!tid || !teamId)
      return res
        .status(500)
        .json({ errors: [{ msg: 'Could not exchange access token' }] });

    // CALLING THE UTILITY FUNCSTION WITH SSO TOKEN
    const data = await getAccessToken(ssoToken);
    if (!data || !data.access_token)
      res
        .status(403)
        .json({ errors: [{ msg: 'User must consent or perform MFA' }] });

    const { access_token: accessToken, refresh_token: refreshToken } = data;
    console.log({ accessToken, refreshToken });
    if (!accessToken)
      return res
        .status(500)
        .json({ errors: [{ msg: 'Could not exchange access token' }] });

    // SUCCESS
    // return 200 status
    //...
  } catch (error) {
    console.error({
      error:
        error.response && error.response.data ? error.response.data : error,
      person: req.person,
      company: req.company,
      headers: req.headers,
      params: req.params,
      url: req.originalUrl,
    });

    // this error should trigger the consent flow in the client.
    res
      .status(403)
      .json({ errors: [{ msg: 'User must consent or perform MFA' }] });
  }
});

Front-end code for step 4 i.e. when consent is required

  1. ConsentPopup.js (Component responsible for starting the user consentt flow)
class ConsentPopup extends React.Component {
  componentDidMount() {
    console.log("consentPopUp initialized");

    microsoftTeams.initialize();

    // Get the user context in order to extract the tenant ID
    microsoftTeams.getContext((context, error) => {
      let tenant = context["tid"]; //Tenant ID of the logged in user
      let client_id = env.REACT_APP_AZURE_APP_REGISTRATION_ID;

      let queryParams = {
        tenant: `${tenant}`,
        client_id: `${client_id}`,
        response_type: "token", //token_id in other samples is only needed if using open ID
        // offline_access is Added for RefreshToken
        scope: "https://graph.microsoft.com/User.Read https://graph.microsoft.com/TeamsActivity.Send offline_access",
        redirect_uri: window.location.origin + "/auth-end",
        nonce: crypto.randomBytes(16).toString("base64"),
      };

      let url = `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize?`;
      queryParams = new URLSearchParams(queryParams).toString();
      let authorizeEndpoint = url + queryParams;

      //Redirect to the Azure authorization endpoint. When that flow completes, the user will be directed to auth-end
      // GO TO ClosePopup.js
      console.log(authorizeEndpoint);
      window.location.assign(authorizeEndpoint);
    });
  }

  render() {
    return (
      <div>
        <h1>Please wait...</h1>
      </div>
    );
  }
}
  1. ClosePopup.js (Component responsible for completing the user consentt flow)
class ClosePopup extends React.Component {

    componentDidMount(){
      microsoftTeams.initialize();

      //The Azure implicit grant flow injects the result into the window.location.hash object. Parse it to find the results.
      let hashParams = this.getHashParameters();
      console.log({hashParams});
      //If consent has been successfully granted, the Graph ACCESS TOKEN and REFRESH TOKEN should be present as a field in the dictionary.
      if (hashParams["access_token"]){
        //Notifify the showConsentDialogue function in Tab.js that authorization succeeded. The success callback should fire. 
        //SENDING BOTH REFRESH TOKEN AND ACCESS TOKEN
        microsoftTeams.authentication.notifySuccess(hashParams); 
      } else {
        microsoftTeams.authentication.notifyFailure("Consent failed");
      }
    }

    getHashParameters() {
      let hashParams = {};
      console.log({hash: window.location.hash, hashSubStr: window.location.hash.substr(1)});
      window.location.hash.substr(1).split("&").forEach(function(item) {
        let [key,value] = item.split('=');
        hashParams[key] = decodeURIComponent(value);
      });
      console.log('Get Hash Params');
      console.log({hashParams});
      return hashParams;
  }    

    render() {
      return (
        <div>
          <h1>Consent flow complete.</h1>
        </div>
      );
    }
}

export default ClosePopup;

Solution

  • The grant (consent) may not be immediate. As of my experience, it can be up to a 30 seconds. I.e. the grant call returns, but the backend still does not "work".

    You could try to just keep querying until you get the refresh_token in the second case. I.e. just do it multiple times (in the backend). I.e. try exchanging the sso-token more than once. I think in a few seconds, the refresh_token should start coming.