Personalize o login federado com o novo trigger Lambda do Amazon Cognito

O desafio da federação de identidades

O Amazon Cognito já permite que aplicações web e mobile autentiquem usuários de diversas formas: contas gerenciadas com senha, fluxos sem senha ou por meio de provedores de identidade (IdP) externos via SAML, OIDC (OpenID Connect) e provedores sociais como Google, Facebook, Apple e Login with Amazon.

Para o usuário final, a federação significa menos senhas para lembrar e uma experiência de login mais fluida. Para fornecedores de Software como Serviço (SaaS) no modelo B2B (Business-to-Business), significa que os clientes corporativos mantêm o controle sobre suas próprias identidades. Mas a federação também traz desafios reais para desenvolvedores e equipes de segurança.

O que acontece quando o provedor SAML de um cliente corporativo envia centenas de grupos que ultrapassam os limites de tamanho de atributos? Ou quando um usuário de e-commerce esquece que já tem uma conta e tenta entrar com um provedor social diferente, criando registros duplicados? Para resolver exatamente esses cenários, a AWS anunciou o inbound federation Lambda trigger para o Amazon Cognito.

Como funciona o inbound federation Lambda trigger

Esse trigger do AWS Lambda é invocado logo após o Amazon Cognito receber e validar a resposta do provedor de identidade externo. Nesse ponto, antes de qualquer criação ou atualização de perfil no user pool, a função Lambda recebe o payload com os dados do usuário e pode adicionar, modificar ou suprimir atributos conforme a necessidade da aplicação.

O payload enviado à função Lambda inclui os parâmetros comuns dos triggers do Cognito (como userPoolId e clientId), o nome e o tipo do provedor externo utilizado (providerName e providerType), além dos atributos do usuário vindos do IdP. O formato desses atributos varia conforme o tipo de provedor: para SAML, chegam como um mapeamento JSON de chave-valor da asserção; para OIDC ou provedores sociais, chegam os tokens de acesso e os dados do endpoint /userinfo. Mais detalhes sobre os parâmetros do trigger estão disponíveis na documentação oficial.

O fluxo completo funciona assim: o usuário acessa a aplicação, seleciona o IdP desejado na tela de login gerenciado (ou o cliente passa o parâmetro identity_provider diretamente), o Cognito encaminha a requisição ao IdP externo, o usuário se autentica, o IdP responde ao Cognito, que valida a assinatura criptográfica — e só então invoca o Lambda. Os atributos retornados pela função são mapeados para o perfil do usuário. Na sequência, o fluxo OAuth 2.0 continua normalmente com a emissão dos tokens. Vale lembrar que é uma boa prática de segurança usar clientes confidenciais (confidential clients) e a extensão Prova de Chave para Troca de Código (PKCE) sempre que possível.

Casos de uso práticos

Caso de uso 1: Filtragem de atributos de grupo excessivos (B2B)

Em aplicações B2B e SaaS, é comum usar os grupos do IdP para determinar o nível de acesso do usuário dentro do serviço. O problema é que clientes corporativos podem inadvertidamente enviar todos os grupos do Active Directory a que um usuário pertence — o que pode chegar a centenas. Se esse atributo for mapeado para um atributo do Cognito, o limite de 2.048 caracteres por atributo pode ser facilmente ultrapassado, causando falha na autenticação.

Outro problema comum é a inconsistência de formato: o mesmo grupo pode chegar como nome canônico (example.com/groups/myApp-readOnly), nome distinto (distinguished name, como cn=myApp-readOnly,OU=groups,DC=example,DC=com) ou texto simples (myApp-readOnly). Antes desse trigger, a solução exigia coordenação com o departamento de TI do cliente para modificar a configuração SAML na origem — um processo que podia levar semanas.

Com o inbound federation Lambda trigger, é possível resolver isso programaticamente. O exemplo de código abaixo filtra o atributo de grupos para incluir apenas os relevantes para a aplicação e normaliza os diferentes formatos de nome:

// Configure the group prefix to filter on (e.g. "App1-", "myApp-", etc.)
// Change this to match the prefix your IdP uses for relevant group names.
const GROUP_PREFIX = process.env.GROUP_PREFIX || 'myApp-';

// The SAML attribute/claim name that contains group membership.
// Common values: "groups", "memberOf", "http://schemas.xmlsoap.org/claims/Group", etc.
const GROUP_ATTRIBUTE = process.env.GROUP_ATTRIBUTE || 'groups';

/**
 * Extracts the short group name from common IdP formats:
 * - Plain text: "myApp-readOnly"
 * - Leading slash: "/myApp-readOnly"
 * - Canonical/URL: "example.com/groups/myApp-readOnly"
 * - Distinguished name (DN): "cn=myApp-readOnly,OU=groups,DC=example,DC=com"
 * Returns the last meaningful segment so all formats normalize to "myApp-readOnly".
 */
function extractGroupName(raw) {
  let name = raw.trim();
  // Some IdPs prefix group names with "/" to indicate a top level group — strip it before format detection
  if (name.startsWith('/')) {
    name = name.substring(1);
  }
  // DN format — extract the CN (common name) value
  if (/^cn=/i.test(name) || /,\s*(ou|dc)=/i.test(name)) {
    const cnMatch = name.match(/^cn=([^,]+)/i);
    return cnMatch ? cnMatch[1].trim() : name;
  }
  // URL / path format — take the last segment after the final "/"
  if (name.includes('/')) {
    const segments = name.split('/').filter(Boolean);
    return segments[segments.length - 1];
  }
  return name;
}

export const handler = async (event) => {
  try {
    console.log('Full event:', JSON.stringify(event, null, 2));
    console.log('Provider type:', event.request?.providerType);

    // Initialize the response structure
    event.response = event.response || {};

    if (event.request?.providerType?.toLowerCase() === "saml") {
      const samlResponse = event.request.attributes?.samlResponse;

      if (samlResponse) {
        console.log('Original SAML Attributes:', JSON.stringify(samlResponse, null, 2));

        // Build the attribute map — you MUST include every attribute you want Cognito to retain. Anything omitted from userAttributesToMap is dropped.
        const mappedAttributes = {};

        Object.keys(samlResponse).forEach(key => {
          if (key === GROUP_ATTRIBUTE) {
            // Parse the groups JSON string from the SAML assertion
            let groupsArray = [];
            try {
              groupsArray = JSON.parse(samlResponse[GROUP_ATTRIBUTE]);
            } catch (error) {
              console.error(`Error parsing ${GROUP_ATTRIBUTE}:`, error);
            }

            // Normalize each group name, then filter to the configured prefix
            const normalizedGroups = groupsArray.map(extractGroupName);
            const filteredGroups = normalizedGroups.filter(group =>
              group.startsWith(GROUP_PREFIX)
            );

            console.log(`Original ${GROUP_ATTRIBUTE}:`, groupsArray);
            console.log(`Normalized ${GROUP_ATTRIBUTE}:`, normalizedGroups);
            console.log(`Filtered ${GROUP_ATTRIBUTE}:`, filteredGroups);

            // Only include the groups attribute if there are matching groups
            if (filteredGroups.length > 0) {
              mappedAttributes[GROUP_ATTRIBUTE] = filteredGroups.map(group => `'${group}'`).join(', ');
            }
          } else {
            // Pass all other SAML attributes through unchanged
            mappedAttributes[key] = samlResponse[key];
          }
        });

        event.response.userAttributesToMap = mappedAttributes;
        console.log('Response to Cognito:', JSON.stringify(event.response, null, 2));
      }
    }

    // For any unhandled provider type (or missing samlResponse), this intentionally does NOT set userAttributesToMap and tells Cognito to keep all original IdP attributes unchanged (no-op).
    // To handle OIDC or social providers, add additional logic here using event.request.attributes.idToken, .userInfo, and/or .tokenResponse.
    return event;

  } catch (error) {
    console.error('Error in Lambda:', error);
    throw error;
  }
};

Com essa abordagem, a lista de grupos é reduzida apenas ao que é relevante para a aplicação. A autenticação passa a funcionar sem depender de mudanças na configuração do IdP do cliente.

Caso de uso 2: Vinculação automática de contas (B2C)

Em aplicações voltadas ao consumidor, como lojas virtuais, é muito comum o seguinte cenário: um cliente cria uma conta com e-mail e senha para fazer uma compra. Meses depois, ele volta ao site, não lembra da conta e decide entrar pelo botão “Login with Amazon”. Sem vinculação de contas, o Cognito cria um novo perfil federado — e agora o mesmo cliente tem dois registros separados, com históricos de compras distintos e preferências fragmentadas.

Embora a vinculação de contas também possa ser implementada em um trigger de pré-cadastro, o inbound federation trigger tem uma vantagem importante: ele é executado em todos os logins federados, não apenas no primeiro. Isso garante acesso contínuo aos atributos mais recentes do IdP e permite aplicar a lógica de vinculação de forma consistente.

O exemplo abaixo demonstra como implementar essa vinculação automática. A lógica é direta: ao receber um login federado, a função extrai o e-mail do usuário, busca no user pool se já existe uma conta local com esse mesmo e-mail e, se encontrar, vincula a identidade federada a ela. Se não existir conta local, cria uma nova conta sem senha (confirmada, com autenticação por OTP via e-mail) e já a vincula ao provedor externo. Em ambos os casos, a conta local se torna a identidade primária — garantindo que o campo sub dos tokens JSON Web (JWT) seja sempre o mesmo, independentemente de como o usuário fizer login.

import {
  CognitoIdentityProviderClient,
  ListUsersCommand,
  AdminCreateUserCommand,
  AdminLinkProviderForUserCommand
} from "@aws-sdk/client-cognito-identity-provider";

const client = new CognitoIdentityProviderClient();

export const handler = async (event) => {
  try {
    console.log('Full event:', JSON.stringify(event, null, 2));

    const { userPoolId, request, userName } = event;
    const { providerName, providerType, attributes } = request;

    // Extract email and profile attributes based on provider type
    const { email, givenName, surname } = extractAttributes(providerType, attributes);

    if (!email) {
      console.error('No email found in federated response');
      return event;
    }

    console.log(`Processing federated login for email: ${email}, provider: ${providerName} (${providerType})`);

    // Check if a local user exists with this email
    const existingUser = await findLocalUserByEmail(userPoolId, email);

    if (existingUser) {
      console.log(`Found existing local user: ${existingUser.Username}`);
      if (isAlreadyLinked(existingUser, providerName, userName)) {
        console.log(`Federated identity ${providerName}:${userName} is already linked to ${existingUser.Username}, skipping link`);
      } else {
        await linkFederatedUser(userPoolId, existingUser.Username, providerName, userName);
      }
    } else {
      console.log('No existing local user found, creating new one');
      const newUsername = await createLocalUser(userPoolId, email, givenName, surname);
      await linkFederatedUser(userPoolId, newUsername, providerName, userName);
    }

    return event;

  } catch (error) {
    console.error('Error in account linking Lambda:', error);
    throw error;
  }
};

/**
 * Check if the federated identity is already linked to the local user by inspecting the identities attribute from the ListUsers response.
 */
function isAlreadyLinked(user, providerName, federatedUsername) {
  const identities = user.Attributes?.find(a => a.Name === 'identities');
  if (!identities?.Value) return false;
  try {
    const parsed = JSON.parse(identities.Value);
    return parsed.some(id => id.providerName === providerName && id.userId === federatedUsername);
  } catch {
    return false;
  }
}

/**
 * Extract email and profile attributes based on provider type.
 * - SAML: attributes come from samlResponse
 * - OIDC/Social: attributes come from userInfo, falling back to idToken (if one exists)
 */
function extractAttributes(providerType, attributes) {
  if (providerType?.toLowerCase() === 'saml') {
    const saml = attributes?.samlResponse;
    return {
      email: saml?.email || null,
      givenName: saml?.givenName || '',
      surname: saml?.surname || ''
    };
  }
  // OIDC and social providers: prefer userInfo, fall back to idToken
  const userInfo = attributes?.userInfo;
  const idToken = attributes?.idToken;
  const source = userInfo?.email ? userInfo : idToken;
  return {
    email: source?.email || null,
    givenName: source?.given_name || '',
    surname: source?.family_name || ''
  };
}

/**
 * Find a local Cognito user (not EXTERNAL_PROVIDER) by email address.
 */
async function findLocalUserByEmail(userPoolId, email) {
  try {
    const command = new ListUsersCommand({
      UserPoolId: userPoolId,
      Filter: `email = "${email}"`
    });
    const response = await client.send(command);
    console.log('ListUsers response:', JSON.stringify(response, null, 2));

    if (!response.Users || response.Users.length === 0) {
      return null;
    }
    // Find the first user that is a true local account (not a federated-only profile)
    const localUser = response.Users.find(u => u.UserStatus !== 'EXTERNAL_PROVIDER');
    return localUser || null;
  } catch (error) {
    console.error('Error finding user by email:', error);
    throw error;
  }
}

/**
 * Create a new local Cognito user without a password.
 * With passwordless (email OTP) enabled on the user pool, the user is created with UserStatus=CONFIRMED and no FORCE_CHANGE_PASSWORD state.
 */
async function createLocalUser(userPoolId, email, givenName, surname) {
  try {
    const userAttributes = [
      { Name: 'email', Value: email }
    ];
    if (givenName) userAttributes.push({ Name: 'given_name', Value: givenName });
    if (surname) userAttributes.push({ Name: 'family_name', Value: surname });

    const command = new AdminCreateUserCommand({
      UserPoolId: userPoolId,
      Username: email,
      UserAttributes: userAttributes,
      MessageAction: 'SUPPRESS'
    });

    const response = await client.send(command);
    console.log(`Created local user: ${email}`, JSON.stringify(response, null, 2));
    return email;
  } catch (error) {
    console.error('Error creating local user:', error);
    throw error;
  }
}

/**
 * Link a federated user identity to a local Cognito user.
 * The local user becomes the primary profile — all future JWTs will represent this local user regardless of sign-in method.
 */
async function linkFederatedUser(userPoolId, localUsername, providerName, federatedUsername) {
  try {
    const command = new AdminLinkProviderForUserCommand({
      UserPoolId: userPoolId,
      DestinationUser: {
        ProviderName: 'Cognito',
        ProviderAttributeValue: localUsername
      },
      SourceUser: {
        ProviderName: providerName,
        ProviderAttributeName: 'Cognito_Subject',
        ProviderAttributeValue: federatedUsername
      }
    });

    const response = await client.send(command);
    console.log(`Linked federated user ${federatedUsername} to local user ${localUsername}`);
    console.log('Link response:', JSON.stringify(response, null, 2));
    return response;
  } catch (error) {
    if (error.name === 'AliasExistsException' || error.message?.includes('already linked')) {
      console.log(`User already linked: ${error.message}`);
      return;
    }
    console.error('Error linking federated user:', error);
    throw error;
  }
}

Um detalhe importante: o recurso Hide My Email da Apple gera um endereço de e-mail único por aplicativo, o que impede a vinculação automática por e-mail. Nesses casos, a aplicação precisará implementar um fluxo de vinculação iniciado pelo próprio usuário, pedindo que ele confirme a propriedade de ambos os endereços antes de chamar a API AdminLinkProviderForUser.

Boas práticas de implementação

Ao colocar esses padrões em prática, algumas orientações merecem atenção:

  • Tempo de execução: a função Lambda deve concluir em até 5 segundos. Otimize para velocidade e, se fizer chamadas externas — como consultas ao Amazon DynamoDB ou APIs externas — implemente cache sempre que possível.
  • Tratamento de erros: se a função lançar uma exceção, a autenticação pode falhar para o usuário. Considere registrar o erro em log e retornar o evento original ao Cognito em vez de interromper o fluxo. Consulte as boas práticas para funções Lambda para mais orientações.
  • Observabilidade: monitore a performance da função com as métricas do Amazon CloudWatch. Configure alertas para erros, timeouts e throttling. Durante o desenvolvimento inicial, capture payloads de exemplo a partir de um grupo de logs do CloudWatch — eles são valiosos para testes locais e depuração, especialmente porque diferentes IdPs (SAML e OIDC, em particular) podem responder com formatos e valores de atributos bastante variados.
  • Alertas de segurança: configure alarmes no CloudWatch para notificar as equipes de segurança e operações caso haja pico de falhas de autenticação, o que pode indicar tentativa de ataque, má configuração ou oportunidade de otimização do trigger.

Disponibilidade e próximos passos

O novo trigger está disponível em todas as regiões AWS onde o Amazon Cognito opera e funciona com provedores SAML, OIDC e provedores sociais suportados. Para saber mais sobre esse e outros triggers disponíveis, a AWS disponibiliza o Guia do Desenvolvedor do Amazon Cognito. Dúvidas e discussões sobre casos de uso específicos podem ser levadas ao AWS re:Post.

Fonte

Customize federated sign-in with new Amazon Cognito Lambda trigger (https://aws.amazon.com/blogs/security/customize-federated-sign-in-with-new-amazon-cognito-lambda-trigger/)

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *