Skip to content

feat(aws-cognito): groups, sign-up and password sign-in with RS256 tokens (C2) - #1440

Merged
NitinKumar004 merged 3 commits into
developmentfrom
feat/aws-cognito-c2
Oct 4, 2026
Merged

NitinKumar004 merged 3 commits into
developmentfrom
feat/aws-cognito-c2

Conversation

@NitinKumar004

@NitinKumar004 NitinKumar004 commented Oct 4, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Cognito C2 from build-out plan A: groups, self sign-up, and password sign-in with real RS256 tokens. This is the first production user of internal/jwtsign.

The plan's C2 row covers groups, sign-up/confirm, the codes endpoint and SECRET_HASH. The token core from C3 is included as well (InitiateAuth, challenges, JWKS, GetUser, sign-out, revoke). Without it, nothing that signs up can sign in. C3 still owns the CloudWatch metrics, --cognito-issuer-base and the exported TokenVerifier for API Gateway and AppSync.

Groups

  • CreateGroup, GetGroup, UpdateGroup, DeleteGroup and ListGroups (Limit up to 60, paginated). A duplicate name returns GroupExistsException.
  • AdminAddUserToGroup, AdminRemoveUserFromGroup, AdminListGroupsForUser and ListUsersInGroup.
  • Deleting a group removes its memberships. Deleting a pool removes its groups, logins and signing keys.

Sign-up

  • SignUp creates an UNCONFIRMED user and returns UserSub plus masked CodeDeliveryDetails (for example a***@e***). It checks the pool password policy (InvalidPasswordException with the real message), required schema attributes, sign-in uniqueness, and UsernameExistsException.
  • ConfirmSignUp handles CodeMismatchException, ExpiredCodeException (codes are 6 digits and last 24h on the clock) and NotAuthorizedException for a user who is already confirmed. A confirmed user gets email_verified=true. Also adds ResendConfirmationCode and AdminConfirmSignUp.
  • GET /_cloudemu/cognito/codes?userPoolId=&username= returns the code the emulator would have sent. Under --enforce-auth it needs the admin token, like every other admin endpoint.
  • SECRET_HASH is checked as Base64(HMAC-SHA256(secret, username+clientId)) on every client-side call, using the real "configured for secret but secret was not received" and "Unable to verify secret hash" messages.

Sign-in and tokens

  • InitiateAuth supports USER_PASSWORD_AUTH and REFRESH_TOKEN(_AUTH). AdminInitiateAuth supports ADMIN_USER_PASSWORD_AUTH, ADMIN_NO_SRP_AUTH and refresh.
  • Each client's ExplicitAuthFlows is enforced, including the legacy names. A disallowed flow returns "USER_PASSWORD_AUTH flow not enabled for this client" or "Auth flow not enabled for this client".
  • Sign-in errors follow real Cognito:
    • wrong password: NotAuthorizedException "Incorrect username or password.";
    • unknown user: UserNotFoundException, or NotAuthorizedException when PreventUserExistenceErrors is ENABLED;
    • other states: UserNotConfirmedException, "User is disabled." and PasswordResetRequiredException.
  • RespondToAuthChallenge and AdminRespondToAuthChallenge handle NEW_PASSWORD_REQUIRED.
    • The challenge returns userAttributes and requiredAttributes.
    • The session is single-use and expires after AuthSessionValidity minutes.
    • The new password is checked against the pool policy.
  • ID and access tokens are signed with two different per-pool RSA keys, so the two token types have different kids.
    • Shared claims: iss=https://cognito-idp.<region>.amazonaws.com/<poolId>, sub, origin_jti, event_id, jti, auth_time, iat, exp and cognito:groups (in precedence order).
    • ID token: aud, token_use=id, cognito:username and the user attributes (email_verified as a boolean). It also carries cognito:roles and cognito:preferred_role when a group has a role.
    • Access token: client_id, token_use=access, scope=aws.cognito.signin.user.admin and username.
    • Lifetimes follow the client's validity settings and units.
  • Refresh tokens are opaque and have the 5-segment JWE shape. Only their SHA-256 is stored. A refresh keeps origin_jti and auth_time and does not issue a new refresh token.
  • GetUser takes an access token. It rejects expired, tampered, ID-type, revoked and deleted-user tokens with the real messages.
  • GlobalSignOut and AdminUserGlobalSignOut revoke every login of the user. RevokeToken revokes one login, returns UnsupportedOperationException when token revocation is off, and returns UnsupportedTokenTypeException for a non-refresh token.
  • GET /<poolId>/.well-known/jwks.json and /openid-configuration are public GETs, and only those exact paths are exempt under --enforce-auth. They register before S3.
  • Signing keys, groups and logins go into the snapshot (keys as PKCS#8), so tokens stay valid across --persist restarts. Challenge sessions are not persisted.
  • Passwords are verified with the PBKDF2 hash (and legacy SHA-256 hashes) from the C1 follow-up. verifyPassword moved from the test file into the package now that sign-in uses it.

Tests

  • Provider tests were written first and failed to build on the old code. They cover the status machine, the error table, SECRET_HASH, code expiry, the claim table with token signatures checked independently with crypto/rsa against the JWKS, expiry, tampering, sign-out, revoke, and a snapshot round trip that keeps the JWKS byte-identical and old tokens valid.
  • An SDK test runs sign-up through sign-out with anonymous credentials and verifies the token signatures against the JWKS fetched over HTTP.
  • An enforce-auth test runs the public flow unsigned and confirms that admin ops, other .well-known paths and POST to the JWKS path still get 403.
  • The authbypass case that expected the JWKS GET to be gated before Cognito served it is removed. The path is public now.

E2E (cloudemu serve, port 64366)

  • aws CLI with --no-sign-request, both in default mode and with --enforce-auth (IAM admin seeded through the admin token): 26/26 checks passed in each mode.
    • Covered: sign-up, the confirm error cases, resend, confirm with the code from the admin endpoint, initiate-auth, get-user, refresh, global-sign-out (the old and refreshed access tokens and the refresh token are then rejected), revoke-token, and the error cases (wrong password, unknown user, flow not enabled, missing SECRET_HASH).
    • The ID and access token signatures were verified with openssl against the curl'd JWKS.
  • Restart with --persist: the JWKS is byte-identical, and the access and refresh tokens issued before the restart still work.
  • Terraform (aws_cognito_user_pool with a password policy, a client with explicit_auth_flows, aws_cognito_user_group, aws_cognito_user, aws_cognito_user_in_group): apply, plan clean, sign in as the Terraform user, update the description and flows, plan clean, AdminInitiateAuth on the new flow, destroy. This passed in both modes. Under --enforce-auth the provider block was written out by hand, because cloudemu-tf pins test/test credentials.

Review fixes

  • RespondToAuthChallenge and AdminRespondToAuthChallenge check the user again before applying the new password and issuing tokens. A user who was disabled, reset or confirmed after the challenge was issued is refused, and nothing changes. The user must still be in FORCE_CHANGE_PASSWORD, and the session is tied to the user's sub, so a deleted and re-created user with the same name cannot take it over.
  • Refresh and every access-token call (GetUser, GlobalSignOut) apply the same check, so a disabled, unconfirmed or reset-required user gets the real error instead of new tokens.
  • When PreventUserExistenceErrors is ENABLED, ConfirmSignUp for an unknown user returns CodeMismatchException and ResendConfirmationCode returns a simulated delivery. A sign-in with an unknown username still runs a PBKDF2 hash, so its response time matches a wrong password.
  • App clients reject token validity outside the real ranges with InvalidParameterException "Invalid range for token validity.": access and ID tokens must be 5 minutes to 1 day, refresh tokens 60 minutes to 10 years. An unknown unit is also rejected, and 0 means the default. The old SDK test that used a 2-minute access token now uses 15 minutes.
  • The IAM service prefix "cognito-idp" is now a constant.

Deferred

  • Brute-force throttling: real Cognito locks sign-in after repeated wrong passwords and limits wrong confirmation codes (TooManyFailedAttemptsException / LimitExceededException). Neither cap is modelled yet.

Not in this PR

  • Unsigned requests always route to the default region, because the region mux reads SigV4 scope. Routing them by the pool id prefix is left for C3.
  • AdminCreateUserConfig.AllowAdminCreateUserOnly is not modeled, so SignUp is never blocked by it.
  • USER_SRP_AUTH, CUSTOM_AUTH and USER_AUTH return "not supported by cloudemu yet". They belong to C5, C6 and later.


rest, ok := strings.CutPrefix(stored, pbkdf2Prefix)
if !ok {
sum := sha256.Sum256([]byte(salt + pw))
@NitinKumar004
NitinKumar004 marked this pull request as ready for review October 4, 2026 12:16
@NitinKumar004
NitinKumar004 merged commit 7bffd39 into development Oct 4, 2026
22 of 23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants