OfficeDev / microsoft-teams-library-js

JavaScript library for use by Microsoft Teams apps
https://docs.microsoft.com/microsoftteams/platform/
Other
426 stars 194 forks source link

Upgrade from v1 to V2 sdk - getAuthToken problem #2445

Open dmcweeney opened 1 month ago

dmcweeney commented 1 month ago

Hi,

We are currently trying to upgrade as existing Teams tab app from the Teams 1.9 SDK to the 2.25 SDK. As part of the exercise we have updated all the deprecated api calls to the new structure.

In the updated code the auth/consent flow works as before, however after the auth/consent flow completes the Teams getAuthToken() still returns the resourceRequiresConsent error. I can keep repeating this loop and getAuthToken() will keep returning resourceRequiresConsent.

If at this point I sign out of Teams and log back in and try and readd the tab app, getAuthToken() will work and return the correct token for calling the backend api.

The app registration has not changed - it is registered as a web application with the correct scopes, api permissions, permissions etc.

The app itself is a web app with a corresponding backend api that it uses.

Question - what or how does Teams kick into the auth flow when authentication.notifySuccess is called - is it relying on MSAL PublicClientApplication.handleRedirectPromise? I tried to change the code to use this in the auth start/end flow as per the samples however this is only supported in an AAD app registered as an SPA.

Original auth end code that worked with 1.9 sdk:

  private async handleAuthEnd(): Promise<void> {
    const hash = window.location.hash.replace(/^#/, '');
    const params = new URLSearchParams(hash);
    if (params.has('error')) {
      MicrosoftTeams.authentication.notifyFailure(params.get('error'));
    } else if (params.has('access_token')) {
      const expectedState = this.getStoredState();
      if (expectedState?.state === params.get('state')) {
        if(expectedState.feature === ConsentFeature.files){
        await this.ensureConsentComplete(expectedState.feature);
      }
        MicrosoftTeams.authentication.notifySuccess(JSON.stringify({
          idToken: params.get('id_token'),
          accessToken: params.get('access_token'),
          tokenType: params.get('token_type'),
          expiresIn: params.get('expires_in')
        }));
      } else {
        MicrosoftTeams.authentication.notifyFailure('StateDoesNotMatch');
      }
    } else {
      MicrosoftTeams.authentication.notifyFailure('UnexpectedFailure');
    }
  }

Current code:

public async componentDidMount(): Promise<void> {
    try {
      console.log('AuthEndPage.componentDidMount - calling app.initialize');
      await MicrosoftTeams.app.initialize();
      const context = await MicrosoftTeams.app.getContext();
      this.setState({context:context});
      await this.handleAuthEnd(context);
    }
    catch(error) {
      this.logger.error( 'AuthEndPage.componentDidMount failed - ', error );
      throw error;
    }
  }

private async handleAuthEnd(context: MicrosoftTeams.app.Context): Promise<void> {
    const hash = window.location.hash.replace(/^#/, "");
    const params = new URLSearchParams(hash);
    if (params.has("error")) {
      MicrosoftTeams.authentication.notifyFailure(params.get("error"));
    } else if (params.has("access_token")) {
      const expectedState = this.getStoredState();
      if (expectedState?.state === params.get("state")) {
        if (expectedState.feature === ConsentFeature.files) {
          await this.ensureConsentComplete(expectedState.feature);
        }

        MicrosoftTeams.authentication.notifySuccess(
          JSON.stringify({
            idToken: params.get("id_token"),
            accessToken: params.get("access_token"),
            tokenType: params.get("token_type"),
            expiresIn: params.get("expires_in")
          })
        );
      } else {
        console.log('calling notifyFailure - StateDoesNotMatch');
        MicrosoftTeams.authentication.notifyFailure("StateDoesNotMatch");
      }
    } else {
      console.log('calling notifyFailure - UnexpectedFailure');
      MicrosoftTeams.authentication.notifyFailure("UnexpectedFailure");
    }
  }

I also turned on Teams SDK debug logging and the notifySuccess() result is passed back to the Teams parent window but there are no errors or warnings logged that might shed some light on why the auth is not getting picked up.

Note I have tried calling notifySuccess() with and without the tokens but it didn’t make any difference.

I know I am missing something obvious and simple - any ideas greatly appreciated.

AE-MS commented 1 month ago

Hi @dmcweeney. Thanks for providing such detailed information and code in your question!

I'd like to understand a little more about the auth flow you are using in your app. If you are calling authentication.getAuthToken(), you should receive a token back directly in practically all cases (assuming the app's Microsoft Entra app registration is configured appropriately and the user has consented). There shouldn't be any need to call any of the authentication.notifySuccess() or authentication.notifyFailure() methods -- those are only used in conjunction with authentication.authenticate() when the app needs to authenticate with a 3rd party identity provider (or show a consent dialog).

Can you tell me more about how the code you shared (which uses authentication.notifySuccess and authentication.notifyFailure) interacts with the code you have calling authentication.getAuthToken()? I see you mention authentication.getAuthToken() in your message but I didn't see it in the code sample.

Thanks!

dmcweeney commented 1 month ago

Hi @AE-MS ,

As you say above the code kicks in when the app needs to be consented.

The consent check code is:

  public async isConsented(
    permissionsType: ConsentFeature = ConsentFeature.users
  ): Promise<boolean> {
    try {
      const token = await MicrosoftTeams.authentication.getAuthToken({ silent: true });
      if (!token) {
        this.logger.info("isConsented - getAuthToken returned null");
        return false;
      }
      this.logger.info(`isConsented - getAuthToken returned token: ${token}`);
    } catch (error) {
      this.logger.error("isConsented - getAuthToken returned error: ", error);
      return false;
    }
    try {
      const response = await axios.post<GraphPermission>(
        `${this.baseUrl}/my/graphPermissions/${permissionsType}`
      );
      this.logger.info(`isConsented - response.status: ${response.status}`);
      return response.status === 200 && response.data?.hasConsented;
    } catch (error) {
      this.logger.error("isConsented - Error checking user consent", error);
      return false;
    }
  }

isConsented is called by this piece of code:

  public async ensureAuthenticated(): Promise<void> {
    try {
      const isConsented: boolean = await this.isConsented();
      if (!isConsented) {
        const context = await MicrosoftTeams.app.getContext();
        try {
          const result = await MicrosoftTeams.authentication.authenticate({
            url: `${window.location.origin}/teams/auth-start?tid=${context.user.tenant.id}&hint=${context.user.loginHint}`,
            width: 600,
            height: 700
          });
          this.logger.info( `authentication.authenticate completed successfully` );

          const isConsentedCheck2: boolean = await this.isConsented();
          this.logger.info( `isConsentedCheck2 = ${isConsentedCheck2}` );
        } catch(reason) {
          this.logger.error("authenticate failed: ", reason);
          throw reason;
        }
      }
    } catch (error) {
      this.logger.error("Error authenticating/consenting", error);
      throw error;
    }
  }

The following auth-start code is called from :

  public async componentDidMount(): Promise<void> {
    try {
      console.log('AuthStartPage.componentDidMount - calling app.initialize');
      await MicrosoftTeams.app.initialize();
      const queryParams: URLSearchParams = new URLSearchParams(location.search);
      if (queryParams.has("tid") && queryParams.has("hint")) {
        this.authorizeUser(queryParams.get("tid"), queryParams.get("hint"));
      } else {
        const context = await MicrosoftTeams.app.getContext();
        this.authorizeUser(context.user.tenant.id, context.user.loginHint);
      }
    }
    catch(error) {
      this.logger.error( 'AuthStartPage.componentDidMount failed - ', error );
      throw error;
    }
  }

  private authorizeUser(tid: string, loginHint: string): void {
    // Generate random state string and store it, so we can verify it in the callback
    const state = uuid();
    localStorage.setItem(
      Constants.LocalStorageStateKey,
      JSON.stringify({ state, feature: this.feature })
    );
    // See https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-v2-protocols-implicit
    // for documentation on these query parameters
    const queryParams: URLSearchParams = new URLSearchParams({
      client_id: globalConfig?.clientId,
      response_type: "id_token token",
      response_mode: "fragment",
      scope: globalConfig.authScopes,
      redirect_uri: window.location.origin + "/teams/auth-end",
      nonce: uuid(),
      state: state,
      login_hint: loginHint
    });
    const authorizeEndpoint = `https://login.microsoftonline.com/${tid}/oauth2/v2.0/authorize?${queryParams.toString()}`;
    // Go to the AzureAD authorization endpoint
    window.location.assign(authorizeEndpoint);
  }

And the auth-end code is listed above.

The following is some of logs printed out:

PC-CONFIG][ERROR][AuthService]: isConsented - getAuthToken returned error: Error: resourceRequiresConsent [PC-CONFIG][INFO][AuthService]: authentication.authenticate completed successfully [PC-CONFIG][ERROR][AuthService]: isConsented - getAuthToken returned error: Error: resourceRequiresConsent

NOTE even if I put in some delays after the authentication.authenticate returns successfully and before I call isConsented again the resourceRequiresConsent error is thrown by getAuthToken.

AE-MS commented 1 month ago

Thank you for the extra detail, that helps provide more info on how your call to getAuthToken and authenticate are interacting now.

Just to check a few easy things first:

dmcweeney commented 1 month ago

Hi @AE-MS,

Are you seeing this only in Teams or also in Outlook and Microsoft365.com?

I am only testing in Teams - both desktop and web and for the web both teams.microsoft.com and teams.cloud.microsoft.

You have both 1fec8e78-bce4-4aaf-ab1b-5451cc387264 and 5e3ce6c0-2b1f-4285-8d4b-75ee78787346 listed as "Authorized Client Applications" in the Microsoft Entra ID app registration for your app; is this correct?

Yes

I'm even more confused now that I was before I started this morning. I have been testing with a couple of users from a cdx demo tenant - where the behavior is documented above.

I tried earlier using my own work account in our own tenant to test this in the Teams desktop app - removed the consent permissions and waited a while etc - and it worked - getAuthToken returned a token, it went through the consent flow and when returned getAuthToken returned a token and api calls to the backend could be made.

Using my own work account I tried the exact same steps through the web app - teams.microsoft.com - removed the consent permissions and waited a while etc - added the app and the consent flow worked,

Using a demo account I tried the exact same steps through the web app - teams.microsoft.com - and the behavior is as documented above - getAuthToken throws a resourceRequiresConsent error before and after the consent flow!

SO why would getAuthToken throw a resourceRequiresConsent error when the app is not consented for a demo tenant user but yet return a token for my work account (even though the app has not been consented).

Totally confused now!

AE-MS commented 1 month ago

I agree that is a confusing experience! It has me confused! 😅

Since it seems like it is specific to Teams, I've added the tags for the Teams dev support team to take a look at this. You should be hearing from them on this thread soon.

FYI @Wajeed-msft

sayali-MSFT commented 1 month ago

@dmcweeney -Thanks for reporting your issue. We will check this at our end and will get back to you.

dmcweeney commented 3 weeks ago

Hi @sayali-MSFT, any updates or progress?

sayali-MSFT commented 3 weeks ago

@dmcweeney -Sorry for the delay. We are working on this from our end but are encountering some issues. We will check with the engineering team and provide you with an update

sayali-MSFT commented 2 weeks ago

@dmcweeney -We got the reply from engineering team, Switching from teams-js v1 to v2 should have no impact on the errors returned from the getAuthToken API. If the API is returning a resourceRequiresConsent error, that means that the user hasn't consented to the resource specified in the app's manifest.

In the sample code provided by the above, you are passing silent: true when calling getAuthToken. You should call the API again with silent: false . if you have received a resourceRequiresConsent error so that Teams correctly prompts the user for consent.

dmcweeney commented 2 weeks ago

Hi @sayali-MSFT,

Thanks from passing on the response from the engineering team. I'm slightly confused though.

Switching from teams-js v1 to v2 should have no impact on the errors returned from the getAuthToken API.

Agreed but this is not what we are seeing.

If the API is returning a resourceRequiresConsent error, that means that the user hasn't consented to the resource specified in the app's manifest.

By resource I presume they are referring to the AAD appId specified in the webApplicationInfo.id property of the Teams app manifest? I've treble checked this and this is the app id I use for the consent flow.

It also does not explain the behaviour I highlighted in the initial issue description.

... however after the auth/consent flow completes the Teams getAuthToken() still returns the resourceRequiresConsent error. I can keep repeating this loop and getAuthToken() will keep returning resourceRequiresConsent.

If at this point I sign out of Teams and log back in and try and readd the tab app, getAuthToken() will work and return the correct token for calling the backend api.

So why would logging out and logging back in again result in getAuthToken() working?

Also why the different behaviours when consenting with my own tenant account versus consenting with a cdx demo account?

I'm more than happy to show ye folks in a demo on a Teams call if needs be.

Thanks

Donal

sayali-MSFT commented 1 week ago

@dmcweeney -We will discuss this internally with the team and let you know the update.

dmcweeney commented 1 week ago

Hi @sayali-MSFT,

Thanks for the update.

For the sake of completeness I tried changing the getAuthToken silent param from true to false and yes indeed Teams kicks in and asks for consent as follows:

Teams asks for consent via redirect - however it only asks to consent email, profile, offline_access, and OpenId as documented here. When user clicks Consent, the flow redirects back to Teams and the Teams web app RELOADS (have not tried this in desktop).

In addition to the above permissions, our app also needs User.Read and User.ReadBasic.All permissions, so when the user tries to readd the tab app, our consent flow is started in a popup dialog. User clicks consent, popup closes. When the await MicrosoftTeams.authentication.authenticate call completes getAuthToken works .

So there is definitely something relevant going on in the reload of the Teams app after the initial consent of the basic permissions.

Thanks, Donal