15

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.

Yves M.
  • 26,153
  • 20
  • 93
  • 125
honkskillet
  • 2,374
  • 1
  • 27
  • 41

5 Answers5

6

For making User's username, email, sub etc. accessible through AppSync API, there's an answer for that: https://stackoverflow.com/a/42405528/1207523

To sum it up, you want to send User Pools ID token to your API (e.g. AppSync or API Gateway). Your API request is IAM authenticated. Then you validate the ID token in a Lambda function and now you have your validated IAM user and User Pools data together.

You want to use the IAM's identity.cognitoIdentityId as primary key for you User table. Add the data included in ID token (username, email, etc.) as attributes.

This way you can make user's claims available through you API. Now, for example, you can set $ctx.identity.cognitoIdentityId as the owner of an item. Then maybe other users can see the name of the owner via GraphQL resolvers.

If you need to access the user's claims in your resolver I'm afraid that doesn't seems to be possible at the moment. I have made a question about this as it would be very helpful for authorization: Group authorization in AppSync using IAM authentication

In this case, instead of using a resolver you could use Lambda as a data source and retrieve the user's claims from the above-mentioned User table.

It's all a bit difficult at the moment :)

Yves M.
  • 26,153
  • 20
  • 93
  • 125
Mikael Lindlöf
  • 2,032
  • 2
  • 16
  • 23
  • So when you send the UserPoolID token, are you sending it as a header? I couldn't figure out how to attach a custom header when using AWS Amplify with AppSync. – honkskillet May 01 '18 at 03:46
  • @honkskillet The point here is to have an API method (called syncUser, for example) just for saving the UserPool ID token to your database. Therefore I'd take the ID Token as the only parameter in this API method. You could make this API call when the user signs in, for example. – Mikael Lindlöf May 01 '18 at 15:02
  • OK I get it. This is probably the only current viable work around if you are using DynamoDB. I am using a lambda data source so it isn't critical that I have access to the user info inside the VTL template. I just need it inisde a Lambda function. My "bad answer" has the downside as this approach, namely an unnecessary call to a database when the data should be right in the original request. Slow. – honkskillet May 01 '18 at 16:43
5

Here is bad answer that works. I notice that 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" contains the Cognito user's sub (the big after CognitoSignIn). You can extract that with a regex and use the aws-sdk to get the user's info from cognito user pool.

///////RETRIEVE THE AUTHENTICATED USER'S INFORMATION//////////
if(event.context.identity.cognitoIdentityAuthType === 'authenticated'){
    let cognitoidentityserviceprovider = new AWS.CognitoIdentityServiceProvider();
    //Extract the user's sub (ID) from one of the context indentity fields
    //the REGEX in match looks for the strings btwn 'CognitoSignIn:' and '"', which represents the user sub
    let userSub = event.context.identity.cognitoIdentityAuthProvider.match(/CognitoSignIn:(.*?)"/)[1];
    let filter = 'sub = \"'+userSub+'\"'    // string with format = 'sub = \"1a072f08-5c61-4c89-807e-417d22702eb7\"'
    let usersData = await cognitoidentityserviceprovider.listUsers( {Filter:  filter, UserPoolId: "us-west-2_KsyTKrQ2M",Limit: 1}).promise()
    event.context.identity.user=usersData.Users[0]; 

}

It's a bad answer because you are pinging the User Pool database instead of just decoding a JWT.

honkskillet
  • 2,374
  • 1
  • 27
  • 41
3

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.

honkskillet
  • 2,374
  • 1
  • 27
  • 41
0

If you are using AWS Amplify, what I did to get around this was to set a custom header username as explained here, like so:

Amplify.configure({
 API: {
   graphql_headers: async () => ({
    // 'My-Custom-Header': 'my value'
     username: 'myUsername'
   })
 }
});

then in my resolver I would have access to the header with:

 $context.request.headers.username

As explained by the AppSync's docs here in the section Access Request Headers

alejo4373
  • 85
  • 6
  • 9
    I think every user could then fake having whatever username they want. Probably not very bad for usernames, but becomes quite dangerous if you are using the sub's for access control. – Philiiiiiipp Oct 19 '18 at 16:00
0

Based on Honkskillets answer, I have written a lambda function that will return you the user attributes. You just supply the function with the JWT.

const jwt = require("jsonwebtoken");
const jwkToPem = require("jwk-to-pem");
const request = require("request-promise");

exports.handler = async (event, context) => {
  try {
    const { token } = event;
    const decodedToken = jwt.decode(token, { complete: true });
    const publicJWT = await request(
      `https://cognito-idp.${process.env.REGION}.amazonaws.com/${process.env.USER_POOL_ID}/.well-known/jwks.json`
    );

    const keyObject = JSON.parse(publicJWT).keys.find(
      key => key.kid == decodedToken.header.kid
    );
    const pem = jwkToPem(keyObject);
    return {
      statusCode: 200,
      body: jwt.verify(token, pem)
    };
  } catch (error) {
    console.error(error);
    return {
      statusCode: 500,
      body: error.message
    };
  }
};

I use it in Appsync where I create Pipeline resolvers and add this function whenever I need user attributes. I supply the JWT by grabbing it from the header in the resolver using $context.request.

mufasa
  • 897
  • 10
  • 14