Simulated Cognito IDP
Yulin simulates Cognito user pools, app clients, users, groups, tokens, hosted domains and Lambda
triggers. Use simAws.cognitoIdp() directly or intercept a CognitoIdentityProviderClient.
Cognito types are available from @kensio/yulin/cognito.
Cognito identity pools are unsupported.
Creating a pool and an app client
Section titled “Creating a pool and an app client”A user pool needs a name. Other properties use Cognito’s defaults.
/** * Creating a simulated user pool and an app client in it. */
import { CreateUserPoolClientCommand, CreateUserPoolCommand,} from "@aws-sdk/client-cognito-identity-provider";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();const cognito = simAws.cognitoIdentityProvider();
const pool = await cognito.createUserPool( new CreateUserPoolCommand({ PoolName: "myapp-users" }),);
console.log(pool.UserPool?.Id); // "us-east-1_aBcDeFgHi"console.log(pool.UserPool?.Arn);// "arn:aws:cognito-idp:us-east-1:888888888888:userpool/us-east-1_aBcDeFgHi"
const appClient = await cognito.createUserPoolClient( new CreateUserPoolClientCommand({ UserPoolId: pool.UserPool?.Id, ClientName: "web", }),);
console.log(appClient.UserPoolClient?.ClientId); // 26 lowercase charactersA pool ID contains the region before its underscore, matching Cognito’s ID format.
Two pools may share a name. Only the id identifies one.
Password policy
Section titled “Password policy”A pool created without Policies requires at least eight characters with uppercase, lowercase,
numeric and symbol characters. Setting part of the policy keeps the defaults for omitted fields.
/** * Reading a simulated user pool's password policy. */
import { CreateUserPoolCommand } from "@aws-sdk/client-cognito-identity-provider";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();
const pool = await simAws.cognitoIdentityProvider().createUserPool( new CreateUserPoolCommand({ PoolName: "myapp-users", Policies: { PasswordPolicy: { MinimumLength: 12 } }, }),);
const passwordPolicy = pool.UserPool?.Policies?.PasswordPolicy;
console.log(passwordPolicy?.MinimumLength); // 12console.log(passwordPolicy?.RequireSymbols); // true, the defaultEvery password a user is given is checked against this policy. A password that breaks it is refused
with InvalidPasswordException, saying which rule it broke.
AdminCreateUser creates a user in FORCE_CHANGE_PASSWORD with a temporary password. Sign-in
returns the NEW_PASSWORD_REQUIRED challenge until AdminSetUserPassword sets a permanent
password. A permanent password changes the status to CONFIRMED.
/** * Creating a simulated user and confirming it. */
import { AdminCreateUserCommand, AdminGetUserCommand, AdminSetUserPasswordCommand, CreateUserPoolCommand,} from "@aws-sdk/client-cognito-identity-provider";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();const cognito = simAws.cognitoIdentityProvider();
const pool = await cognito.createUserPool( new CreateUserPoolCommand({ PoolName: "myapp-users" }),);const userPoolId = pool.UserPool?.Id;
const created = await cognito.adminCreateUser( new AdminCreateUserCommand({ UserPoolId: userPoolId, Username: "alice", UserAttributes: [{ Name: "email", Value: "alice@example.com" }], }),);
console.log(created.User?.UserStatus); // "FORCE_CHANGE_PASSWORD"
// Without this the user stays in FORCE_CHANGE_PASSWORD and cannot sign in.await cognito.adminSetUserPassword( new AdminSetUserPasswordCommand({ UserPoolId: userPoolId, Username: "alice", Password: "Sup3rSecret!", Permanent: true, }),);
const read = await cognito.adminGetUser( new AdminGetUserCommand({ UserPoolId: userPoolId, Username: "alice" }),);
console.log(read.UserStatus); // "CONFIRMED"console.log(read.UserAttributes?.find((each) => each.Name === "sub")?.Value);// A UUID, and not "alice"A password set without Permanent: true is temporary, and leaves the user in
FORCE_CHANGE_PASSWORD again.
A user’s sub is a generated UUID and differs from the username. Yulin’s admin operations accept
the username only. Cognito also accepts a sub for some operations, which Yulin reports as an
unsupported lookup.
Attributes come back under Attributes from AdminCreateUser and ListUsers, and under
UserAttributes from AdminGetUser, as the real API names them.
AdminUpdateUserAttributes changes the attributes it names and leaves the rest alone.
AdminDisableUser sets Enabled to false without changing the user’s status, and
AdminEnableUser sets it back.
Signing in by email or phone number
Section titled “Signing in by email or phone number”A pool with UsernameAttributes signs users in with an email address or phone number. Cognito
generates a UUID for the stored username. AdminGetUser and the cognito:username token claim
report that UUID.
A CDK UserPool with signInAliases: { email: true } emits UsernameAttributes: ["email"], the
usual way to build an email sign-in pool.
/** * A simulated pool that signs its users in by email address. */
import { AdminConfirmSignUpCommand, AdminGetUserCommand, CreateUserPoolClientCommand, CreateUserPoolCommand, InitiateAuthCommand, SignUpCommand,} from "@aws-sdk/client-cognito-identity-provider";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();const cognito = simAws.cognitoIdentityProvider();
const pool = await cognito.createUserPool( new CreateUserPoolCommand({ PoolName: "myapp-users", UsernameAttributes: ["email"], }),);const userPoolId = pool.UserPool?.Id;
const client = await cognito.createUserPoolClient( new CreateUserPoolClientCommand({ UserPoolId: userPoolId, ClientName: "web", ExplicitAuthFlows: ["ALLOW_USER_PASSWORD_AUTH"], }),);const clientId = client.UserPoolClient?.ClientId;
await cognito.signUp( new SignUpCommand({ ClientId: clientId, Username: "alice@example.com", Password: "Sup3rSecret!", }),);
await cognito.adminConfirmSignUp( new AdminConfirmSignUpCommand({ UserPoolId: userPoolId, // Naming the user by the address reaches it, as it does on real Cognito. Username: "alice@example.com", }),);
const read = await cognito.adminGetUser( new AdminGetUserCommand({ UserPoolId: userPoolId, Username: "alice@example.com", }),);
console.log(read.Username); // A UUID, and not "alice@example.com"console.log(read.UserAttributes?.find((each) => each.Name === "email")?.Value);// "alice@example.com"
const signedIn = await cognito.initiateAuth( new InitiateAuthCommand({ ClientId: clientId, AuthFlow: "USER_PASSWORD_AUTH", AuthParameters: { USERNAME: "alice@example.com", PASSWORD: "Sup3rSecret!", }, }),);
console.log(signedIn.AuthenticationResult?.IdToken !== undefined); // true// The id token's cognito:username claim is the generated username above.The address goes on reaching the user. The admin operations and the sign-in flows resolve it to the
user holding it, as real Cognito resolves it, so AdminGetUser, ConfirmSignUp, InitiateAuth and
AdminInitiateAuth all take the address as well as the generated username.
A SECRET_HASH covers the value the request itself carries. A sign-up or a sign-in naming the
address computes it over the address. REFRESH_TOKEN_AUTH names no user, so its hash is computed
over the username the token was issued to, the generated one.
Two users cannot sign in by the same address. A second sign-up with one is refused with
UsernameExistsException, and an AdminUpdateUserAttributes request setting one another user
already holds is refused with AliasExistsException. A username written some other way than the
attribute’s values are, such as a plain name on a pool signing in by email, is refused too, because
a user created that way could never sign in. A request naming one address as the username and a
different one as the attribute is refused, and never resolved in favour of either.
UsernameAttributes takes email and phone_number, and a pool can name both. It is settled when
the pool is created. Real UpdateUserPool has no such input, and a request carrying one here is
refused.
Custom attributes
Section titled “Custom attributes”A pool holds the standard OpenID Connect attributes, and the ones its Schema declares beside them.
A custom attribute is the ordinary way to hold an application’s own identifier for a user. A sub
belongs to the pool that issued it, so keying application data on one welds that data to a pool that
cannot be moved.
Cognito prefixes an attribute a pool declares with custom:. A Schema naming userId is written
and read as custom:userId.
/** * A user pool holding an application's own identifier for a user. */
import { AdminGetUserCommand, AdminUpdateUserAttributesCommand, CreateUserPoolClientCommand, CreateUserPoolCommand, DescribeUserPoolCommand, SignUpCommand,} from "@aws-sdk/client-cognito-identity-provider";
import { SimAws } from "@kensio/yulin";
const cognito = new SimAws().cognitoIdentityProvider();
const pool = await cognito.createUserPool( new CreateUserPoolCommand({ PoolName: "myapp-users", Schema: [ // Immutable, because this is the identifier the application keys its own // data on: Cognito takes it when the user is created and refuses every // write after that. { Name: "userId", AttributeDataType: "String", Mutable: false }, { Name: "seats", AttributeDataType: "Number", Mutable: true, NumberAttributeConstraints: { MinValue: "1", MaxValue: "10" }, }, ], }),);const userPoolId = pool.UserPool!.Id!;
const appClient = await cognito.createUserPoolClient( new CreateUserPoolClientCommand({ UserPoolId: userPoolId, ClientName: "web", }),);const clientId = appClient.UserPoolClient!.ClientId!;
await cognito.signUp( new SignUpCommand({ ClientId: clientId, Username: "alice", Password: "Sup3rSecret!", UserAttributes: [ { Name: "custom:userId", Value: "usr_01H8" }, { Name: "custom:seats", Value: "3" }, ], }),);
const user = await cognito.adminGetUser( new AdminGetUserCommand({ UserPoolId: userPoolId, Username: "alice" }),);
console.log(user.UserAttributes?.find((each) => each.Name === "custom:userId"));// { Name: "custom:userId", Value: "usr_01H8" }
// A mutable attribute changes.await cognito.adminUpdateUserAttributes( new AdminUpdateUserAttributesCommand({ UserPoolId: userPoolId, Username: "alice", UserAttributes: [{ Name: "custom:seats", Value: "7" }], }),);
// The pool reports its whole schema, the standard attributes included.const described = await cognito.describeUserPool( new DescribeUserPoolCommand({ UserPoolId: userPoolId }),);
console.log( described.UserPool?.SchemaAttributes?.map((attribute) => attribute.Name),);// [ "sub", "address", ..., "custom:userId", "custom:seats" ]The declaration is held to what real Cognito accepts. A pool AWS would have refused is refused here.
A Required custom attribute, a DeveloperOnlyAttribute, a name longer than 20 characters, a name
carrying a character Cognito rejects or its own custom: prefix, an attribute type Cognito lacks,
the same attribute declared twice, and an empty Schema or one with more than the 50
attributes one request may carry are each refused, saying why.
What an attribute may hold is held to the schema too. A Number attribute refuses a non-numeric
value and one outside its NumberAttributeConstraints, a String attribute refuses a value outside
its StringAttributeConstraints, and an attribute the schema declares Mutable: false refuses
every write once the user exists. An immutable attribute is one a user is created with or does
without. An attribute no schema declares is refused, saying which ones the pool does hold.
A Schema can also redeclare a standard attribute, which a CDK UserPool emits for its
standardAttributes. That is how a pool makes email required, and a user created without a
required attribute is refused. Cognito defaults Mutable to false in a declaration, and a
redeclared standard attribute is fixed unless the declaration says otherwise.
A pool’s schema is settled when the pool is created. UpdateUserPool has no Schema input on real
Cognito, and a request carrying one here is refused, leaving the attributes of a pool that already
has users written against them alone. Real Cognito adds one with AddCustomAttributes. That is
outside the simulation.
Signing up
Section titled “Signing up”SignUp creates an UNCONFIRMED user through an app client. The operation does not use IAM
authorization.
Read the confirmation code from confirmationCode on the simulated pool. Cognito sends the code by
email or text and never exposes it through the service API.
The pool also records the message it would have sent, holding the wording and the code a user would have read. That is in Messages a pool would have sent below.
/** * Signing a user up and confirming it with the code the pool issued. */
import { ConfirmSignUpCommand, CreateUserPoolClientCommand, CreateUserPoolCommand, InitiateAuthCommand, SignUpCommand,} from "@aws-sdk/client-cognito-identity-provider";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();const cognito = simAws.cognitoIdentityProvider();
const pool = await cognito.createUserPool( new CreateUserPoolCommand({ PoolName: "myapp-users", AutoVerifiedAttributes: ["email"], }),);const userPoolId = pool.UserPool!.Id!;
const appClient = await cognito.createUserPoolClient( new CreateUserPoolClientCommand({ UserPoolId: userPoolId, ClientName: "web", ExplicitAuthFlows: ["ALLOW_USER_PASSWORD_AUTH"], }),);const clientId = appClient.UserPoolClient!.ClientId!;
const signedUp = await cognito.signUp( new SignUpCommand({ ClientId: clientId, Username: "alice", Password: "Sup3rSecret!", UserAttributes: [{ Name: "email", Value: "alice@example.com" }], }),);
console.log(signedUp.UserConfirmed); // falseconsole.log(signedUp.UserSub); // A UUID, and not "alice"
// Real Cognito sends this to the user and never reports it. Nothing here// delivers a message, so the pool hands it over instead.const code = cognito.userPool(userPoolId).confirmationCode("alice");
await cognito.confirmSignUp( new ConfirmSignUpCommand({ ClientId: clientId, Username: "alice", ConfirmationCode: code, }),);
// The user is CONFIRMED now, and signs in with the password it chose.const signedIn = await cognito.initiateAuth( new InitiateAuthCommand({ ClientId: clientId, AuthFlow: "USER_PASSWORD_AUTH", AuthParameters: { USERNAME: "alice", PASSWORD: "Sup3rSecret!" }, }),);
console.log(signedIn.AuthenticationResult?.AccessToken !== undefined); // trueSigning in before confirming is refused with UserNotConfirmedException, which real Cognito answers
with even when the password was right. An application can tell the two apart and send the user to
ConfirmSignUp. A wrong password is still refused as any wrong password is, and gives no hint that
the account is unconfirmed.
A wrong code is refused with CodeMismatchException, and leaves the user unconfirmed holding the
code it was issued, so a second attempt with the right one works. A code is single use, and
confirming spends it.
ResendConfirmationCode issues a fresh code and the earlier one stops working, as it does on real
Cognito. A test holding the earlier code has to read the new one from the pool. Asking for a code
for a user that has already confirmed is refused with InvalidParameterException.
AdminConfirmSignUp confirms a user with no code at all. It names the pool and the user, and is
authorized by IAM the way the other admin operations are.
A pool with a PreSignUp trigger can confirm a user at sign-up instead, and one with a
PostConfirmation trigger runs it whichever way the user got confirmed. See
“Lambda triggers” below.
The pool’s AutoVerifiedAttributes decide what confirming verifies. A pool created with
["email"] has email_verified set to true on the user when it confirms, because answering with
the code shows the address is the user’s. AdminConfirmSignUp verifies no attribute, as on
real Cognito. An admin confirming a user says nothing about whose address it is. Only email and
phone_number can be verified, and an attribute the user lacks is left alone.
A pool created with AdminCreateUserConfig: { AllowAdminCreateUserOnly: true } refuses SignUp
with NotAuthorizedException, as a real one does. That value is what a CDK UserPool without
selfSignUpEnabled emits. A project testing its registration flow gets the same answer here that
the deployed pool would give. A pool created without the setting allows sign-up, the AWS default.
Resetting a forgotten password
Section titled “Resetting a forgotten password”ForgotPassword sends a code and ConfirmForgotPassword sets the new password. Both operations use
an app client and bypass IAM authorization.
The code goes to the same place a sign-up code goes, and is read back the same way, through
confirmationCode on the pool object. ForgotPassword answers with CodeDeliveryDetails naming
the medium and a masked destination, which an application prints to say where the user should go
and look.
/** * Resetting a forgotten password with the code the pool issued. */
import { ConfirmForgotPasswordCommand, ConfirmSignUpCommand, CreateUserPoolClientCommand, CreateUserPoolCommand, ForgotPasswordCommand, InitiateAuthCommand, SignUpCommand,} from "@aws-sdk/client-cognito-identity-provider";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();const cognito = simAws.cognitoIdentityProvider();
const pool = await cognito.createUserPool( new CreateUserPoolCommand({ PoolName: "myapp-users", AutoVerifiedAttributes: ["email"], }),);const userPoolId = pool.UserPool!.Id!;
const appClient = await cognito.createUserPoolClient( new CreateUserPoolClientCommand({ UserPoolId: userPoolId, ClientName: "web", ExplicitAuthFlows: ["ALLOW_USER_PASSWORD_AUTH"], }),);const clientId = appClient.UserPoolClient!.ClientId!;
await cognito.signUp( new SignUpCommand({ ClientId: clientId, Username: "alice", Password: "Sup3rSecret!", UserAttributes: [{ Name: "email", Value: "alice@example.com" }], }),);await cognito.confirmSignUp( new ConfirmSignUpCommand({ ClientId: clientId, Username: "alice", ConfirmationCode: cognito.userPool(userPoolId).confirmationCode("alice"), }),);
// The user has forgotten the password it chose at sign-up.const asked = await cognito.forgotPassword( new ForgotPasswordCommand({ ClientId: clientId, Username: "alice" }),);
console.log(asked.CodeDeliveryDetails?.DeliveryMedium); // "EMAIL"console.log(asked.CodeDeliveryDetails?.Destination); // "a***@e***.com"
// Real Cognito sends this to the user and never reports it, as with a sign-up// code. The pool hands it over instead.const code = cognito.userPool(userPoolId).confirmationCode("alice");
await cognito.confirmForgotPassword( new ConfirmForgotPasswordCommand({ ClientId: clientId, Username: "alice", ConfirmationCode: code, Password: "Ev3nBetter!", }),);
// The user is CONFIRMED, and the new password is the one that signs it in.const signedIn = await cognito.initiateAuth( new InitiateAuthCommand({ ClientId: clientId, AuthFlow: "USER_PASSWORD_AUTH", AuthParameters: { USERNAME: "alice", PASSWORD: "Ev3nBetter!" }, }),);
console.log(signedIn.AuthenticationResult?.AccessToken !== undefined); // trueThe user reaches CONFIRMED once the reset lands, which also confirms one that never confirmed its
sign-up. The password it had before stops working.
A wrong code is refused with CodeMismatchException, and the user keeps the code it was issued for
a second attempt. A spent code is refused with ExpiredCodeException. That is what real Cognito
calls a code it will no longer take. The new password is held to the pool’s password policy, and one the policy refuses raises
InvalidPasswordException (the same refusal AdminSetUserPassword gives).
Asking twice issues a second code and the first one stops working, the way ResendConfirmationCode
replaces a sign-up code.
ForgotPassword sends its code to an attribute the pool verifies automatically, preferring email
over phone_number. A user the pool can reach at neither is refused with
InvalidParameterException, in the words real Cognito refuses with. A user still holding a
temporary password is refused with NotAuthorizedException and belongs at the
NEW_PASSWORD_REQUIRED challenge.
An app client’s PreventUserExistenceErrors decides what a reset naming an unknown user gets. A
client left on the LEGACY default answers UserNotFoundException. A client set to ENABLED
answers as though a code had gone out, with a made-up destination. That closes the operation as a
way of finding out who has an account.
An app client created with a secret has its SECRET_HASH checked on both operations, computed over
the username and the client id the way the sign-in operations compute it.
Resetting a password as an administrator
Section titled “Resetting a password as an administrator”AdminResetUserPassword takes a user’s password away and leaves it in RESET_REQUIRED. It names
the pool and the user, and is authorized by IAM the way the other admin operations are. From there
the user is refused at sign-in with PasswordResetRequiredException until it answers
ConfirmForgotPassword with the code the reset issued. The pool records the message carrying that
code, under the ForgotPassword occasion.
A federated user has no password in the pool, and both the user’s own reset and the administrator’s are refused for one.
Messages a pool would have sent
Section titled “Messages a pool would have sent”The pool records outgoing email and text messages in sentMessages. Each entry contains the
recipient, medium, subject, body and message type.
A message is recorded on five occasions. Those are a SignUp, a ResendConfirmationCode, an
AdminCreateUser that did not ask for MessageAction: SUPPRESS, an MFA code sent by text message,
and a password reset the user or an administrator started. The verification wording is the pool’s
own, and {####} is replaced with the code the user was issued. A sign-up a PreSignUp
handler auto-confirmed records none. That user has no code to answer with, and real Cognito sends it
nothing.
/** * Reading the verification message a pool would have sent. */
import { CreateUserPoolClientCommand, CreateUserPoolCommand, SignUpCommand,} from "@aws-sdk/client-cognito-identity-provider";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();const cognito = simAws.cognitoIdentityProvider();
const pool = await cognito.createUserPool( new CreateUserPoolCommand({ PoolName: "myapp-users", AutoVerifiedAttributes: ["email"], EmailVerificationSubject: "Welcome to Acme", EmailVerificationMessage: "Your Acme code is {####}", }),);const userPoolId = pool.UserPool!.Id!;
const appClient = await cognito.createUserPoolClient( new CreateUserPoolClientCommand({ UserPoolId: userPoolId, ClientName: "web", }),);
await cognito.signUp( new SignUpCommand({ ClientId: appClient.UserPoolClient!.ClientId!, Username: "alice", Password: "Sup3rSecret!", UserAttributes: [{ Name: "email", Value: "alice@example.com" }], }),);
const [message] = cognito.userPool(userPoolId).sentMessages();
console.log(message?.recipient); // "alice@example.com"console.log(message?.medium); // "EMAIL"console.log(message?.subject); // "Welcome to Acme"console.log(message?.occasion); // "SignUp"
// The placeholder carries the code the user was issued.const code = cognito.userPool(userPoolId).confirmationCode("alice")!;
console.log(message?.body === `Your Acme code is ${code}`); // trueWhere the message goes comes from the user’s own attributes, as it does on real Cognito. A
verification message goes to an attribute the pool verifies automatically. A pool created with
AutoVerifiedAttributes: ["email"] writes to the user’s email, and a pool that verifies no
attribute records no verification message at all. An invitation goes to the user’s email, or to
its phone_number where it has no email address. An email is recorded with a subject and a text
message without one, and a user the pool has no address for is sent nothing.
AdminCreateUser records the invitation, carrying the username and the temporary password the
request named, unless the request asked for MessageAction: SUPPRESS.
A pool created with no wording of its own uses the wording real Cognito uses: Your verification code and Your verification code is {####} for a verification message, and Your temporary password and Your username is {username} and temporary password is {####}. for an invitation.
EmailVerificationMessage, EmailVerificationSubject, SmsVerificationMessage and
VerificationMessageTemplate are all read, at whatever wording a request sets, held to the two
rules real Cognito holds them to. A message carries {####}, and runs to 20,000 characters for an
email and the 140 an SMS carries.
A pool keeps this record whichever service sent the message. One sending through Cognito’s own
email stops there, exactly as on real AWS. One whose EmailConfiguration names DEVELOPER also
went through simulated SES, covered under
Sending a pool’s email through SES.
SmsConfiguration is refused. It names the IAM role Cognito assumes to publish a text message
through SNS. A pool here records the text message rather than publishing it, leaving that role to
name a permission the simulation never exercises.
The CustomMessage trigger
Section titled “The CustomMessage trigger”A pool with a CustomMessage Lambda trigger runs it before the message is recorded, and what the
handler writes into response.emailSubject, response.emailMessage and response.smsMessage
replaces the pool’s own wording. The handler writes request.codeParameter into its message where
the code belongs, as it does on real Cognito, and the code goes in afterwards.
triggerSource names the occasion: CustomMessage_SignUp, CustomMessage_ResendCode,
CustomMessage_AdminCreateUser, CustomMessage_Authentication or CustomMessage_ForgotPassword.
The invitation carries request.usernameParameter as well.
/** * A CustomMessage trigger writing the wording of a verification message. */
import { CreateUserPoolClientCommand, CreateUserPoolCommand, SignUpCommand,} from "@aws-sdk/client-cognito-identity-provider";import { AddPermissionCommand, CreateFunctionCommand,} from "@aws-sdk/client-lambda";
import { SimAws } from "@kensio/yulin";import { makeLambdaZipFileInput } from "@kensio/yulin/lambda";
/** * The part of the CustomMessage event this handler reads and writes. */interface CustomMessageEvent { readonly triggerSource: string; readonly request: { readonly codeParameter: string }; response: { emailSubject?: string; emailMessage?: string; };}
const simAws = new SimAws();const lambda = simAws.lambda();const cognito = simAws.cognitoIdentityProvider();
await lambda.createFunction( new CreateFunctionCommand({ FunctionName: "custom-message", Role: "arn:aws:iam::888888888888:role/CustomMessageRole", Code: { ZipFile: makeLambdaZipFileInput((event: CustomMessageEvent) => { if (event.triggerSource === "CustomMessage_SignUp") { event.response.emailSubject = "Welcome to Acme"; event.response.emailMessage = `Your code is ${event.request.codeParameter}. ` + `It is good for one sign-up.`; }
return event; }), }, }),);
const pool = await cognito.createUserPool( new CreateUserPoolCommand({ PoolName: "myapp-users", AutoVerifiedAttributes: ["email"], LambdaConfig: { CustomMessage: "arn:aws:lambda:us-east-1:888888888888:function:custom-message", }, }),);const userPoolId = pool.UserPool!.Id!;
await lambda.addPermission( new AddPermissionCommand({ FunctionName: "custom-message", StatementId: "AllowCognito", Action: "lambda:InvokeFunction", Principal: "cognito-idp.amazonaws.com", SourceArn: pool.UserPool?.Arn, }),);
const appClient = await cognito.createUserPoolClient( new CreateUserPoolClientCommand({ UserPoolId: userPoolId, ClientName: "web", }),);
await cognito.signUp( new SignUpCommand({ ClientId: appClient.UserPoolClient!.ClientId!, Username: "alice", Password: "Sup3rSecret!", UserAttributes: [{ Name: "email", Value: "alice@example.com" }], }),);
const [message] = cognito.userPool(userPoolId).sentMessages();
console.log(message?.subject); // "Welcome to Acme"
// The code parameter the handler wrote carries the real code.const code = cognito.userPool(userPoolId).confirmationCode("alice")!;
console.log(message?.body.startsWith(`Your code is ${code}.`)); // trueA handler that writes nothing leaves the pool’s own wording, which a handler that only cares about
one occasion does for the others. A handler that throws fails the request with
UserLambdaValidationException and leaves the pool with no message, because the trigger runs before
the message is recorded. A response of any other type than an object, or a message in it of any
other type than a string, fails with InvalidLambdaResponseException.
ClientMetadata on SignUp, ResendConfirmationCode and AdminCreateUser reaches the handler as
request.clientMetadata.
Reading the messages over HTTP
Section titled “Reading the messages over HTTP”serveSimAws lists a pool’s recorded messages at GET /<userPoolId>/messages, readable during
local development. Real Cognito serves nothing at that path. This is the serving side of
sentMessages, and a divergence for the same reason that accessor is one.
The response is { "messages": [ ... ] }, each message carrying username, recipient, medium,
subject where it has one, body, occasion and an ISO sentDate.
Sending a pool’s email through SES
Section titled “Sending a pool’s email through SES”A pool created with EmailConfiguration: { EmailSendingAccount: "DEVELOPER", ... } sends its email
through simulated SES, in the region its SourceArn names. That is the CDK
cognito.UserPoolEmail.withSES({ ... }) configuration. An account still in the
SES sandbox reaches only verified recipients, which is most of a real sign-up
list turned away, and a pool recording only its own messages would report that as a working
sign-up.
The message is recorded in both places. sesV2().sentEmails() holds it as it went out, with the
configured From, ReplyToEmailAddress and ConfigurationSet. sentMessages() on the pool holds
it as well, which is what GET /<userPoolId>/messages lists for a developer reading a confirmation
code out of a browser sign-up.
/** * A user pool sending its verification message through simulated SES. */
import { CreateUserPoolClientCommand, CreateUserPoolCommand, SignUpCommand,} from "@aws-sdk/client-cognito-identity-provider";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws({ defaultRegionName: "eu-west-2" });const cognito = simAws.cognitoIdentityProvider();const ses = simAws.sesV2();
// The sending domain, and the applicant the sandbox would otherwise refuse.ses.verifyIdentity("example.com");ses.verifyIdentity("alice@example.org");
const pool = await cognito.createUserPool( new CreateUserPoolCommand({ PoolName: "myapp-users", AutoVerifiedAttributes: ["email"], EmailConfiguration: { EmailSendingAccount: "DEVELOPER", From: "Acme <no-reply@example.com>", // The Account in the ARN is read past: the pool resolves the identity in // its own Account, so a synthesized template needs no rewriting. SourceArn: "arn:aws:ses:eu-west-2:111122223333:identity/example.com", ReplyToEmailAddress: "support@example.com", }, }),);const userPoolId = pool.UserPool!.Id!;
const appClient = await cognito.createUserPoolClient( new CreateUserPoolClientCommand({ UserPoolId: userPoolId, ClientName: "web", }),);
await cognito.signUp( new SignUpCommand({ ClientId: appClient.UserPoolClient!.ClientId!, Username: "alice", Password: "Sup3rSecret!", UserAttributes: [{ Name: "email", Value: "alice@example.org" }], }),);
const [email] = ses.sentEmails();
console.log(email?.fromEmailAddress); // "Acme <no-reply@example.com>"console.log(email?.destination.toAddresses); // ["alice@example.org"]console.log(email?.replyToAddresses); // ["support@example.com"]
// The pool kept it too, which is what the messages endpoint lists.console.log(cognito.userPool(userPoolId).sentMessages().length); // 1What fails, and how
Section titled “What fails, and how”The identity is resolved when a message is sent rather than when the pool is created, so a pool can be created before the identity it names and a stack can deploy the two in either order.
A SourceArn naming a domain has to come with a From, as it does on real Cognito. A domain
identity covers every address at it and names none of them, so there is no one address for Cognito
to write as. An address identity needs no From, and a pool without one sends as that address.
A sign-up against a pool whose SourceArn identity is missing or still unverified fails with
InvalidEmailRoleAccessPolicyException, which is what real Cognito raises when it cannot use the
identity. A message SES then refuses fails with CodeDeliveryFailureException, which in a
simulation means the sandbox turned down an unverified recipient. The two are kept apart because
they are different problems. The first is an account set up wrong, and the second is one that has
yet to leave the sandbox. Neither records a message on the pool or on SES.
Real Cognito also needs an identity policy letting Cognito send as the identity. That part is left out, because simulated SES has no identity policies. A verified identity is as far as the check goes.
The caller’s own permissions decide none of this. Real Cognito sends through a service-linked role,
so a Role allowed to call AdminCreateUser and nothing on SES still gets its invitation sent.
Which region, and which account
Section titled “Which region, and which account”SourceArn is read for its region and its identity name. The Account in it is read past, and the
pool resolves the identity in its own Account instead. CDK synthesizes the Account the stack
deploys to, which is a real one, while a simulation runs under
888888888888 unless it is told otherwise, so matching the whole ARN would leave every project
rewriting the Account id in its template before a sign-up could send.
The region is honoured. A pool in eu-west-2 whose SourceArn names us-east-1 sends through the
us-east-1 SES, and the identity has to be verified there. Real Cognito restricts which regions a
pool may pair with, and this simulation accepts any of them.
COGNITO_DEFAULT is the default and needs no SourceArn. Such a pool records its messages and
reaches SES at no point, which is what real Cognito’s built-in sending does. A
ReplyToEmailAddress alongside it, which is what UserPoolEmail.withCognito({ replyTo }) emits, is
accepted and reported back.
DescribeUserPool answers with the EmailConfiguration the request set, and a pool created without
one describes itself without one.
Listing users
Section titled “Listing users”ListUsers pages by Limit and PaginationToken, as the real operation calls them.
/** * Listing the users of a simulated user pool. */
import { AdminCreateUserCommand, CreateUserPoolCommand, ListUsersCommand,} from "@aws-sdk/client-cognito-identity-provider";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();const cognito = simAws.cognitoIdentityProvider();
const pool = await cognito.createUserPool( new CreateUserPoolCommand({ PoolName: "myapp-users" }),);const userPoolId = pool.UserPool?.Id;
await cognito.adminCreateUser( new AdminCreateUserCommand({ UserPoolId: userPoolId, Username: "alice" }),);await cognito.adminCreateUser( new AdminCreateUserCommand({ UserPoolId: userPoolId, Username: "bob" }),);
const listed = await cognito.listUsers( new ListUsersCommand({ UserPoolId: userPoolId }),);
console.log(listed.Users?.map((user) => user.Username)); // [ "alice", "bob" ]Filter is refused rather than ignored. A filter that was quietly dropped would answer with the
wrong users and no error, and that is the kind of pass that turns into a failure in a deployment.
List the users and filter them in the test instead.
Groups
Section titled “Groups”Most authorization code built on Cognito reads cognito:groups off a verified token and decides
what the caller may do. Groups are what put a user in that claim.
A group belongs to a pool, holds users, and carries a Precedence that decides which of a user’s
groups comes first.
/** * Putting a simulated user in groups, and reading them back by precedence. */
import { AdminAddUserToGroupCommand, AdminCreateUserCommand, AdminListGroupsForUserCommand, CreateGroupCommand, CreateUserPoolCommand,} from "@aws-sdk/client-cognito-identity-provider";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();const cognito = simAws.cognitoIdentityProvider();
const pool = await cognito.createUserPool( new CreateUserPoolCommand({ PoolName: "myapp-users" }),);const userPoolId = pool.UserPool?.Id;
await cognito.adminCreateUser( new AdminCreateUserCommand({ UserPoolId: userPoolId, Username: "alice" }),);
await cognito.createGroup( new CreateGroupCommand({ UserPoolId: userPoolId, GroupName: "readers", Precedence: 10, }),);await cognito.createGroup( new CreateGroupCommand({ UserPoolId: userPoolId, GroupName: "admins", Precedence: 1, }),);
await cognito.adminAddUserToGroup( new AdminAddUserToGroupCommand({ UserPoolId: userPoolId, Username: "alice", GroupName: "readers", }),);await cognito.adminAddUserToGroup( new AdminAddUserToGroupCommand({ UserPoolId: userPoolId, Username: "alice", GroupName: "admins", }),);
const groups = await cognito.adminListGroupsForUser( new AdminListGroupsForUserCommand({ UserPoolId: userPoolId, Username: "alice", }),);
console.log(groups.Groups?.map((group) => group.GroupName));// [ "admins", "readers" ], strongest precedence firstZero is the strongest precedence, not the weakest, and a group created without one is weaker than
any group that has one. AdminListGroupsForUser sorts by it, lowest value first, the order the
cognito:groups claim will use once tokens are simulated. ListGroups leaves its answer unsorted,
listing a pool’s groups in creation order.
Adding a user to a group they are already in succeeds and changes nothing, as it does on real Cognito, and nothing has to check first. Removing a user who was never in the group succeeds too.
Deleting a group takes the membership with it and leaves the users alone. Deleting a user takes them out of every group, and a group never holds a member the pool cannot describe.
ListUsersInGroup reads the membership the other way round, and answers with the same user shape
ListUsers does.
UpdateGroup replaces the description, the precedence and the role together, so a property the
request leaves out is cleared. Real Cognito documents neither replacing nor merging here, and naming
every property is the one thing that behaves the same either way.
App clients
Section titled “App clients”An app client is how an application reaches a pool. What it holds decides what that application can do, and the settings later work depends on are stored and reported, never dropped.
/** * A simulated app client with a secret, its authentication flows and its token * lifetimes. */
import { CreateUserPoolClientCommand, CreateUserPoolCommand, DescribeUserPoolClientCommand,} from "@aws-sdk/client-cognito-identity-provider";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();const cognito = simAws.cognitoIdentityProvider();
const pool = await cognito.createUserPool( new CreateUserPoolCommand({ PoolName: "myapp-users" }),);
const appClient = await cognito.createUserPoolClient( new CreateUserPoolClientCommand({ UserPoolId: pool.UserPool?.Id, ClientName: "server", GenerateSecret: true, ExplicitAuthFlows: ["ALLOW_USER_PASSWORD_AUTH", "ALLOW_REFRESH_TOKEN_AUTH"], AccessTokenValidity: 15, TokenValidityUnits: { AccessToken: "minutes" }, }),);
// The secret is readable again afterwards, as it is on real Cognito.const described = await cognito.describeUserPoolClient( new DescribeUserPoolClientCommand({ UserPoolId: pool.UserPool?.Id, ClientId: appClient.UserPoolClient?.ClientId, }),);
console.log(described.UserPoolClient?.ClientSecret?.length); // 52console.log(described.UserPoolClient?.RefreshTokenValidity); // 30, the defaultA client created without GenerateSecret has no ClientSecret at all, rather than an empty one. A
client created without ExplicitAuthFlows supports ALLOW_REFRESH_TOKEN_AUTH,
ALLOW_USER_SRP_AUTH and ALLOW_CUSTOM_AUTH, which real Cognito gives it. Sign-in with a username
and password is absent from that set, and that is why USER_PASSWORD_AUTH fails on a client nobody
configured for it, here and on real AWS.
PreventUserExistenceErrors decides what a sign-in naming a user the pool lacks answers with. On
ENABLED it is the NotAuthorizedException a wrong password gets, and on LEGACY it is
UserNotFoundException. The API default is LEGACY, what a client created here without the setting
gets, and the Cognito console sets ENABLED on a client made through it.
Token lifetimes default to an hour for access and ID tokens and thirty days for refresh tokens. The
units are separate inputs, so AccessTokenValidity: 1 means an hour and RefreshTokenValidity: 1
means a day unless TokenValidityUnits says otherwise.
AuthSessionValidity is how long a challenge issued through the client can be answered for. It is
counted in whole minutes, between three and fifteen, and a client that asked for none gets the
three minutes real Cognito gives it. A test with anything to say about a challenge session running
out is quicker to write against a client that asked for fifteen.
The legacy authentication flows (ADMIN_NO_SRP_AUTH, CUSTOM_AUTH_FLOW_ONLY and
USER_PASSWORD_AUTH) work on their own, and a request mixing them with the ALLOW_ prefixed values
is refused, as real Cognito refuses it. A client holding ADMIN_NO_SRP_AUTH can run
ADMIN_USER_PASSWORD_AUTH and one holding USER_PASSWORD_AUTH can run USER_PASSWORD_AUTH, what
those settings meant before the ALLOW_ prefixed ones replaced them.
Updating an app client
Section titled “Updating an app client”UpdateUserPoolClient changes a client’s settings, and DescribeUserPoolClient reports them along
with a LastModifiedDate from when the update happened. A client never updated reports its creation
date there.
/** * Changing a simulated app client's settings, which replaces them rather than * merging into them. */
import { CreateUserPoolClientCommand, CreateUserPoolCommand, UpdateUserPoolClientCommand,} from "@aws-sdk/client-cognito-identity-provider";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();const cognito = simAws.cognitoIdentityProvider();
const pool = await cognito.createUserPool( new CreateUserPoolCommand({ PoolName: "myapp-users" }),);
const appClient = await cognito.createUserPoolClient( new CreateUserPoolClientCommand({ UserPoolId: pool.UserPool?.Id, ClientName: "web", ExplicitAuthFlows: ["ALLOW_USER_PASSWORD_AUTH", "ALLOW_REFRESH_TOKEN_AUTH"], }),);
const updated = await cognito.updateUserPoolClient( new UpdateUserPoolClientCommand({ UserPoolId: pool.UserPool?.Id, ClientId: appClient.UserPoolClient?.ClientId, ClientName: "web", ExplicitAuthFlows: ["ALLOW_USER_PASSWORD_AUTH", "ALLOW_REFRESH_TOKEN_AUTH"], AccessTokenValidity: 5, TokenValidityUnits: { AccessToken: "minutes" }, }),);
// The next sign-in gets an access token lasting five minutes.console.log(updated.UserPoolClient?.AccessTokenValidity); // 5console.log(updated.UserPoolClient?.LastModifiedDate); // when the update ranAn update replaces the client’s configuration rather than merging into it, as real Cognito does. A
setting the request leaves out goes back to the default CreateUserPoolClient would have given it,
and that is why the example above repeats ExplicitAuthFlows to keep them. A request carrying only
ClientName sends the authentication flows back to ALLOW_REFRESH_TOKEN_AUTH,
ALLOW_USER_SRP_AUTH and ALLOW_CUSTOM_AUTH, the token validities back to an hour and thirty days,
and PreventUserExistenceErrors back to LEGACY.
ClientName is the one setting an omitted request keeps rather than resets. A client has to have a
name, and CreateUserPoolClient requires one, leaving no default to go back to.
The client’s secret is untouched by an update. UpdateUserPoolClient has no GenerateSecret input
on real Cognito, and a client created without a secret never gains one while a client with one keeps
the same value.
A token already issued keeps the expiry it was issued with, because that expiry was stamped when the
token was handed out. Shortening AccessTokenValidity therefore applies to the next sign-in, and
not to a token a test is already holding, as it does on real Cognito. A changed ExplicitAuthFlows
takes effect for the next InitiateAuth, and removing ALLOW_USER_PASSWORD_AUTH starts refusing
that flow.
The inputs CreateUserPoolClient refuses are refused here too, in the same words, saying
UpdateUserPoolClient.
Signing in and verifying tokens
Section titled “Signing in and verifying tokens”AdminInitiateAuth runs the ADMIN_USER_PASSWORD_AUTH flow and answers with real signed tokens.
The app client has to be created with ALLOW_ADMIN_USER_PASSWORD_AUTH among its
ExplicitAuthFlows, as it does on real Cognito, and a request against a client without it is
refused whatever the password was.
This is the server-side flow, which needs AWS credentials and the
cognito-idp:AdminInitiateAuth permission. The client-side flow a browser or mobile app uses is
InitiateAuth, below.
The tokens are RS256 JWTs signed by a key the pool publishes. Hand that JWKS to a verifier and it verifies them with nothing stubbed and no network involved.
/** * Signing a simulated user in, and verifying the token with aws-jwt-verify. */
import { AdminCreateUserCommand, AdminInitiateAuthCommand, AdminSetUserPasswordCommand, CreateUserPoolClientCommand, CreateUserPoolCommand,} from "@aws-sdk/client-cognito-identity-provider";import { CognitoJwtVerifier } from "aws-jwt-verify";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();const cognito = simAws.cognitoIdentityProvider();
const pool = await cognito.createUserPool( new CreateUserPoolCommand({ PoolName: "myapp-users" }),);const userPoolId = pool.UserPool!.Id!;
const appClient = await cognito.createUserPoolClient( new CreateUserPoolClientCommand({ UserPoolId: userPoolId, ClientName: "web", ExplicitAuthFlows: ["ALLOW_ADMIN_USER_PASSWORD_AUTH"], }),);const clientId = appClient.UserPoolClient!.ClientId!;
await cognito.adminCreateUser( new AdminCreateUserCommand({ UserPoolId: userPoolId, Username: "alice" }),);await cognito.adminSetUserPassword( new AdminSetUserPasswordCommand({ UserPoolId: userPoolId, Username: "alice", Password: "Sup3rSecret!", Permanent: true, }),);
const { AuthenticationResult } = await cognito.adminInitiateAuth( new AdminInitiateAuthCommand({ UserPoolId: userPoolId, ClientId: clientId, AuthFlow: "ADMIN_USER_PASSWORD_AUTH", AuthParameters: { USERNAME: "alice", PASSWORD: "Sup3rSecret!" }, }),);
// The verifier is the one the application uses, configured as it is there.const verifier = CognitoJwtVerifier.create({ userPoolId, tokenUse: "access", clientId,});
// The pool's own JWKS, so nothing reaches for the network.verifier.cacheJwks(cognito.userPool(userPoolId).jwks());
const payload = await verifier.verify(AuthenticationResult!.AccessToken!);
console.log(payload.username); // "alice"console.log(payload.sub); // a UUID, and not "alice"An AuthenticationResult carries an AccessToken, an IdToken, a RefreshToken, an ExpiresIn
of 3600 and a TokenType of Bearer. The refresh token is an opaque string, not a JWT, as it is on
real Cognito, and the pool that issued it is what knows whose it is.
The claim split is the real one. The id token carries aud, token_use: "id", cognito:username
and the user’s attributes. The access token carries client_id and no aud, token_use: "access",
username and a scope of aws.cognito.signin.user.admin. Both carry the same sub, and both
carry cognito:groups in precedence order when the user is in any groups. Code reading the wrong
token for a claim fails here the way it would in production.
The iss claim is https://cognito-idp.<region>.amazonaws.com/<userPoolId>, and the JWT header
names RS256 and a kid the pool’s JWKS holds. That is everything a verifier checks.
The new password challenge
Section titled “The new password challenge”A user an admin created is in FORCE_CHANGE_PASSWORD and cannot sign in. Signing in with its
temporary password answers with the NEW_PASSWORD_REQUIRED challenge and a session, not
tokens, and AdminRespondToAuthChallenge completes it.
/** * Getting a simulated user past the NEW_PASSWORD_REQUIRED challenge. */
import { AdminCreateUserCommand, AdminInitiateAuthCommand, AdminRespondToAuthChallengeCommand, CreateUserPoolClientCommand, CreateUserPoolCommand,} from "@aws-sdk/client-cognito-identity-provider";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();const cognito = simAws.cognitoIdentityProvider();
const pool = await cognito.createUserPool( new CreateUserPoolCommand({ PoolName: "myapp-users" }),);const userPoolId = pool.UserPool!.Id!;
const appClient = await cognito.createUserPoolClient( new CreateUserPoolClientCommand({ UserPoolId: userPoolId, ClientName: "web", ExplicitAuthFlows: ["ALLOW_ADMIN_USER_PASSWORD_AUTH"], }),);const clientId = appClient.UserPoolClient!.ClientId!;
await cognito.adminCreateUser( new AdminCreateUserCommand({ UserPoolId: userPoolId, Username: "alice", TemporaryPassword: "Temp0rary!", }),);
const challenged = await cognito.adminInitiateAuth( new AdminInitiateAuthCommand({ UserPoolId: userPoolId, ClientId: clientId, AuthFlow: "ADMIN_USER_PASSWORD_AUTH", AuthParameters: { USERNAME: "alice", PASSWORD: "Temp0rary!" }, }),);
console.log(challenged.ChallengeName); // "NEW_PASSWORD_REQUIRED"
const signedIn = await cognito.adminRespondToAuthChallenge( new AdminRespondToAuthChallengeCommand({ UserPoolId: userPoolId, ClientId: clientId, ChallengeName: "NEW_PASSWORD_REQUIRED", Session: challenged.Session, ChallengeResponses: { USERNAME: "alice", NEW_PASSWORD: "Sup3rSecret!" }, }),);
console.log(signedIn.AuthenticationResult?.IdToken?.split(".").length); // 3Name a TemporaryPassword on AdminCreateUser when the test means to sign the user in. Real
Cognito generates one and emails it, and nothing here delivers a message. A user created without one
has no password that works.
The new password is checked against the pool’s policy and confirms the user, which signs in normally
from then on. A session is single use and lasts three minutes of simulated time, so a replayed one
and one left too long both fail with NotAuthorizedException.
A wrong password and a disabled user fail with NotAuthorizedException too, saying no more than
real Cognito says. An app client created with GenerateSecret: true needs a correct SECRET_HASH
in AuthParameters, the HMAC-SHA256 of the username and client id keyed by the client secret.
Signing in from a client
Section titled “Signing in from a client”InitiateAuth is what a browser or mobile app calls. It names the app client and not the pool, and
it needs no AWS credentials and no IAM permission, because real Cognito evaluates no IAM policy for
it. The flow is USER_PASSWORD_AUTH, which the app client has to have ALLOW_USER_PASSWORD_AUTH
for.
A user that has to change its password gets the NEW_PASSWORD_REQUIRED challenge here too, and
RespondToAuthChallenge completes it the way AdminRespondToAuthChallenge does.
/** * Signing in with InitiateAuth, then refreshing the tokens. */
import { AdminCreateUserCommand, AdminSetUserPasswordCommand, CreateUserPoolClientCommand, CreateUserPoolCommand, InitiateAuthCommand,} from "@aws-sdk/client-cognito-identity-provider";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();const cognito = simAws.cognitoIdentityProvider();
const pool = await cognito.createUserPool( new CreateUserPoolCommand({ PoolName: "myapp-users" }),);const userPoolId = pool.UserPool!.Id!;
const appClient = await cognito.createUserPoolClient( new CreateUserPoolClientCommand({ UserPoolId: userPoolId, ClientName: "web", ExplicitAuthFlows: ["ALLOW_USER_PASSWORD_AUTH", "ALLOW_REFRESH_TOKEN_AUTH"], }),);const clientId = appClient.UserPoolClient!.ClientId!;
await cognito.adminCreateUser( new AdminCreateUserCommand({ UserPoolId: userPoolId, Username: "alice" }),);await cognito.adminSetUserPassword( new AdminSetUserPasswordCommand({ UserPoolId: userPoolId, Username: "alice", Password: "Sup3rSecret!", Permanent: true, }),);
// No pool id, and no caller: the app client id is what finds the pool.const signedIn = await cognito.initiateAuth( new InitiateAuthCommand({ ClientId: clientId, AuthFlow: "USER_PASSWORD_AUTH", AuthParameters: { USERNAME: "alice", PASSWORD: "Sup3rSecret!" }, }),);
// Two hours on, the access token has expired and the refresh token has not.await simAws.clock().advanceBy({ hours: 2 });
const refreshed = await cognito.initiateAuth( new InitiateAuthCommand({ ClientId: clientId, AuthFlow: "REFRESH_TOKEN_AUTH", AuthParameters: { REFRESH_TOKEN: signedIn.AuthenticationResult!.RefreshToken!, }, }),);
console.log(refreshed.AuthenticationResult!.IdToken !== undefined); // trueconsole.log(refreshed.AuthenticationResult!.RefreshToken); // undefinedRefreshing tokens
Section titled “Refreshing tokens”REFRESH_TOKEN_AUTH exchanges a refresh token for a new access token and a new id token. No new
refresh token comes back, as none does on real Cognito with refresh token rotation off. The client
keeps the one it has until that expires. REFRESH_TOKEN is the same flow under its other
name, and both InitiateAuth and AdminInitiateAuth run it. An app client that rotates its refresh
tokens renews through GetTokensFromRefreshToken, covered in the section below.
The app client has to have ALLOW_REFRESH_TOKEN_AUTH, one of the flows a client created without
ExplicitAuthFlows gets.
A refresh token lasts the app client’s RefreshTokenValidity, thirty days by default, counted on
the simulated clock. Advancing time past that is what makes a refresh fail with
NotAuthorizedException, and a test can exercise the sign-in-again path without waiting a month.
A refresh token belongs to the app client that got it, so presenting one to another client in the same pool is refused. A refresh for a user that has been disabled or deleted is refused too.
Rotating refresh tokens
Section titled “Rotating refresh tokens”An app client created with a RefreshTokenRotation renews its sessions through
GetTokensFromRefreshToken. That operation takes the app client id, the refresh token and the app
client’s secret. It carries no SECRET_HASH and names no user, because the refresh token is what
says whose session it is.
Each renewal answers with a replacement refresh token, and the token that bought it stops working.
RetryGracePeriodSeconds is how long the spent one keeps being accepted, up to the minute Cognito
allows. A client that never saw the answer to a request can retry inside that window and be answered
rather than sent back to the sign-in page.
The replacement runs out when the token it replaced would have. A session on an app client whose
RefreshTokenValidity is thirty days ends thirty days after the sign-in, however often it was
renewed in between.
/** * Renewing a session on an app client that rotates its refresh tokens. */
import { AdminCreateUserCommand, AdminSetUserPasswordCommand, CreateUserPoolClientCommand, CreateUserPoolCommand, GetTokensFromRefreshTokenCommand, InitiateAuthCommand,} from "@aws-sdk/client-cognito-identity-provider";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();const cognito = simAws.cognitoIdentityProvider();
const pool = await cognito.createUserPool( new CreateUserPoolCommand({ PoolName: "myapp-users" }),);const userPoolId = pool.UserPool!.Id!;
// A rotating client has no ALLOW_REFRESH_TOKEN_AUTH, which is what// aws-cdk-lib synthesizes for a refreshTokenRotationGracePeriod.const appClient = await cognito.createUserPoolClient( new CreateUserPoolClientCommand({ UserPoolId: userPoolId, ClientName: "web", ExplicitAuthFlows: ["ALLOW_USER_PASSWORD_AUTH"], RefreshTokenRotation: { Feature: "ENABLED", RetryGracePeriodSeconds: 30 }, }),);const clientId = appClient.UserPoolClient!.ClientId!;
await cognito.adminCreateUser( new AdminCreateUserCommand({ UserPoolId: userPoolId, Username: "alice" }),);await cognito.adminSetUserPassword( new AdminSetUserPasswordCommand({ UserPoolId: userPoolId, Username: "alice", Password: "Sup3rSecret!", Permanent: true, }),);
const signedIn = await cognito.initiateAuth( new InitiateAuthCommand({ ClientId: clientId, AuthFlow: "USER_PASSWORD_AUTH", AuthParameters: { USERNAME: "alice", PASSWORD: "Sup3rSecret!" }, }),);const refreshToken = signedIn.AuthenticationResult!.RefreshToken!;
// An hour on, the access token has expired and the session is renewed.await simAws.clock().advanceBy({ hours: 1 });
const renewed = await cognito.getTokensFromRefreshToken( new GetTokensFromRefreshTokenCommand({ ClientId: clientId, RefreshToken: refreshToken, }),);
// A replacement came back, and the application holds that from now on.console.log(renewed.AuthenticationResult!.RefreshToken !== refreshToken); // true
// The spent token is still accepted inside the thirty second grace period.await simAws.clock().advanceBy({ seconds: 10 });
const retried = await cognito.getTokensFromRefreshToken( new GetTokensFromRefreshTokenCommand({ ClientId: clientId, RefreshToken: refreshToken, }),);
console.log(retried.AuthenticationResult!.AccessToken !== undefined); // true
// A minute later it has been rotated out for good.await simAws.clock().advanceBy({ minutes: 1 });
try { await cognito.getTokensFromRefreshToken( new GetTokensFromRefreshTokenCommand({ ClientId: clientId, RefreshToken: refreshToken, }), );} catch (error) { console.log((error as Error).message); // "Refresh Token has been revoked."}GetTokensFromRefreshToken renews a session on an app client that does not rotate as well. There it
answers with a new access token and id token and no refresh token, the way REFRESH_TOKEN_AUTH
does.
REFRESH_TOKEN_AUTH against a rotating app client is refused, and the refusal names the operation
to use instead. aws-cdk-lib drops ALLOW_REFRESH_TOKEN_AUTH from a client the moment it is given
a refreshTokenRotationGracePeriod, so a client synthesized from CDK is refused by the flow check
before it reaches this.
A confidential app client sends ClientSecret with the request, and a request carrying the wrong
secret or none at all is refused with NotAuthorizedException. DeviceKey is refused, because
device remembering is not simulated.
Signing out
Section titled “Signing out”GlobalSignOut revokes the tokens a user holds, and is authorized by that user’s own access token
rather than by IAM. AdminUserGlobalSignOut does the same thing for a user an administrator names,
and does need the cognito-idp:AdminUserGlobalSignOut permission on the pool.
After either, the user’s refresh tokens are gone, and a REFRESH_TOKEN_AUTH request fails while the
user has to sign in again. Signing out leaves the user free to sign in again. It ends the sessions
it had.
/** * Signing a user out, and the refresh that then fails. */
import { AdminCreateUserCommand, AdminSetUserPasswordCommand, CreateUserPoolClientCommand, CreateUserPoolCommand, GlobalSignOutCommand, InitiateAuthCommand,} from "@aws-sdk/client-cognito-identity-provider";
import { SimAws } from "@kensio/yulin";import { SimCognitoNotAuthorizedException } from "@kensio/yulin/cognito";
const simAws = new SimAws();const cognito = simAws.cognitoIdentityProvider();
const pool = await cognito.createUserPool( new CreateUserPoolCommand({ PoolName: "myapp-users" }),);const userPoolId = pool.UserPool!.Id!;
const appClient = await cognito.createUserPoolClient( new CreateUserPoolClientCommand({ UserPoolId: userPoolId, ClientName: "web", ExplicitAuthFlows: ["ALLOW_USER_PASSWORD_AUTH", "ALLOW_REFRESH_TOKEN_AUTH"], }),);const clientId = appClient.UserPoolClient!.ClientId!;
await cognito.adminCreateUser( new AdminCreateUserCommand({ UserPoolId: userPoolId, Username: "alice" }),);await cognito.adminSetUserPassword( new AdminSetUserPasswordCommand({ UserPoolId: userPoolId, Username: "alice", Password: "Sup3rSecret!", Permanent: true, }),);
const signedIn = await cognito.initiateAuth( new InitiateAuthCommand({ ClientId: clientId, AuthFlow: "USER_PASSWORD_AUTH", AuthParameters: { USERNAME: "alice", PASSWORD: "Sup3rSecret!" }, }),);
await cognito.globalSignOut( new GlobalSignOutCommand({ AccessToken: signedIn.AuthenticationResult!.AccessToken!, }),);
try { await cognito.initiateAuth( new InitiateAuthCommand({ ClientId: clientId, AuthFlow: "REFRESH_TOKEN_AUTH", AuthParameters: { REFRESH_TOKEN: signedIn.AuthenticationResult!.RefreshToken!, }, }), );} catch (error) { // The session is over, so the refresh token no longer buys new tokens. console.log(error instanceof SimCognitoNotAuthorizedException); // true}Signing in through a hosted domain
Section titled “Signing in through a hosted domain”A pool with a domain serves the OAuth endpoints an authorization code grant runs through, on the domain’s own hostname. That is the only way a user signs in with Google or another external provider. No Cognito API operation does it, so an application redirects the browser to the authorize endpoint and exchanges the code it comes back with.
Three things have to be in place. The pool needs a domain, the app client needs the OAuth settings that say what it may ask for and where the user may be sent back to, and the provider itself has to be configured.
/** * Giving a pool a domain, an identity provider and an app client that can use * them. */
import { CreateIdentityProviderCommand, CreateUserPoolClientCommand, CreateUserPoolCommand, CreateUserPoolDomainCommand, DescribeUserPoolDomainCommand,} from "@aws-sdk/client-cognito-identity-provider";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws({ defaultRegionName: "eu-west-2" });const cognito = simAws.cognitoIdentityProvider();
const pool = await cognito.createUserPool( new CreateUserPoolCommand({ PoolName: "myapp-users" }),);const userPoolId = pool.UserPool!.Id!;
await cognito.createUserPoolDomain( new CreateUserPoolDomainCommand({ UserPoolId: userPoolId, Domain: "myapp-login", }),);
await cognito.createIdentityProvider( new CreateIdentityProviderCommand({ UserPoolId: userPoolId, ProviderName: "Google", ProviderType: "Google", ProviderDetails: { client_id: "google-client-id", client_secret: "google-client-secret", authorize_scopes: "openid email", }, AttributeMapping: { email: "email", given_name: "given_name" }, }),);
const appClient = await cognito.createUserPoolClient( new CreateUserPoolClientCommand({ UserPoolId: userPoolId, ClientName: "web", GenerateSecret: true, AllowedOAuthFlowsUserPoolClient: true, AllowedOAuthFlows: ["code"], AllowedOAuthScopes: ["openid", "email"], CallbackURLs: ["https://www.example.com/user/callback"], LogoutURLs: ["https://www.example.com/"], SupportedIdentityProviders: ["Google"], }),);console.log(appClient.UserPoolClient!.ClientId);
const domain = await cognito.describeUserPoolDomain( new DescribeUserPoolDomainCommand({ Domain: "myapp-login" }),);console.log(domain.DomainDescription!.Status); // "ACTIVE"A prefix domain is served at <prefix>.auth.<region>.amazoncognito.com, and a custom domain, one
created with a CustomDomainConfig, at the hostname it names. A pool has one domain, and a domain
string is unique across every simulated account and region, as it is across the whole of real AWS.
Who is signed in at the provider
Section titled “Who is signed in at the provider”Nothing here calls Google. A simulated identity provider holds the user signed in at it instead, and an authorize request naming that provider signs that user in. The URL the application builds is therefore the URL it builds in production, and the test says who is at the other end of it:
/** * Saying who is signed in at a simulated identity provider. */
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws({ defaultRegionName: "eu-west-2" });const cognito = simAws.cognitoIdentityProvider();
declare const userPoolId: string;
cognito .userPool(userPoolId) .auth.identityProviders.require("Google") .signInAs({ Subject: "108412093487519382745", Claims: { email: "someone@example.com", given_name: "Someone" }, });signInAs stands in for everything that happens at the provider, where a real user types a password
this simulation never sees. An authorize request that reaches a provider nobody is signed in at is
refused, saying so, rather than signing in a user nothing put there. signOut() on the provider
puts it back that way.
Driving the code flow
Section titled “Driving the code flow”The browser goes to /oauth2/authorize, comes back to the app client’s callback URL with a code,
and the application’s own server exchanges that code at /oauth2/token. Both endpoints are on the
domain’s hostname, and SimAwsHttp sends a request into the simulation without a server listening.
A domain answers on the real AWS hostname as well as on the localhost one serveSimAws rewrites it
to. The URL an application already builds needs no changing:
/** * Completing an authorization code grant against a simulated hosted domain. */
import { CognitoJwtVerifier } from "aws-jwt-verify";
import type { SimAws } from "@kensio/yulin";import { SimAwsHttp } from "@kensio/yulin/serve";
declare const simAws: SimAws;declare const userPoolId: string;declare const clientId: string;declare const clientSecret: string;
const http = new SimAwsHttp({ simAws });const callbackUrl = "https://www.example.com/user/callback";const hosted = (path: string, query = ""): string => `https://myapp-login.auth.eu-west-2.amazoncognito.com${path}${query}`;
// The browser is sent to the authorize endpoint, naming the provider.const authorizeQuery = new URLSearchParams({ response_type: "code", client_id: clientId, redirect_uri: callbackUrl, scope: "openid email", state: "csrf-token", identity_provider: "Google",});const authorized = await http.fetch( hosted("/oauth2/authorize", `?${authorizeQuery.toString()}`),);
console.log(authorized.status); // 302
// It comes back to the callback URL with a code and the state it was given.const callback = new URL(authorized.headers.get("location")!);const code = callback.searchParams.get("code")!;console.log(callback.searchParams.get("state")); // "csrf-token"
// The application's own server exchanges the code, authenticating as the app// client with its secret.const exchanged = await http.fetch(hosted("/oauth2/token"), { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`, }, body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: callbackUrl, }).toString(),});
const tokens = (await exchanged.json()) as { access_token: string; id_token: string; refresh_token: string; expires_in: number;};
const verifier = CognitoJwtVerifier.create({ userPoolId, tokenUse: "id", clientId,});verifier.cacheJwks( simAws.cognitoIdentityProvider().userPool(userPoolId).jwks(),);
const claims = await verifier.verify(tokens.id_token);console.log(claims["cognito:username"]); // "Google_108412093487519382745"console.log(claims["email"]); // "someone@example.com"A simulated Lambda reaches the same token endpoint. A handler that exchanges the code with fetch
or node:https is answered by the simulated domain on the hostname its own code already names, so
the callback the whole of a site’s sign-in passes through can be tested as it is deployed. See
the HTTP requests function code makes.
The pool creates a user of its own for each external subject the first time it signs in, exactly as
real Cognito does. The username is the provider name and the subject with an underscore between
them, the status is EXTERNAL_PROVIDER, and the provider’s claims reach the user through the
provider’s attribute mapping. The same subject signing in again reaches the same user, with its
mapped attributes brought up to date. AdminGetUser reports where it came from in an identities
attribute, and the id token carries the same thing as an identities claim.
An authorization code is single use and lasts five minutes on the simulated clock. The token
endpoint also answers a grant_type of refresh_token, with the refresh token the grant handed
out.
Signing a local user in
Section titled “Signing a local user in”An authorize request naming no identity_provider signs in one of the pool’s own users. Real
managed login answers that request with a form and takes an email address and a password from it.
Here the two arrive as a username and a password beside the parameters the request already
carries, and everything after them is the same grant. A pool that allows passkeys offers one on the
same form, which is covered in A passkey at managed login. The app client needs COGNITO among its
SupportedIdentityProviders, which is what real Cognito needs before managed login offers the form
at all.
/** * Signing one of a pool's own users in at the authorize endpoint. */
import type { SimAws } from "@kensio/yulin";
declare const simAws: SimAws;declare const userPoolId: string;declare const clientId: string;
const cognito = simAws.cognitoIdentityProvider();const pool = cognito.userPool(userPoolId);const callbackUrl = "https://www.example.com/user/callback";
// The two fields managed login's form would have taken, passed with the// parameters the browser arrived on.const redirect = await cognito.hostedAuthorize(pool, { response_type: "code", client_id: clientId, redirect_uri: callbackUrl, scope: "openid email", state: "csrf-token", username: "alice", password: "Sup3rSecret!",});
const callback = new URL(redirect.location);console.log(callback.searchParams.get("state")); // "csrf-token"
// The application's own server exchanges the code, as it does after a// federated sign-in.const tokens = await cognito.hostedToken(pool, { grant_type: "authorization_code", client_id: clientId, code: callback.searchParams.get("code")!, redirect_uri: callbackUrl,});
console.log(tokens.token_type); // "Bearer"The password is checked the way InitiateAuth checks it. A wrong password is a
NotAuthorizedException, a user that has not confirmed its sign-up is a
UserNotConfirmedException, and a username the pool does not hold depends on the app client’s
PreventUserExistenceErrors. None of the three issues a code.
An identity_provider of COGNITO reaches the same place, which is where real managed login sends
a request that skipped the provider choice.
Two sign-ins real managed login answers with a further page are refused instead. A user that has
registered a second factor is one, and a user holding a temporary password from AdminCreateUser is
the other. Both say which page would have come next, and where the simulation does answer that
challenge. InitiateAuth issues the MFA challenge and the new password challenge, and
AdminSetUserPassword gives a user a permanent password.
Users sign themselves up through SignUp and ConfirmSignUp, which are covered under
Signing up. A user confirmed that way signs in here with the password it chose, and
so does one that signed up on the page below.
The pages managed login serves
Section titled “The pages managed login serves”A served domain answers six pages, so a browser in a local development server completes a whole sign-up, password reset and sign-in without any of it being stubbed out.
GET /oauth2/authorize naming no identity_provider answers HTML holding the sign-in form. The
form has a username field, a password field, and the authorize parameters as hidden inputs, and it
posts back to /oauth2/authorize. A pool that allows passkeys carries a second button beside them.
The pool’s identity providers are links to the same endpoint with identity_provider set, so every
way in is on the one page. Posting the form redirects to the app client’s callback URL with the code
and the state, honouring a code_challenge the request carried.
Following one of those provider links reaches a page standing in for the provider’s own sign-in page. Real Cognito sends the browser to Google here, and nothing in this simulation calls Google, so the page asks who Google would have said is signing in. It says on its face that it is Yulin rather than the provider, twice, because somebody meeting it in a screenshot or a screen recording has to be able to tell that no real sign-in happened.
The page asks for the subject and for one field per claim the provider’s AttributeMapping reads,
and every field arrives filled in, so the common case is pressing the button. The address it
pre-fills is on example.com, which is reserved, so nothing left unedited reaches a real mailbox.
Editing the address is what drives a federated sign-up against a pool that already holds one of its
own users at the same address, which real Cognito keeps as two accounts until
AdminLinkProviderForUser merges them. That operation is unsimulated.
Posting the page carries on into the sign-in the authorize endpoint already runs, ending in the same
<ProviderName>_<subject> user and the same authorization code. Provider state is discarded after
the request. A later authorize request asks again because real Cognito asks the provider afresh each
time. A provider configured with signInAs skips the page and uses the configured identity.
/signup is a link from that page. Its form asks for a username, a password and the attributes the
pool needs, which are the ones its Schema made required and the ones its AutoVerifiedAttributes
names. Posting it does what SignUp does and sends the browser to /confirm.
/confirm asks for the code, does what ConfirmSignUp does with it, and sends the browser back to
/oauth2/authorize to sign in. Its second button is ResendConfirmationCode. Nothing delivers a
code to anybody, so a test reads it off the pool the way it does for any other sign-up:
/** * Signing up, confirming and signing in through the served pages. */
import type { SimAws } from "@kensio/yulin";import { SimAwsHttp } from "@kensio/yulin/serve";
declare const simAws: SimAws;declare const userPoolId: string;declare const clientId: string;
const http = new SimAwsHttp({ simAws });const domain = "https://myapp-login.auth.eu-west-2.amazoncognito.com";const parameters = { response_type: "code", client_id: clientId, redirect_uri: "https://www.example.com/user/callback", scope: "openid email", state: "csrf-token",};
const posted = async ( path: string, fields: Record<string, string>,): Promise<Response> => http.fetch(`${domain}${path}`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ ...parameters, ...fields }).toString(), });
// The sign-in page is what the authorize endpoint answers a browser with.const signInPage = await http.fetch( `${domain}/oauth2/authorize?${new URLSearchParams(parameters).toString()}`,);console.log(signInPage.headers.get("content-type")); // "text/html; charset=utf-8"
// The sign-up form creates the user, unconfirmed.await posted("/signup", { username: "alice", password: "Sup3rSecret!" });
// The code the pool would have emailed is read off the pool.const pool = simAws.cognitoIdentityProvider().userPool(userPoolId);await posted("/confirm", { username: "alice", code: pool.confirmationCode("alice") ?? "",});
// That same user then signs in and reaches the callback with a code.const signedIn = await posted("/oauth2/authorize", { username: "alice", password: "Sup3rSecret!",});const callbackUrl = new URL(signedIn.headers.get("location")!);console.log(callbackUrl.searchParams.get("state")); // "csrf-token"console.log(callbackUrl.searchParams.get("code") !== null); // true/forgotPassword is the other link from the sign-in page, and it is where a person who cannot get
in goes. Its form asks who has forgotten the password, does what ForgotPassword does, and sends
the browser to /confirmForgotPassword. That page takes the code and a new password, does what
ConfirmForgotPassword does, and sends the browser back to /oauth2/authorize to sign in with the
password it has just chosen. Both paths are the ones real managed login serves these two steps at.
/** * Resetting a forgotten password through the served pages. */
import type { SimAws } from "@kensio/yulin";import { SimAwsHttp } from "@kensio/yulin/serve";
declare const simAws: SimAws;declare const userPoolId: string;declare const clientId: string;
const http = new SimAwsHttp({ simAws });const domain = "https://myapp-login.auth.eu-west-2.amazoncognito.com";const parameters = { response_type: "code", client_id: clientId, redirect_uri: "https://www.example.com/user/callback", scope: "openid email", state: "csrf-token",};
const posted = async ( path: string, fields: Record<string, string>,): Promise<Response> => http.fetch(`${domain}${path}`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ ...parameters, ...fields }).toString(), });
// A confirmed user of the pool has forgotten its password.await posted("/forgotPassword", { username: "alice" });
// The code the pool would have emailed is read off the pool, as a sign-up// code is.const pool = simAws.cognitoIdentityProvider().userPool(userPoolId);await posted("/confirmForgotPassword", { username: "alice", code: pool.confirmationCode("alice") ?? "", password: "Ev3nBetter!",});
// The user signs in with the new password and reaches the callback with a// code and the state the application began with.const signedIn = await posted("/oauth2/authorize", { username: "alice", password: "Ev3nBetter!",});const callbackUrl = new URL(signedIn.headers.get("location")!);console.log(callbackUrl.searchParams.get("state")); // "csrf-token"console.log(callbackUrl.searchParams.get("code") !== null); // trueA refusal a person can do something about is shown on the form they posted. A wrong password comes
back on the sign-in form and issues no code, a password the pool’s policy turns down comes back on
the sign-up form or the new password form, and a wrong reset code comes back on the form that asked
for it, leaving the password alone. A refusal the application caused, such as a redirect_uri the
app client never registered, is answered the way every other authorize refusal is.
What /forgotPassword shows for a username the pool lacks is the app client’s
PreventUserExistenceErrors decision. A client set to ENABLED sends the browser on to the code
page either way, and one left on the LEGACY default says the user is not there.
All five carry a small inline stylesheet that approximates real managed login. A card centred on the page, a bold heading, labels above full-width fields, and a full-width blue submit button. The stylesheet is part of the page. Nothing else is fetched to render one. There is no script on any of them, and no close match to what real managed login looks like. Real managed login is built on Cloudscape and draws components these pages have no equivalent for.
The browser’s managed login session
Section titled “The browser’s managed login session”A sign-in at the hosted domain starts a session for that browser. Real managed login keeps it in a
cookie named cognito on the pool’s domain and holds it for an hour, and a served sign-in here sets
the same cookie. An authorize request carrying it is answered with a code and asks for no
credentials, which is what signs a returning browser back in without the form.
A test driving the endpoints in process passes the session as a third argument, and reads what
happened from redirect.session.
/** * A browser signing in once with its password, and again from its session. */
import type { SimAws } from "@kensio/yulin";
declare const simAws: SimAws;declare const userPoolId: string;declare const clientId: string;
const cognito = simAws.cognitoIdentityProvider();const pool = cognito.userPool(userPoolId);const parameters = { response_type: "code", client_id: clientId, redirect_uri: "https://www.example.com/user/callback", scope: "openid email",};
const first = await cognito.hostedAuthorize(pool, { ...parameters, username: "alice", password: "Sup3rSecret!",});
console.log(first.session.outcome); // "started"
// The same browser, sent back to authorize carrying no credentials.const second = await cognito.hostedAuthorize( pool, parameters, first.session.startedSession,);
console.log(second.session.outcome); // "reused"console.log(second.username); // "alice"The outcome is started where the sign-in took credentials, reused where the browser’s own
session answered it, and ended at the logout endpoint. Asserting on it is how a test tells a
sign-in that needed a password from one that did not.
Three parts of the real behaviour are modelled with it.
- Signing in from the session leaves the hour where the interactive sign-in put it. A browser coming back at fifty minutes gets a code, and the same browser at seventy minutes gets the form.
- The session belongs to the pool’s domain. A browser that signed in for one app client is a returning browser to every other app client of the same pool.
- A sign-in at an identity provider starts one too, so a Google user comes back to a plain authorize request already signed in.
A user disabled since the session started is refused the way every other sign-in refuses one, and a user deleted since then leaves the session with nobody to sign in, so the form answers instead. Attribute and password changes go by without disturbing it.
Signing out
Section titled “Signing out”GET /logout?client_id=...&logout_uri=... ends the browser’s managed login session and redirects to
the sign-out URL, once it has checked it is one of the app client’s LogoutURLs. The served form
clears the cognito cookie. After that the next authorize request asks for a password again.
GlobalSignOut and AdminUserGlobalSignOut revoke a user’s tokens and leave the managed login
session alone, as they do on real Cognito. An application that clears its own cookies and revokes
the tokens has not signed the browser out of the hosted domain, and the sign-in link takes it
straight back in with no password. Sending the browser to /logout is what ends it.
Nobody is signed out at the identity provider, which real Cognito also leaves undone. A user signed out here is still signed in at Google.
An authorize request carrying a code_challenge and a code_challenge_method of S256 gets a code
that only the matching code_verifier exchanges. plain is refused, as it is by real Cognito.
Putting a web ACL in front of the domain
Section titled “Putting a web ACL in front of the domain”AssociateWebACL on simulated WAFv2 attaches a REGIONAL web ACL to a pool by its ARN, and the
pool’s endpoints are then evaluated against it. A blocked request gets 403 and the endpoint behind
it never runs. A blocked sign-up creates no user. The two .well-known documents are covered along
with the hosted domain pages, and the /<pool-id>/messages listing is left outside. See
protecting a Cognito user pool for the whole
example, including the request body that Cognito withholds from AWS WAF at a hosted domain.
Lambda triggers
Section titled “Lambda triggers”A pool created with a LambdaConfig runs the functions it names as part of a sign-up, a sign-in or
a message. Each one is given the real event and has to return it, changed or not.
| Trigger | Fires | triggerSource |
|---|---|---|
PreSignUp |
SignUp, before the pool takes the new user |
PreSignUp_SignUp |
PreSignUp |
AdminCreateUser, before the pool takes the new user |
PreSignUp_AdminCreateUser |
PostConfirmation |
ConfirmSignUp and AdminConfirmSignUp, once the user is CONFIRMED |
PostConfirmation_ConfirmSignUp |
PostConfirmation |
ConfirmForgotPassword, once the reset has confirmed the user |
PostConfirmation_ConfirmForgotPassword |
PreAuthentication |
a sign-in, once the user is known and before its password is checked | PreAuthentication_Authentication |
PreTokenGeneration |
a sign-in, where the claims of its tokens are settled | TokenGeneration_Authentication |
PreTokenGeneration |
the sign-in that finishes by answering the new password challenge | TokenGeneration_NewPasswordChallenge |
PreTokenGeneration |
a REFRESH_TOKEN_AUTH refresh, over the tokens it reissues |
TokenGeneration_RefreshTokens |
PostAuthentication |
a sign-in, once the tokens have been issued | PostAuthentication_Authentication |
CustomMessage |
SignUp, before the verification message is recorded |
CustomMessage_SignUp |
CustomMessage |
ResendConfirmationCode, before the message is recorded |
CustomMessage_ResendCode |
CustomMessage |
AdminCreateUser, before the invitation is recorded |
CustomMessage_AdminCreateUser |
CustomMessage |
an MFA code, before the text message is recorded | CustomMessage_Authentication |
CustomMessage |
ForgotPassword and AdminResetUserPassword, before the message is recorded |
CustomMessage_ForgotPassword |
CustomMessage is the one whose response is read for more than a flag, and it is covered in
The CustomMessage trigger above. The rest are here.
The function is a simulated Lambda function anywhere in the simulation, and it has to admit
cognito-idp.amazonaws.com for the pool. AddPermission grants that, and CDK’s addTrigger emits
an AWS::Lambda::Permission for it.
A SAM template puts a function on a pool with a Cognito event, naming the pool under UserPool
and the trigger under Trigger. The transform writes the LambdaConfig entry onto the pool and the
permission beside it. See
the SAM section of the CloudFormation docs.
A LambdaConfig ARN can carry a version number or an alias name on the end, and the trigger runs the
version that qualifier names. The permission is made on the same qualifier:
/** * A user pool whose PreSignUp trigger names a Lambda alias, so sign-ups run the * version the alias points at. */
import { CreateUserPoolClientCommand, CreateUserPoolCommand, SignUpCommand,} from "@aws-sdk/client-cognito-identity-provider";import { AddPermissionCommand, CreateAliasCommand, CreateFunctionCommand, PublishVersionCommand,} from "@aws-sdk/client-lambda";
import { SimAws } from "@kensio/yulin";import { makeLambdaZipFileInput } from "@kensio/yulin/lambda";
const simAws = new SimAws();const lambda = simAws.lambda();const cognito = simAws.cognitoIdentityProvider();const triggerArn = `arn:aws:lambda:${simAws.defaultRegionName}:${simAws.defaultAccountId}:function:auth-trigger`;
await lambda.createFunction( new CreateFunctionCommand({ FunctionName: "auth-trigger", Role: `arn:aws:iam::${simAws.defaultAccountId}:role/TriggerRole`, Code: { ZipFile: makeLambdaZipFileInput((event, context) => { console.log(context.functionVersion); // "1", the version behind `live`
// A trigger hands the event back, changed or not. return event; }), }, }),);
const published = await lambda.publishVersion( new PublishVersionCommand({ FunctionName: "auth-trigger" }),);
await lambda.createAlias( new CreateAliasCommand({ FunctionName: "auth-trigger", Name: "live", FunctionVersion: published.Version, }),);
const pool = await cognito.createUserPool( new CreateUserPoolCommand({ PoolName: "myapp-users", LambdaConfig: { PreSignUp: `${triggerArn}:live` }, }),);
await lambda.addPermission( new AddPermissionCommand({ FunctionName: "auth-trigger", Qualifier: "live", StatementId: "AllowCognito", Action: "lambda:InvokeFunction", Principal: "cognito-idp.amazonaws.com", SourceArn: pool.UserPool!.Arn!, }),);
const client = await cognito.createUserPoolClient( new CreateUserPoolClientCommand({ UserPoolId: pool.UserPool!.Id!, ClientName: "web", }),);
await cognito.signUp( new SignUpCommand({ ClientId: client.UserPoolClient!.ClientId!, Username: "alice", Password: "Sup3rSecret!", }),);The qualifier is resolved when the trigger fires, the same way the function itself is, so the pool
can be created before either exists. One naming no version and no alias refuses the sign-in with
UnexpectedLambdaException, and the message says what it reached for.
Sign-up triggers
Section titled “Sign-up triggers”PreSignUp runs before the pool takes the new user. A handler that throws refuses the sign-up with
UserLambdaValidationException and leaves no user behind. PostConfirmation runs once the user has
reached CONFIRMED, and is given its attributes with the sub the pool allocated among them, which
a handler keys an external record on.
The handler runs as its function’s execution role, so a call it makes to another simulated service is authorized by simulated IAM the way the deployed function’s would be.
/** * A user pool that auto-confirms its users and writes each one to DynamoDB. */
import { CreateUserPoolClientCommand, CreateUserPoolCommand, SignUpCommand,} from "@aws-sdk/client-cognito-identity-provider";import { CreateTableCommand, GetItemCommand, PutItemCommand,} from "@aws-sdk/client-dynamodb";import { CreateRoleCommand, PutRolePolicyCommand } from "@aws-sdk/client-iam";import { AddPermissionCommand, CreateFunctionCommand,} from "@aws-sdk/client-lambda";
import { SimAws } from "@kensio/yulin";import { makeLambdaZipFileInput } from "@kensio/yulin/lambda";
/** * The parts of the two sign-up events these handlers read. */interface SignUpTriggerEvent { readonly userName: string; readonly request: { readonly userAttributes: Record<string, string> }; readonly response: { autoConfirmUser?: boolean; autoVerifyEmail?: boolean; };}
const simAws = new SimAws();const lambda = simAws.lambda();const cognito = simAws.cognitoIdentityProvider();
await simAws.dynamoDb().createTable( new CreateTableCommand({ TableName: "users", KeySchema: [{ AttributeName: "sub", KeyType: "HASH" }], AttributeDefinitions: [{ AttributeName: "sub", AttributeType: "S" }], BillingMode: "PAY_PER_REQUEST", }),);
// The execution role is what the handler's own writes are authorized as, so a// missing grant fails the confirmation rather than writing nothing.const role = await simAws.iam().createRole( new CreateRoleCommand({ RoleName: "SignUpTriggerRole", AssumeRolePolicyDocument: JSON.stringify({ Version: "2012-10-17", Statement: { Effect: "Allow", Principal: { Service: "lambda.amazonaws.com" }, Action: "sts:AssumeRole", }, }), }),);
await simAws.iam().putRolePolicy( new PutRolePolicyCommand({ RoleName: "SignUpTriggerRole", PolicyName: "WriteUsers", PolicyDocument: JSON.stringify({ Version: "2012-10-17", Statement: { Effect: "Allow", Action: "dynamodb:PutItem", Resource: `arn:aws:dynamodb:${simAws.defaultRegionName}:${simAws.defaultAccountId}:table/users`, }, }), }),);
// Anyone on the domain the pool is for skips confirmation, and their address// counts as verified without a code ever being answered.await lambda.createFunction( new CreateFunctionCommand({ FunctionName: "pre-sign-up", Role: role.Role.Arn, Code: { ZipFile: makeLambdaZipFileInput((event: SignUpTriggerEvent) => { const email = event.request.userAttributes["email"] ?? "";
if (email.endsWith("@example.com")) { event.response.autoConfirmUser = true; event.response.autoVerifyEmail = true; }
return event; }), }, }),);
// The confirmed user gets a row of its own, keyed on the sub Cognito// allocated rather than on the username.await lambda.createFunction( new CreateFunctionCommand({ FunctionName: "post-confirmation", Role: role.Role.Arn, Code: { ZipFile: makeLambdaZipFileInput(async (event: SignUpTriggerEvent) => { await simAws.dynamoDb().putItem( new PutItemCommand({ TableName: "users", Item: { sub: { S: event.request.userAttributes["sub"] ?? "" }, email: { S: event.request.userAttributes["email"] ?? "" }, username: { S: event.userName }, }, }), );
return event; }), }, }),);
const pool = await cognito.createUserPool( new CreateUserPoolCommand({ PoolName: "myapp-users", LambdaConfig: { PreSignUp: `arn:aws:lambda:${simAws.defaultRegionName}:${simAws.defaultAccountId}:function:pre-sign-up`, PostConfirmation: `arn:aws:lambda:${simAws.defaultRegionName}:${simAws.defaultAccountId}:function:post-confirmation`, }, }),);
for (const functionName of ["pre-sign-up", "post-confirmation"]) { await lambda.addPermission( new AddPermissionCommand({ FunctionName: functionName, StatementId: "AllowCognito", Action: "lambda:InvokeFunction", Principal: "cognito-idp.amazonaws.com", SourceArn: pool.UserPool?.Arn, }), );}
const appClient = await cognito.createUserPoolClient( new CreateUserPoolClientCommand({ UserPoolId: pool.UserPool?.Id, ClientName: "web", ExplicitAuthFlows: ["ALLOW_USER_PASSWORD_AUTH"], }),);
const signedUp = await cognito.signUp( new SignUpCommand({ ClientId: appClient.UserPoolClient?.ClientId, Username: "alice", Password: "Sup3rSecret!", UserAttributes: [{ Name: "email", Value: "alice@example.com" }], }),);
// The pre sign-up handler confirmed the user, so no ConfirmSignUp call is// needed and the post confirmation handler has already run.console.log(signedUp.UserConfirmed); // true
const written = await simAws.dynamoDb().getItem( new GetItemCommand({ TableName: "users", Key: { sub: { S: signedUp.UserSub ?? "" } }, }),);
console.log(written.Item?.["email"]?.S); // "alice@example.com"A PreSignUp handler answers in the response it is given, which arrives with autoConfirmUser,
autoVerifyEmail and autoVerifyPhone all set to false. Setting autoConfirmUser takes the new
user straight to CONFIRMED, and SignUp reports UserConfirmed: true. The two verify flags set
email_verified and phone_number_verified without a code being answered, and work whether or not
the user was confirmed. Asking to verify an attribute the sign-up left out refuses the sign-up, as
it does on real Cognito, rather than creating a user with the flag quietly unset.
A user confirmed that way still reaches PostConfirmation, at sign-up and not at a
ConfirmSignUp that never comes. A project whose users never confirm is covered by that, here as on
real Cognito.
AdminCreateUser reaches PreSignUp and never reaches PostConfirmation. That matters more than
it looks. AdminCreateUser is the obvious place to hang the trigger and the wrong one, and a
project relying on it would pass here and write nothing in production. What the handler wrote into
the response is ignored on that occasion too, because an admin-created user is already past
confirmation. Real Cognito ignores all three flags there.
The ValidationData and ClientMetadata a request carries reach the handler. SignUp and
AdminCreateUser pass both to PreSignUp, as request.validationData and
request.clientMetadata. ConfirmSignUp and AdminConfirmSignUp pass their ClientMetadata to
PostConfirmation. Neither is stored on the user, as on real Cognito.
The admin operations have no app client to name. A handler fired by AdminCreateUser or
AdminConfirmSignUp reads callerContext.clientId as CLIENT_ID_NOT_APPLICABLE, which real
Cognito sends.
Sign-in triggers
Section titled “Sign-in triggers”PreAuthentication runs once the user is known and before its password is checked, so a wrong
password reaches it too. The trigger is given the user to decide about, and deciding is what it is
for. PostAuthentication runs once the tokens have been issued.
/** * A user pool that runs a PreAuthentication trigger on every sign-in. */
import { AdminCreateUserCommand, AdminInitiateAuthCommand, AdminSetUserPasswordCommand, CreateUserPoolClientCommand, CreateUserPoolCommand,} from "@aws-sdk/client-cognito-identity-provider";import { AddPermissionCommand, CreateFunctionCommand,} from "@aws-sdk/client-lambda";
import { SimAws } from "@kensio/yulin";import { makeLambdaZipFileInput } from "@kensio/yulin/lambda";
/** * The part of the PreAuthentication event this handler reads. */interface PreAuthenticationEvent { readonly request: { readonly userAttributes: Record<string, string> };}
const simAws = new SimAws();const lambda = simAws.lambda();const cognito = simAws.cognitoIdentityProvider();
// The trigger turns away anyone who is not on the domain the pool is for, and// hands the event back otherwise, as every trigger handler has to.await lambda.createFunction( new CreateFunctionCommand({ FunctionName: "pre-auth", Role: "arn:aws:iam::888888888888:role/PreAuthRole", Code: { ZipFile: makeLambdaZipFileInput((event: PreAuthenticationEvent) => { const email = event.request.userAttributes["email"] ?? "";
if (!email.endsWith("@example.com")) { throw new Error("Only example.com may sign in"); }
return event; }), }, }),);
// The pool names the function by ARN. Nothing is resolved until a sign-in runs// the trigger, so the pool can be created before the function exists.const pool = await cognito.createUserPool( new CreateUserPoolCommand({ PoolName: "myapp-users", LambdaConfig: { PreAuthentication: "arn:aws:lambda:us-east-1:888888888888:function:pre-auth", }, }),);
// Cognito invokes the function as a service, so the function's resource policy// has to admit it for this pool.await lambda.addPermission( new AddPermissionCommand({ FunctionName: "pre-auth", StatementId: "AllowCognito", Action: "lambda:InvokeFunction", Principal: "cognito-idp.amazonaws.com", SourceArn: pool.UserPool?.Arn, }),);
const appClient = await cognito.createUserPoolClient( new CreateUserPoolClientCommand({ UserPoolId: pool.UserPool?.Id, ClientName: "web", ExplicitAuthFlows: ["ALLOW_ADMIN_USER_PASSWORD_AUTH"], }),);
await cognito.adminCreateUser( new AdminCreateUserCommand({ UserPoolId: pool.UserPool?.Id, Username: "mallory", UserAttributes: [{ Name: "email", Value: "mallory@elsewhere.test" }], }),);await cognito.adminSetUserPassword( new AdminSetUserPasswordCommand({ UserPoolId: pool.UserPool?.Id, Username: "mallory", Password: "Sup3rSecretPassw0rd!", Permanent: true, }),);
try { await cognito.adminInitiateAuth( new AdminInitiateAuthCommand({ UserPoolId: pool.UserPool?.Id, ClientId: appClient.UserPoolClient?.ClientId, AuthFlow: "ADMIN_USER_PASSWORD_AUTH", AuthParameters: { USERNAME: "mallory", PASSWORD: "Sup3rSecretPassw0rd!", }, }), );} catch (error) { // The handler's own words, in the error real Cognito refuses the sign-in // with. console.log((error as Error).name); // "UserLambdaValidationException" console.log((error as Error).message); // "PreAuthentication failed with error Only example.com may sign in."}ClientMetadata on the sign-in reaches the handler as request.validationData for
PreAuthentication and as request.clientMetadata for PostAuthentication, as it does on real
Cognito. Neither fires for REFRESH_TOKEN_AUTH, as neither does on real Cognito, where
PreTokenGeneration does.
A sign-in at the hosted domain runs PreAuthentication too, under the same
PreAuthentication_Authentication source real Cognito reports it from /login. It runs for a
password and for a passkey, once per sign-in, before the password is checked. A handler that throws
refuses the sign-in, and a served domain draws that refusal on the sign-in form the way it draws a
wrong password. A browser coming back on the managed login session it already holds runs nothing,
because the PreAuthentication docs say the trigger does not activate on the renewal of a session
that already exists.
PostAuthentication stays unfired at the hosted domain. AWS names it for a federated sign-in and
leaves it out of the table for a local user at managed login, and what real Cognito does there was
not checked against a live account.
Federated sign-in triggers
Section titled “Federated sign-in triggers”A user arriving from an identity provider runs the pool’s triggers too, and which ones it runs depends on whether the pool has met the subject before. AWS documents the split, and this is what it comes to:
| Sign-in event | Trigger | Source |
|---|---|---|
| First sign-in | PreSignUp |
PreSignUp_ExternalProvider |
PostConfirmation |
PostConfirmation_ConfirmSignUp |
|
PreTokenGeneration |
TokenGeneration_HostedAuth |
|
| Later sign-ins | PreAuthentication |
PreAuthentication_Authentication |
PostAuthentication |
PostAuthentication_Authentication |
|
PreTokenGeneration |
TokenGeneration_HostedAuth |
That split is what an application hangs its own user record off. PostConfirmation runs the first
time a Google user arrives and never again, which is the same place a local sign-up’s record is
written from, so one handler covers both ways in.
/** * A PostConfirmation trigger that runs on a federated user's first sign-in. */
import { CreateIdentityProviderCommand, CreateUserPoolClientCommand, CreateUserPoolCommand, CreateUserPoolDomainCommand,} from "@aws-sdk/client-cognito-identity-provider";import { AddPermissionCommand, CreateFunctionCommand,} from "@aws-sdk/client-lambda";
import { SimAws } from "@kensio/yulin";import { makeLambdaZipFileInput } from "@kensio/yulin/lambda";
/** * The part of the PostConfirmation event this handler reads. */interface PostConfirmationEvent { readonly triggerSource: string; readonly userName: string;}
const written: string[] = [];
// A record is written here. A deployed handler writes it to DynamoDB.const postConfirmation = (event: PostConfirmationEvent): unknown => { written.push(`${event.triggerSource} ${event.userName}`);
return event;};
const simAws = new SimAws({ defaultRegionName: "eu-west-2" });const lambda = simAws.lambda();const cognito = simAws.cognitoIdentityProvider();const callbackUrl = "https://www.example.com/user/callback";
// The function comes first, because the pool names it by ARN.const functionArn = `arn:aws:lambda:eu-west-2:${simAws.defaultAccountId}` + `:function:post-confirmation`;await lambda.createFunction( new CreateFunctionCommand({ FunctionName: "post-confirmation", Role: `arn:aws:iam::${simAws.defaultAccountId}:role/TriggerRole`, Code: { ZipFile: makeLambdaZipFileInput(postConfirmation) }, }),);
const created = await cognito.createUserPool( new CreateUserPoolCommand({ PoolName: "myapp-users", LambdaConfig: { PostConfirmation: functionArn }, }),);const userPoolId = created.UserPool?.Id ?? "";
// The permission a CDK `addTrigger` emits, which lets Cognito invoke it.await lambda.addPermission( new AddPermissionCommand({ FunctionName: "post-confirmation", StatementId: "AllowCognito", Action: "lambda:InvokeFunction", Principal: "cognito-idp.amazonaws.com", SourceArn: created.UserPool?.Arn, }),);
await cognito.createIdentityProvider( new CreateIdentityProviderCommand({ UserPoolId: userPoolId, ProviderName: "Google", ProviderType: "Google", ProviderDetails: { client_id: "google-client-id", client_secret: "google-client-secret", authorize_scopes: "openid email", }, AttributeMapping: { email: "email" }, }),);
const client = await cognito.createUserPoolClient( new CreateUserPoolClientCommand({ UserPoolId: userPoolId, ClientName: "web", AllowedOAuthFlowsUserPoolClient: true, AllowedOAuthFlows: ["code"], AllowedOAuthScopes: ["openid", "email"], CallbackURLs: [callbackUrl], SupportedIdentityProviders: ["Google"], }),);
await cognito.createUserPoolDomain( new CreateUserPoolDomainCommand({ UserPoolId: userPoolId, Domain: "myapp-login", }),);
const pool = cognito.userPool(userPoolId);
pool.auth.identityProviders.require("Google").signInAs({ Subject: "108412093487519382745", Claims: { email: "someone@example.com" },});
const authorize = { response_type: "code", client_id: client.UserPoolClient?.ClientId ?? "", redirect_uri: callbackUrl, identity_provider: "Google",};
// The first sign-in creates the pool's user for the subject.await cognito.hostedAuthorize(pool, authorize);console.log(written);// ["PostConfirmation_ConfirmSignUp Google_108412093487519382745"]
// The second reaches the same user, and the sign-up triggers stay unfired.await cognito.hostedAuthorize(pool, authorize);console.log(written.length); // 1A handler that throws from PreSignUp refuses the sign-in, and the pool is left without the user,
because that trigger runs before the user is added. autoVerifyEmail and autoVerifyPhone are
applied the way they are for a sign-up. autoConfirmUser has nothing to do, as a federated user is
created in EXTERNAL_PROVIDER and never passes through UNCONFIRMED.
The event, and what a failure gets back
Section titled “The event, and what a failure gets back”Every event carries version, region, userPoolId, userName, the triggerSource naming the
occasion, a callerContext naming the app client, and the request and response pair. The
request holds userAttributes as a plain object of strings, not the Name/Value pairs the
API answers with.
sub is among those attributes everywhere except PreSignUp, where the user is yet to exist. On
real Cognito the sub is allocated once the sign-up has got past that handler. A handler keying an
external record on sub has to be a PostConfirmation one, here as there.
Three failures are reported the way real Cognito reports them:
- A handler that throws fails the request with
UserLambdaValidationException, carrying the message it threw. ForPreSignUpandPreAuthenticationthat is how the trigger turns the request down. - A trigger naming a function the simulation lacks, or one whose resource policy withholds
cognito-idp.amazonaws.comfor the pool, fails withUnexpectedLambdaException. - A handler that returns something other than the event it was given fails with
InvalidLambdaResponseException.
Only the triggers in the table above run. Every other LambdaConfig key is refused when the pool is
created or updated, naming the trigger. A pool never quietly drops one.
Custom claims from a token trigger
Section titled “Custom claims from a token trigger”PreTokenGeneration decides what the pool puts on a token. The handler writes
response.claimsOverrideDetails, and the id token is signed with what it asked for.
claimsToAddOrOverride adds or replaces a claim, claimsToSuppress removes one, and
groupOverrideDetails.groupsToOverride replaces the cognito:groups claim. The group override
reaches the access token too, the one change a V1_0 trigger makes to one.
/** * A user pool whose token trigger puts a tenant on every id token. */
import { AdminCreateUserCommand, AdminInitiateAuthCommand, AdminSetUserPasswordCommand, CreateUserPoolClientCommand, CreateUserPoolCommand,} from "@aws-sdk/client-cognito-identity-provider";import { AddPermissionCommand, CreateFunctionCommand,} from "@aws-sdk/client-lambda";import { CognitoJwtVerifier } from "aws-jwt-verify";
import { SimAws } from "@kensio/yulin";import { makeLambdaZipFileInput } from "@kensio/yulin/lambda";
/** * The part of the PreTokenGeneration event this handler reads and writes. */interface PreTokenGenerationEvent { readonly request: { readonly userAttributes: Record<string, string> }; readonly response: object;}
const simAws = new SimAws();const lambda = simAws.lambda();const cognito = simAws.cognitoIdentityProvider();
// The trigger reads the user's email and puts the tenant it belongs to on the// token, along with the groups that tenant's users get.await lambda.createFunction( new CreateFunctionCommand({ FunctionName: "pre-token", Role: "arn:aws:iam::888888888888:role/PreTokenRole", Code: { ZipFile: makeLambdaZipFileInput((event: PreTokenGenerationEvent) => { const email = event.request.userAttributes["email"] ?? "";
return { ...event, response: { claimsOverrideDetails: { claimsToAddOrOverride: { tenantId: email.split("@", 2)[1] ?? "" }, claimsToSuppress: ["email"], groupOverrideDetails: { groupsToOverride: ["tenant-admin"] }, }, }, }; }), }, }),);
const pool = await cognito.createUserPool( new CreateUserPoolCommand({ PoolName: "myapp-users", LambdaConfig: { PreTokenGeneration: "arn:aws:lambda:us-east-1:888888888888:function:pre-token", }, }),);const userPoolId = pool.UserPool!.Id!;
await lambda.addPermission( new AddPermissionCommand({ FunctionName: "pre-token", StatementId: "AllowCognito", Action: "lambda:InvokeFunction", Principal: "cognito-idp.amazonaws.com", SourceArn: pool.UserPool!.Arn!, }),);
const appClient = await cognito.createUserPoolClient( new CreateUserPoolClientCommand({ UserPoolId: userPoolId, ClientName: "web", ExplicitAuthFlows: ["ALLOW_ADMIN_USER_PASSWORD_AUTH"], }),);const clientId = appClient.UserPoolClient!.ClientId!;
await cognito.adminCreateUser( new AdminCreateUserCommand({ UserPoolId: userPoolId, Username: "alice", UserAttributes: [{ Name: "email", Value: "alice@acme.example" }], }),);await cognito.adminSetUserPassword( new AdminSetUserPasswordCommand({ UserPoolId: userPoolId, Username: "alice", Password: "Sup3rSecretPassw0rd!", Permanent: true, }),);
const signedIn = await cognito.adminInitiateAuth( new AdminInitiateAuthCommand({ UserPoolId: userPoolId, ClientId: clientId, AuthFlow: "ADMIN_USER_PASSWORD_AUTH", AuthParameters: { USERNAME: "alice", PASSWORD: "Sup3rSecretPassw0rd!" }, }),);
// The overridden token is signed like any other, so the application's own// verifier is what reads the claims off it.const verifier = CognitoJwtVerifier.create({ userPoolId, tokenUse: "id", clientId,});
verifier.cacheJwks(cognito.userPool(userPoolId).jwks());
const payload = await verifier.verify(signedIn.AuthenticationResult!.IdToken!);
// The claim the handler added is there, and the one it suppressed is not.console.log(payload["tenantId"]); // "acme.example"console.log(payload["email"]); // undefinedconsole.log(payload["cognito:groups"]); // ["tenant-admin"]The trigger runs wherever the pool issues tokens, and names the occasion in triggerSource:
TokenGeneration_Authentication for a sign-in, TokenGeneration_NewPasswordChallenge for the
sign-in that finishes by answering the new password challenge, and TokenGeneration_RefreshTokens
for REFRESH_TOKEN_AUTH. A refresh runs the handler again. A claim that changed since the
sign-in is on the reissued token, never stale for the life of the session.
The request the handler is given carries userAttributes, a groupConfiguration.groupsToOverride
holding the groups the user is in, and, from a challenge response, clientMetadata. Real Cognito
passes ClientMetadata to this trigger from RespondToAuthChallenge and
AdminRespondToAuthChallenge only, and not from InitiateAuth or AdminInitiateAuth.
A response naming something this simulation would have to drop is refused with
InvalidLambdaResponseException rather than applied in part:
- A reserved claim in
claimsToAddOrOverrideorclaimsToSuppress, such assub,aud,iss,token_use,exp,iatorauth_time. Real Cognito ignores an override of one. A handler that appeared to work here would have no effect deployed. - Any
cognito:claim inclaimsToAddOrOverride.cognito:groupsis changed throughgroupOverrideDetailsinstead, and the refusal says so. groupOverrideDetails.iamRolesToOverrideorpreferredRole, because thecognito:rolesandcognito:preferred_roleclaims they feed go unissued here.- A claim value of any other type than a string. Complex claim values arrived with the
V2_0event, which is outside the simulation.
Token timestamps and expiry
Section titled “Token timestamps and expiry”iat, exp and auth_time come from the simulation’s clock, not the host’s, and the tokens
last the hour a pool’s tokens last unless the app client says otherwise.
That makes an expired token something a test can produce. Sign the user in with the simulated clock set far enough in the past, and the token a verifier gets is already expired.
/** * Producing a simulated token that a verifier rejects as expired. */
import { AdminCreateUserCommand, AdminInitiateAuthCommand, AdminSetUserPasswordCommand, CreateUserPoolClientCommand, CreateUserPoolCommand,} from "@aws-sdk/client-cognito-identity-provider";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();const cognito = simAws.cognitoIdentityProvider();
const pool = await cognito.createUserPool( new CreateUserPoolCommand({ PoolName: "myapp-users" }),);const userPoolId = pool.UserPool!.Id!;
const appClient = await cognito.createUserPoolClient( new CreateUserPoolClientCommand({ UserPoolId: userPoolId, ClientName: "web", ExplicitAuthFlows: ["ALLOW_ADMIN_USER_PASSWORD_AUTH"], }),);
await cognito.adminCreateUser( new AdminCreateUserCommand({ UserPoolId: userPoolId, Username: "alice" }),);await cognito.adminSetUserPassword( new AdminSetUserPasswordCommand({ UserPoolId: userPoolId, Username: "alice", Password: "Sup3rSecret!", Permanent: true, }),);
// Sign in two hours ago, so the hour the token lasts is already over.await simAws.clock().setTo(new Date(Date.now() - 2 * 60 * 60 * 1000));
const signedIn = await cognito.adminInitiateAuth( new AdminInitiateAuthCommand({ UserPoolId: userPoolId, ClientId: appClient.UserPoolClient!.ClientId!, AuthFlow: "ADMIN_USER_PASSWORD_AUTH", AuthParameters: { USERNAME: "alice", PASSWORD: "Sup3rSecret!" }, }),);
// A verifier now refuses this token, because its exp has passed.console.log(signedIn.AuthenticationResult?.ExpiresIn); // 3600Advancing the clock after a sign-in is a different thing. It moves what the simulation calls now, and the timestamps on tokens issued after it, but a verifier reading the host clock still judges a token it already holds by host time. Signing in in the past is what produces a token such a verifier refuses.
What a pool counts in CloudWatch
Section titled “What a pool counts in CloudWatch”A pool publishes into AWS/Cognito what real Cognito publishes about the requests it handles, dimensioned by UserPool and UserPoolClient. Nothing has to be turned on for it, and no caller needs a permission. Real Cognito behaves the same way.
Four counts are kept. SignInSuccesses counts every authentication request, SignUpSuccesses every registration, TokenRefreshSuccesses every renewal from a refresh token, and FederationSuccesses every sign-in through an identity provider. Each is a 1 where the request issued tokens and a 0 where it did not, so Sum is how many succeeded, SampleCount is how many were made, and Average between them is the success rate. That is how the AWS documentation says to read them.
/** * An alarm on the rate at which a pool is turning sign-ins away. */
import { DescribeAlarmsCommand, PutMetricAlarmCommand,} from "@aws-sdk/client-cloudwatch";import { InitiateAuthCommand } from "@aws-sdk/client-cognito-identity-provider";
import type { SimAws } from "@kensio/yulin";
declare const simAws: SimAws;declare const userPoolId: string;declare const clientId: string;
await simAws.cloudWatch().putMetricAlarm( new PutMetricAlarmCommand({ AlarmName: "SignInsFailing", Namespace: "AWS/Cognito", MetricName: "SignInSuccesses", Dimensions: [ { Name: "UserPool", Value: userPoolId }, { Name: "UserPoolClient", Value: clientId }, ], Statistic: "Average", Period: 300, EvaluationPeriods: 3, DatapointsToAlarm: 1, Threshold: 0.5, ComparisonOperator: "LessThanThreshold", TreatMissingData: "notBreaching", }),);
// Two of these three fail, so the average falls under the threshold.for (const password of ["Wr0ng!", "AlsoWr0ng!", "Sup3rSecret!"]) { const attempt = new InitiateAuthCommand({ ClientId: clientId, AuthFlow: "USER_PASSWORD_AUTH", AuthParameters: { USERNAME: "alice", PASSWORD: password }, });
try { await simAws.cognitoIdentityProvider().initiateAuth(attempt); } catch { // A refused sign-in is counted as a zero rather than going uncounted. }}
await simAws.backgroundTasksComplete();await simAws.clock().advanceBy({ minutes: 6 });
const { MetricAlarms } = await simAws .cloudWatch() .describeAlarms( new DescribeAlarmsCommand({ AlarmNames: ["SignInsFailing"] }), );
// ALARM.console.log(MetricAlarms?.[0]?.StateValue);Two UserPoolClient values are fixed names rather than ids. A user an administrator registers through AdminCreateUser reaches the pool through no app client and counts against Admin. An admin authentication request naming a client the pool has none of counts against Invalid, and the id it gave is left out.
A token refresh stays out of SignInSuccesses and lands in TokenRefreshSuccesses, whether it arrived as a REFRESH_TOKEN_AUTH flow or as GetTokensFromRefreshToken. A federated sign-in counts where its tokens are issued, at the token endpoint, rather than at the authorization code the provider sent the browser back with.
Turning requests away
Section titled “Turning requests away”Real Cognito turns a request away when the account has gone over a rate limit, answering TooManyRequestsException and counting a *Throttles beside the *Successes it counts a 0 in. Those limits belong to the account and are mostly undocumented. Working one out here would be inventing a number, and a test asserting against an invented number proves little about a real pool. A test says how many requests a pool should turn away instead, and the pool turns away that many.
/** * An alarm on the sign-ins a pool is turning away. */
import { DescribeAlarmsCommand, PutMetricAlarmCommand,} from "@aws-sdk/client-cloudwatch";import { InitiateAuthCommand } from "@aws-sdk/client-cognito-identity-provider";
import type { SimAws } from "@kensio/yulin";
declare const simAws: SimAws;declare const userPoolId: string;declare const clientId: string;
await simAws.cloudWatch().putMetricAlarm( new PutMetricAlarmCommand({ AlarmName: "SignInsThrottling", Namespace: "AWS/Cognito", MetricName: "SignInThrottles", Dimensions: [ { Name: "UserPool", Value: userPoolId }, { Name: "UserPoolClient", Value: clientId }, ], Statistic: "Sum", Period: 300, EvaluationPeriods: 3, DatapointsToAlarm: 1, Threshold: 0, ComparisonOperator: "GreaterThanThreshold", TreatMissingData: "notBreaching", }),);
const cognito = simAws.cognitoIdentityProvider();
cognito.userPool(userPoolId).auth.throttle.signIns(1);
try { await cognito.initiateAuth( new InitiateAuthCommand({ ClientId: clientId, AuthFlow: "USER_PASSWORD_AUTH", AuthParameters: { USERNAME: "alice", PASSWORD: "Sup3rSecret!" }, }), );} catch { // TooManyRequestsException, counted in SignInThrottles.}
await simAws.backgroundTasksComplete();await simAws.clock().advanceBy({ minutes: 6 });
const { MetricAlarms } = await simAws .cloudWatch() .describeAlarms( new DescribeAlarmsCommand({ AlarmNames: ["SignInsThrottling"] }), );
// ALARM.console.log(MetricAlarms?.[0]?.StateValue);signUps, tokenRefreshes and federations sit beside signIns on the same object, and each turns away only its own kind of request. A pool answers normally until a test asks it to turn requests away, and it goes back to answering normally once it has turned away the number it was given.
Pool ARNs and IAM policies
Section titled “Pool ARNs and IAM policies”A pool ARN is the pool id after userpool/, and the region appears twice:
arn:aws:cognito-idp:eu-west-2:111111111111:userpool/eu-west-2_aBcDeFgHi.
App clients have no ARN of their own. Every app client operation authorizes against the ARN of the
pool the client belongs to. A policy granting cognito-idp:DescribeUserPoolClient on a pool reaches
every client in it. There is no way to narrow it to one client, here or on real AWS.
/** * A simulated IAM policy allowing a Role to read one user pool's app clients. */
import { CreateUserPoolClientCommand, CreateUserPoolCommand, DescribeUserPoolClientCommand,} from "@aws-sdk/client-cognito-identity-provider";import { CreateRoleCommand, PutRolePolicyCommand } from "@aws-sdk/client-iam";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();const accountId = simAws.defaultAccountId;const regionName = simAws.defaultRegionName;const cognito = simAws.cognitoIdentityProvider();
const pool = await cognito.createUserPool( new CreateUserPoolCommand({ PoolName: "myapp-users" }),);const userPoolId = pool.UserPool!.Id!;
const appClient = await cognito.createUserPoolClient( new CreateUserPoolClientCommand({ UserPoolId: userPoolId, ClientName: "web", }),);
const role = await simAws.iam().createRole( new CreateRoleCommand({ RoleName: "AppClientReader", AssumeRolePolicyDocument: JSON.stringify({ Version: "2012-10-17", Statement: { Effect: "Allow", Principal: { AWS: `arn:aws:iam::${accountId}:root` }, Action: "sts:AssumeRole", }, }), }),);
await simAws.iam().putRolePolicy( new PutRolePolicyCommand({ RoleName: "AppClientReader", PolicyName: "ReadAppClients", PolicyDocument: JSON.stringify({ Version: "2012-10-17", Statement: { Effect: "Allow", Action: "cognito-idp:DescribeUserPoolClient", // The app client is reached through its pool's ARN. Resource: `arn:aws:cognito-idp:${regionName}:${accountId}:userpool/${userPoolId}`, }, }), }),);
const described = await cognito.describeUserPoolClient( new DescribeUserPoolClientCommand({ UserPoolId: userPoolId, ClientId: appClient.UserPoolClient?.ClientId, }), { caller: { kind: "arn", arn: role.Role.Arn } },);
console.log(described.UserPoolClient?.ClientName); // "web"CreateUserPool and ListUserPools are the exception. Real Cognito gives those two actions no
resource-level permissions. They authorize against * here, and a policy naming individual pool
ARNs grants nothing.
The client-side operations are the other exception. InitiateAuth, RespondToAuthChallenge and
GlobalSignOut authorize against no resource at all, because real Cognito evaluates no IAM policy
for them. They are what an application calls on behalf of a user, holding no AWS credentials. A
caller goes unread on those three, and the tokens or the app client id are what authorizes them.
That is the difference the two sign-in paths make to a policy. Code calling AdminInitiateAuth
needs cognito-idp:AdminInitiateAuth on the pool, and code calling InitiateAuth needs no policy
statement at all. The same goes for AdminRespondToAuthChallenge against RespondToAuthChallenge,
and for AdminUserGlobalSignOut against GlobalSignOut.
Listing pools and clients
Section titled “Listing pools and clients”ListUserPools requires MaxResults, as the real API does. A request without it is refused, never
answered with a default.
/** * Listing simulated user pools. */
import { CreateUserPoolCommand, ListUserPoolsCommand,} from "@aws-sdk/client-cognito-identity-provider";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();const cognito = simAws.cognitoIdentityProvider();
await cognito.createUserPool(new CreateUserPoolCommand({ PoolName: "staff" }));await cognito.createUserPool( new CreateUserPoolCommand({ PoolName: "customers" }),);
const listed = await cognito.listUserPools( new ListUserPoolsCommand({ MaxResults: 60 }),);
console.log(listed.UserPools?.map((pool) => pool.Name));// [ "staff", "customers" ]Both listings are in creation order and hold at most sixty entries. Follow NextToken to read the
rest. A listed pool carries no ARN and a listed app client carries no secret, as real Cognito leaves
those out of a listing.
Deploying a pool from CloudFormation
Section titled “Deploying a pool from CloudFormation”AWS::Cognito::UserPool, AWS::Cognito::UserPoolClient and
AWS::Cognito::UserPoolGroup deploy into simulated Cognito. A stack that already declares a pool
needs no duplicating in SDK calls to be tested.
/** * Deploying a user pool, an app client and a group from a template. */
import { AdminAddUserToGroupCommand, AdminCreateUserCommand, AdminInitiateAuthCommand, AdminSetUserPasswordCommand,} from "@aws-sdk/client-cognito-identity-provider";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws({ defaultRegionName: "eu-west-2" });
const stack = await simAws.cloudFormation().deployTemplate({ stackName: "app-stack", template: { Resources: { AppPool: { Type: "AWS::Cognito::UserPool", Properties: { UserPoolName: "myapp-users", Policies: { PasswordPolicy: { MinimumLength: 12 } }, }, }, AppClient: { Type: "AWS::Cognito::UserPoolClient", Properties: { UserPoolId: { Ref: "AppPool" }, ClientName: "web", ExplicitAuthFlows: ["ALLOW_ADMIN_USER_PASSWORD_AUTH"], }, }, AdminsGroup: { Type: "AWS::Cognito::UserPoolGroup", Properties: { UserPoolId: { Ref: "AppPool" }, GroupName: "admins", Precedence: 0, }, }, }, Outputs: { UserPoolId: { Value: { Ref: "AppPool" } }, ClientId: { Value: { Ref: "AppClient" } }, ProviderUrl: { Value: { "Fn::GetAtt": ["AppPool", "ProviderURL"] } }, }, },});await stack.waitForDeployComplete();
const userPoolId = stack.output("UserPoolId");const clientId = stack.output("ClientId");
console.log(userPoolId); // "eu-west-2_aBcDeFgHi"console.log(stack.output("ProviderUrl"));// "https://cognito-idp.eu-west-2.amazonaws.com/eu-west-2_aBcDeFgHi"
// The deployed pool, client and group are what the test then works with.const cognito = simAws.cognitoIdentityProvider();
await cognito.adminCreateUser( new AdminCreateUserCommand({ UserPoolId: userPoolId, Username: "alice" }),);await cognito.adminSetUserPassword( new AdminSetUserPasswordCommand({ UserPoolId: userPoolId, Username: "alice", Password: "Sup3rSecretPassw0rd!", Permanent: true, }),);await cognito.adminAddUserToGroup( new AdminAddUserToGroupCommand({ UserPoolId: userPoolId, Username: "alice", GroupName: "admins", }),);
// The sign-in runs through the flow the template opened on the app client.const { AuthenticationResult } = await cognito.adminInitiateAuth( new AdminInitiateAuthCommand({ UserPoolId: userPoolId, ClientId: clientId, AuthFlow: "ADMIN_USER_PASSWORD_AUTH", AuthParameters: { USERNAME: "alice", PASSWORD: "Sup3rSecretPassw0rd!" }, }),);
console.log(AuthenticationResult?.AccessToken !== undefined); // trueRef and Fn::GetAtt answer what real CloudFormation answers:
| Resource type | Ref |
Fn::GetAtt |
|---|---|---|
AWS::Cognito::UserPool |
pool id | Arn, ProviderName, ProviderURL, UserPoolId |
AWS::Cognito::UserPoolClient |
client id | ClientId |
AWS::Cognito::UserPoolGroup |
group name | none |
ProviderName is cognito-idp.<region>.amazonaws.com/<userPoolId> and ProviderURL is the same
with an https:// prefix, also the iss claim of the tokens the pool issues.
An app client publishes no ClientSecret attribute, because real CloudFormation publishes none.
Read the secret with DescribeUserPoolClient, which reports it here as it does on real Cognito.
The properties each type reads are the ones this simulation models:
AWS::Cognito::UserPool:UserPoolName,Policies,DeletionProtection,LambdaConfig,AdminCreateUserConfig,AutoVerifiedAttributes,UsernameAttributes,Schema,MfaConfiguration,EnabledMfas,UserPoolTier,AccountRecoverySetting,EmailConfiguration,EmailVerificationMessage,EmailVerificationSubject,SmsVerificationMessageandVerificationMessageTemplate.LambdaConfigis read a trigger at a time. A template naming a trigger this simulation runs deploys, and one naming a trigger it lacks fails the stack.UsernameAttributesis what a CDKUserPoolemits for itssignInAliases, and a stack building an email sign-in pool deploys one that identifies its users the way a deployed one would.Schemais what a CDKUserPoolemits for itscustomAttributesand itsstandardAttributes, and a stack keying its own data on acustom:attribute deploys with the sign-up it was built for working.MfaConfigurationandEnabledMfasare deployed in aSetUserPoolMfaConfigcall once the pool exists, the way real CloudFormation deploys them and why a stack declaring MFA needscognito-idp:SetUserPoolMfaConfigon its execution role. A template asking for neither makes no such call.AccountRecoverySettingis recorded as the template declared it, and a setting outside the shape Cognito states fails the stack.EmailConfigurationis what a CDKUserPoolemits for itsemail, and a template namingEmailSendingAccount: DEVELOPERdeploys a pool that sends through simulated SES. The last four are the wording of the messages the pool records.AWS::Cognito::UserPoolClient:UserPoolId,ClientName,GenerateSecret,ExplicitAuthFlows,PreventUserExistenceErrors,AccessTokenValidity,IdTokenValidity,RefreshTokenValidity,AuthSessionValidity,RefreshTokenRotation,TokenValidityUnits,AllowedOAuthFlowsUserPoolClient,AllowedOAuthFlows,AllowedOAuthScopes,CallbackURLs,LogoutURLs,DefaultRedirectURIandSupportedIdentityProviders.AWS::Cognito::UserPoolGroup:UserPoolId,GroupName,Description,PrecedenceandRoleArn.AWS::Cognito::UserPoolDomain:UserPoolId,Domain,CustomDomainConfigandManagedLoginVersion.Refreturns the domain string, andFn::GetAtt CloudFrontDistributionthe distribution name, which only a custom domain has.AWS::Cognito::UserPoolIdentityProvider:UserPoolId,ProviderName,ProviderType,ProviderDetails,AttributeMappingandIdpIdentifiers.Refreturns the provider name.
Any other property is left out of what is created and recorded in
stack.ignoredProperties,
naming the logical id, the property and the ones this can act on instead. The pool or client is
created either way. A stack full of Cognito resources deploys, and the record says which of them
behaves differently to the template. A stack that forgets ALLOW_ADMIN_USER_PASSWORD_AUTH still
fails at the sign-in here as it would in a deployment, the point of deploying the template at
all.
A property one of the Cognito commands refuses by name is recorded in that command’s own words.
SmsConfiguration and SmsAuthenticationMessage on a pool, and AnalyticsConfiguration,
EnablePropagateAdditionalUserContextData, ReadAttributes and WriteAttributes on a client, all
read as the refusal reads:
AWS::Cognito::UserPool property SmsConfiguration is not simulated: SMS delivery would be ignored here and applied on real AWS. The Resource is created without it.CreateUserPool refuses that same input outright, and the template deploys. The two paths say the
same thing about the property and differ in what they do about it. A direct API call has one
request to fail, and a template has every other resource in it to think about.
UserPoolName and ClientName are optional. A template that sets neither gets
<stack name>-<logical id>-<tail>, as real CloudFormation generates a name, trimmed to the 128
characters Cognito allows if the parts are longer than that together. The tail is twelve characters
derived from the other two, where real CloudFormation ends the name in twelve random ones. The name
is the same on every deployment of the same template, and the CloudFormation docs
cover how a long name is trimmed.
Registering a pool with a chosen pool id
Section titled “Registering a pool with a chosen pool id”CreateUserPoolCommand allocates its own pool id, as real Cognito does, and takes none from you.
CreateUserPoolClientCommand allocates the app client id the same way. When something else already
decided either, register the pool and the app client as part of your test setup.
The usual reason is a CDK app whose pool lives in one stack and whose Lambda function lives in another, with the two deliberately not joined by a CloudFormation export. Both ids reach the synthesized template as literal strings, the pool id in the function’s environment and the pool ARN in its execution role’s policy. Registering the pool and the client first lets that template deploy as it is, with no rewriting.
/** * Registering a simulated Cognito user pool and app client with chosen ids. */
import { DescribeUserPoolClientCommand } from "@aws-sdk/client-cognito-identity-provider";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws({ defaultRegionName: "eu-west-2" });const cognito = simAws.cognitoIdentityProvider();
// The ids the CDK app pins, the stack that creates the pool being another one.cognito.registerUserPool({ id: "eu-west-2_aBcDeFgHi", name: "myapp-users", settings: { Policies: { PasswordPolicy: { MinimumLength: 12 } } },});
cognito.registerUserPoolClient({ userPoolId: "eu-west-2_aBcDeFgHi", id: "examplewebclient0000000000", name: "web", settings: { ExplicitAuthFlows: ["ALLOW_USER_PASSWORD_AUTH"] },});
const described = await cognito.describeUserPoolClient( new DescribeUserPoolClientCommand({ UserPoolId: "eu-west-2_aBcDeFgHi", ClientId: "examplewebclient0000000000", }),);
console.log(described.UserPoolClient?.ClientName);A registered pool behaves like any other. It answers DescribeUserPoolCommand and
ListUserPoolsCommand, holds users, groups and app clients, and serves its JWKS and OpenID
configuration on localhost. The registered ID determines the pool ARN, issuer URL, token iss claim
and ProviderName. A
policy naming arn:aws:cognito-idp:eu-west-2:111111111111:userpool/eu-west-2_aBcDeFgHi authorizes
the handler that reads the pool, which is what a template carrying the id in two places needs.
registerUserPool takes the same optional settings as CreateUserPoolCommand, and
registerUserPoolClient the same optional settings as CreateUserPoolClientCommand, with
ClientName given as name. A registered app client signs users in through InitiateAuthCommand,
which names no pool and finds one from the client id alone.
Registration is refused rather than allowed to produce a pool no real Cognito matches:
- A pool id another pool holds, whether it was registered or created, gives
UserPoolAlreadyExists. - A client id another pool in the simulation holds gives
UserPoolClientAlreadyExists. That lookup from a client id to a pool is whatInitiateAuthmakes, and two pools sharing a client id would make it ambiguous. - A value that is no pool id or client id gives
InvalidParameterException. - A pool id naming another Region gives
InvalidParameterException, saying which simulated Cognito to register it on. A pool id carries the Region its pool lives in, and the ARN carries the Region of the simulated Cognito holding it, so crossing the two would name two Regions in one pool.
Properties accepted without being simulated
Section titled “Properties accepted without being simulated”A CDK UserPool construct emits six properties on AWS::Cognito::UserPool before it has been asked
for anything, and a client created with disableOAuth emits two on AWS::Cognito::UserPoolClient.
Most of them are simulated: AdminCreateUserConfig decides whether SignUp works against the pool,
and the four verification wording properties are what a recorded message says.
AccountRecoverySetting is the exception. ForgotPassword sends its code to an attribute the pool
verifies automatically, so the mechanisms a pool ranked decide nothing here. The pool records the
mechanisms it was asked for and DescribeUserPool reports them back, so what a template declared
stays visible. The two app client properties are accepted at one value each instead.
/** * Deploying the Resources a CDK UserPool construct emits by default. */
import { DescribeUserPoolCommand } from "@aws-sdk/client-cognito-identity-provider";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws({ defaultRegionName: "eu-west-2" });
const verificationMessage = "The verification code to your new account is {####}";
const stack = await simAws.cloudFormation().deployTemplate({ stackName: "app-stack", template: { Resources: { // What `new cognito.UserPool(stack, "Pool")` synthesizes, with no // UserPoolName among it. Pool: { Type: "AWS::Cognito::UserPool", Properties: { AccountRecoverySetting: { RecoveryMechanisms: [ { Name: "verified_phone_number", Priority: 1 }, { Name: "verified_email", Priority: 2 }, ], }, AdminCreateUserConfig: { AllowAdminCreateUserOnly: true }, EmailVerificationMessage: verificationMessage, EmailVerificationSubject: "Verify your new account", SmsVerificationMessage: verificationMessage, VerificationMessageTemplate: { DefaultEmailOption: "CONFIRM_WITH_CODE", EmailMessage: verificationMessage, EmailSubject: "Verify your new account", SmsMessage: verificationMessage, }, }, }, // What `pool.addClient("Client", { disableOAuth: true })` synthesizes. PoolClient: { Type: "AWS::Cognito::UserPoolClient", Properties: { UserPoolId: { Ref: "Pool" }, AllowedOAuthFlowsUserPoolClient: false, SupportedIdentityProviders: ["COGNITO"], }, }, }, Outputs: { PoolId: { Value: { Ref: "Pool" } } }, },});await stack.waitForDeployComplete();
const userPoolId = stack.output("PoolId");
// The pool is named after the stack, the logical id and a tail derived from// both, as the template named neither it nor the client.const described = await simAws .cognitoIdentityProvider() .describeUserPool(new DescribeUserPoolCommand({ UserPoolId: userPoolId }));
console.log(described.UserPool?.Name); // "app-stack-Pool-2c3041dc539d"
// What the template declared is reported back. This one is acted on: it is// what says only an admin creates users in this pool.console.log(described.UserPool?.AdminCreateUserConfig);// { AllowAdminCreateUserOnly: true }
// So is this one: it is what a verification message the pool records says.console.log(described.UserPool?.EmailVerificationSubject);// "Verify your new account"The accepted value of each app client property is below. A pool or a client created without one of these reports it not at all, and never reports the value it would have had to use.
| Property | Accepted value |
|---|---|
AllowedOAuthFlowsUserPoolClient |
false |
SupportedIdentityProviders |
["COGNITO"] |
AccountRecoverySetting takes any mechanisms Cognito has, in any order: verified_email,
verified_phone_number and admin_only. Email-only recovery is the one worth naming, because it is
what goes with a pool that sends no SMS, and CDK writes it for AccountRecovery.EMAIL_ONLY.
/** * Creating a pool that recovers an account by email alone. */
import { CreateUserPoolCommand, DescribeUserPoolCommand,} from "@aws-sdk/client-cognito-identity-provider";
import { SimAws } from "@kensio/yulin";
const cognito = new SimAws().cognitoIdentityProvider();
const created = await cognito.createUserPool( new CreateUserPoolCommand({ PoolName: "myapp-users", AccountRecoverySetting: { RecoveryMechanisms: [{ Name: "verified_email", Priority: 1 }], }, }),);
const userPoolId = created.UserPool?.Id;
// The pool reports back the mechanisms it was asked for, rather than the two// real Cognito gives a pool that asked for none.const described = await cognito.describeUserPool( new DescribeUserPoolCommand({ UserPoolId: userPoolId }),);
console.log(described.UserPool?.AccountRecoverySetting);// { RecoveryMechanisms: [{ Name: "verified_email", Priority: 1 }] }The setting is held to the shape real Cognito states for it, because a pool created outside that shape would exist here and fail to be created on real AWS. A list of mechanisms carries one or two of them, each naming a mechanism Cognito has at a priority of 1 or 2. The refusal says which of those it was.
VerificationMessageTemplate is read and never compared, and the one thing refused in it is
DefaultEmailOption: CONFIRM_WITH_LINK, along with EmailMessageByLink and EmailSubjectByLink.
Nothing here serves a link, and ConfirmSignUp with the code is the only way a user is confirmed. A
pool that asked for a link would be tested against a flow it lacks in a deployment.
Serving a pool’s JWKS on localhost
Section titled “Serving a pool’s JWKS on localhost”serveSimAws serves the two public endpoints of every simulated pool:
GET /<userPoolId>/.well-known/jwks.jsonGET /<userPoolId>/.well-known/openid-configuration
It also lists the messages a pool would have sent, at GET /<userPoolId>/messages, an endpoint
real Cognito lacks. That one is anonymous too, and it hands out confirmation codes and
temporary passwords to anyone who asks, so serve a simulation on a port other people can reach only
if you mean to.
The two real endpoints are anonymous as they are on real Cognito, and no SigV4 signature is needed
to fetch them. The real hostname cognito-idp.<region>.amazonaws.com maps to
cognito-idp.<region>.sim-aws.localhost, and srv.localUrl(...) does that rewriting for you. An
unknown pool id gets a 404, as does a pool reached through another region’s hostname.
/** * Fetching a simulated user pool's JWKS over HTTP. */
import { AdminCreateUserCommand, AdminInitiateAuthCommand, AdminSetUserPasswordCommand, CreateUserPoolClientCommand, CreateUserPoolCommand,} from "@aws-sdk/client-cognito-identity-provider";import { CognitoJwtVerifier } from "aws-jwt-verify";import type { Jwks } from "aws-jwt-verify/jwk";
import { SimAws } from "@kensio/yulin";import { serveSimAws } from "@kensio/yulin/serve";
const simAws = new SimAws({ defaultRegionName: "eu-west-2" });const cognito = simAws.cognitoIdentityProvider();
const pool = await cognito.createUserPool( new CreateUserPoolCommand({ PoolName: "myapp-users" }),);const userPoolId = pool.UserPool!.Id!;
const appClient = await cognito.createUserPoolClient( new CreateUserPoolClientCommand({ UserPoolId: userPoolId, ClientName: "web", ExplicitAuthFlows: ["ALLOW_ADMIN_USER_PASSWORD_AUTH"], }),);const clientId = appClient.UserPoolClient!.ClientId!;
await cognito.adminCreateUser( new AdminCreateUserCommand({ UserPoolId: userPoolId, Username: "alice" }),);await cognito.adminSetUserPassword( new AdminSetUserPasswordCommand({ UserPoolId: userPoolId, Username: "alice", Password: "Sup3rSecret!", Permanent: true, }),);
const { AuthenticationResult } = await cognito.adminInitiateAuth( new AdminInitiateAuthCommand({ UserPoolId: userPoolId, ClientId: clientId, AuthFlow: "ADMIN_USER_PASSWORD_AUTH", AuthParameters: { USERNAME: "alice", PASSWORD: "Sup3rSecret!" }, }),);
const srv = await serveSimAws({ simAws });
try { // The real Cognito JWKS URL, adapted for the local server. const jwksUrl = srv.localUrl( `https://cognito-idp.eu-west-2.amazonaws.com/${userPoolId}/.well-known/jwks.json`, ); console.log(jwksUrl.pathname); // "/eu-west-2_aBcDeFgHi/.well-known/jwks.json"
const response = await fetch(jwksUrl); const jwks = (await response.json()) as Jwks;
const verifier = CognitoJwtVerifier.create({ userPoolId, tokenUse: "access", clientId, }); verifier.cacheJwks(jwks);
const payload = await verifier.verify(AuthenticationResult!.AccessToken!);
console.log(payload.username); // "alice"} finally { await srv.close();}aws-jwt-verify fetches over HTTPS only, and CognitoJwtVerifier builds its own JWKS URI from the
pool id rather than taking one, leaving it impossible to point at the local URL. Fetching the
document and calling cacheJwks with it is one way round that. The other is to hand the verifier a
SimpleJwksCache from aws-jwt-verify/jwk whose fetcher passes the URI through srv.localUrl,
which leaves the verifier setup in the application untouched. A verifier that takes a jwksUri and
accepts plain HTTP can be pointed at the local URL as it is.
The OpenID configuration names the origin the request arrived on in issuer and jwks_uri. A
client that discovers the document can go on to fetch the keys it points at. The tokens keep the
real https://cognito-idp.<region>.amazonaws.com/<userPoolId> in iss, what a verifier built from
a pool id checks against. The two disagree here where they agree on real Cognito.
A simulated Lambda
reads both documents at the real regional endpoint, with no local server and no URL rewriting. A
CognitoJwtVerifier inside a handler fetches the pool’s JWKS for itself and verifies the token,
which is the verifier setup the deployed code already has.
Intercepting the SDK client
Section titled “Intercepting the SDK client”Code that builds its own CognitoIdentityProviderClient needs no changes. Intercepting the client
class routes every Command through simulated Cognito.
/** * Intercepting a CognitoIdentityProviderClient into simulated Cognito. */
import { CognitoIdentityProviderClient, CreateUserPoolCommand, DescribeUserPoolCommand,} from "@aws-sdk/client-cognito-identity-provider";
import { SimSdk } from "@kensio/yulin/sdk";
const simSdk = new SimSdk();simSdk.intercept(CognitoIdentityProviderClient);
// The code under test uses the AWS SDK as normal.const client = new CognitoIdentityProviderClient({ region: "eu-west-2" });
const created = await client.send( new CreateUserPoolCommand({ PoolName: "myapp-users" }),);const described = await client.send( new DescribeUserPoolCommand({ UserPoolId: created.UserPool?.Id }),);
console.log(described.UserPool?.Id); // "eu-west-2_aBcDeFgHi"
simSdk.restoreAll();The same applies inside a simulated Lambda handler. A client the handler builds is intercepted and dispatched with the function’s execution role as the caller. The role’s policy decides whether the call succeeds.
Account and region scoping
Section titled “Account and region scoping”A pool belongs to one account and region, as it does on real AWS. A pool id from one scope reaches nothing in another.
/** * A simulated user pool in one Account and Region scope. */
import { CreateUserPoolCommand } from "@aws-sdk/client-cognito-identity-provider";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();
const pool = await simAws .account("111111111111") .region("eu-west-2") .cognitoIdentityProvider() .createUserPool(new CreateUserPoolCommand({ PoolName: "myapp-users" }));
console.log(pool.UserPool?.Id); // "eu-west-2_aBcDeFgHi"console.log(pool.UserPool?.Arn);// "arn:aws:cognito-idp:eu-west-2:111111111111:userpool/eu-west-2_aBcDeFgHi"Updating a pool
Section titled “Updating a pool”UpdateUserPool changes a pool’s password policy, deletion protection, auto-verified attributes,
Lambda triggers and AdminCreateUserConfig.AllowAdminCreateUserOnly.
It replaces those settings rather than merging into them, as real Cognito does. A setting the
request leaves out goes back to the default CreateUserPool would have given it. A request that
names only the one setting it wants to change resets the others. Name every setting the pool should
keep. That is the sharp edge on real Cognito too, and a request written that way behaves the same
here and in a deployment.
A pool’s LambdaConfig is replaced the same way. An update that says nothing about it stops the
pool running the triggers it was created with, as real Cognito would.
The pool’s name falls outside the settings an update carries. Real UpdateUserPool renames a pool
with PoolName, and a rename is outside the simulation. A request carrying one is refused. Every
other input CreateUserPool refuses is refused here too, in the same words, saying
UpdateUserPool.
UpdateUserPool answers with the response metadata alone, as the real operation does, so
DescribeUserPool is what reads the change back.
/** * Changing a simulated user pool's settings. */
import { CreateUserPoolCommand, DeleteUserPoolCommand, DescribeUserPoolCommand, UpdateUserPoolCommand,} from "@aws-sdk/client-cognito-identity-provider";
import { SimAws } from "@kensio/yulin";
const cognito = new SimAws().cognitoIdentityProvider();
const created = await cognito.createUserPool( new CreateUserPoolCommand({ PoolName: "myapp-users", DeletionProtection: "ACTIVE", Policies: { PasswordPolicy: { MinimumLength: 12 } }, }),);
const UserPoolId = created.UserPool?.Id;
await cognito.updateUserPool( new UpdateUserPoolCommand({ UserPoolId, DeletionProtection: "INACTIVE" }),);
const described = await cognito.describeUserPool( new DescribeUserPoolCommand({ UserPoolId }),);
console.log(described.UserPool?.DeletionProtection); // "INACTIVE"
// The update said nothing about the password policy, so it is back at the// default rather than the twelve characters the pool was created with.console.log(described.UserPool?.Policies?.PasswordPolicy?.MinimumLength); // 8
// The pool can be deleted now its protection is off.await cognito.deleteUserPool(new DeleteUserPoolCommand({ UserPoolId }));A pool reports the time of its last update as its LastModifiedDate, in DescribeUserPool and in
ListUserPools. A pool that has never been updated reports its creation date as its
LastModifiedDate.
Deletion protection
Section titled “Deletion protection”A pool created through the API is unprotected unless the request asks for protection, the opposite
of what the console does. A pool created with DeletionProtection: "ACTIVE" refuses
DeleteUserPool with InvalidParameterException.
Real Cognito wants an UpdateUserPool request deactivating the protection before the pool can go,
and so does this. Send an UpdateUserPool with DeletionProtection: "INACTIVE" first, then delete
the pool.
Multi-factor authentication
Section titled “Multi-factor authentication”A pool can be created with an MfaConfiguration of OFF, OPTIONAL or ON, and reports back what
it was asked for. SetUserPoolMfaConfig sets which factors are behind that setting, and
GetUserPoolMfaConfig reads both back, as they do on real Cognito. SOFTWARE_TOKEN_MFA and
SMS_MFA are the factors a pool can offer. A code sent by email is refused, because no pool here
has the EmailConfiguration real Cognito wants before it will send one, and so is the
SmsConfiguration inside an SmsMfaConfiguration. No message is delivered here, and the IAM role
that would send one is never assumed.
A pool configured OPTIONAL challenges the users that have registered a factor, and one configured
ON challenges every user. A pool configured OFF challenges nobody. What a user registered is
covered in Registering a second factor for a user below,
and being challenged for it in Signing in with a second
factor after that.
The one sign-in still refused is by a user of an ON pool that has registered no factor at all.
Real Cognito answers that one with MFA_SETUP, which registers a factor mid-sign-in, so
InitiateAuth, AdminInitiateAuth and the new password challenge response are refused with
InvalidParameterException where that challenge would have been, rather than handing out tokens a
deployment would not.
/** * A user pool that offers multi-factor authentication. */
import { ConfirmSignUpCommand, CreateUserPoolClientCommand, CreateUserPoolCommand, DescribeUserPoolCommand, GetUserPoolMfaConfigCommand, InitiateAuthCommand, SetUserPoolMfaConfigCommand, SignUpCommand,} from "@aws-sdk/client-cognito-identity-provider";
import { SimAws } from "@kensio/yulin";
const cognito = new SimAws().cognitoIdentityProvider();
const pool = await cognito.createUserPool( new CreateUserPoolCommand({ PoolName: "myapp-users", MfaConfiguration: "OPTIONAL", }),);const userPoolId = pool.UserPool!.Id!;
// Which factors the pool offers is set separately, as it is on real Cognito.await cognito.setUserPoolMfaConfig( new SetUserPoolMfaConfigCommand({ UserPoolId: userPoolId, MfaConfiguration: "OPTIONAL", SoftwareTokenMfaConfiguration: { Enabled: true }, }),);
const described = await cognito.describeUserPool( new DescribeUserPoolCommand({ UserPoolId: userPoolId }),);
console.log(described.UserPool?.MfaConfiguration); // "OPTIONAL"
const mfa = await cognito.getUserPoolMfaConfig( new GetUserPoolMfaConfigCommand({ UserPoolId: userPoolId }),);
console.log(mfa.SoftwareTokenMfaConfiguration?.Enabled); // true
// A user of the pool signs up and signs in with a password alone, because no// user here has registered a second factor.const appClient = await cognito.createUserPoolClient( new CreateUserPoolClientCommand({ UserPoolId: userPoolId, ClientName: "web", ExplicitAuthFlows: ["ALLOW_USER_PASSWORD_AUTH"], }),);const clientId = appClient.UserPoolClient!.ClientId!;
await cognito.signUp( new SignUpCommand({ ClientId: clientId, Username: "alice", Password: "Sup3rSecret!", }),);
await cognito.confirmSignUp( new ConfirmSignUpCommand({ ClientId: clientId, Username: "alice", ConfirmationCode: cognito.userPool(userPoolId).confirmationCode("alice"), }),);
const signedIn = await cognito.initiateAuth( new InitiateAuthCommand({ ClientId: clientId, AuthFlow: "USER_PASSWORD_AUTH", AuthParameters: { USERNAME: "alice", PASSWORD: "Sup3rSecret!" }, }),);
console.log(typeof signedIn.AuthenticationResult?.AccessToken); // "string"Registering a second factor for a user
Section titled “Registering a second factor for a user”A user registers an authenticator app in the three steps Cognito’s own documentation gives.
AssociateSoftwareToken issues a SecretCode, VerifySoftwareToken proves the app holds it, and
SetUserMFAPreference turns the factor on. AdminSetUserMFAPreference does the same for a user an
administrator names, and AdminGetUser and GetUser report the result as UserMFASettingList and
PreferredMfaSetting.
Each of those, and GetUser, is authorized by the user’s own access token rather than by any IAM
policy, and the token has to carry the aws.cognito.signin.user.admin scope. A sign-in through the
API always carries it. A sign-in at the hosted domain carries it only where the app client asked for
it among its AllowedOAuthScopes. A browser sign-in granted openid email alone is refused with
NotAuthorizedException, as real Cognito refuses one. GlobalSignOut is held to the same rule.
The SecretCode is a real RFC 6238 shared secret. An authenticator app or any TOTP library given
it produces the codes VerifySoftwareToken accepts, and a code from another secret is refused with
EnableSoftwareTokenMFAException. A test that would rather not compute one reads the code the
user’s app would be showing off the pool, through SimCognitoUserPool.softwareTokenCode, in the way
it reads a sign-up confirmation code. Real Cognito reports neither to anyone, and nothing here is
holding the user’s phone.
Verifying a token registers it and leaves it disabled. SetUserMFAPreference is what turns a factor
on, the step the Cognito documentation gives for activating one. Whether real Cognito also activates
a TOTP factor on verification alone was not checked against a live account. Enabling SMS_MFA for a
user with no phone_number attribute is refused, because there would be nowhere to send the code,
and so is enabling SOFTWARE_TOKEN_MFA for a user that has verified no token. A factor a request
says nothing about is left as it was, and an application turning on an authenticator app can leave
what it wants for SMS unstated. One factor at most is preferred, and preferring one means enabling
it in the same request.
/** * Registering an authenticator app for a user of a simulated pool. */
import { AdminCreateUserCommand, AdminSetUserPasswordCommand, AssociateSoftwareTokenCommand, CreateUserPoolClientCommand, CreateUserPoolCommand, GetUserCommand, InitiateAuthCommand, SetUserMFAPreferenceCommand, SetUserPoolMfaConfigCommand, VerifySoftwareTokenCommand,} from "@aws-sdk/client-cognito-identity-provider";
import { SimAws } from "@kensio/yulin";
const cognito = new SimAws().cognitoIdentityProvider();
const pool = await cognito.createUserPool( new CreateUserPoolCommand({ PoolName: "myapp-users", MfaConfiguration: "OPTIONAL", }),);const userPoolId = pool.UserPool!.Id!;
await cognito.setUserPoolMfaConfig( new SetUserPoolMfaConfigCommand({ UserPoolId: userPoolId, MfaConfiguration: "OPTIONAL", SoftwareTokenMfaConfiguration: { Enabled: true }, }),);
const appClient = await cognito.createUserPoolClient( new CreateUserPoolClientCommand({ UserPoolId: userPoolId, ClientName: "web", ExplicitAuthFlows: ["ALLOW_USER_PASSWORD_AUTH"], }),);const clientId = appClient.UserPoolClient!.ClientId!;
await cognito.adminCreateUser( new AdminCreateUserCommand({ UserPoolId: userPoolId, Username: "alice" }),);await cognito.adminSetUserPassword( new AdminSetUserPasswordCommand({ UserPoolId: userPoolId, Username: "alice", Password: "Sup3rSecret!", Permanent: true, }),);
const signedIn = await cognito.initiateAuth( new InitiateAuthCommand({ ClientId: clientId, AuthFlow: "USER_PASSWORD_AUTH", AuthParameters: { USERNAME: "alice", PASSWORD: "Sup3rSecret!" }, }),);const AccessToken = signedIn.AuthenticationResult!.AccessToken!;
// The secret an authenticator app would be given, behind a QR code.const associated = await cognito.associateSoftwareToken( new AssociateSoftwareTokenCommand({ AccessToken }),);
console.log(typeof associated.SecretCode); // "string"
// The code the user's app is showing, which a test reads off the pool rather// than computing from the secret itself.const verified = await cognito.verifySoftwareToken( new VerifySoftwareTokenCommand({ AccessToken, UserCode: cognito.userPool(userPoolId).softwareTokenCode("alice"), }),);
console.log(verified.Status); // "SUCCESS"
// Verifying registers the token. Turning the factor on is a step of its own.await cognito.setUserMFAPreference( new SetUserMFAPreferenceCommand({ AccessToken, SoftwareTokenMfaSettings: { Enabled: true, PreferredMfa: true }, }),);
const user = await cognito.getUser(new GetUserCommand({ AccessToken }));
console.log(user.UserMFASettingList); // ["SOFTWARE_TOKEN_MFA"]console.log(user.PreferredMfaSetting); // "SOFTWARE_TOKEN_MFA"Signing in with a second factor
Section titled “Signing in with a second factor”A sign-in by a user that has registered a factor is answered with SMS_MFA or SOFTWARE_TOKEN_MFA
and a Session in place of tokens, on InitiateAuth and AdminInitiateAuth alike, and after
a NEW_PASSWORD_REQUIRED response as well, one challenge following the other. The code goes back
through RespondToAuthChallenge or AdminRespondToAuthChallenge as SMS_MFA_CODE or
SOFTWARE_TOKEN_MFA_CODE, and that request is what hands out the tokens.
An SMS_MFA challenge carries CODE_DELIVERY_DELIVERY_MEDIUM and a masked
CODE_DELIVERY_DESTINATION in its ChallengeParameters, as real Cognito does, and the pool records
the message it would have texted, on an occasion of Authentication. That is where a test reads the
code from, the way it reads a sign-up confirmation code. A SOFTWARE_TOKEN_MFA challenge sends
nothing anywhere. The code is whatever the user’s authenticator app is showing, and a test computes
it from the SecretCode or reads it off the pool.
The code goes to the user’s phone_number whatever the pool’s AutoVerifiedAttributes say, and to
the phone number, not the email address, of a user that has both, because the phone is the
factor.
A wrong code is refused with CodeMismatchException and leaves the challenge standing. The user can
be asked to type it again. How long a session lasts is the app client’s AuthSessionValidity,
three minutes on a client that asked for none. A session that has run out, one already spent, and
one issued for a different challenge are each refused with NotAuthorizedException.
PostAuthentication runs where the tokens are issued, the response rather than the sign-in that
was challenged. PreTokenGeneration runs there too, reporting
TokenGeneration_Authentication, including for a sign-in that answered the new password challenge
before this one. Which source real Cognito reports for that pair of challenges was not checked
against a live account.
A user with both factors enabled and neither preferred is refused. Real Cognito answers that
sign-in with SELECT_MFA_TYPE, a challenge of its own. SetUserMFAPreference naming one
of them as PreferredMfa is what settles it.
/** * Signing in with a code texted to the user's phone. */
import { AdminCreateUserCommand, AdminSetUserPasswordCommand, CreateUserPoolClientCommand, CreateUserPoolCommand, InitiateAuthCommand, RespondToAuthChallengeCommand, SetUserMFAPreferenceCommand,} from "@aws-sdk/client-cognito-identity-provider";
import { SimAws } from "@kensio/yulin";
const cognito = new SimAws().cognitoIdentityProvider();
const pool = await cognito.createUserPool( new CreateUserPoolCommand({ PoolName: "myapp-users", MfaConfiguration: "OPTIONAL", }),);const userPoolId = pool.UserPool!.Id!;
const appClient = await cognito.createUserPoolClient( new CreateUserPoolClientCommand({ UserPoolId: userPoolId, ClientName: "web", ExplicitAuthFlows: ["ALLOW_USER_PASSWORD_AUTH"], }),);const ClientId = appClient.UserPoolClient!.ClientId!;
// The code has somewhere to go, which is what enabling SMS_MFA needs.await cognito.adminCreateUser( new AdminCreateUserCommand({ UserPoolId: userPoolId, Username: "alice", UserAttributes: [{ Name: "phone_number", Value: "+441632960123" }], }),);await cognito.adminSetUserPassword( new AdminSetUserPasswordCommand({ UserPoolId: userPoolId, Username: "alice", Password: "Sup3rSecret!", Permanent: true, }),);
const signIn = new InitiateAuthCommand({ ClientId, AuthFlow: "USER_PASSWORD_AUTH", AuthParameters: { USERNAME: "alice", PASSWORD: "Sup3rSecret!" },});
// This sign-in is not challenged: the user has registered no factor yet.const first = await cognito.initiateAuth(signIn);
await cognito.setUserMFAPreference( new SetUserMFAPreferenceCommand({ AccessToken: first.AuthenticationResult!.AccessToken, SMSMfaSettings: { Enabled: true, PreferredMfa: true }, }),);
const challenged = await cognito.initiateAuth(signIn);
console.log(challenged.ChallengeName); // "SMS_MFA"console.log(challenged.ChallengeParameters?.["CODE_DELIVERY_DESTINATION"]);// "+*******0123"
// Nothing is delivered, so the code is read out of the message the pool// recorded, as a sign-up confirmation code is.const texted = cognito .userPool(userPoolId) .sentMessages() .find((message) => message.occasion === "Authentication");const code = /\d{6}/.exec(texted!.body)![0];
const signedIn = await cognito.respondToAuthChallenge( new RespondToAuthChallengeCommand({ ClientId, ChallengeName: "SMS_MFA", Session: challenged.Session, ChallengeResponses: { USERNAME: "alice", SMS_MFA_CODE: code }, }),);
console.log(typeof signedIn.AuthenticationResult?.AccessToken); // "string"Registering a passkey
Section titled “Registering a passkey”StartWebAuthnRegistration and CompleteWebAuthnRegistration register a passkey for the user whose
access token authorized the call. ListWebAuthnCredentials reads back what that user has, and
DeleteWebAuthnCredential forgets one. All four are authorized by the access token alone, and real
Cognito evaluates no IAM policy for any of them. That is what makes a passkey something a user adds
to an account it is already signed in to. The first one has to be registered from a session some
other factor started.
The pool needs a relying party before it can register anything. It arrives as
WebAuthnConfiguration.RelyingPartyId on SetUserPoolMfaConfig, or as WebAuthnRelyingPartyID on
an AWS::Cognito::UserPool Resource. A pool that names none falls back to its own hosted domain,
which is what real Cognito falls back to, and a pool with neither refuses the registration with
WebAuthnConfigurationMissingException.
StartWebAuthnRegistration answers with the CredentialCreationOptions a browser passes to
navigator.credentials.create(). They carry a fresh challenge, the relying party, the user handle
(the user’s sub), the ECDSA P-256 algorithm the pool takes, and the passkeys the user already has
under excludeCredentials. Starting a second registration replaces the first, and the challenge the
browser was part way through answering is spent.
A test has no browser and no phone. The simulator plays the authenticator instead, and
SimCognitoUserPool.webAuthnCredential hands back the credential the user’s own device would have
made from the options it was just given, in the way softwareTokenCode hands back the code an
authenticator app would be showing. Pass that credential to CompleteWebAuthnRegistration.
/** * Registering a passkey for a signed-in user. */
import { AdminCreateUserCommand, AdminSetUserPasswordCommand, CompleteWebAuthnRegistrationCommand, CreateUserPoolClientCommand, CreateUserPoolCommand, InitiateAuthCommand, ListWebAuthnCredentialsCommand, SetUserPoolMfaConfigCommand, StartWebAuthnRegistrationCommand,} from "@aws-sdk/client-cognito-identity-provider";
import { SimAws } from "@kensio/yulin";
const cognito = new SimAws().cognitoIdentityProvider();
const pool = await cognito.createUserPool( new CreateUserPoolCommand({ PoolName: "myapp-users" }),);const userPoolId = pool.UserPool!.Id!;
// A passkey belongs to a domain, and the pool has to name the one it registers// against.await cognito.setUserPoolMfaConfig( new SetUserPoolMfaConfigCommand({ UserPoolId: userPoolId, MfaConfiguration: "OPTIONAL", WebAuthnConfiguration: { RelyingPartyId: "myapp.example.com", UserVerification: "required", }, }),);
const appClient = await cognito.createUserPoolClient( new CreateUserPoolClientCommand({ UserPoolId: userPoolId, ClientName: "web", ExplicitAuthFlows: ["ALLOW_USER_PASSWORD_AUTH"], }),);
await cognito.adminCreateUser( new AdminCreateUserCommand({ UserPoolId: userPoolId, Username: "alice" }),);await cognito.adminSetUserPassword( new AdminSetUserPasswordCommand({ UserPoolId: userPoolId, Username: "alice", Password: "Sup3rSecret!", Permanent: true, }),);
// A passkey is added from a session that already exists, so the user signs in// with its password first.const signedIn = await cognito.initiateAuth( new InitiateAuthCommand({ ClientId: appClient.UserPoolClient!.ClientId!, AuthFlow: "USER_PASSWORD_AUTH", AuthParameters: { USERNAME: "alice", PASSWORD: "Sup3rSecret!" }, }),);const AccessToken = signedIn.AuthenticationResult!.AccessToken!;
// The options a browser would hand to navigator.credentials.create().await cognito.startWebAuthnRegistration( new StartWebAuthnRegistrationCommand({ AccessToken }),);
// The credential that browser's authenticator would have handed back.await cognito.completeWebAuthnRegistration( new CompleteWebAuthnRegistrationCommand({ AccessToken, Credential: cognito.userPool(userPoolId).webAuthnCredential("alice"), }),);
const listed = await cognito.listWebAuthnCredentials( new ListWebAuthnCredentialsCommand({ AccessToken }),);
console.log(listed.Credentials?.[0]?.RelyingPartyId); // "myapp.example.com"The keys are real. Every passkey is an ECDSA key pair over P-256, and the credential carries the
public half as base64url of its SubjectPublicKeyInfo, where a browser’s own
PublicKeyCredential.toJSON() puts it. The pool reads the key itself rather than the algorithm the
credential names beside it, so an RSA key labelled -7 is refused. A test that would rather hold
its own key can build the credential document by hand, because nothing here reads a field a browser
leaves out.
What the pool was sent is read rather than trusted. The client data has to name the ceremony, answer
the challenge the pool issued and carry the relying party’s own origin, and the authenticator data
has to hash to the relying party the pool registers against. A spent or unknown challenge is refused
with WebAuthnChallengeNotFoundException, another origin with
WebAuthnOriginNotAllowedException, another domain with WebAuthnRelyingPartyMismatchException,
and a key the pool cannot use with WebAuthnCredentialNotSupportedException. A refusal spends the
challenge, so a registration that was refused is started again rather than retried.
ListWebAuthnCredentials reports each passkey with the credential id, the relying party, how the
authenticator says it is attached and how it can be reached, and when it was registered. It pages by
MaxResults and NextToken, twenty to a page at most, and a MaxResults of zero is read as the
whole page, which is what Cognito documents as its minimum. FriendlyCredentialName is the relying
party ID. Real Cognito reads the authenticator’s own model out of the attestation and names the
credential after it, and this simulation parses no attestation.
Signing in with a passkey
Section titled “Signing in with a passkey”InitiateAuth and AdminInitiateAuth run the USER_AUTH flow. That is choice-based sign-in, and
it is the flow a passkey is presented through. An app client has to be created with
ALLOW_USER_AUTH among its ExplicitAuthFlows before either will run it.
A request naming only a USERNAME is answered with SELECT_CHALLENGE and an AvailableChallenges
list. The factors in it come from the pool’s Policies.SignInPolicy.AllowedFirstAuthFactors,
narrowed to the ones this user could actually present. WEB_AUTHN appears once the user has
registered a passkey, and EMAIL_OTP and SMS_OTP where the user has the address or the number a
code would go to. A pool that named no policy allows a password, the fallback real Cognito
applies.
RespondToAuthChallenge picks one of them with an ANSWER. WEB_AUTHN is answered with the
WEB_AUTHN challenge and the options a browser presents a passkey against, and PASSWORD carries
the password in the same request and finishes the sign-in. A request that already knows which factor
it wants skips the choice by naming a PREFERRED_CHALLENGE of PASSWORD or WEB_AUTHN, and one
carrying a PASSWORD outright is signed in there and then.
A WEB_AUTHN challenge carries CREDENTIAL_REQUEST_OPTIONS in its ChallengeParameters, as JSON,
which is what a browser passes to navigator.credentials.get(). It names the relying party, the
passkeys this user has under allowCredentials, and the challenge the authenticator signs. A test has no browser.
SimCognitoUserPool.webAuthnAssertion reads the credential the user’s own device would have handed
back, taking the Session the challenge answered with. The response goes back as CREDENTIAL, JSON
in the ChallengeResponses.
/** * Signing in with a registered passkey. */
import { InitiateAuthCommand, RespondToAuthChallengeCommand,} from "@aws-sdk/client-cognito-identity-provider";
import type { SimCognitoIdentityProvider } from "@kensio/yulin/cognito";
declare const cognito: SimCognitoIdentityProvider;declare const userPoolId: string;declare const clientId: string;
// The pool answers with the factors this user could sign in with. They are// what its SignInPolicy allows, narrowed to what the user has.const offered = await cognito.initiateAuth( new InitiateAuthCommand({ ClientId: clientId, AuthFlow: "USER_AUTH", AuthParameters: { USERNAME: "alice" }, }),);
console.log(offered.ChallengeName); // "SELECT_CHALLENGE"console.log(offered.AvailableChallenges); // ["PASSWORD", "WEB_AUTHN"]
// Choosing the passkey asks for one, carrying the options a browser would pass// to navigator.credentials.get().const challenged = await cognito.respondToAuthChallenge( new RespondToAuthChallengeCommand({ ClientId: clientId, ChallengeName: "SELECT_CHALLENGE", Session: offered.Session, ChallengeResponses: { USERNAME: "alice", ANSWER: "WEB_AUTHN" }, }),);
console.log(challenged.ChallengeName); // "WEB_AUTHN"
// The credential that browser's authenticator would have signed, read off the// pool because a test has neither.const presented = cognito .userPool(userPoolId) .webAuthnAssertion(challenged.Session!);
const signedIn = await cognito.respondToAuthChallenge( new RespondToAuthChallengeCommand({ ClientId: clientId, ChallengeName: "WEB_AUTHN", Session: challenged.Session, ChallengeResponses: { USERNAME: "alice", CREDENTIAL: JSON.stringify(presented), }, }),);
console.log(typeof signedIn.AuthenticationResult?.AccessToken); // "string"The signature is checked against the public key the registration stored. A credential another key
signed is refused with NotAuthorizedException, and so is one presenting a passkey this user never
registered. The challenge session lasts the app client’s AuthSessionValidity and is spent when it
is answered, as every other challenge session is.
A passkey finishes the sign-in on its own. Real Cognito counts one as having met the pool’s MFA requirement. A user that has registered a second factor presents its passkey and is signed in, where the same user signing in with a password would answer for that factor first.
Choosing EMAIL_OTP or SMS_OTP is refused, because nothing here delivers a message. The pool
offers them where its policy allows them, and the refusal lands on the choice.
A passkey at managed login
Section titled “A passkey at managed login”Managed login’s sign-in form offers a passkey where the pool allows one at the first prompt, beside the username and password it already asks for. It takes two requests, as it does on real managed login.
Posting the form with that button is answered with a second page. The pool has issued a WEB_AUTHN
challenge by then, and the page carries the session it belongs to in a hidden passkey_session
input alongside a credential field. Posting that back signs the user in and sends the browser to
the application with an authorization code, which exchanges for tokens the way a password sign-in’s
code does.
Real managed login runs the WebAuthn ceremony between the two requests, in the browser, with the
person’s own authenticator. These pages serve no script, so the credential is a field on a form and
SimCognitoUserPool.webAuthnAssertion is where a test reads it from, passing the session the page
carried. The button alone signs nobody in. A passkey a caller does not hold is one it cannot
present, so knowing a username reaches the challenge and no further.
A credential the pool refuses sends the browser back to the sign-in form with the reason on it, where a wrong password would land, and the sign-in starts again.
Available functionality
Section titled “Available functionality”Sim Cognito currently supports:
CreateUserPoolCommand,DescribeUserPoolCommand,UpdateUserPoolCommand,DeleteUserPoolCommandandListUserPoolsCommandSetUserPoolMfaConfigCommandandGetUserPoolMfaConfigCommand, which set and read what a pool offers as a second factorCreateUserPoolClientCommand,DescribeUserPoolClientCommand,UpdateUserPoolClientCommand,DeleteUserPoolClientCommandandListUserPoolClientsCommandAdminCreateUserCommand,AdminGetUserCommand,AdminDeleteUserCommand,AdminSetUserPasswordCommand,AdminUpdateUserAttributesCommand,AdminDisableUserCommand,AdminEnableUserCommandandListUsersCommand, andGetUserCommand, the signed-in user reading itselfAssociateSoftwareTokenCommand,VerifySoftwareTokenCommandandSetUserMFAPreferenceCommand, which register an authenticator app for the signed-in user, andAdminSetUserMFAPreferenceCommand, which sets a named user’s factors- The
SMS_MFAandSOFTWARE_TOKEN_MFAchallenges, issued to a user that has registered the factor and answered throughRespondToAuthChallengeCommandorAdminRespondToAuthChallengeCommand, with the texted code recorded as a message on the pool - The
USER_AUTHflow on both sides of the API, with theSELECT_CHALLENGE,PASSWORDandWEB_AUTHNchallenges it issues, and the passkey a test presents read off the pool StartWebAuthnRegistrationCommand,CompleteWebAuthnRegistrationCommand,ListWebAuthnCredentialsCommandandDeleteWebAuthnCredentialCommand, which register and manage the signed-in user’s passkeys, with the credential the user’s own authenticator would have made read back off the poolSignUpCommand,ConfirmSignUpCommandandResendConfirmationCodeCommand, authorized by no IAM policy as they are on real Cognito, andAdminConfirmSignUpCommand, authorized like the other admin operationsForgotPasswordCommandandConfirmForgotPasswordCommand, authorized by no IAM policy either, with the reset code read back off the pool and the maskedCodeDeliveryDetailsreported, andAdminResetUserPasswordCommand, which leaves a user inRESET_REQUIRED- The
PreSignUpandPostConfirmationLambda triggers, withautoConfirmUser,autoVerifyEmailandautoVerifyPhoneapplied, and with theValidationDataandClientMetadataa request carries reaching the handler AutoVerifiedAttributes, so confirming a sign-up setsemail_verifiedorphone_number_verified, andAdminCreateUserConfig.AllowAdminCreateUserOnly, which refusesSignUpagainst a pool created with it- A pool’s
Schema, with acustom:attribute set and read on its users, held to the type, the bounds and the mutability it was declared with, and reported asSchemaAttributesalongside the standard attributes - A pool’s
AccountRecoverySetting, recorded as the request set it and reported back byDescribeUserPool, held to the one or two mechanisms Cognito takes CreateGroupCommand,GetGroupCommand,UpdateGroupCommand,DeleteGroupCommand,ListGroupsCommand,AdminAddUserToGroupCommand,AdminRemoveUserFromGroupCommand,AdminListGroupsForUserCommandandListUsersInGroupCommandAdminInitiateAuthCommandandAdminRespondToAuthChallengeCommand, for theADMIN_USER_PASSWORD_AUTHandREFRESH_TOKEN_AUTHflows and theNEW_PASSWORD_REQUIREDchallengeInitiateAuthCommandandRespondToAuthChallengeCommand, for the client-sideUSER_PASSWORD_AUTHandREFRESH_TOKEN_AUTHflows, authorized by no IAM policy as they are on real CognitoGlobalSignOutCommandandAdminUserGlobalSignOutCommand, which revoke the tokens a user holds- The
PreAuthenticationandPostAuthenticationLambda triggers, invoked with the real event around a sign-in, with the sign-in’s ownClientMetadatareaching them - The
PreTokenGenerationLambda trigger atV1_0, whoseclaimsOverrideDetailsadds, overrides and suppresses the claims of an id token, and whosegroupOverrideDetailsreplacescognito:groups, on a sign-in and on a refresh alike - A record on each pool of the messages it would have sent, read with
sentMessages, carrying the recipient, the medium, the subject, the body and the occasion, with the pool’s own verification wording and the{####}placeholder filled in - The
CustomMessageLambda trigger, invoked before a message is recorded, with the occasion in itstriggerSourceand the wording it writes replacing the pool’s - The recorded messages listed over HTTP by
serveSimAws, at/<userPoolId>/messages - Real RS256 JWTs, signed by a key the pool publishes as a JWKS, verified unchanged by a verifier configured for the pool
- A pool’s
.well-known/jwks.jsonand.well-known/openid-configurationserved over HTTP byserveSimAws, anonymously, letting a verifier fetch the keys instead of taking them by hand CreateUserPoolDomainCommand,DescribeUserPoolDomainCommandandDeleteUserPoolDomainCommand, for a Cognito prefix domain and for a custom domainCreateIdentityProviderCommand,DescribeIdentityProviderCommand,UpdateIdentityProviderCommand,DeleteIdentityProviderCommandandListIdentityProvidersCommand- The
/oauth2/authorize,/oauth2/tokenand/logoutendpoints of a pool’s domain, served on the domain’s own hostname, for an authorization code grant through an external identity provider, with PKCE and with arefresh_tokengrant - An authorize request naming no identity provider, signing one of the pool’s own users in from a
usernameand apasswordit carries - The managed login session a sign-in starts for the browser, held in the
cognitocookie for an hour, signing a returning browser in without credentials until/logoutends it - A served sign-in form at
/oauth2/authorize, a sign-up form at/signup, a confirmation form at/confirm, and the two password reset forms at/forgotPasswordand/confirmForgotPassword, each carrying the authorize parameters through to the next - A served stand-in for an identity provider’s own sign-in page, so a browser completes a federated sign-in on a local development server, with the subject and the mapped claims pre-filled and editable
- The pool user a federated sign-in creates, named
<ProviderName>_<subject>, in theEXTERNAL_PROVIDERstatus, carrying theidentitiesattribute and claim and the attributes the provider’sAttributeMappingnamed - The triggers a federated sign-in runs, being
PreSignUpandPostConfirmationon a first sign-in,PreAuthenticationandPostAuthenticationon every one after it, andPreTokenGenerationunderTokenGeneration_HostedAuthwhen the code is exchanged - The
PreAuthenticationtrigger a local user’s managed login sign-in runs, for a password and for a passkey alike, with a handler’s refusal drawn on the sign-in form - A
REGIONALweb ACL in front of the pool, attached byAssociateWebACLon simulated WAFv2 and evaluated against every request the hosted domain and the two.well-knowndocuments answer - App client OAuth settings:
AllowedOAuthFlowsUserPoolClient,AllowedOAuthFlows,AllowedOAuthScopes,CallbackURLs,LogoutURLs,DefaultRedirectURIandSupportedIdentityProviders, each of which an authorize or token request is checked against AWS::Cognito::UserPool,AWS::Cognito::UserPoolClient,AWS::Cognito::UserPoolGroup,AWS::Cognito::UserPoolDomainandAWS::Cognito::UserPoolIdentityProviderdeployed from a CloudFormation template, with theRefandFn::GetAttvalues real CloudFormation returns- Pool ids in the real
<region>_<nine characters>form, and pool ARNs built from them registerUserPoolandregisterUserPoolClient, which stand a pool and an app client up under chosen ids, for a template that names ids another stack allocated- The real default password policy, applied to the passwords users are given
- The real user status lifecycle, in which an admin-created user stays in
FORCE_CHANGE_PASSWORDuntil it has a permanent password, a signed-up user stays inUNCONFIRMEDuntil it confirms, and a user an administrator reset stays inRESET_REQUIREDuntil it sets a password of its own - Group membership, and the precedence order the
cognito:groupsclaim uses - App client authentication flows, token lifetimes, generated client secrets and
PreventUserExistenceErrors - Refresh tokens that expire at the app client’s
RefreshTokenValidity, thirty days by default on the simulated clock - Refresh token rotation on an app client, renewed with
GetTokensFromRefreshToken, including theRetryGracePeriodSecondsa rotated-out token keeps working for - Authorization of the administrative operations by simulated IAM, against the real IAM action and ARN
- Calls made from inside a simulated Lambda handler, authorized as the function’s execution role
Limitations
Section titled “Limitations”Current documented limitations:
SignInSuccesses,SignUpSuccesses,TokenRefreshSuccesses,FederationSuccessesand the four*Throttlesbeside them are theAWS/Cognitometrics a pool publishes. No rate limit works itself out here. A pool turns a request away only where a test has told it to.CallCountandThrottleCountunderAWS/Usageare absent. A client-side request naming an app client the pool has none of counts nothing at all, because the app client id is what finds the pool and an unknown one reaches no pool to report against.- Five authentication flows run:
ADMIN_USER_PASSWORD_AUTHandREFRESH_TOKEN_AUTHthroughAdminInitiateAuth,USER_PASSWORD_AUTHandREFRESH_TOKEN_AUTHthroughInitiateAuth, andUSER_AUTHthrough either. SRP, custom authentication and device tracking are outside the simulation, and anAuthFlownaming one of them is refused, never run as a flow that is.GetTokensFromRefreshTokenruns beside them and is not a flow, as it is not one on real Cognito. NEW_PASSWORD_REQUIRED,SMS_MFA,SOFTWARE_TOKEN_MFA,SELECT_CHALLENGE,PASSWORDandWEB_AUTHNare the challenges issued, soMFA_SETUP,SELECT_MFA_TYPEand the custom authentication challenges cannot be reached. AChallengeNamethis simulation never issues is refused, never answered as one it does.EMAIL_OTPandSMS_OTPare named in aUSER_AUTHsign-in’sAvailableChallengeswhere the pool’s policy allows them, and choosing one is refused. Nothing here delivers a message, and the pool’s ownEmailConfigurationandSmsConfigurationare refused for the same reason.PASSWORD_SRPis absent fromAvailableChallenges, where real Cognito names it besidePASSWORDfor a pool allowing thePASSWORDfactor. SRP is outside the simulation.- A passkey completes a sign-in without a second factor being asked for, because real Cognito counts
one as having met the pool’s MFA requirement. Whether it does so for a pool configured
ONwith auserVerificationofpreferredwas not checked against a live account. - A passkey’s
attestationObjectis stored by nobody and parsed by nothing. The public key is read fromresponse.publicKey, where a browser’s own JSON serialization puts it, and the credential is named after the relying party because the authenticator model real Cognito names it after lives in the attestation.ES256is the only algorithm accepted, and a key of another kind is refused withWebAuthnCredentialNotSupportedExceptionwhatever the credential labels it. ListWebAuthnCredentialsreads aMaxResultsof zero as the whole page. Cognito documents zero as the minimum for this listing and says nothing about what it answers with, and what a real pool does with it was not checked against a live account.- The private half of a passkey lives in the simulator, because a test has no authenticator to hold
it.
SimCognitoUserPool.webAuthnCredentialis what reads a credential out of it, and it is a deliberate divergence rather than an operation to write application code against. The signatures are real, and a credential built from another key is refused. RevokeTokenis unimplemented. A refresh token is revoked by signing the user out, or by the rotation that replaces it.GetTokensFromRefreshTokenrefuses aDeviceKey, because device remembering is unsimulated, and passes aClientMetadatato the pool’sPreTokenGenerationtrigger.REFRESH_TOKEN_AUTHpasses none, as realInitiateAuthpasses none.REFRESH_TOKEN_AUTHagainst a rotating app client is refused as anInvalidParameterExceptionnamingGetTokensFromRefreshToken. Real Cognito was not checked for the exception it raises there, andaws-cdk-libkeeps the combination from arising by droppingALLOW_REFRESH_TOKEN_AUTHfrom a rotating client.- Signing out revokes the user’s tokens inside the simulation, and a token already handed to a verifier goes on verifying against the pool’s JWKS until it expires. Verification happens in the caller’s own verifier, which asks this simulation nothing and cannot be told the token was revoked. Real Cognito is the same for a verifier reading only the JWKS.
- The
cognito:preferred_roleandcognito:rolesclaims are absent from the tokens. A group’sRoleArnis stored and reported, and nothing assumes that role. APreTokenGenerationhandler naming either claim is refused, never half-applied. - A pool publishes one signing key where real Cognito publishes two and rotates between them, so
code assuming a single JWKS entry passes here and is still wrong against real AWS. The key is
generated with
node:cryptothe first time the pool signs or publishes one, and kept in memory for the life of the simulation. - A password is kept so a user can sign in with it, and no operation reads one back.
- Users are resolved by username only. Real Cognito also accepts a user’s
subwhere an admin operation asks for a username, and that fails here withUserNotFoundException. - A pool reports the confirmation code a signed-up user is waiting to answer with, through
confirmationCodeon the pool object. Real Cognito sends the code and never reports it to anyone. Nothing here delivers a message, and this is what makes a registration flow testable. It is a deliberate divergence rather than an operation to write application code against. - A confirmation code never expires, where a real one lasts 24 hours.
ResendConfirmationCodeis what replaces one. AdminConfirmSignUpverifies nothing, whatever the pool’sAutoVerifiedAttributessay, as it verifies nothing on real Cognito.ConfirmSignUpsetsemail_verifiedandphone_number_verified, and only where the user has the attribute to verify, and aPreSignUptrigger sets them by asking forautoVerifyEmailorautoVerifyPhone.AutoVerifiedAttributesis accepted atemailandphone_number, and anything else is refused. Those are the two Cognito can send a code to.ConfirmSignUpandResendConfirmationCodereport a user the pool lacks whatever the app client’sPreventUserExistenceErrorssays. The sign-ins and the two password reset operations honour the setting.- A reset code never expires either, where a real one lasts an hour. A second
ForgotPasswordreplaces it, and answering with it spends it. A spent code is refused withExpiredCodeException. That is what real Cognito calls a code it will no longer take. ForgotPasswordsends its code to an attribute the pool verifies automatically, and refuses a user the pool can reach at neitheremailnorphone_number. Real Cognito chooses by the pool’sAccountRecoverySetting, which is recorded here and read by nothing, so a pool that recovers by email alone behaves here exactly as one that recovers by phone number would.- The destination
ForgotPasswordreports is masked in real Cognito’s shape rather than in a shape read back from a live account. Assert that a destination came back and which medium carried it. ChangePasswordis unimplemented. It is the signed-in user replacing a password it still knows, and it belongs to a different flow.- Unsimulated sign-up inputs are refused rather than ignored:
AnalyticsMetadataandUserContextDataon the three client-side operations and on the two password reset ones, andForceAliasCreationandSessiononConfirmSignUp.ClientMetadatais absent from that list, because each reaches a trigger that runs here. - No message is ever delivered. A pool records what it would have sent and
sentMessagesreads it back, which real Cognito reports to nobody. Nothing leaves the simulation, and noCodeDeliveryDetailsis reported bySignUporResendConfirmationCode.ForgotPasswordreports one, as real Cognito does. - A message is recorded on five occasions, being
SignUp,ResendConfirmationCode,AdminCreateUser,Authentication(an MFA code sent by text message) andForgotPassword, which covers the reset an administrator starts as well as the one the user asks for. Attribute verification and the account-taken-over notices are occasions real Cognito sends on and this simulation never reaches. - A verification message is recorded only for an attribute the pool verifies automatically. A pool
with no
AutoVerifiedAttributesrecords none, and only for a user that has to confirm. One aPreSignUphandler auto-confirmed is sent nothing, as on real Cognito. An invitation is recorded whatever the pool verifies, and both are recorded only where the user has anemailor aphone_numberto be reached at. DesiredDeliveryMediumsis refused. The medium comes from the attribute the message is written to. A request namingSMSfor a user with an email address would be recorded as an email. Real Cognito defaultsAdminCreateUsertoSMS, and this records an email where the user has an address.- An invitation for a user created with no
TemporaryPasswordkeeps the{####}placeholder, because real Cognito generates a password there and this simulation leaves the user with none at all. EmailConfigurationandSmsConfigurationstay refused. A pool configured to send through SES or an SNS SMS role would still only record here, and accepting the configuration would say the messages went that way. The record is Cognito’s own, what the defaultEmailSendingAccountofCOGNITO_DEFAULTsends by, and there is no simulated SES.- Confirming a sign-up by following a link is outside the simulation. A
VerificationMessageTemplatewithDefaultEmailOption: CONFIRM_WITH_LINK, anEmailMessageByLinkor anEmailSubjectByLinkis refused, and aCustomMessageevent carries nolinkParameter. VerificationMessageTemplatewins overEmailVerificationMessage,EmailVerificationSubjectandSmsVerificationMessagewhere a request sets both, and each of those fills in what the template left out. What real Cognito does when the two disagree was not checked against a live account.- Verification wording with no
{####}in it is refused, as it is on real Cognito, because the message would reach a user with no code in it. So is wording outside the lengths Cognito takes: 6 to 20,000 characters for an email, and 6 to 140 for a text message. The subject goes unchecked against its own length. - The default wording, for a pool that set none, is taken from the Cognito API documentation rather than read back from a live account.
- The recorded messages are listed over HTTP at
GET /<userPoolId>/messages, an endpoint real Cognito lacks. It is the serving side ofsentMessagesand a divergence for the same reason. - The
CustomEmailSenderandCustomSMSSendertriggers are refused. Real Cognito hands those the code as an AWS Encryption SDK ciphertext to decrypt through KMS, that envelope is outside the simulation anywhere here, and a version handing over a plain code would give a handler that cannot decrypt anything on real AWS. - A temporary password never expires.
TemporaryPasswordValidityDaysis stored on the pool and acted on nowhere. - Unsimulated
AdminCreateUserinputs are refused, never ignored:DesiredDeliveryMediums,ForceAliasCreation, and aMessageActionofRESEND, which invites a user that already exists. ItsValidationDataandClientMetadataare read, and reach thePreSignUpandCustomMessagetriggers.AdminUpdateUserAttributesrefusesClientMetadata, because the message an attribute update would have sent is one this simulation never sends. ListUsersrefusesFilterandAttributesToGetoutright, and lists users in creation order. Real Cognito chooses its own order and promises none.ListUsers,ListGroups,AdminListGroupsForUserandListUsersInGrouprefuse aLimitof zero, which the real operations accept without saying what they return. Refusing it is better than guessing between an empty page and a full one.- Real Cognito leaves the order
AdminListGroupsForUserreturns groups in undocumented. Here it is by precedence, because that is the order thecognito:groupsclaim uses, and it is what a test reading the first group is usually after. UpdateGroupreplaces all three group properties rather than merging, and an omitted one is cleared. Real Cognito says which it does nowhere.- A group’s
RoleArnis stored and reported, and no code assumes that role. It reaches thecognito:rolesandcognito:preferred_roleclaims, which are outside the simulation yet, and identity pools, which are outside the simulation at all. - Group to IAM role mapping is an identity pool feature and is outside the simulation.
- A pool holds the standard user attributes and the ones its
Schemadeclared, and an attribute no schema declares is refused, as is a request settingsub. ADeveloperOnlyAttributeis refused, because adev:attribute needs the developer credentials that read and write it.AdminDeleteUserAttributesis unimplemented, so an attribute can be changed and never removed. An app client’sReadAttributesandWriteAttributesare refused, so every client of a pool sees and sets every attribute the pool holds. EstimatedNumberOfUsersis how many users the pool holds now. Real Cognito refreshes that number periodically rather than on each write, and it can lag there in a way it never does here.- A sign-in by a user of an
ONpool that has registered no factor is refused, because real Cognito answers that one with theMFA_SETUPchallenge, which registers a factor mid-sign-in and is outside the simulation. A user with both factors enabled and neither preferred is refused for the same kind of reason. Real Cognito answers that withSELECT_MFA_TYPE. - An MFA code is texted to the user’s
phone_numberwhatever the pool’sAutoVerifiedAttributessay, and the pool records it as a message with an occasion ofAuthentication, where a test reads it from. Real Cognito delivers it and reports it to nobody. - A
SOFTWARE_TOKEN_MFAchallenge carries noFRIENDLY_DEVICE_NAMEin itsChallengeParameters, because no device is remembered here. - A user’s software token secret is a real RFC 6238 shared secret, and the pool reports the code the
user’s authenticator app would be showing through
SimCognitoUserPool.softwareTokenCode. Real Cognito reports that to nobody, because the code is on the user’s own device, in the way a confirmation code is in the user’s own inbox. VerifySoftwareTokenregisters a token and enables nothing, soSetUserMFAPreferenceis what turns a factor on. Whether real Cognito also activates a TOTP factor on verification alone was not checked against a live account.- A
SetUserMFAPreferencerequest leaves a factor it says nothing about as it was. Real Cognito documents neither replacing nor merging there, and a request naming both factors behaves the same either way. AdminGetUserreports noMFAOptions. That field is the deprecated way of reporting an SMS factor, andUserMFASettingListandPreferredMfaSettingare what report one here.SetUserPoolMfaConfigacceptsSoftwareTokenMfaConfigurationandSmsMfaConfiguration. TheSmsConfigurationinside the latter is refused, in the same wordsCreateUserPoolrefuses the pool’s own. No message is delivered here, and the IAM role Cognito would assume to send one is never assumed.EmailMfaConfigurationis refused because a pool here has noEmailConfigurationto send that message with. AWebAuthnConfigurationis reported back byGetUserPoolMfaConfigand read byStartWebAuthnRegistration, which registers a passkey against the relying party it names.AssociateSoftwareTokenandVerifySoftwareTokentake anAccessTokenand refuse aSession, because theMFA_SETUPchallenge that would issue one is outside the simulation. AFriendlyDeviceNameis refused for the same kind of reason. Device tracking is outside the simulation.UpdateUserPoolreplaces a pool’s settings rather than merging into them, as real Cognito does. A setting the request leaves out goes back to the defaultCreateUserPoolwould have given it. It covers the settings this simulation models:Policies.PasswordPolicy,DeletionProtection,AdminCreateUserConfig.AllowAdminCreateUserOnly,AutoVerifiedAttributes,LambdaConfig,MfaConfigurationand the verification wording. An update carries no factor configuration, and the factors aSetUserPoolMfaConfigrequest set are left alone, as real Cognito leaves them.PoolNameis refused, leaving a pool unrenameable, aSchemais refused because realUpdateUserPoolhas no such input, and every inputCreateUserPoolrefuses is refused here too, in the same words.UpdateUserPoolClientreplaces an app client’s settings the same way. A setting the request leaves out goes back to the defaultCreateUserPoolClientwould have given it.ClientNameis the exception. A client has to have a name and there is no default to reset to, so an update that names none keeps the one the client has.- An update leaves the client’s secret alone.
UpdateUserPoolClienthas noGenerateSecretinput on real Cognito, and a client created without a secret never gains one. - A redeployed template reaches neither update. Sim CloudFormation replaces a resource whose resolved template entry changed rather than updating it in place, whatever the resource type.
PreSignUp,PostConfirmation,PreAuthentication,PostAuthentication,PreTokenGenerationandCustomMessageare the only Lambda triggers that run. Every otherLambdaConfigkey is refused when the pool is created or updated, naming the trigger, because a pool that accepted one would never call the function the template named. The custom challenge triggers would need a challenge loop this simulation lacks, and the migration and inbound federation triggers have no external directory to reach. A simulated identity provider answers with the usersignInAsput there, beyond the reach of a trigger.AdminCreateUserleavesPostConfirmationunfired, here and on real Cognito. It is the tempting place to hang the trigger and the wrong one. A project relying on it would pass here and write no record in production.PostConfirmationreports two sources, beingPostConfirmation_ConfirmSignUpandPostConfirmation_ConfirmForgotPassword. A handler that hangs a profile record off the first confirmation sees the second one too.- A
PreSignUphandler asking to verify an attribute the sign-up did not carry refuses the sign-up withInvalidParameterException. Real Cognito refuses it too, and what it names the error has not been checked against a live account. - A federated first sign-in reports
PreSignUp_ExternalProviderand thenPostConfirmation_ConfirmSignUp, and every sign-in after it reportsPreAuthentication_AuthenticationandPostAuthentication_Authentication. That is the split AWS documents for a federated user, and it is what makes a handler that hangs a profile record off a first sign-in run once for a Google user. autoConfirmUserhas nothing to do on a federated sign-in. The user is created inEXTERNAL_PROVIDERand never passes throughUNCONFIRMED, so there is no confirmation for a handler to skip.autoVerifyEmailandautoVerifyPhoneare applied there as they are for a sign-up.- A local user signing in at managed login runs
PreAuthenticationand leavesPostAuthenticationunfired. The documented table names the first for/loginand the second only for a federated sign-in, and what real Cognito does with the second there was not checked against a live account. - A browser signed in from the managed login session it was already holding runs no sign-in trigger,
which is what the
PreAuthenticationdocs say of a renewed session. - A code exchanged at the token endpoint reports
TokenGeneration_HostedAuthwhere the sign-in happened at an identity provider, andTokenGeneration_Authenticationwhere the pool signed in one of its own users. Real Cognito reports the same two. A browser signed in from the managed login session it was already holding reports the local source whichever way that session started, because the session records nothing about the method that began it. - A
PostConfirmationorPostAuthenticationhandler that throws fails the request, and what it ran after stands. A confirmed user stays confirmed and the tokens a pool issued stay issued, as they do on real Cognito. - Neither sign-in trigger fires for
REFRESH_TOKEN_AUTH, as neither does on real Cognito.PreTokenGenerationdoes fire there, because the pool is issuing tokens. PreTokenGenerationConfigis refused, and the trigger runs atV1_0alone. TheV2_0andV3_0events customise access token claims and carryscopesToAddandscopesToSuppress, none of which is simulated. The one change aV1_0event makes to an access token, replacing itscognito:groups, is applied.- A
V1_0claim value is a string, and a handler returning anything else is refused. The complex claim values arrived with theV2_0event. claimsToAddOrOverrideandclaimsToSuppressreach the id token alone, because aV1_0event customises that token.cognito:groupson an access token is changed throughgroupOverrideDetails, what real Cognito calls the only change aV1_0event makes to an access token.claimsToSuppressnaming acognito:claim other thancognito:groupsis refused, since real Cognito suppresses none of the others.- A
PreTokenGenerationresponse naming a claim real Cognito reserves is refused, where real Cognito ignores one. That is a deliberate divergence. A claim that silently goes missing in production is the failure a test with a trigger in it is there to catch. groupOverrideDetails.iamRolesToOverrideandpreferredRoleare refused, and the request’sgroupConfigurationcarries neither, because thecognito:rolesandcognito:preferred_roleclaims they feed go unissued here. A handler copying the request’sgroupConfigurationback into its response, the way real Cognito says to leave the groups alone, works unchanged.ClientMetadatareachesPreTokenGenerationfromRespondToAuthChallengeandAdminRespondToAuthChallengealone, as it does on real Cognito. A refresh accepts one and it reaches nothing.- A
CustomMessageevent reportsCLIENT_ID_NOT_APPLICABLEas itscallerContext.clientIdfor anAdminCreateUserand anAdminResetUserPassword, as real Cognito does for an admin operation, and names the app client for the occasions that come through one. - Unsimulated
CreateUserPoolinputs are refused, never ignored:AliasAttributes,UsernameConfiguration,UserAttributeUpdateSettings,DeviceConfiguration,UserPoolAddOns,KeyConfiguration,IssuerConfiguration,UserPoolTags, the email and SMS configurations, anSmsAuthenticationMessage, aUserPoolTierother thanESSENTIALS, and aPasswordHistorySize. Policies.SignInPolicyis recorded and reported back byDescribeUserPool. The four factor names Cognito accepts arePASSWORD,EMAIL_OTP,SMS_OTPandWEB_AUTHN, a list names five at most, and a policy offeringWEB_AUTHNand nothing else is refused however many times it repeats the name, as AWS states it must be accompanied by at least one other option. AUSER_AUTHsign-in reads the policy to decide what to offer, andPASSWORDandWEB_AUTHNare the two factors this simulation presents.WebAuthnRelyingPartyIDandWebAuthnUserVerificationdeploy from anAWS::Cognito::UserPoolResource. Real CloudFormation configures both in aSetUserPoolMfaConfigcall once the pool exists, and this deploys them the same way, so a stack declaring passkeys needscognito-idp:SetUserPoolMfaConfigas well ascognito-idp:CreateUserPool. A relying party ID is a domain of between one and 127 characters, and one outside that is refused.AccountRecoverySettingis recorded and reported back byDescribeUserPool, and no code reads it. Any mechanisms Cognito has are accepted, in any order, and a setting outside the shape Cognito states is refused.ForgotPasswordpicks its destination from the pool’sAutoVerifiedAttributes(emailbeforephone_number), so a pool that recovers by email alone behaves here exactly as one that recovers by phone number would.AdminCreateUserConfig.AllowAdminCreateUserOnlyis acted on, and the two keys beside it are refused.InviteMessageTemplateis the wording of the invitation, and a pool cannot set its own yet, leaving an invitation recorded at Cognito’s default wording.UnusedAccountValidityDaysexpires a temporary password, and no code here expires one.UsernameAttributesis simulated, andAliasAttributesis outside it. The two differ in what the username is. AUsernameAttributespool generates one, and that is what this stores. AnAliasAttributespool keeps the username the request chose and takes the attribute as a second way of naming the user, and that is unmodelled.- A
UsernameAttributespool resolves the address for its admin operations and its sign-ins, and refuses a second user holding an address another user already signs in by. - Unsimulated
CreateUserPoolClientinputs are refused the same way. They are aClientSecretof your own,AnalyticsConfiguration,EnablePropagateAdditionalUserContextData,ReadAttributes,WriteAttributes, and anEnableTokenRevocationoffalse.UpdateUserPoolClientrefuses the same inputs, in the same words. - The OAuth settings need
AllowedOAuthFlowsUserPoolClientto be true before they can be set, as they do on real Cognito.AllowedOAuthFlowstakescodealone:implicithands tokens to the browser, andclient_credentialsneeds the resource servers that define its scopes, so both are refused.AllowedOAuthScopestakes the system scopes, and a custom scope is refused for the same reason. - Unsimulated authentication inputs are refused the same way. They are
AnalyticsMetadataon all four operations,ContextDataon the admin ones,UserContextDataon the client ones, and aSessiononInitiateAuthorAdminInitiateAuth. A challenge this simulation issued is answered throughRespondToAuthChallengeorAdminRespondToAuthChallengerather than by starting a fresh sign-in that carries the session. - A pool’s schema is settled when the pool is created.
AddCustomAttributesis unimplemented, and anUpdateUserPoolrequest carrying aSchemais refused, because realUpdateUserPoolhas no such input. - The managed login pages approximate what real managed login looks like and go no closer. Their
stylesheet is a few dozen lines held inline, where real managed login is built on Cloudscape.
There is no script on them, and
AWS::Cognito::ManagedLoginBrandingis an unsupported resource type. They are also at paths of this simulation’s own. Real managed login serves its sign-in form at/loginand confirms a sign-up within/signup, where here the authorize endpoint answers with the form itself and/confirmis a page./oauth2/userInfo,/oauth2/revoke,/oauth2/idpresponseand the SAML endpoints go unserved. - The pages carry the authorize parameters in hidden inputs, where real managed login carries them
in a
page-datacookie. Thecognitosession cookie is set and read, and theXSRF-TOKEN,csrf-state,langandpage-datacookies real managed login also sets go unset. - A hosted sign-in that real managed login would answer with a further page is refused. That is a
user which has registered a second factor, and a user holding a temporary password. Both
challenges are simulated at
InitiateAuthandAdminInitiateAuth, which is where a test drives them. A user inRESET_REQUIREDis refused there as well, where real managed login prompts for a new password at sign-in. The served reset pages are the ones a person reaches from the sign-in form, and they start a reset rather than finishing one an administrator forced. - The implicit grant is refused, and so is the client credentials grant, which needs resource servers.
- A web ACL in front of a pool sees the endpoints this simulation serves and no others. The
user-interactive endpoints real Cognito also has at
/login,/resendcode,/confirmUserand/passkeys/addare unserved here, so a rule written for one of them is never reached. The/<pool-id>/messageslisting is outside the web ACL, because real Cognito serves nothing there. - The public user pool API operations reached over the SDK,
SignUpandInitiateAuthamong them, are evaluated against no web ACL. Real WAF inspects them, including their bodies. They arrive here as SDK Commands and carry no HTTP request for a rule to read. - The served OpenID configuration names its
authorization_endpoint,token_endpointandend_session_endpointonce the pool has a domain, at that domain’s local hostname. It carries nouserinfo_endpoint, where real Cognito names one. - Nothing calls an external identity provider. A provider’s
ProviderDetailsare recorded and validated for presence, and never used. The user a simulated provider signs in is the onesignInAsput there, or the one the served stand-in page for that provider posted back. Both are divergences for the same reasonconfirmationCodeis one. - The stand-in page is served, and an authorize request that reaches
hostedAuthorizedirectly is refused instead, namingsignInAs. Only the serving layer can answer a caller with a page. - A custom domain answers on its own hostname with no Route53 record of its own, where real AWS needs an alias record to the CloudFront distribution Cognito creates. The distribution name a domain reports is a name unserved here.
/logoutends the browser’s managed login session and redirects. It signs nobody out at the identity provider, which real Cognito also leaves undone, so a user signed out here is still signed in at Google.- A managed login session is reused for any authorize request that names no identity provider and
carries no credentials. Real Cognito records which method started the session, and a request
naming a provider here signs in at that provider afresh whatever the browser is holding.
promptgoes unread, so there is no way to ask for a sign-in the session cannot answer. - A pool creates no group for each identity provider, where real Cognito creates one named
<userPoolId>_<ProviderName>and puts each federated user in it. Acognito:groupsclaim here therefore names only the groups something added the user to. - A federated sign-in is refused with
UsernameExistsExceptionwhere the username it would take is already a user of the pool’s own. A username may hold an underscore, soGoogle_1234can be a local user, and signing in as it would hand the application someone else’s account. AdminLinkProviderForUseris unimplemented, and a federated user is never linked to a user that was already in the pool, and theidentitiesit carries always names one provider.- The
PreAuthentication,PostAuthenticationand migrate user triggers do not fire on a sign-in at the hosted domain, federated or local.PreTokenGenerationdoes, at the token endpoint where the claims are settled. - A domain reports no
S3BucketorVersion, both of which name parts of the machinery real Cognito builds a domain out of. ItsStatusisACTIVEas soon as it exists, where a real prefix domain takes a minute and a custom domain up to an hour. - A custom domain’s
CertificateArnis required and recorded, and goes unresolved against simulated ACM. Nothing here terminates TLS. - A
SupportedIdentityProvidersnaming a provider the pool lacks is accepted, and the authorize request naming that provider is what refuses. Real Cognito checks the provider exists when the app client is written. - The served
issuerandjwks_uriname the localhost origin the request arrived on, letting a client fetch the keys they point at. A token’sissclaim still names the realhttps://cognito-idp.<region>.amazonaws.com/<userPoolId>. The two disagree here and agree on real Cognito. - Resource servers and risk configuration are outside the simulation.
- Tags are outside the simulation.
UserPoolTagsis refused, andTagResource,UntagResourceandListTagsForResourceare unimplemented. - Listings carry no filtering, and are in creation order rather than any order real Cognito chooses.
- Of the CloudFormation resource types,
AWS::Cognito::UserPool,AWS::Cognito::UserPoolClient,AWS::Cognito::UserPoolGroup,AWS::Cognito::UserPoolDomainandAWS::Cognito::UserPoolIdentityProviderdeploy. The others, includingAWS::Cognito::UserPoolResourceServer,AWS::Cognito::UserPoolUserand everything underAWS::Cognito::IdentityPool, are reported as unsupported and skipped, never deployed. - The Cognito API itself is not served as HTTP by
serveSimAws, only the two public pool endpoints. ACognitoIdentityProviderClientreaches the simulator throughSimSdkrather than through an endpoint override. - Cognito identity pools are a different service and nothing about them is simulated.
