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/)
Leave a Reply