Workaround for Mapping AWS Cognito Identity ID to Username

There is a longstanding issue where there is no way to go from an AWS Cognito identity ID to a username. The workaround I found is complicated, but should work without posing a security risk (e.g. client setting the username must not be relied on otherwise they could hijack someone’s account). Using Cognito credentials (authenticated), make a call to a Lambda from the client app with an identity ID and ID token. In the Lambda compare the identity ID to the context’s identity ID, verify the ID token, then set a mapping in S3.

As a further optimization, rather than performing this operation every time the client app is loaded, use a custom attribute in the Cognito User Pool to set a field when the mapping is performed. The client can then check the user attribute on load and skip the operation if has already been performed.

import * as AWS from 'aws-sdk/global';
import S3 from 'aws-sdk/clients/s3';
import CognitoIdentity from 'aws-sdk/clients/cognitoidentity';
import jwt from 'jsonwebtoken';
import jwkToPem from 'jwk-to-pem';
import fetch from 'node-fetch';

const env = process.env;
const AWS_S3_BUCKET = env.AWS_S3_BUCKET as string;
const AWS_COGNITO_USER_POOL_URL = env.AWS_COGNITO_USER_POOL_URL as string;
const AWS_COGNITO_USER_POOL_CLIENT_ID = env.AWS_COGNITO_USER_POOL_CLIENT_ID as string;


export const handler = async (event: any, context: any) => {
  const context_identity_id = context.identity.cognitoIdentityId;
  const request_token = event.idToken;
  const request_identity_id = event.identityId;

  if (context_identity_id !== request_identity_id) {
    console.error('Identity ID not verified', { context_identity_id, request_identity_id });
    return;
  }

  // Verify the JWT to make sure it is valid
  // https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-verifying-a-jwt.html

  // WARNING: This is not verified, we need to
  // get some info from the token to verify it
  const decoded_jwt: any = jwt.decode(request_token, { complete: true })!;

  if (decoded_jwt.payload.aud !== AWS_COGNITO_USER_POOL_CLIENT_ID) {
    throw new Error(`JWT token has invalid audience: got ${decoded_jwt.payload.aud} expected ${AWS_COGNITO_USER_POOL_CLIENT_ID}`);
  }

  if (decoded_jwt.payload.iss !== AWS_COGNITO_USER_POOL_URL) {
    throw new Error(`JWT token has invalid issuer: got ${decoded_jwt.payload.iss} expected ${AWS_COGNITO_USER_POOL_URL}`);
  }

  // Fetch the key used to sign the jwt
  const response = await fetch(`${ AWS_COGNITO_USER_POOL_URL }/.well-known/jwks.json`,  { method: 'GET' });
  const jwk = await response.json();
  const key = jwk.keys.find((key: any) => {
    return key.kid === decoded_jwt.header.kid
  });
  const pem = jwkToPem(key);

  jwt.verify(request_token, pem, function (err: any, decoded: any) {
    if (err) {
      throw new Error(err);
    }
  });

  // Now we can trust the claims of the jwt
  const username = decoded_jwt['cognito:username'];

  // Write the mapping to S3

  const s3 = new S3();

  // Add a mapping from identity ID to username
  const identity_id_to_username_key = `app/identity_id_to_username/${context_identity_id}/${username}`;

  // This S3 location should never be writeable from the client app
  // otherwise they could hijack someone's account by setting someone
  // else's username!
  await s3.putObject({
    Bucket: AWS_S3_BUCKET,
    Key: identity_id_to_username_key,
    // There is no need to put a body in the object since all the
    // information is encoded into the key.
    Body: undefined
  }).promise();

  // Add a mapping from username to identity ID
  // Not sure if this will be needed, but would be annoying to find
  // out later and write a migration.
  const username_to_identity_id_key = `app/username_to_identity_id/${username}/${context_identity_id}`;

  await s3.putObject({
    Bucket: AWS_S3_BUCKET,
    Key: username_to_identity_id_key,
    Body: undefined
  }).promise();
};