Microsoft oidc in AWS Cognito allowing multiple tenants
L

3

14

I'm trying to implement social login using Microsoft account in AWS Cognito User Pools.

I followed documentation and the solution mentioned in this thread: https://forums.aws.amazon.com/thread.jspa?threadID=287376&tstart=0
My problem is with setting the issuer to allow multiple tenants.

This issuer works only for private accounts:
https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0

This issuer works only for accounts in our directory (tenant): https://login.microsoftonline.com/AZURE_ACTIVE_DIRECTORY/v2.0

This issuer does not work at all. I get bad issuer error or bad request after sign in with Microsoft: https://login.microsoftonline.com/common/v2.0

I need to have one oidc provider that will work for any Microsoft account (all tenants) is that even possible?

If I set issuer tenant to common in the AWS Cognito oidc config, then this starts the correct Microsoft flow, but I assume the check for issuer in Cognito fails because Microsoft always returns the specific tenant id inside the jwt token as part of the issuer.

Additional info from microsoft documentation I have checked:
https://learn.microsoft.com/de-de/azure/active-directory/develop/v2-protocols-oidc https://learn.microsoft.com/de-de/azure/active-directory/develop/id-tokens

Lemmy answered 13/10, 2020 at 8:12 Comment(3)
Hello @Nishant-MSFTIdentity, thanks for your answer, I am still looking for the answer to this question, I tried the issuer you mentioned, and works only for private accounts. For my work account, I receive the following error "error_description=Bad+id_token+issuer". I am able to successfully login on Microsoft so probably some checks for issuer on aws side are causing this problemLemmy
I thought it might be possible to change the "iss" claim in the azure ad jwt token, so the jwt token would always contain: login.microsoftonline.com/common/v2.0 - But it seems it isn't possible because "iss" belongs to the restricted claim set, see here: learn.microsoft.com/en-gb/azure/active-directory/develop/… and here youtu.be/4wmKLAPvU6c?t=225Falsehood
any solution as of now?Towland
F
16

I am a colleague of Dragan and after a lot of trying we have found a solution in our team that actually works. Just to notice that we had access to premium AWS and Microsoft support, but they couldn't help us. The AWS Cognito Team is aware of the issue, but seems like it has no priority - since nearly a year there hasn't been any fix.

Flow chart: Custom microsoft auth flow

Explanation of the flow

We authenticate against Microsoft using their JavaScript library msal in the frontend (no Cognito involved). We receive a JWT token and use this one to create a normal Cognito user in the user pool. The e-mail is read from the Microsoft token and the password is autogenerated with a secure random (as long as possible). Additionally we send the Microsoft token as custom user attribute. In PreSignUp Lambda we auto activate the user if the Microsoft token is valid, so no password verify e-mail is sent to the user.

Back in the frontend we use the amplify custom auth challenge signIn with the e-mail we have cached in the frontend. Now we go through DefineAuthChallenge and then CreateAuthChallenge. CreateAuthChallenge doesn't do anything as the microsoft token is our challenge and doesn't need to be created. Back in the frontend we call CustomChallenge containing sessionKey and Microsoft token. We are now in VerifyChallenge Lambda where we verify the Microsoft token itself using open source JWT libraries. The flow goes back through DefineAuthChallenge where we only allow one try. Finally the user receives the Cognito tokens from Cognito.

The following snippets are the full code snippets for the Lambdas. I had to remove some specific stuff from our project so hopefully didn't break anything while doing so. All files are the index.js and no additional files are needed for the Lambdas. You could for sure outsource some duplicated code, which we haven't done yet. The most important parts of the FE code are also included here.

PreSignUp Lambda

const jwksClient = require('jwks-rsa');
const jwt = require('jsonwebtoken');

const client = jwksClient({
    jwksUri: 'https://login.microsoftonline.com/common/discovery/v2.0/keys'
});

const options = {
    algorithms: ['RS256']
};

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

const verifyMicrosoftToken = async (jwt, token, key) => {
    if (!token) return {};
    return new Promise((resolve, reject) =>
        jwt.verify(token, key, options, (err, decoded) => err ? reject({}) :
            resolve(decoded))
    );
};

exports.handler = async (event) => {

    const email = event.request.userAttributes.email.toLowerCase();

        //verify microsoft and auto enable user
        if (event.request.userAttributes['custom:msalIdtoken']) {
            const token = await verifyMicrosoftToken(
                jwt, event.request.userAttributes['custom:msalIdtoken'], getKey
            );
            const emailFromToken = token.email !== undefined ? token.email : token.preferred_username;
            if (token && emailFromToken.toLowerCase() === email) {
                event.response.autoConfirmUser = true;
                event.response.autoVerifyEmail = true;
            }

        }

    return event;
};

DefineAuthChallenge Lambda

exports.handler = (event, context, callback) => {

   if (event.request.session &&
       event.request.session.length > 0 &&
       event.request.session.slice(-1)[0].challengeName === 'CUSTOM_CHALLENGE' &&
       event.request.session.slice(-1)[0].challengeResult === true){
       console.log("Session: ", event.request.session);
       event.response.issueTokens = true;
       event.response.failAuthentication = false;

   } else {
       event.response.failAuthentication = false;
       event.response.issueTokens = false;
       event.response.challengeName = 'CUSTOM_CHALLENGE';
   }
    
   // Return to Amazon Cognito
   callback(null, event);
};

CreateChallenge Lambda

exports.handler = (event, context, callback) => {
   if (event.request.challengeName === 'CUSTOM_CHALLENGE') {
       event.response.publicChallengeParameters = {};
       event.response.publicChallengeParameters.dummy = 'dummy';
       event.response.privateChallengeParameters = {};
       event.response.privateChallengeParameters.dummy = 'dummy';
       event.response.challengeMetadata = 'MICROSOFT_JWT_CHALLENGE';
   }
   callback(null, event);
};

VerifyAuthChallenge Lambda

const AWS = require('aws-sdk');
const jwksClient = require('jwks-rsa');
const jwt = require('jsonwebtoken');
const client = jwksClient({
    jwksUri: 'https://login.microsoftonline.com/common/discovery/v2.0/keys'
});

const options = {
    algorithms: ['RS256']
};
function getKey(header, callback){
    client.getSigningKey(header.kid, function(err, key) {
        const signingKey = key.publicKey || key.rsaPublicKey;
        callback(null, signingKey);
    });
}

exports.handler = (event, context, callback) => {
    if(event.request.challengeAnswer){
        jwt.verify(event.request.challengeAnswer, getKey, options, function(err, decoded) {
            if(decoded){
                const email = decoded.email !== undefined ? decoded.email : decoded.preferred_username;
                if (email.toLowerCase() === event.request.userAttributes['email'].toLowerCase()) {
                    event.response.answerCorrect = true;
                    // it is necessary to add this group to user so in BE we can resolve microsoft provider
                    const cognitoIdentityServiceProvider = new AWS.CognitoIdentityServiceProvider();
                    var params = {
                        GroupName: "CUSTOM_MICROSOFT_AUTH",
                        UserPoolId: event.userPoolId,
                        Username: event.userName
                    };

                    cognitoIdentityServiceProvider.adminAddUserToGroup(params, function (err) {
                        if (err) {
                            console.log("Group cannot be added to the user: " + event.userName, err);
                        }
                        callback(null, event);
                    });
                }
            }
            if(err){
                console.log(err);
            }
        });
    }else{
        event.response.answerCorrect = false;
        callback(null, event);
    }
};

Frontend (Angular component)

ngOnInit() {
    // after microsoft successful sign in we need to continue to cognito authentication
    this.authMsalService.handleRedirectCallback((authError, response) => {
        if (authError) {
            this.showLoginError = true;
            return;
        }
        this.signUpOrSignInWithMicrosoftToken(response.idToken.rawIdToken);
    });
}

onSignInWithProvider(provider: string) {
    this.cognitoService.clearAuthData();
    if (provider === SINGLE_SIGN_ON_PROVIDER.MICROSOFT) {
        this.authMsalService.loginRedirect({
            scopes: ['user.read', 'email'],
        });
    } else {
        const options: FederatedSignInOptions = {provider: CognitoHostedUIIdentityProvider[GeneralUtils.capitalize(provider)]};
        this.socialSignIn(options);
    }
}

private socialSignIn(options: any): void {
    Auth.federatedSignIn(options).catch(() => {
        this.showLoginError = true;
        this.uiBlockerService.setIsUiBlocked(false);
    });
}

private signUpOrSignInWithMicrosoftToken(microsoftIdToken: string) {
    this.uiBlockerService.setIsUiBlocked(true);
    const attributes = {};
    const userName: string = this.authMsalService.getAccount().userName.toLowerCase();
    attributes['email'] = userName;
    attributes['custom:msalIdtoken'] = microsoftIdToken;
    if (this.authMsalService.getAccount().idToken['family_name']) {
        attributes['family_name'] = this.authMsalService.getAccount().idToken['family_name'];
    }
    if (this.authMsalService.getAccount().idToken['given_name']) {
        attributes['given_name'] = this.authMsalService.getAccount().idToken['given_name'];
    }
    Auth.signUp({
        username: userName,
        password: SSOUtils.getSecureRandomString(20),
        attributes: attributes
    }).then(user => {
        // register
        // after successfully signup we need to continue with authentication so user is signed in automatically
        this.authenticateWithMicrosoftToken(microsoftIdToken);
    }).catch(error => {
        // login
        // if user is already registered we continue with sign in
        if (error.code === 'UsernameExistsException') {
            this.authenticateWithMicrosoftToken(microsoftIdToken);
        }
        this.uiBlockerService.setIsUiBlocked(false);
    });

}

private authenticateWithMicrosoftToken(microsoftIdToken: string) {
    const userName: string = this.authMsalService.getAccount().userName.toLowerCase();
    Auth.signIn(userName).then(cognitoUser => {
        // after sign in is started we need to continue with authentication and we sent microsft token
        Auth.sendCustomChallengeAnswer(cognitoUser, microsoftIdToken);
    });
}

Here are some links we used

PostScript

If you find any security relevant issue in this code, please contact me privately and our company will show some appreciation ($) depending on severity.

Falsehood answered 18/9, 2021 at 19:39 Comment(7)
Thank you for the solution approach. I'm trying to rebuild it right now. Not sure about the PreSignUp Lambda, you have the code in custom.js and return the event as callback? Or did you override the index.js?Pentosan
All files represent the index.js files. The PresignUp lambda was written with async/await syntax - see: docs.aws.amazon.com/lambda/latest/dg/nodejs-handler.html. Sorry for different syntax in the different lambda. Hopefully I find time to adjust this one day. I also added full code for PresignUp lambda now.Falsehood
Would have been a great help if some snippet of initiating the custom auth would have been shared.Meyerbeer
I have added frontend code.Falsehood
hi @flohall, we're facing the same issue here in 2023 :) . Is there any public tracking of the issue in AWS's side? Any way to upvote it? Thanks for sharing your solution.Nobles
@Nobles - AWS Support didn't give us any tracking number, even after asking for one. But this was more than two years ago. So you are welcome to contact them again.Falsehood
@flohall, we are also facing the same issue. Thanks for the workaround. And we are thinking of going this approach. Just wondering how did you configure in the Azure app registration end. I mean do we need to do take care of to create any special thing in the Azure AD app registration. Thanks a lot in advance.Fiery
E
6

Root cause of the issue:

When we integrate Microsoft login via OIDC, we have a couple of options based on our requirement.

In the case where only the users with work or school accounts from Azure AD can sign in to the application then, we have to refer to https://login.microsoftonline.com/organizations/v2.0/.well-known/openid-configuration

Also in the case where any user who has a Microsoft account (work or school Azure AD accounts, OR personal - outlook, live, etc) can sign in to the application then, we have to refer to https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration

In those metadata files, we can see that the issuer is https://login.microsoftonline.com/{tenantid}/v2.0.
So basically, depending on the end user’s Azure AD tenant, the id_token issued by Azure AD will have a different value for issuer (iss) claim.

Which means the iss claim is dynamically change for each user. Right now, that dynamic behavior isn’t being supported by Cognito. In Cognito, under the OIDC Identity provider configurations, we have to specify the issuer manually and we can only specify one. So Cognito can not properly validate the id_token issued by Azure AD. and it returns an error saying Bad id_token issuer.

Another Workaround:

There are identity providers which support this dynamic iss claim behavior of Azure AD. (Auth0, Azure AD B2C, etc). So we can select one of them and configure that to communicate with Microsoft (Azure AD) via OIDC. Then add that IDP as an OIDC identity provider in Cognito. Basically we place that IDP in between Cognito and Microsoft (Azure AD).

Eudoca answered 20/1, 2022 at 5:32 Comment(4)
I tried to use Azure AD B2C as an intermediate as suggested, but I found that it was only possible to login in with personal Microsoft accounts along with individually selected/local AD tenants. This doesn't seem like a general solution where you can log in with any organization or personal Microsoft account, but maybe I missed something.Porush
@Porush that has been tested and working fine. probably you may have not properly configured the AAD B2C for that?Eudoca
It has been a while, but as I recall when I tried to configure AD B2C I only saw a way to allow users from tenants that had been explicitly added, rather than allowing any user from any organization to find the page and login. If you have any pointers on how to achieve that I'd be interested to try again.Porush
hi @SampathDilhan We have followed the approach you recommended to use Azure AD B2C, but we're still facing issues. 1) Created an Azure AD B2C tenant 2) Linked the Azure AD application in the identity providers 3) Created a new application (Azure AD B2C tenant) & generated client secret/id for the application 4) Linked newly created Azure AD B2C in the aws cognito as a new identity provider. We are still encountering the same error: Bad id_token issuer. It would be helpful if you could let us know what we have missed or share the correct configuration steps to resolve this issue.Gala
P
1

I avoided this (tenancy/issuer) problem by avoiding usage of the userpool, and directly interacting with the azure endpoints https://login.microsoftonline.com/common/oauth2/v2.0/authorize etc..

I still have to use the identitypool, to map to IAM role.

Understandably, this is more work than having the userpool handle token stuff, but this is the only way I found it to work with all azure ad accounts.

Purdy answered 11/3, 2021 at 22:19 Comment(1)
This might be a solution for some use cases, but wasn't in ours as we want to manage all users in the user pool regardless if the users uses ms, google, facebook or username+password. Also we don't use identity pools at all, as we don't need IAM permissions for users.Falsehood

© 2022 - 2024 — McMap. All rights reserved.