Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Microsoft oidc in AWS Cognito allowing multiple tenants

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://docs.microsoft.com/de-de/azure/active-directory/develop/v2-protocols-oidc https://docs.microsoft.com/de-de/azure/active-directory/develop/id-tokens

like image 578
Dragan Velkovski Avatar asked Oct 13 '20 08:10

Dragan Velkovski


1 Answers

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 actually 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 FE code is not 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

  • https://aws.amazon.com/blogs/mobile/implementing-passwordless-email-authentication-with-amazon-cognito/
  • https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-challenge.html
  • https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-overview

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.

like image 63
flohall Avatar answered Nov 15 '22 10:11

flohall