Search code examples
aws-cloudformationamazon-cognitoaws-appsyncaws-amplify

AppSync: Get user information in $context when using AWS_IAM auth


In AppSync, when you use Cognito User Pools as your auth setting your identity you get

identity: 
   { sub: 'bcb5cd53-315a-40df-a41b-1db02a4c1bd9',
     issuer: 'https://cognito-idp.us-west-2.amazonaws.com/us-west-2_oicu812',
     username: 'skillet',
     claims: 
      { sub: 'bcb5cd53-315a-40df-a41b-1db02a4c1bd9',
        aud: '7re1oap5fhm3ngpje9r81vgpoe',
        email_verified: true,
        event_id: 'bb65ba5d-4689-11e8-bee7-2d0da8da81ab',
        token_use: 'id',
        auth_time: 1524441800,
        iss: 'https://cognito-idp.us-west-2.amazonaws.com/us-west-2_oicu812',
        'cognito:username': 'skillet',
        exp: 1524459387,
        iat: 1524455787,
        email: 'myemail@nope.com' },
     sourceIp: [ '11.222.33.200' ],
     defaultAuthStrategy: 'ALLOW',
     groups: null }

However when you use AWS_IAM auth you get

identity:
{ accountId: '12121212121', //<--- my amazon account ID
  cognitoIdentityPoolId: 'us-west-2:39b1f3e4-330e-40f6-b738-266682302b59',
  cognitoIdentityId: 'us-west-2:a458498b-b1ac-46c1-9c5e-bf932bad0d95',
  sourceIp: [ '33.222.11.200' ],
  username: 'AROAJGBZT5A433EVW6O3Q:CognitoIdentityCredentials',
  userArn: 'arn:aws:sts::454227793445:assumed-role/MEMORYCARDS-CognitoAuthorizedRole-dev/CognitoIdentityCredentials',
  cognitoIdentityAuthType: 'authenticated',
  cognitoIdentityAuthProvider: '"cognito-idp.us-west-2.amazonaws.com/us-west-2_HighBob","cognito-idp.us-west-2.amazonaws.com/us-west-2_HighBob:CognitoSignIn:1a072f08-5c61-4c89-807e-417d22702eb7"' }

The Docs says that this is expected, https://docs.aws.amazon.com/appsync/latest/devguide/resolver-context-reference.html . However, if you use AWS_IAM connected to Cognito (which is required to have unauthenticated access), how are you supposed to get at the User's username, email, sub, etc? I need access to the user's claims when using AWS_IAM type Auth.


Solution

  • Here is my answer. There was a bug in the appSync client library that would overwrite all custom headers. That has since been fixed. Now you can pass down custom headers that will make it all the way to you resolvers, which I pass to my lambda functions (again, note I am using lambda datasourcres and not using dynamoDB).

    So I attach my logged in JWT on the client side and, server side in my lambda function, I decode it. You need the public key created by cognito to validate the JWT. (YOU DO NOT NEED A SECRET KEY.) There is a "well known key" url associated with every user pool which I ping the first time my lambda is spun up but, just like my mongoDB connection, it is persisted between lambda calls (at least for a while.)

    Here is lambda resolver...

    const mongoose = require('mongoose');
    const jwt = require('jsonwebtoken');
    const jwkToPem = require('jwk-to-pem');
    const request = require('request-promise-native');
    const _ = require('lodash')
    
    //ITEMS THAT SHOULD BE PERSISTED BETWEEN LAMBDA EXECUTIONS
    let conn = null; //MONGODB CONNECTION
    let pem = null;  //PROCESSED JWT PUBLIC KEY FOR OUR COGNITO USER POOL, SAME FOR EVERY USER
    
    exports.graphqlHandler =  async (event, lambdaContext) => {
        // Make sure to add this so you can re-use `conn` between function calls.
        // See https://www.mongodb.com/blog/post/serverless-development-with-nodejs-aws-lambda-mongodb-atlas
        lambdaContext.callbackWaitsForEmptyEventLoop = false; 
    
        try{
            ////////////////// AUTHORIZATION/USER INFO /////////////////////////
            //ADD USER INFO, IF A LOGGED IN USER WITH VALID JWT MAKES THE REQUEST
            var token = _.get(event,'context.request.headers.jwt'); //equivalen to "token = event.context.re; quest.headers.alexauthorization;" but fails gracefully
            if(token){
                //GET THE ID OF THE PUBLIC KEY (KID) FROM THE TOKEN HEADER
                var decodedToken = jwt.decode(token, {complete: true});
                // GET THE PUBLIC KEY TO NEEDED TO VERIFY THE SIGNATURE (no private/secret key needed)
                if(!pem){ 
                    await request({ //blocking, waits for public key if you don't already have it
                        uri:`https://cognito-idp.${process.env.REGION}.amazonaws.com/${process.env.USER_POOL_ID}/.well-known/jwks.json`,
                        resolveWithFullResponse: true //Otherwise only the responce body would be returned
                    })
                        .then(function ( resp) {
                            if(resp.statusCode != 200){
                                throw new Error(resp.statusCode,`Request of JWT key with unexpected statusCode: expecting 200, received ${resp.statusCode}`);
                            }
                            let {body} = resp; //GET THE REPSONCE BODY
                            body = JSON.parse(body);  //body is a string, convert it to JSON
                            // body is an array of more than one JW keys.  User the key id in the JWT header to select the correct key object
                            var keyObject = _.find(body.keys,{"kid":decodedToken.header.kid});
                            pem = jwkToPem(keyObject);//convert jwk to pem
                        });
                }
                //VERIFY THE JWT SIGNATURE. IF THE SIGNATURE IS VALID, THEN ADD THE JWT TO THE IDENTITY OBJECT.
                jwt.verify(token, pem, function(error, decoded) {//not async
                    if(error){
                        console.error(error);
                        throw new Error(401,error);
                    }
                    event.context.identity.user=decoded;
                });
            }
            return run(event)
        } catch (error) {//catch all errors and return them in an orderly manner
            console.error(error);
            throw new Error(error);
        }
    };
    
    //async/await keywords used for asynchronous calls to prevent lambda function from returning before mongodb interactions return
    async function run(event) {
        // `conn` is in the global scope, Lambda may retain it between function calls thanks to `callbackWaitsForEmptyEventLoop`.
        if (conn == null) {
            //connect asyncoronously to mongodb
            conn = await mongoose.createConnection(process.env.MONGO_URL);
            //define the mongoose Schema
            let mySchema = new mongoose.Schema({ 
                ///my mongoose schem
            }); 
            mySchema('toJSON', { virtuals: true }); //will include both id and _id
            conn.model('mySchema', mySchema );  
        }
        //Get the mongoose Model from the Schema
        let mod = conn.model('mySchema');
        switch(event.field) {
            case "getOne": {
                return mod.findById(event.context.arguments.id);
            }   break;
            case "getAll": {
                return mod.find()
            }   break;
            default: {
                throw new Error ("Lambda handler error: Unknown field, unable to resolve " + event.field);
            }   break;
        }           
    }
    

    This is WAY better than my other "bad" answer because you are not always querying a DB to get info that you already have on the client side. About 3x faster in my experience.