diff --git a/README.md b/README.md index 394b5f7d..6e459daa 100644 --- a/README.md +++ b/README.md @@ -1323,7 +1323,7 @@ grant_type=refresh_token" ## AWS -S3, SQS, IAM, and STS emulation with AWS SDK-compatible S3 paths. S3 uploads and downloads preserve arbitrary binary payloads, including raw byte lengths and ETags. SQS supports AWS JSON 1.0 and XML Query protocols; IAM and STS use XML Query. +S3, SQS, Cognito, IAM, and STS emulation with AWS SDK-compatible S3 paths. S3 uploads and downloads preserve arbitrary binary payloads, including raw byte lengths and ETags. SQS supports AWS JSON 1.0 and XML Query protocols; IAM and STS use XML Query. ### S3 @@ -1373,6 +1373,56 @@ All operations via `POST /iam/` with `Action` parameter: - `CreateAccessKey`, `ListAccessKeys`, `DeleteAccessKey` - `CreateRole`, `GetRole`, `ListRoles`, `DeleteRole` +### Cognito + +Use `CognitoIdentityProviderClient` with endpoint `/cognito-idp` and `CognitoIdentityClient` with `/cognito-identity`. Both accept AWS JSON 1.1 and work with the official AWS SDK v3: + +```typescript +import { CognitoIdentityProviderClient, InitiateAuthCommand } from "@aws-sdk/client-cognito-identity-provider"; + +const cognito = new CognitoIdentityProviderClient({ + endpoint: "http://localhost:4007/cognito-idp", + region: "us-east-1", + credentials: { accessKeyId: "local-access-key", secretAccessKey: "local-secret" }, +}); +const result = await cognito.send(new InitiateAuthCommand({ + ClientId: "emulateclient", + AuthFlow: "USER_PASSWORD_AUTH", + AuthParameters: { USERNAME: "developer", PASSWORD: "LocalPass123!" }, +})); +``` + +A default User Pool `us-east-1_emulate`, public app client `emulateclient`, and Identity Pool `us-east-1:00000000-0000-4000-8000-000000000001` are created without users. Seed a confirmed account for the example above: + +```yaml +aws: + cognito: + user_pools: + - id: us-east-1_emulate + confirmation_code: "123456" + users: + - username: developer + password: LocalPass123! + attributes: + email: dev@example.com + email_verified: "true" + name: Developer +``` + +User pools also accept `name` and `clients` with `client_id`, optional `client_secret`, exact `redirect_uris`, and allowed `scopes`. Users accept `status` (`CONFIRMED`, `UNCONFIRMED`, or `FORCE_CHANGE_PASSWORD`) and `enabled`. Passwords require at least eight characters and are stored as salted hashes. Confidential clients require Cognito's HMAC `SecretHash` or `SECRET_HASH` for SDK flows. + +- `SignUp`, `ConfirmSignUp`, `ResendConfirmationCode`, `ForgotPassword`, and `ConfirmForgotPassword` model account confirmation and recovery. Codes expire after ten minutes and use the pool's `confirmation_code` fixture, default `123456`. No email or SMS is sent. Inspect active codes at `/_inspector?tab=cognito`; seeded unconfirmed users can request a code with `ResendConfirmationCode`. +- `InitiateAuth` supports `USER_PASSWORD_AUTH`, `REFRESH_TOKEN_AUTH`, and `REFRESH_TOKEN`. `AdminInitiateAuth` also supports `ADMIN_USER_PASSWORD_AUTH`. Admin-created temporary passwords return `NEW_PASSWORD_REQUIRED`, completed with `RespondToAuthChallenge` or `AdminRespondToAuthChallenge`. Challenge sessions expire after three minutes and are bound to the user and client. +- `GetUser`, `ChangePassword`, `GlobalSignOut`, and `RevokeToken` use issued tokens. `AdminCreateUser` (with `TemporaryPassword`), `AdminGetUser`, `AdminConfirmSignUp`, `AdminSetUserPassword`, `AdminEnableUser`, `AdminDisableUser`, `AdminDeleteUser`, and paginated `ListUsers` support test setup and administration. + +Tokens are RS256 JWTs with Cognito `token_use` claims. ID tokens identify the app client in `aud`; access tokens use `client_id`. ID and access tokens expire after one hour; refresh tokens after thirty days. Sign-out, revocation, disabling, deletion, and password resets invalidate affected sessions. Users, sessions, identities, and signing keys participate in snapshots, restore, and reset. + +The local issuer is `http://localhost:4007/cognito-idp/USER_POOL_ID`. Discovery and public keys are served at `ISSUER/.well-known/openid-configuration` and `ISSUER/.well-known/jwks.json`. Configure token verifiers with this local issuer and JWKS. `ISSUER/oauth2/authorize` presents a local user picker, `ISSUER/oauth2/token` exchanges authorization codes or refresh tokens, and `ISSUER/oauth2/userInfo` returns scoped profile claims. Authorization codes expire after five minutes, are single-use, and bind the client, exact redirect URI, and optional S256 PKCE challenge. Client authentication accepts `client_secret_basic` or `client_secret_post`. The default client permits `http://localhost:3000/callback`. + +Identity Pools support `GetId` and `GetCredentialsForIdentity`. Supply the ID token in `Logins` under `cognito-idp.REGION.amazonaws.com/USER_POOL_ID`. The default Identity Pool accepts the default User Pool and app client. Custom `aws.cognito.identity_pools` entries take `id`, optional `name`, `providers: [{ user_pool_id, client_id }]`, and `allow_unauthenticated_identities` (default `false`). Identities remain stable for the same authenticated subject. Credentials expire after one hour; login signatures, expiry, audience, and identity ownership are checked. + +SRP, MFA, external identity providers, Lambda triggers, pool/client management APIs, `ListUsers` filters, and Identity Pool role selection are not simulated. Administrative requests follow the AWS emulator's permissive credential behavior: SigV4 signatures and IAM policies are not evaluated. Identity Pool credentials are generated test credentials, without role or policy evaluation. + ### STS All operations via `POST /sts/` with `Action` parameter: - `GetCallerIdentity`, `AssumeRole` diff --git a/apps/web/content/docs/aws.mdx b/apps/web/content/docs/aws.mdx index 79d8d579..19fb7d7c 100644 --- a/apps/web/content/docs/aws.mdx +++ b/apps/web/content/docs/aws.mdx @@ -2,7 +2,7 @@ title: "AWS" --- -S3, SQS, IAM, and STS emulation with AWS SDK-compatible S3 paths. S3 uploads and downloads preserve arbitrary binary payloads, including raw byte lengths and ETags. SQS supports AWS JSON 1.0 and XML Query protocols; IAM and STS use XML Query. All state is in-memory. +S3, SQS, Cognito, IAM, and STS emulation with AWS SDK-compatible S3 paths. S3 uploads and downloads preserve arbitrary binary payloads, including raw byte lengths and ETags. SQS supports AWS JSON 1.0 and XML Query protocols; IAM and STS use XML Query. All state is in-memory. ## S3 @@ -59,6 +59,56 @@ All IAM operations use `POST /iam/` with an `Action` form parameter. - `CreateAccessKey` / `ListAccessKeys` / `DeleteAccessKey` - access key management - `CreateRole` / `GetRole` / `ListRoles` / `DeleteRole` - role management +## Cognito + +Use `CognitoIdentityProviderClient` with endpoint `/cognito-idp` and `CognitoIdentityClient` with `/cognito-identity`. Both accept AWS JSON 1.1 and work with the official AWS SDK v3: + +```typescript +import { CognitoIdentityProviderClient, InitiateAuthCommand } from "@aws-sdk/client-cognito-identity-provider"; + +const cognito = new CognitoIdentityProviderClient({ + endpoint: "http://localhost:4007/cognito-idp", + region: "us-east-1", + credentials: { accessKeyId: "local-access-key", secretAccessKey: "local-secret" }, +}); +const result = await cognito.send(new InitiateAuthCommand({ + ClientId: "emulateclient", + AuthFlow: "USER_PASSWORD_AUTH", + AuthParameters: { USERNAME: "developer", PASSWORD: "LocalPass123!" }, +})); +``` + +A default User Pool `us-east-1_emulate`, public app client `emulateclient`, and Identity Pool `us-east-1:00000000-0000-4000-8000-000000000001` are created without users. Seed a confirmed account for the example above: + +```yaml +aws: + cognito: + user_pools: + - id: us-east-1_emulate + confirmation_code: "123456" + users: + - username: developer + password: LocalPass123! + attributes: + email: dev@example.com + email_verified: "true" + name: Developer +``` + +User pools also accept `name` and `clients` with `client_id`, optional `client_secret`, exact `redirect_uris`, and allowed `scopes`. Users accept `status` (`CONFIRMED`, `UNCONFIRMED`, or `FORCE_CHANGE_PASSWORD`) and `enabled`. Passwords require at least eight characters and are stored as salted hashes. Confidential clients require Cognito's HMAC `SecretHash` or `SECRET_HASH` for SDK flows. + +- `SignUp`, `ConfirmSignUp`, `ResendConfirmationCode`, `ForgotPassword`, and `ConfirmForgotPassword` model account confirmation and recovery. Codes expire after ten minutes and use the pool's `confirmation_code` fixture, default `123456`. No email or SMS is sent. Inspect active codes at `/_inspector?tab=cognito`; seeded unconfirmed users can request a code with `ResendConfirmationCode`. +- `InitiateAuth` supports `USER_PASSWORD_AUTH`, `REFRESH_TOKEN_AUTH`, and `REFRESH_TOKEN`. `AdminInitiateAuth` also supports `ADMIN_USER_PASSWORD_AUTH`. Admin-created temporary passwords return `NEW_PASSWORD_REQUIRED`, completed with `RespondToAuthChallenge` or `AdminRespondToAuthChallenge`. Challenge sessions expire after three minutes and are bound to the user and client. +- `GetUser`, `ChangePassword`, `GlobalSignOut`, and `RevokeToken` use issued tokens. `AdminCreateUser` (with `TemporaryPassword`), `AdminGetUser`, `AdminConfirmSignUp`, `AdminSetUserPassword`, `AdminEnableUser`, `AdminDisableUser`, `AdminDeleteUser`, and paginated `ListUsers` support test setup and administration. + +Tokens are RS256 JWTs with Cognito `token_use` claims. ID tokens identify the app client in `aud`; access tokens use `client_id`. ID and access tokens expire after one hour; refresh tokens after thirty days. Sign-out, revocation, disabling, deletion, and password resets invalidate affected sessions. Users, sessions, identities, and signing keys participate in snapshots, restore, and reset. + +The local issuer is `http://localhost:4007/cognito-idp/USER_POOL_ID`. Discovery and public keys are served at `ISSUER/.well-known/openid-configuration` and `ISSUER/.well-known/jwks.json`. Configure token verifiers with this local issuer and JWKS. `ISSUER/oauth2/authorize` presents a local user picker, `ISSUER/oauth2/token` exchanges authorization codes or refresh tokens, and `ISSUER/oauth2/userInfo` returns scoped profile claims. Authorization codes expire after five minutes, are single-use, and bind the client, exact redirect URI, and optional S256 PKCE challenge. Client authentication accepts `client_secret_basic` or `client_secret_post`. The default client permits `http://localhost:3000/callback`. + +Identity Pools support `GetId` and `GetCredentialsForIdentity`. Supply the ID token in `Logins` under `cognito-idp.REGION.amazonaws.com/USER_POOL_ID`. The default Identity Pool accepts the default User Pool and app client. Custom `aws.cognito.identity_pools` entries take `id`, optional `name`, `providers: [{ user_pool_id, client_id }]`, and `allow_unauthenticated_identities` (default `false`). Identities remain stable for the same authenticated subject. Credentials expire after one hour; login signatures, expiry, audience, and identity ownership are checked. + +SRP, MFA, external identity providers, Lambda triggers, pool/client management APIs, `ListUsers` filters, and Identity Pool role selection are not simulated. Administrative requests follow the AWS emulator's permissive credential behavior: SigV4 signatures and IAM policies are not evaluated. Identity Pool credentials are generated test credentials, without role or policy evaluation. + ## STS All STS operations use `POST /sts/` with an `Action` form parameter. @@ -68,4 +118,4 @@ All STS operations use `POST /sts/` with an `Action` form parameter. ## Inspector -The emulator serves an HTML dashboard at `GET /_inspector` with `tab` query parameter (`s3`, `sqs`, `iam`) for browsing current state. +The emulator serves an HTML dashboard at `GET /_inspector` with `tab` query parameter (`s3`, `sqs`, `iam`, `cognito`) for browsing current state. diff --git a/packages/@emulators/aws/README.md b/packages/@emulators/aws/README.md index 5ccdcf61..b7f0f42d 100644 --- a/packages/@emulators/aws/README.md +++ b/packages/@emulators/aws/README.md @@ -1,6 +1,6 @@ # @emulators/aws -S3, SQS, IAM, and STS emulation with AWS SDK-compatible S3 paths and query-style SQS/IAM/STS endpoints. S3 uploads and downloads preserve arbitrary binary payloads, including raw byte lengths and ETags. All responses use AWS-compatible XML. +S3, SQS, Cognito, IAM, and STS emulation with AWS SDK-compatible S3 paths, JSON SQS/Cognito endpoints, and XML Query IAM/STS endpoints. S3 uploads and downloads preserve arbitrary binary payloads, including raw byte lengths and ETags. Responses use the corresponding AWS wire format. Part of [emulate](https://github.com/vercel-labs/emulate) — local drop-in replacement services for CI and no-network sandboxes. @@ -39,6 +39,56 @@ All operations via `POST /iam/` with `Action` parameter: - `CreateAccessKey`, `ListAccessKeys`, `DeleteAccessKey` - `CreateRole`, `GetRole`, `ListRoles`, `DeleteRole` +### Cognito + +Use `CognitoIdentityProviderClient` with endpoint `/cognito-idp` and `CognitoIdentityClient` with `/cognito-identity`. Both accept AWS JSON 1.1 and work with the official AWS SDK v3: + +```typescript +import { CognitoIdentityProviderClient, InitiateAuthCommand } from "@aws-sdk/client-cognito-identity-provider"; + +const cognito = new CognitoIdentityProviderClient({ + endpoint: "http://localhost:4007/cognito-idp", + region: "us-east-1", + credentials: { accessKeyId: "local-access-key", secretAccessKey: "local-secret" }, +}); +const result = await cognito.send(new InitiateAuthCommand({ + ClientId: "emulateclient", + AuthFlow: "USER_PASSWORD_AUTH", + AuthParameters: { USERNAME: "developer", PASSWORD: "LocalPass123!" }, +})); +``` + +A default User Pool `us-east-1_emulate`, public app client `emulateclient`, and Identity Pool `us-east-1:00000000-0000-4000-8000-000000000001` are created without users. Seed a confirmed account for the example above: + +```yaml +aws: + cognito: + user_pools: + - id: us-east-1_emulate + confirmation_code: "123456" + users: + - username: developer + password: LocalPass123! + attributes: + email: dev@example.com + email_verified: "true" + name: Developer +``` + +User pools also accept `name` and `clients` with `client_id`, optional `client_secret`, exact `redirect_uris`, and allowed `scopes`. Users accept `status` (`CONFIRMED`, `UNCONFIRMED`, or `FORCE_CHANGE_PASSWORD`) and `enabled`. Passwords require at least eight characters and are stored as salted hashes. Confidential clients require Cognito's HMAC `SecretHash` or `SECRET_HASH` for SDK flows. + +- `SignUp`, `ConfirmSignUp`, `ResendConfirmationCode`, `ForgotPassword`, and `ConfirmForgotPassword` model account confirmation and recovery. Codes expire after ten minutes and use the pool's `confirmation_code` fixture, default `123456`. No email or SMS is sent. Inspect active codes at `/_inspector?tab=cognito`; seeded unconfirmed users can request a code with `ResendConfirmationCode`. +- `InitiateAuth` supports `USER_PASSWORD_AUTH`, `REFRESH_TOKEN_AUTH`, and `REFRESH_TOKEN`. `AdminInitiateAuth` also supports `ADMIN_USER_PASSWORD_AUTH`. Admin-created temporary passwords return `NEW_PASSWORD_REQUIRED`, completed with `RespondToAuthChallenge` or `AdminRespondToAuthChallenge`. Challenge sessions expire after three minutes and are bound to the user and client. +- `GetUser`, `ChangePassword`, `GlobalSignOut`, and `RevokeToken` use issued tokens. `AdminCreateUser` (with `TemporaryPassword`), `AdminGetUser`, `AdminConfirmSignUp`, `AdminSetUserPassword`, `AdminEnableUser`, `AdminDisableUser`, `AdminDeleteUser`, and paginated `ListUsers` support test setup and administration. + +Tokens are RS256 JWTs with Cognito `token_use` claims. ID tokens identify the app client in `aud`; access tokens use `client_id`. ID and access tokens expire after one hour; refresh tokens after thirty days. Sign-out, revocation, disabling, deletion, and password resets invalidate affected sessions. Users, sessions, identities, and signing keys participate in snapshots, restore, and reset. + +The local issuer is `http://localhost:4007/cognito-idp/USER_POOL_ID`. Discovery and public keys are served at `ISSUER/.well-known/openid-configuration` and `ISSUER/.well-known/jwks.json`. Configure token verifiers with this local issuer and JWKS. `ISSUER/oauth2/authorize` presents a local user picker, `ISSUER/oauth2/token` exchanges authorization codes or refresh tokens, and `ISSUER/oauth2/userInfo` returns scoped profile claims. Authorization codes expire after five minutes, are single-use, and bind the client, exact redirect URI, and optional S256 PKCE challenge. Client authentication accepts `client_secret_basic` or `client_secret_post`. The default client permits `http://localhost:3000/callback`. + +Identity Pools support `GetId` and `GetCredentialsForIdentity`. Supply the ID token in `Logins` under `cognito-idp.REGION.amazonaws.com/USER_POOL_ID`. The default Identity Pool accepts the default User Pool and app client. Custom `aws.cognito.identity_pools` entries take `id`, optional `name`, `providers: [{ user_pool_id, client_id }]`, and `allow_unauthenticated_identities` (default `false`). Identities remain stable for the same authenticated subject. Credentials expire after one hour; login signatures, expiry, audience, and identity ownership are checked. + +SRP, MFA, external identity providers, Lambda triggers, pool/client management APIs, `ListUsers` filters, and Identity Pool role selection are not simulated. Administrative requests follow the AWS emulator's permissive credential behavior: SigV4 signatures and IAM policies are not evaluated. Identity Pool credentials are generated test credentials, without role or policy evaluation. + ### STS All operations via `POST /sts/` with `Action` parameter: - `GetCallerIdentity`, `AssumeRole` diff --git a/packages/@emulators/aws/package.json b/packages/@emulators/aws/package.json index c4d9f8d3..d3107e9f 100644 --- a/packages/@emulators/aws/package.json +++ b/packages/@emulators/aws/package.json @@ -38,9 +38,12 @@ "prepack": "node ../../../scripts/prepare-package.mjs" }, "dependencies": { - "@emulators/core": "workspace:*" + "@emulators/core": "workspace:*", + "jose": "^6.2.12" }, "devDependencies": { + "@aws-sdk/client-cognito-identity": "^3.1150.0", + "@aws-sdk/client-cognito-identity-provider": "^3.1150.0", "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/client-sqs": "^3.1149.0", "@aws-sdk/s3-presigned-post": "^3.1031.0", diff --git a/packages/@emulators/aws/src/__tests__/cognito-sdk.e2e.test.ts b/packages/@emulators/aws/src/__tests__/cognito-sdk.e2e.test.ts new file mode 100644 index 00000000..d97187a8 --- /dev/null +++ b/packages/@emulators/aws/src/__tests__/cognito-sdk.e2e.test.ts @@ -0,0 +1,609 @@ +import { createHash, createHmac } from "node:crypto"; +import type { AddressInfo } from "node:net"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { serve } from "@emulators/core"; +import { createRemoteJWKSet, jwtVerify } from "jose"; +import * as idp from "@aws-sdk/client-cognito-identity-provider"; +import * as identity from "@aws-sdk/client-cognito-identity"; +import { getCognitoStore, seedFromConfig } from "../index.js"; +import { + DEFAULT_CLIENT as ClientId, + DEFAULT_USER_POOL as UserPoolId, + DEFAULT_IDENTITY_POOL as IdentityPoolId, +} from "../cognito/helpers.js"; +import { createTestApp } from "./helpers.js"; + +const password = "LocalPass123!"; +const callback = "http://localhost:3000/callback"; +const provider = `cognito-idp.us-east-1.amazonaws.com/${UserPoolId}`; +let active: ReturnType; +let base: string; +let sdk: idp.CognitoIdentityProviderClient; +let identities: identity.CognitoIdentityClient; +let close: () => Promise; + +beforeAll(async () => { + const server = serve({ fetch: (request) => active.app.fetch(request), port: 0 }); + await new Promise((resolve, reject) => { + server.once("listening", resolve); + server.once("error", reject); + }); + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + close = () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); +}); +beforeEach(() => { + active = createTestApp(base); + seedFromConfig(active.store, base, { + cognito: { + user_pools: [ + { + id: UserPoolId, + clients: [ + { client_id: "other-client", redirect_uris: [callback] }, + { client_id: "private-client", client_secret: "client-secret", redirect_uris: [callback] }, + ], + users: [ + { + username: "alice", + password, + attributes: { email: "alice@example.com", email_verified: "true", name: "Alice" }, + }, + { username: "bob", password }, + ], + }, + ], + }, + }); + const config = { + region: "us-east-1", + credentials: { accessKeyId: "local-access-key", secretAccessKey: "local-secret" }, + maxAttempts: 1, + }; + sdk = new idp.CognitoIdentityProviderClient({ ...config, endpoint: `${base}/cognito-idp` }); + identities = new identity.CognitoIdentityClient({ ...config, endpoint: `${base}/cognito-identity` }); +}); +afterEach(() => { + sdk.destroy(); + identities.destroy(); + vi.restoreAllMocks(); + vi.useRealTimers(); +}); +afterAll(async () => { + await close(); +}); + +async function authenticate(username = "alice", clientId = ClientId, parameters: Record = {}) { + const result = await sdk.send( + new idp.InitiateAuthCommand({ + ClientId: clientId, + AuthFlow: "USER_PASSWORD_AUTH", + AuthParameters: { USERNAME: username, PASSWORD: password, ...parameters }, + }), + ); + return result.AuthenticationResult!; +} +function form(path: string, fields: Record) { + return active.app.request(`${base}${path}`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(fields), + }); +} +async function codeGrant(fields: Record = {}) { + const response = await form(`/cognito-idp/${UserPoolId}/oauth2/authorize`, { + client_id: ClientId, + response_type: "code", + redirect_uri: callback, + scope: "openid email profile", + username: "alice", + ...fields, + }); + expect(response.status).toBe(302); + const location = new URL(response.headers.get("Location")!); + if (fields.state) expect(location.searchParams.get("state")).toBe(fields.state); + return location.searchParams.get("code")!; +} + +describe("Cognito User Pools with the official AWS SDK", () => { + it("confirms sign-up, signs tokens, and publishes verifiable discovery and JWKS", async () => { + const signup = await sdk.send( + new idp.SignUpCommand({ + ClientId, + Username: "new-user", + Password: password, + UserAttributes: [{ Name: "email", Value: "new@example.com" }], + }), + ); + expect(signup).toMatchObject({ + UserConfirmed: false, + UserSub: expect.any(String), + $metadata: { httpStatusCode: 200, requestId: expect.any(String) }, + }); + await expect(authenticate("new-user")).rejects.toMatchObject({ name: "UserNotConfirmedException" }); + await expect( + sdk.send(new idp.ConfirmSignUpCommand({ ClientId, Username: "new-user", ConfirmationCode: "000000" })), + ).rejects.toMatchObject({ name: "CodeMismatchException" }); + await sdk.send(new idp.ConfirmSignUpCommand({ ClientId, Username: "new-user", ConfirmationCode: "123456" })); + const tokens = await authenticate("new-user"); + const issuer = `${base}/cognito-idp/${UserPoolId}`; + const discovery = (await (await fetch(`${issuer}/.well-known/openid-configuration`)).json()) as { + jwks_uri: string; + }; + expect(discovery).toMatchObject({ + issuer, + jwks_uri: `${issuer}/.well-known/jwks.json`, + authorization_endpoint: `${issuer}/oauth2/authorize`, + }); + const jwks = createRemoteJWKSet(new URL(discovery.jwks_uri)); + const verified = await jwtVerify(tokens.IdToken!, jwks, { issuer, audience: ClientId }); + expect(verified.payload).toMatchObject({ + sub: signup.UserSub, + token_use: "id", + email_verified: true, + "cognito:username": "new-user", + }); + const access = await jwtVerify(tokens.AccessToken!, jwks, { issuer }); + expect(access.payload).toMatchObject({ sub: signup.UserSub, token_use: "access", client_id: ClientId }); + expect(await sdk.send(new idp.GetUserCommand({ AccessToken: tokens.AccessToken }))).toMatchObject({ + Username: "new-user", + UserAttributes: expect.arrayContaining([{ Name: "sub", Value: signup.UserSub }]), + }); + const response = await active.app.request(`${base}/cognito-idp`, { + method: "POST", + headers: { "X-Amz-Target": "AWSCognitoIdentityProviderService.GetUser" }, + body: JSON.stringify({ AccessToken: tokens.AccessToken }), + }); + expect(response.headers.get("Content-Type")).toBe("application/x-amz-json-1.1"); + }); + + it("rejects duplicates, incorrect passwords, ID tokens, and tampered access tokens", async () => { + await expect( + sdk.send(new idp.SignUpCommand({ ClientId, Username: "alice", Password: password })), + ).rejects.toMatchObject({ name: "UsernameExistsException" }); + await expect(authenticate("alice", ClientId, { PASSWORD: "wrong-password" })).rejects.toMatchObject({ + name: "NotAuthorizedException", + }); + const tokens = await authenticate(); + const [header, body, signature] = tokens.AccessToken!.split("."); + const forged = `${header}.${Buffer.from(JSON.stringify({ ...JSON.parse(Buffer.from(body, "base64url").toString()), sub: "forged-user" })).toString("base64url")}.${signature}`; + for (const token of ["unknown", tokens.IdToken, tokens.RefreshToken, forged]) { + await expect(sdk.send(new idp.GetUserCommand({ AccessToken: token }))).rejects.toMatchObject({ + name: "NotAuthorizedException", + }); + } + }); + + it("enforces client secret hashes for authentication and refresh", async () => { + await expect(authenticate("alice", "private-client")).rejects.toMatchObject({ name: "NotAuthorizedException" }); + const hash = createHmac("sha256", "client-secret").update("aliceprivate-client").digest("base64"); + const tokens = await authenticate("alice", "private-client", { SECRET_HASH: hash }); + await expect( + sdk.send( + new idp.InitiateAuthCommand({ + ClientId: "other-client", + AuthFlow: "REFRESH_TOKEN_AUTH", + AuthParameters: { REFRESH_TOKEN: tokens.RefreshToken! }, + }), + ), + ).rejects.toMatchObject({ name: "NotAuthorizedException" }); + const refreshed = await sdk.send( + new idp.InitiateAuthCommand({ + ClientId: "private-client", + AuthFlow: "REFRESH_TOKEN_AUTH", + AuthParameters: { REFRESH_TOKEN: tokens.RefreshToken!, SECRET_HASH: hash }, + }), + ); + expect(refreshed.AuthenticationResult).toMatchObject({ + AccessToken: expect.any(String), + IdToken: expect.any(String), + }); + expect(refreshed.AuthenticationResult).not.toHaveProperty("RefreshToken"); + }); + + it("completes an admin-created user's new-password challenge exactly once", async () => { + const created = await sdk.send( + new idp.AdminCreateUserCommand({ + UserPoolId, + Username: "invited", + TemporaryPassword: password, + MessageAction: "SUPPRESS", + }), + ); + expect(created.User).toMatchObject({ + Username: "invited", + UserStatus: "FORCE_CHANGE_PASSWORD", + UserCreateDate: expect.any(Date), + }); + const challenge = await sdk.send( + new idp.AdminInitiateAuthCommand({ + UserPoolId, + ClientId, + AuthFlow: "ADMIN_USER_PASSWORD_AUTH", + AuthParameters: { USERNAME: "invited", PASSWORD: password }, + }), + ); + expect(challenge.ChallengeName).toBe("NEW_PASSWORD_REQUIRED"); + const input = { + UserPoolId, + ClientId, + ChallengeName: "NEW_PASSWORD_REQUIRED" as const, + Session: challenge.Session, + ChallengeResponses: { USERNAME: "invited", NEW_PASSWORD: "NewPassword123!" }, + }; + await expect( + sdk.send(new idp.RespondToAuthChallengeCommand({ ...input, ClientId: "other-client" })), + ).rejects.toMatchObject({ name: "NotAuthorizedException" }); + const result = await sdk.send(new idp.AdminRespondToAuthChallengeCommand(input)); + expect(result.AuthenticationResult?.AccessToken).toBeTruthy(); + await expect(sdk.send(new idp.RespondToAuthChallengeCommand(input))).rejects.toMatchObject({ + name: "NotAuthorizedException", + }); + await expect(authenticate("invited")).rejects.toMatchObject({ name: "NotAuthorizedException" }); + expect(await authenticate("invited", ClientId, { PASSWORD: "NewPassword123!" })).toHaveProperty("IdToken"); + }); + + it("supports password reset and invalidates old sessions", async () => { + const tokens = await authenticate(); + await sdk.send(new idp.ForgotPasswordCommand({ ClientId, Username: "alice" })); + await expect( + sdk.send( + new idp.ConfirmForgotPasswordCommand({ + ClientId, + Username: "alice", + ConfirmationCode: "wrong", + Password: "NewPassword123!", + }), + ), + ).rejects.toMatchObject({ name: "CodeMismatchException" }); + await sdk.send( + new idp.ConfirmForgotPasswordCommand({ + ClientId, + Username: "alice", + ConfirmationCode: "123456", + Password: "NewPassword123!", + }), + ); + await expect(sdk.send(new idp.GetUserCommand({ AccessToken: tokens.AccessToken }))).rejects.toMatchObject({ + name: "NotAuthorizedException", + }); + await expect(authenticate()).rejects.toMatchObject({ name: "NotAuthorizedException" }); + expect(await authenticate("alice", ClientId, { PASSWORD: "NewPassword123!" })).toHaveProperty("AccessToken"); + await expect( + sdk.send( + new idp.ConfirmForgotPasswordCommand({ + ClientId, + Username: "alice", + ConfirmationCode: "123456", + Password: "OtherPassword123!", + }), + ), + ).rejects.toMatchObject({ name: "ExpiredCodeException" }); + }); + + it("expires confirmation codes and access and refresh tokens", async () => { + const tokens = await authenticate(); + await sdk.send(new idp.SignUpCommand({ ClientId, Username: "pending", Password: password })); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(Date.now() + 3601_000); + await expect( + sdk.send(new idp.ConfirmSignUpCommand({ ClientId, Username: "pending", ConfirmationCode: "123456" })), + ).rejects.toMatchObject({ name: "ExpiredCodeException" }); + await expect(sdk.send(new idp.GetUserCommand({ AccessToken: tokens.AccessToken }))).rejects.toMatchObject({ + name: "NotAuthorizedException", + }); + vi.setSystemTime(Date.now() + 31 * 86400_000); + await expect( + sdk.send( + new idp.InitiateAuthCommand({ + ClientId, + AuthFlow: "REFRESH_TOKEN_AUTH", + AuthParameters: { REFRESH_TOKEN: tokens.RefreshToken! }, + }), + ), + ).rejects.toMatchObject({ name: "NotAuthorizedException" }); + }); + + it("changes passwords, sets permanent admin passwords, and signs users out", async () => { + await sdk.send( + new idp.AdminSetUserPasswordCommand({ + UserPoolId, + Username: "alice", + Password: "AdminPassword123!", + Permanent: true, + }), + ); + const tokens = await authenticate("alice", ClientId, { PASSWORD: "AdminPassword123!" }); + await expect( + sdk.send( + new idp.ChangePasswordCommand({ + AccessToken: tokens.AccessToken, + PreviousPassword: password, + ProposedPassword: "ChangedPassword123!", + }), + ), + ).rejects.toMatchObject({ name: "NotAuthorizedException" }); + await sdk.send( + new idp.ChangePasswordCommand({ + AccessToken: tokens.AccessToken, + PreviousPassword: "AdminPassword123!", + ProposedPassword: "ChangedPassword123!", + }), + ); + await expect(authenticate("alice", ClientId, { PASSWORD: "AdminPassword123!" })).rejects.toMatchObject({ + name: "NotAuthorizedException", + }); + const changed = await authenticate("alice", ClientId, { PASSWORD: "ChangedPassword123!" }); + await sdk.send(new idp.GlobalSignOutCommand({ AccessToken: changed.AccessToken })); + for (const token of [tokens.AccessToken, changed.AccessToken]) + await expect(sdk.send(new idp.GetUserCommand({ AccessToken: token }))).rejects.toMatchObject({ + name: "NotAuthorizedException", + }); + await expect( + sdk.send( + new idp.InitiateAuthCommand({ + ClientId, + AuthFlow: "REFRESH_TOKEN_AUTH", + AuthParameters: { REFRESH_TOKEN: changed.RefreshToken! }, + }), + ), + ).rejects.toMatchObject({ name: "NotAuthorizedException" }); + }); + + it("resends local codes and keeps invalid sign-ups out of the store", async () => { + await expect( + sdk.send(new idp.SignUpCommand({ ClientId, Username: "invalid", Password: "short" })), + ).rejects.toMatchObject({ name: "InvalidPasswordException" }); + expect(getCognitoStore(active.store).users.all()).toHaveLength(2); + await sdk.send(new idp.SignUpCommand({ ClientId, Username: "pending", Password: password })); + const user = getCognitoStore(active.store).users.findOneBy("username", "pending")!; + getCognitoStore(active.store).users.update(user.id, { code_expires_at: Date.now() - 1 }); + await sdk.send(new idp.ResendConfirmationCodeCommand({ ClientId, Username: "pending" })); + const inspector = await (await active.app.request(`${base}/_inspector?tab=cognito`)).text(); + expect(inspector).toContain("123456"); + expect(inspector).not.toContain(password); + await sdk.send(new idp.AdminConfirmSignUpCommand({ UserPoolId, Username: "pending" })); + expect(await authenticate("pending")).toHaveProperty("AccessToken"); + await expect( + sdk.send(new idp.ConfirmSignUpCommand({ ClientId, Username: "pending", ConfirmationCode: "123456" })), + ).rejects.toMatchObject({ name: "NotAuthorizedException" }); + }); + + it("isolates pools, paginates users, and revokes disabled and deleted users", async () => { + seedFromConfig(active.store, base, { + cognito: { + user_pools: [ + { + id: "us-east-1_other", + clients: [{ client_id: "isolated-client" }], + users: [{ username: "alice", password: "DifferentPassword!" }], + }, + ], + }, + }); + await expect(authenticate("alice", "isolated-client")).rejects.toMatchObject({ name: "NotAuthorizedException" }); + await expect( + sdk.send( + new idp.AdminInitiateAuthCommand({ + UserPoolId: "us-east-1_other", + ClientId, + AuthFlow: "ADMIN_USER_PASSWORD_AUTH", + AuthParameters: { USERNAME: "alice", PASSWORD: password }, + }), + ), + ).rejects.toMatchObject({ name: "ResourceNotFoundException" }); + const first = await sdk.send(new idp.ListUsersCommand({ UserPoolId, Limit: 1 })); + expect(first.Users).toHaveLength(1); + expect( + (await sdk.send(new idp.ListUsersCommand({ UserPoolId, PaginationToken: first.PaginationToken }))).Users?.[0] + .Username, + ).toBe("bob"); + const tokens = await authenticate(); + await sdk.send(new idp.AdminDisableUserCommand({ UserPoolId, Username: "alice" })); + await expect(authenticate()).rejects.toMatchObject({ name: "NotAuthorizedException" }); + await sdk.send(new idp.AdminEnableUserCommand({ UserPoolId, Username: "alice" })); + await expect(sdk.send(new idp.GetUserCommand({ AccessToken: tokens.AccessToken }))).rejects.toMatchObject({ + name: "NotAuthorizedException", + }); + await sdk.send(new idp.AdminDeleteUserCommand({ UserPoolId, Username: "alice" })); + await expect(sdk.send(new idp.AdminGetUserCommand({ UserPoolId, Username: "alice" }))).rejects.toMatchObject({ + name: "UserNotFoundException", + }); + }); + + it("preserves signing keys and sessions through snapshots and clears them on reset", async () => { + const tokens = await authenticate(); + const snapshot = JSON.parse(JSON.stringify(active.store.snapshot())); + expect(JSON.stringify(snapshot)).not.toContain(password); + active = createTestApp(base); + active.store.restore(snapshot); + expect(await sdk.send(new idp.GetUserCommand({ AccessToken: tokens.AccessToken }))).toMatchObject({ + Username: "alice", + }); + const refreshed = await sdk.send( + new idp.InitiateAuthCommand({ + ClientId, + AuthFlow: "REFRESH_TOKEN_AUTH", + AuthParameters: { REFRESH_TOKEN: tokens.RefreshToken! }, + }), + ); + expect(refreshed.AuthenticationResult?.AccessToken).toBeTruthy(); + active.store.reset(); + await expect(sdk.send(new idp.GetUserCommand({ AccessToken: tokens.AccessToken }))).rejects.toMatchObject({ + name: "NotAuthorizedException", + }); + }); + + it("returns explicit errors for malformed JSON and unsupported SRP flows", async () => { + const response = await active.app.request(`${base}/cognito-idp`, { + method: "POST", + headers: { "X-Amz-Target": "AWSCognitoIdentityProviderService.SignUp" }, + body: "{", + }); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ __type: "SerializationException" }); + await expect( + sdk.send( + new idp.InitiateAuthCommand({ + ClientId, + AuthFlow: "USER_SRP_AUTH", + AuthParameters: { USERNAME: "alice", SRP_A: "123" }, + }), + ), + ).rejects.toMatchObject({ name: "InvalidParameterException", message: expect.stringContaining("SRP") }); + }); +}); + +describe("Cognito OAuth authorization code flow", () => { + const path = `/cognito-idp/${UserPoolId}/oauth2/token`; + it("renders a shared user picker and exchanges a PKCE-bound code with state and nonce", async () => { + const authorize = `/cognito-idp/${UserPoolId}/oauth2/authorize?${new URLSearchParams({ client_id: ClientId, redirect_uri: callback, response_type: "code" })}`; + const page = await active.app.request(`${base}${authorize}`); + expect(page.status).toBe(200); + expect(await page.text()).toContain("Choose a local test user"); + const verifier = "v".repeat(43); + const code = await codeGrant({ + state: "state-value", + nonce: "nonce-value", + code_challenge: createHash("sha256").update(verifier).digest("base64url"), + code_challenge_method: "S256", + }); + const fields = { grant_type: "authorization_code", client_id: ClientId, redirect_uri: callback, code }; + expect((await form(path, fields)).status).toBe(400); + expect((await form(path, { ...fields, client_id: "other-client", code_verifier: verifier })).status).toBe(400); + expect( + (await form(path, { ...fields, redirect_uri: "http://localhost:3000/other", code_verifier: verifier })).status, + ).toBe(400); + const exchanged = await form(path, { ...fields, code_verifier: verifier }); + expect(exchanged.status).toBe(200); + expect(exchanged.headers.get("Cache-Control")).toBe("no-store"); + const tokens = (await exchanged.json()) as { access_token: string; id_token: string; refresh_token: string }; + const jwks = createRemoteJWKSet(new URL(`${base}/cognito-idp/${UserPoolId}/.well-known/jwks.json`)); + expect( + (await jwtVerify(tokens.id_token, jwks, { issuer: `${base}/cognito-idp/${UserPoolId}`, audience: ClientId })) + .payload, + ).toMatchObject({ nonce: "nonce-value" }); + expect((await form(path, { ...fields, code_verifier: verifier })).status).toBe(400); + const info = await active.app.request(`${base}/cognito-idp/${UserPoolId}/oauth2/userInfo`, { + headers: { Authorization: `Bearer ${tokens.access_token}` }, + }); + expect(await info.json()).toMatchObject({ username: "alice", email: "alice@example.com", name: "Alice" }); + expect( + (await form(path, { grant_type: "refresh_token", client_id: ClientId, refresh_token: tokens.refresh_token })) + .status, + ).toBe(200); + await sdk.send(new idp.RevokeTokenCommand({ ClientId, Token: tokens.refresh_token })); + expect( + (await form(path, { grant_type: "refresh_token", client_id: ClientId, refresh_token: tokens.refresh_token })) + .status, + ).toBe(400); + }); + + it("validates redirects and confidential clients before issuing tokens", async () => { + const invalid = await form(`/cognito-idp/${UserPoolId}/oauth2/authorize`, { + client_id: ClientId, + response_type: "code", + redirect_uri: "https://attacker.example/callback", + username: "alice", + }); + expect(invalid.status).toBe(400); + expect(invalid.headers.get("Location")).toBeNull(); + const code = await codeGrant({ client_id: "private-client" }); + const fields = { grant_type: "authorization_code", client_id: "private-client", redirect_uri: callback, code }; + expect((await form(path, fields)).status).toBe(401); + expect((await form(path, { ...fields, client_secret: "client-secret" })).status).toBe(200); + + const basicCode = await codeGrant({ client_id: "private-client" }); + const basic = await active.app.request(`${base}${path}`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${Buffer.from("private-client:client-secret").toString("base64")}`, + }, + body: new URLSearchParams({ grant_type: "authorization_code", redirect_uri: callback, code: basicCode }), + }); + expect(basic.status).toBe(200); + }); + + it("expires authorization codes and prevents profile claims without granted scopes", async () => { + const code = await codeGrant({ scope: "openid" }); + const response = await form(path, { + grant_type: "authorization_code", + client_id: ClientId, + redirect_uri: callback, + code, + }); + const tokens = (await response.json()) as { access_token: string }; + const info = await active.app.request(`${base}/cognito-idp/${UserPoolId}/oauth2/userInfo`, { + headers: { Authorization: `Bearer ${tokens.access_token}` }, + }); + expect(await info.json()).not.toHaveProperty("email"); + const expired = await codeGrant(); + const record = getCognitoStore(active.store).codes.findOneBy("token", expired)!; + getCognitoStore(active.store).codes.update(record.id, { expires_at: Date.now() - 1 }); + expect( + ( + await form(path, { + grant_type: "authorization_code", + client_id: ClientId, + redirect_uri: callback, + code: expired, + }) + ).status, + ).toBe(400); + }); +}); + +describe("Cognito Identity Pools with the official AWS SDK", () => { + it("keeps identities stable and issues temporary credentials only for the matching login", async () => { + const alice = await authenticate(); + const Logins = { [provider]: alice.IdToken! }; + const result = await identities.send(new identity.GetIdCommand({ IdentityPoolId, Logins })); + expect(result.IdentityId).toMatch(/^us-east-1:[0-9a-f-]{36}$/); + expect(await identities.send(new identity.GetIdCommand({ IdentityPoolId, Logins }))).toMatchObject({ + IdentityId: result.IdentityId, + }); + const credentials = await identities.send( + new identity.GetCredentialsForIdentityCommand({ IdentityId: result.IdentityId, Logins }), + ); + expect(credentials.Credentials).toMatchObject({ + AccessKeyId: expect.stringMatching(/^ASIA/), + SecretKey: expect.any(String), + SessionToken: expect.any(String), + Expiration: expect.any(Date), + }); + const bob = await authenticate("bob"); + await expect( + identities.send( + new identity.GetCredentialsForIdentityCommand({ + IdentityId: result.IdentityId, + Logins: { [provider]: bob.IdToken! }, + }), + ), + ).rejects.toMatchObject({ name: "NotAuthorizedException" }); + for (const invalid of [alice.AccessToken!, "forged-token"]) { + await expect( + identities.send(new identity.GetIdCommand({ IdentityPoolId, Logins: { [provider]: invalid } })), + ).rejects.toMatchObject({ name: "NotAuthorizedException" }); + } + const wrongClient = await authenticate("alice", "other-client"); + await expect( + identities.send(new identity.GetIdCommand({ IdentityPoolId, Logins: { [provider]: wrongClient.IdToken! } })), + ).rejects.toMatchObject({ name: "NotAuthorizedException" }); + }); + + it("permits anonymous identities only for explicitly configured pools", async () => { + await expect(identities.send(new identity.GetIdCommand({ IdentityPoolId }))).rejects.toMatchObject({ + name: "NotAuthorizedException", + }); + const poolId = "us-east-1:00000000-0000-4000-8000-000000000002"; + seedFromConfig(active.store, base, { + cognito: { identity_pools: [{ id: poolId, allow_unauthenticated_identities: true }] }, + }); + const anonymous = await identities.send(new identity.GetIdCommand({ IdentityPoolId: poolId })); + expect( + (await identities.send(new identity.GetCredentialsForIdentityCommand({ IdentityId: anonymous.IdentityId }))) + .Credentials?.AccessKeyId, + ).toMatch(/^ASIA/); + expect(getCognitoStore(active.store).identities.all()).toHaveLength(1); + }); +}); diff --git a/packages/@emulators/aws/src/cognito/helpers.ts b/packages/@emulators/aws/src/cognito/helpers.ts new file mode 100644 index 00000000..ce2bd9b5 --- /dev/null +++ b/packages/@emulators/aws/src/cognito/helpers.ts @@ -0,0 +1,282 @@ +import { + createHmac, + createPrivateKey, + createPublicKey, + generateKeyPairSync, + randomBytes, + randomUUID, + scryptSync, + timingSafeEqual, +} from "node:crypto"; +import { constantTimeSecretEqual, type Context, type Store } from "@emulators/core"; +import { SignJWT, jwtVerify } from "jose"; +import { getCognitoStore, type CognitoClient, type CognitoSession, type CognitoUser } from "./store.js"; + +export const DEFAULT_USER_POOL = "us-east-1_emulate"; +export const DEFAULT_CLIENT = "emulateclient"; +export const DEFAULT_IDENTITY_POOL = "us-east-1:00000000-0000-4000-8000-000000000001"; + +export class CognitoError extends Error { + constructor( + public code: string, + message: string, + ) { + super(message); + } +} + +export function fail(code: string, message: string): never { + throw new CognitoError(code, message); +} +export function str(value: unknown): string { + return typeof value === "string" ? value : ""; +} +export function object(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) + fail("InvalidParameterException", "Expected an object."); + return value as Record; +} +export function required(value: unknown, name: string): string { + const result = str(value); + if (!result) fail("InvalidParameterException", `${name} is required.`); + return result; +} +export function attributes(value: unknown): Record { + if (value === undefined) return {}; + if (!Array.isArray(value)) fail("InvalidParameterException", "Attributes must be an array."); + return Object.fromEntries( + value.map((item) => { + const attribute = object(item); + const name = required(attribute.Name, "Attribute name"); + if (name === "sub") fail("InvalidParameterException", "sub is read-only."); + if (typeof attribute.Value !== "string") fail("InvalidParameterException", "Attribute values must be strings."); + return [name, attribute.Value]; + }), + ); +} +export function userAttributes(user: CognitoUser) { + return Object.entries({ ...user.attributes, sub: user.sub }).map(([Name, Value]) => ({ Name, Value })); +} +export function userResponse(user: CognitoUser) { + return { + Username: user.username, + Attributes: userAttributes(user), + UserStatus: user.status, + Enabled: user.enabled, + UserCreateDate: Date.parse(user.created_at) / 1000, + UserLastModifiedDate: Date.parse(user.updated_at) / 1000, + }; +} +export function passwordHash(password: string): string { + if (password.length < 8) fail("InvalidPasswordException", "Password must contain at least 8 characters."); + const salt = randomBytes(16).toString("hex"); + return `${salt}:${scryptSync(password, salt, 32).toString("hex")}`; +} +export function matchesPassword(password: string, hash: string): boolean { + const [salt, digest] = hash.split(":"); + if (!salt || !digest || !password) return false; + const expected = Buffer.from(digest, "hex"); + const actual = scryptSync(password, salt, 32); + return expected.length === actual.length && timingSafeEqual(expected, actual); +} +export function findUser(store: Store, poolId: string, username: string) { + return getCognitoStore(store) + .users.findBy("pool_id", poolId) + .find((user) => user.username === username); +} +export function getUser(store: Store, poolId: string, username: string) { + return findUser(store, poolId, username) ?? fail("UserNotFoundException", "User does not exist."); +} +export function getPool(store: Store, poolId: string) { + return ( + getCognitoStore(store).pools.findOneBy("pool_id", poolId) ?? + fail("ResourceNotFoundException", "User pool does not exist.") + ); +} +export function getClient(store: Store, clientId: string) { + return ( + getCognitoStore(store).clients.findOneBy("client_id", clientId) ?? + fail("ResourceNotFoundException", "App client does not exist.") + ); +} +export function checkSecretHash(client: CognitoClient, username: string, provided: unknown) { + if (!client.client_secret) return; + const expected = createHmac("sha256", client.client_secret) + .update(username + client.client_id) + .digest("base64"); + if (!constantTimeSecretEqual(str(provided), expected)) + fail("NotAuthorizedException", "Unable to verify secret hash for client."); +} +export function checkUser(user: CognitoUser) { + if (!user.enabled) fail("NotAuthorizedException", "User is disabled."); + if (user.status !== "CONFIRMED") fail("UserNotConfirmedException", "User is not confirmed."); +} +export function revokeSessions(store: Store, user: CognitoUser) { + const cs = getCognitoStore(store); + for (const collection of [cs.sessions, cs.challenges, cs.codes]) { + for (const record of collection.all()) { + if (record.pool_id === user.pool_id && record.username === user.username) collection.delete(record.id); + } + } +} +export function setCode(store: Store, user: CognitoUser, kind: "signup" | "reset") { + getCognitoStore(store).users.update(user.id, { + confirmation_code: getPool(store, user.pool_id).confirmation_code, + code_expires_at: Date.now() + 10 * 60_000, + code_kind: kind, + }); + return { + CodeDeliveryDetails: { + DeliveryMedium: "EMAIL", + AttributeName: "email", + Destination: user.attributes.email ?? user.username, + }, + }; +} +export function checkCode(user: CognitoUser, code: unknown, kind: "signup" | "reset") { + if ( + !user.confirmation_code || + user.code_kind !== kind || + !user.code_expires_at || + user.code_expires_at <= Date.now() + ) { + fail("ExpiredCodeException", "Confirmation code has expired."); + } + if (!constantTimeSecretEqual(str(code), user.confirmation_code)) + fail("CodeMismatchException", "Invalid verification code."); +} + +export function issuer(baseUrl: string, poolId: string) { + return `${baseUrl}/cognito-idp/${poolId}`; +} +type SigningKey = { private_key: string; kid: string; jwk: Record }; +export function signingKey(store: Store, poolId: string): SigningKey { + const name = `aws.cognito.signing.${poolId}`; + let key = store.getData(name); + if (!key) { + const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const kid = randomUUID(); + key = { + private_key: privateKey.export({ type: "pkcs8", format: "pem" }).toString(), + kid, + jwk: { ...publicKey.export({ format: "jwk" }), kid, use: "sig", alg: "RS256" }, + }; + store.setData(name, key); + } + return key; +} + +export async function issueTokens( + store: Store, + baseUrl: string, + client: CognitoClient, + user: CognitoUser, + options: { session?: CognitoSession; scope?: string; nonce?: string } = {}, +) { + checkUser(user); + const cs = getCognitoStore(store); + const session = + options.session ?? + cs.sessions.insert({ + session_id: randomUUID(), + refresh_token: randomBytes(48).toString("base64url"), + pool_id: user.pool_id, + client_id: client.client_id, + username: user.username, + scope: options.scope ?? "aws.cognito.signin.user.admin", + auth_time: Math.floor(Date.now() / 1000), + expires_at: Date.now() + 30 * 86400_000, + }); + const key = signingKey(store, user.pool_id); + const privateKey = createPrivateKey(key.private_key); + const common = { auth_time: session.auth_time, origin_jti: session.session_id }; + const sign = (claims: Record) => + new SignJWT({ ...common, ...claims }) + .setProtectedHeader({ alg: "RS256", kid: key.kid }) + .setIssuer(issuer(baseUrl, user.pool_id)) + .setSubject(user.sub) + .setIssuedAt() + .setExpirationTime("1h") + .setJti(randomUUID()) + .sign(privateKey); + const allowed = new Set([ + "email", + "email_verified", + "name", + "given_name", + "family_name", + "picture", + "phone_number", + "phone_number_verified", + "preferred_username", + ]); + const claims = Object.fromEntries( + Object.entries(user.attributes) + .filter(([name]) => allowed.has(name) || name.startsWith("custom:")) + .map(([name, value]) => [name, name.endsWith("_verified") ? value === "true" : value]), + ); + return { + AccessToken: await sign({ + token_use: "access", + client_id: client.client_id, + username: user.username, + scope: session.scope, + }), + IdToken: await sign({ + ...claims, + token_use: "id", + aud: client.client_id, + "cognito:username": user.username, + ...(options.nonce ? { nonce: options.nonce } : {}), + }), + ...(!options.session ? { RefreshToken: session.refresh_token } : {}), + ExpiresIn: 3600, + TokenType: "Bearer", + }; +} +export function refreshSession(store: Store, client: CognitoClient, token: string) { + const session = getCognitoStore(store).sessions.findOneBy("refresh_token", token); + if ( + !session || + session.client_id !== client.client_id || + session.pool_id !== client.pool_id || + session.expires_at <= Date.now() + ) { + fail("NotAuthorizedException", "Invalid refresh token."); + } + checkUser(getUser(store, session.pool_id, session.username)); + return session; +} +export async function verifyToken(store: Store, baseUrl: string, token: string, use: "access" | "id", poolId?: string) { + const cs = getCognitoStore(store); + for (const pool of poolId ? [getPool(store, poolId)] : cs.pools.all()) { + try { + const key = signingKey(store, pool.pool_id); + const { payload } = await jwtVerify(token, createPublicKey(key.private_key), { + algorithms: ["RS256"], + issuer: issuer(baseUrl, pool.pool_id), + }); + if (payload.token_use !== use) continue; + const session = cs.sessions.findOneBy("session_id", str(payload.origin_jti)); + if (!session || session.pool_id !== pool.pool_id || session.expires_at <= Date.now()) continue; + if ((use === "id" ? payload.aud : payload.client_id) !== session.client_id) continue; + const user = getUser(store, pool.pool_id, session.username); + if (user.sub !== payload.sub) continue; + checkUser(user); + return { user, session, payload }; + } catch { + /* Try the next pool's signing key. */ + } + } + return fail("NotAuthorizedException", "Invalid or expired token."); +} +export function jsonResponse(c: Context, data: unknown, status: 200 | 400 = 200) { + c.header("Content-Type", "application/x-amz-json-1.1"); + c.header("x-amzn-requestid", randomUUID()); + return c.json(data, status); +} +export function errorResponse(c: Context, error: unknown) { + if (!(error instanceof CognitoError)) throw error; + c.header("x-amzn-errortype", error.code); + return jsonResponse(c, { __type: error.code, message: error.message }, 400); +} diff --git a/packages/@emulators/aws/src/cognito/seed.ts b/packages/@emulators/aws/src/cognito/seed.ts new file mode 100644 index 00000000..218b162c --- /dev/null +++ b/packages/@emulators/aws/src/cognito/seed.ts @@ -0,0 +1,95 @@ +import { randomUUID } from "node:crypto"; +import type { Store } from "@emulators/core"; +import { DEFAULT_CLIENT, DEFAULT_IDENTITY_POOL, DEFAULT_USER_POOL, findUser, passwordHash } from "./helpers.js"; +import { getCognitoStore, type CognitoUser } from "./store.js"; + +export interface CognitoSeedConfig { + user_pools?: Array<{ + id: string; + name?: string; + confirmation_code?: string; + clients?: Array<{ client_id: string; client_secret?: string; redirect_uris?: string[]; scopes?: string[] }>; + users?: Array<{ + username: string; + password: string; + attributes?: Record; + status?: CognitoUser["status"]; + enabled?: boolean; + }>; + }>; + identity_pools?: Array<{ + id: string; + name?: string; + allow_unauthenticated_identities?: boolean; + providers?: Array<{ user_pool_id: string; client_id: string }>; + }>; +} + +export function seedCognito(store: Store, config: CognitoSeedConfig) { + const cs = getCognitoStore(store); + for (const pool of config.user_pools ?? []) { + const existing = cs.pools.findOneBy("pool_id", pool.id); + const data = { + pool_id: pool.id, + name: pool.name ?? pool.id, + confirmation_code: pool.confirmation_code ?? "123456", + }; + if (existing) cs.pools.update(existing.id, data); + else cs.pools.insert(data); + for (const client of pool.clients ?? []) { + const previous = cs.clients.findOneBy("client_id", client.client_id); + if (previous && previous.pool_id !== pool.id) + throw new Error(`Cognito client ${client.client_id} belongs to another user pool.`); + const clientData = { + ...client, + pool_id: pool.id, + redirect_uris: client.redirect_uris ?? [], + scopes: client.scopes ?? ["openid", "email", "profile", "aws.cognito.signin.user.admin"], + }; + if (previous) cs.clients.update(previous.id, clientData); + else cs.clients.insert(clientData); + } + for (const user of pool.users ?? []) { + if (findUser(store, pool.id, user.username)) continue; + cs.users.insert({ + pool_id: pool.id, + username: user.username, + sub: randomUUID(), + password_hash: passwordHash(user.password), + attributes: user.attributes ?? {}, + status: user.status ?? "CONFIRMED", + enabled: user.enabled ?? true, + }); + } + } + for (const pool of config.identity_pools ?? []) { + const existing = cs.identityPools.findOneBy("pool_id", pool.id); + const data = { + pool_id: pool.id, + name: pool.name ?? pool.id, + allow_unauthenticated_identities: pool.allow_unauthenticated_identities ?? false, + providers: pool.providers ?? [], + }; + if (existing) cs.identityPools.update(existing.id, data); + else cs.identityPools.insert(data); + } +} + +export function seedCognitoDefaults(store: Store) { + seedCognito(store, { + user_pools: [ + { + id: DEFAULT_USER_POOL, + name: "emulate", + clients: [{ client_id: DEFAULT_CLIENT, redirect_uris: ["http://localhost:3000/callback"] }], + }, + ], + identity_pools: [ + { + id: DEFAULT_IDENTITY_POOL, + name: "emulate", + providers: [{ user_pool_id: DEFAULT_USER_POOL, client_id: DEFAULT_CLIENT }], + }, + ], + }); +} diff --git a/packages/@emulators/aws/src/cognito/store.ts b/packages/@emulators/aws/src/cognito/store.ts new file mode 100644 index 00000000..e95a0a82 --- /dev/null +++ b/packages/@emulators/aws/src/cognito/store.ts @@ -0,0 +1,80 @@ +import { type Entity, type Store } from "@emulators/core"; + +export interface CognitoUserPool extends Entity { + pool_id: string; + name: string; + confirmation_code: string; +} + +export interface CognitoClient extends Entity { + client_id: string; + pool_id: string; + client_secret?: string; + redirect_uris: string[]; + scopes: string[]; +} + +export interface CognitoUser extends Entity { + pool_id: string; + username: string; + sub: string; + password_hash: string; + attributes: Record; + status: "UNCONFIRMED" | "CONFIRMED" | "FORCE_CHANGE_PASSWORD"; + enabled: boolean; + confirmation_code?: string; + code_expires_at?: number; + code_kind?: "signup" | "reset"; +} + +export interface CognitoSession extends Entity { + session_id: string; + refresh_token: string; + pool_id: string; + client_id: string; + username: string; + scope: string; + auth_time: number; + expires_at: number; +} + +export interface CognitoChallenge extends Entity { + token: string; + pool_id: string; + client_id: string; + username: string; + expires_at: number; +} + +export interface CognitoCode extends CognitoChallenge { + redirect_uri: string; + scope: string; + nonce?: string; + code_challenge?: string; +} + +export interface CognitoIdentityPool extends Entity { + pool_id: string; + name: string; + allow_unauthenticated_identities: boolean; + providers: Array<{ user_pool_id: string; client_id: string }>; +} + +export interface CognitoIdentity extends Entity { + identity_id: string; + pool_id: string; + principal?: string; +} + +export function getCognitoStore(store: Store) { + return { + pools: store.collection("aws.cognito.user_pools", ["pool_id"]), + clients: store.collection("aws.cognito.clients", ["client_id", "pool_id"]), + users: store.collection("aws.cognito.users", ["pool_id", "username", "sub"]), + sessions: store.collection("aws.cognito.sessions", ["session_id", "refresh_token", "pool_id"]), + challenges: store.collection("aws.cognito.challenges", ["token"]), + codes: store.collection("aws.cognito.codes", ["token"]), + identityPools: store.collection("aws.cognito.identity_pools", ["pool_id"]), + identities: store.collection("aws.cognito.identities", ["identity_id", "pool_id"]), + }; +} diff --git a/packages/@emulators/aws/src/index.ts b/packages/@emulators/aws/src/index.ts index 3c2840eb..33fe8c10 100644 --- a/packages/@emulators/aws/src/index.ts +++ b/packages/@emulators/aws/src/index.ts @@ -6,6 +6,13 @@ import { s3Routes } from "./routes/s3.js"; import { sqsRoutes } from "./routes/sqs.js"; import { iamRoutes } from "./routes/iam.js"; import { inspectorRoutes } from "./routes/inspector.js"; +import { cognitoIdpRoutes } from "./routes/cognito-idp.js"; +import { cognitoIdentityRoutes } from "./routes/cognito-identity.js"; +import { cognitoOAuthRoutes } from "./routes/cognito-oauth.js"; +import { seedCognito, seedCognitoDefaults, type CognitoSeedConfig } from "./cognito/seed.js"; + +export { getCognitoStore } from "./cognito/store.js"; +export type { CognitoSeedConfig } from "./cognito/seed.js"; export { getAwsStore, type AwsStore } from "./store.js"; export * from "./entities.js"; @@ -14,6 +21,7 @@ export interface AwsSeedConfig { port?: number; region?: string; account_id?: string; + cognito?: CognitoSeedConfig; s3?: { buckets?: Array<{ name: string; @@ -47,6 +55,8 @@ function seedDefaults(store: Store, baseUrl: string): void { const accountId = getAccountId(); const region = getDefaultRegion(); + seedCognitoDefaults(store); + // Create a default S3 bucket aws.s3Buckets.insert({ bucket_name: "emulate-default", @@ -91,6 +101,7 @@ export function seedFromConfig(store: Store, baseUrl: string, config: AwsSeedCon const aws = getAwsStore(store); const accountId = getAccountId(); const region = config.region ?? getDefaultRegion(); + if (config.cognito) seedCognito(store, config.cognito); if (config.s3?.buckets) { for (const b of config.s3.buckets) { @@ -181,6 +192,9 @@ export const awsPlugin: ServicePlugin = { // Register inspector and service-specific routes first (static paths), // then S3 last since its routes use wildcard path params (/:bucket, /:bucket/:key) inspectorRoutes(ctx); + cognitoIdpRoutes(ctx); + cognitoIdentityRoutes(ctx); + cognitoOAuthRoutes(ctx); sqsRoutes(ctx); iamRoutes(ctx); s3Routes(ctx); diff --git a/packages/@emulators/aws/src/routes/cognito-identity.ts b/packages/@emulators/aws/src/routes/cognito-identity.ts new file mode 100644 index 00000000..a64dc155 --- /dev/null +++ b/packages/@emulators/aws/src/routes/cognito-identity.ts @@ -0,0 +1,87 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import type { RouteContext } from "@emulators/core"; +import { getCognitoStore, type CognitoIdentityPool } from "../cognito/store.js"; +import { + CognitoError, + errorResponse, + fail, + jsonResponse, + object, + required, + str, + verifyToken, +} from "../cognito/helpers.js"; + +export function cognitoIdentityRoutes({ app, store, baseUrl }: RouteContext) { + const cs = () => getCognitoStore(store); + async function principal(pool: CognitoIdentityPool, logins: unknown) { + const entries = Object.entries(logins === undefined ? {} : object(logins)); + if (entries.length === 0) { + if (!pool.allow_unauthenticated_identities) + fail("NotAuthorizedException", "Unauthenticated access is not supported for this identity pool."); + return undefined; + } + if (entries.length !== 1) fail("InvalidParameterException", "Only one Cognito User Pool login is simulated."); + const [provider, token] = entries[0]; + const configured = pool.providers.filter( + (item) => provider === `cognito-idp.${item.user_pool_id.split("_")[0]}.amazonaws.com/${item.user_pool_id}`, + ); + if (configured.length === 0) + fail("NotAuthorizedException", "Login provider is not configured for this identity pool."); + const { user, session } = await verifyToken(store, baseUrl, str(token), "id", configured[0].user_pool_id); + if (!configured.some((item) => session.client_id === item.client_id)) + fail("NotAuthorizedException", "Token audience does not match the configured app client."); + return `${provider}:${user.sub}`; + } + for (const path of ["/cognito-identity", "/cognito-identity/"]) + app.post(path, async (c) => { + try { + const body = object(await c.req.json()); + const target = c.req.header("X-Amz-Target") ?? ""; + if ( + target !== "AWSCognitoIdentityService.GetId" && + target !== "AWSCognitoIdentityService.GetCredentialsForIdentity" + ) { + fail("UnknownOperationException", "Only GetId and GetCredentialsForIdentity are simulated."); + } + if (target.endsWith(".GetId")) { + const pool = cs().identityPools.findOneBy("pool_id", required(body.IdentityPoolId, "IdentityPoolId")); + if (!pool) fail("ResourceNotFoundException", "Identity pool does not exist."); + const login = await principal(pool, body.Logins); + const existing = login + ? cs() + .identities.findBy("pool_id", pool.pool_id) + .find((identity) => identity.principal === login) + : undefined; + const identity = + existing ?? + cs().identities.insert({ + pool_id: pool.pool_id, + identity_id: `${pool.pool_id.split(":")[0]}:${randomUUID()}`, + principal: login, + }); + return jsonResponse(c, { IdentityId: identity.identity_id }); + } + const identity = cs().identities.findOneBy("identity_id", required(body.IdentityId, "IdentityId")); + if (!identity) fail("ResourceNotFoundException", "Identity does not exist."); + const pool = cs().identityPools.findOneBy("pool_id", identity.pool_id); + if (!pool) fail("ResourceNotFoundException", "Identity pool does not exist."); + const login = await principal(pool, body.Logins); + if (login !== identity.principal) fail("NotAuthorizedException", "Login does not match this identity."); + if (body.CustomRoleArn) fail("InvalidParameterException", "Role selection is not simulated."); + return jsonResponse(c, { + IdentityId: identity.identity_id, + Credentials: { + AccessKeyId: `ASIA${randomBytes(8).toString("hex").toUpperCase()}`, + SecretKey: randomBytes(30).toString("base64"), + SessionToken: randomBytes(64).toString("base64"), + Expiration: Math.floor(Date.now() / 1000) + 3600, + }, + }); + } catch (error) { + if (error instanceof SyntaxError) + return errorResponse(c, new CognitoError("SerializationException", "Invalid JSON request body.")); + return errorResponse(c, error); + } + }); +} diff --git a/packages/@emulators/aws/src/routes/cognito-idp.ts b/packages/@emulators/aws/src/routes/cognito-idp.ts new file mode 100644 index 00000000..d25d714c --- /dev/null +++ b/packages/@emulators/aws/src/routes/cognito-idp.ts @@ -0,0 +1,274 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import type { RouteContext } from "@emulators/core"; +import { getCognitoStore, type CognitoUser } from "../cognito/store.js"; +import { + CognitoError, + attributes, + checkCode, + checkSecretHash, + checkUser, + errorResponse, + fail, + findUser, + getClient, + getPool, + getUser, + issueTokens, + jsonResponse, + matchesPassword, + object, + passwordHash, + refreshSession, + required, + revokeSessions, + setCode, + str, + userAttributes, + userResponse, + verifyToken, +} from "../cognito/helpers.js"; + +export function cognitoIdpRoutes({ app, store, baseUrl }: RouteContext) { + const cs = () => getCognitoStore(store); + const clearCode = { confirmation_code: undefined, code_expires_at: undefined, code_kind: undefined }; + const confirm = (user: CognitoUser, verifyEmail = false) => + cs().users.update(user.id, { + ...clearCode, + status: "CONFIRMED", + attributes: { ...user.attributes, ...(verifyEmail && user.attributes.email ? { email_verified: "true" } : {}) }, + })!; + + for (const path of ["/cognito-idp", "/cognito-idp/"]) + app.post(path, async (c) => { + try { + const body = object(await c.req.json()); + const target = c.req.header("X-Amz-Target") ?? ""; + const prefix = "AWSCognitoIdentityProviderService."; + if (!target.startsWith(prefix)) fail("UnknownOperationException", "Unknown Cognito operation."); + return jsonResponse(c, await dispatch(target.slice(prefix.length), body)); + } catch (error) { + if (error instanceof SyntaxError) + return errorResponse(c, new CognitoError("SerializationException", "Invalid JSON request body.")); + return errorResponse(c, error); + } + }); + + async function dispatch(action: string, body: Record): Promise { + if (action.startsWith("Admin") || action === "ListUsers") { + const poolId = required(body.UserPoolId, "UserPoolId"); + getPool(store, poolId); + if (action === "ListUsers") { + if (body.Filter) fail("InvalidParameterException", "ListUsers filters are not simulated."); + const offset = body.PaginationToken === undefined ? 0 : Number(body.PaginationToken); + const limit = body.Limit === undefined ? 60 : Number(body.Limit); + if (!Number.isInteger(offset) || offset < 0 || !Number.isInteger(limit) || limit < 1 || limit > 60) + fail("InvalidParameterException", "Invalid pagination parameters."); + const users = cs().users.findBy("pool_id", poolId); + return { + Users: users.slice(offset, offset + limit).map(userResponse), + ...(offset + limit < users.length ? { PaginationToken: String(offset + limit) } : {}), + }; + } + if (action === "AdminInitiateAuth") { + const client = getClient(store, required(body.ClientId, "ClientId")); + if (client.pool_id !== poolId) + fail("ResourceNotFoundException", "App client does not belong to this user pool."); + return authenticate(body, true); + } + if (action === "AdminRespondToAuthChallenge") { + const client = getClient(store, required(body.ClientId, "ClientId")); + if (client.pool_id !== poolId) + fail("ResourceNotFoundException", "App client does not belong to this user pool."); + return respondToChallenge(body); + } + if (action === "AdminCreateUser") { + const username = required(body.Username, "Username"); + if (findUser(store, poolId, username)) fail("UsernameExistsException", "User already exists."); + const password = required(body.TemporaryPassword, "TemporaryPassword"); + const user = cs().users.insert({ + pool_id: poolId, + username, + sub: randomUUID(), + password_hash: passwordHash(password), + attributes: attributes(body.UserAttributes), + status: "FORCE_CHANGE_PASSWORD", + enabled: true, + }); + return { User: userResponse(user) }; + } + const user = getUser(store, poolId, required(body.Username, "Username")); + switch (action) { + case "AdminGetUser": { + const { Attributes, ...rest } = userResponse(user); + return { ...rest, UserAttributes: Attributes }; + } + case "AdminConfirmSignUp": + confirm(user); + return {}; + case "AdminSetUserPassword": { + const hash = passwordHash(required(body.Password, "Password")); + if (body.Permanent !== undefined && typeof body.Permanent !== "boolean") + fail("InvalidParameterException", "Permanent must be a boolean."); + revokeSessions(store, user); + cs().users.update(user.id, { + ...clearCode, + password_hash: hash, + status: body.Permanent ? "CONFIRMED" : "FORCE_CHANGE_PASSWORD", + }); + return {}; + } + case "AdminDisableUser": + revokeSessions(store, user); + cs().users.update(user.id, { enabled: false }); + return {}; + case "AdminEnableUser": + cs().users.update(user.id, { enabled: true }); + return {}; + case "AdminDeleteUser": + revokeSessions(store, user); + + cs().users.delete(user.id); + return {}; + default: + return fail("UnknownOperationException", `Operation ${action} is not simulated.`); + } + } + + if (action === "InitiateAuth") return authenticate(body); + if (action === "RespondToAuthChallenge") return respondToChallenge(body); + + if (action === "GetUser" || action === "ChangePassword" || action === "GlobalSignOut") { + const { user, session } = await verifyToken(store, baseUrl, required(body.AccessToken, "AccessToken"), "access"); + if (!session.scope.split(" ").includes("aws.cognito.signin.user.admin")) + fail("NotAuthorizedException", "Access token does not have the required scope."); + if (action === "GetUser") return { Username: user.username, UserAttributes: userAttributes(user) }; + if (action === "ChangePassword") { + if (!matchesPassword(str(body.PreviousPassword), user.password_hash)) + fail("NotAuthorizedException", "Incorrect username or password."); + cs().users.update(user.id, { + password_hash: passwordHash(required(body.ProposedPassword, "ProposedPassword")), + }); + } else revokeSessions(store, user); + return {}; + } + + const client = getClient(store, required(body.ClientId, "ClientId")); + if (action === "RevokeToken") { + if (client.client_secret && body.ClientSecret !== client.client_secret) + fail("NotAuthorizedException", "Invalid client secret."); + const session = refreshSession(store, client, required(body.Token, "Token")); + cs().sessions.delete(session.id); + return {}; + } + const username = required(body.Username, "Username"); + checkSecretHash(client, username, body.SecretHash); + if (action === "SignUp") { + if (findUser(store, client.pool_id, username)) fail("UsernameExistsException", "User already exists."); + const attrs = attributes(body.UserAttributes); + const user = cs().users.insert({ + pool_id: client.pool_id, + username, + sub: randomUUID(), + password_hash: passwordHash(required(body.Password, "Password")), + attributes: { ...attrs, ...(attrs.email ? { email_verified: "false" } : {}) }, + status: "UNCONFIRMED", + enabled: true, + }); + return { UserConfirmed: false, UserSub: user.sub, ...setCode(store, user, "signup") }; + } + const user = getUser(store, client.pool_id, username); + switch (action) { + case "ConfirmSignUp": + if (user.status !== "UNCONFIRMED") fail("NotAuthorizedException", "User is already confirmed."); + checkCode(user, body.ConfirmationCode, "signup"); + confirm(user, true); + return {}; + case "ResendConfirmationCode": + if (user.status !== "UNCONFIRMED") fail("InvalidParameterException", "User is already confirmed."); + return setCode(store, user, "signup"); + case "ForgotPassword": + checkUser(user); + return setCode(store, user, "reset"); + case "ConfirmForgotPassword": + checkUser(user); + checkCode(user, body.ConfirmationCode, "reset"); + cs().users.update(user.id, { ...clearCode, password_hash: passwordHash(required(body.Password, "Password")) }); + revokeSessions(store, user); + return {}; + default: + return fail("UnknownOperationException", `Operation ${action} is not simulated.`); + } + } + + async function respondToChallenge(body: Record) { + const client = getClient(store, required(body.ClientId, "ClientId")); + if (body.ChallengeName !== "NEW_PASSWORD_REQUIRED") + fail("InvalidParameterException", "Only NEW_PASSWORD_REQUIRED is simulated."); + const challenge = cs().challenges.findOneBy("token", required(body.Session, "Session")); + if ( + !challenge || + challenge.client_id !== client.client_id || + challenge.pool_id !== client.pool_id || + challenge.expires_at <= Date.now() + ) + fail("NotAuthorizedException", "Invalid challenge session."); + const response = object(body.ChallengeResponses); + if (response.USERNAME !== challenge.username) fail("NotAuthorizedException", "Challenge username does not match."); + checkSecretHash(client, challenge.username, response.SECRET_HASH); + const user = getUser(store, client.pool_id, challenge.username); + if (!user.enabled || user.status !== "FORCE_CHANGE_PASSWORD") + fail("NotAuthorizedException", "Challenge is no longer valid."); + const hash = passwordHash(required(response.NEW_PASSWORD, "NEW_PASSWORD")); + cs().challenges.delete(challenge.id); + const updated = cs().users.update(user.id, { password_hash: hash, status: "CONFIRMED" })!; + return { AuthenticationResult: await issueTokens(store, baseUrl, client, updated) }; + } + + async function authenticate(body: Record, admin = false) { + const client = getClient(store, required(body.ClientId, "ClientId")); + const params = object(body.AuthParameters); + if (body.AuthFlow === "REFRESH_TOKEN_AUTH" || body.AuthFlow === "REFRESH_TOKEN") { + const session = refreshSession(store, client, required(params.REFRESH_TOKEN, "REFRESH_TOKEN")); + checkSecretHash(client, session.username, params.SECRET_HASH); + return { + AuthenticationResult: await issueTokens( + store, + baseUrl, + client, + getUser(store, client.pool_id, session.username), + { session }, + ), + }; + } + if (body.AuthFlow !== "USER_PASSWORD_AUTH" && !(admin && body.AuthFlow === "ADMIN_USER_PASSWORD_AUTH")) { + fail( + "InvalidParameterException", + "Supported flows are USER_PASSWORD_AUTH, ADMIN_USER_PASSWORD_AUTH, and REFRESH_TOKEN_AUTH. SRP and MFA are not simulated.", + ); + } + const username = required(params.USERNAME, "USERNAME"); + checkSecretHash(client, username, params.SECRET_HASH); + const user = findUser(store, client.pool_id, username); + if (!user || !user.enabled || !matchesPassword(str(params.PASSWORD), user.password_hash)) + fail("NotAuthorizedException", "Incorrect username or password."); + if (user.status === "FORCE_CHANGE_PASSWORD") { + const challenge = cs().challenges.insert({ + token: randomBytes(32).toString("base64url"), + pool_id: client.pool_id, + client_id: client.client_id, + username, + expires_at: Date.now() + 3 * 60_000, + }); + return { + ChallengeName: "NEW_PASSWORD_REQUIRED", + Session: challenge.token, + ChallengeParameters: { + USER_ID_FOR_SRP: username, + userAttributes: JSON.stringify(user.attributes), + requiredAttributes: "[]", + }, + }; + } + return { AuthenticationResult: await issueTokens(store, baseUrl, client, user) }; + } +} diff --git a/packages/@emulators/aws/src/routes/cognito-oauth.ts b/packages/@emulators/aws/src/routes/cognito-oauth.ts new file mode 100644 index 00000000..11048d0d --- /dev/null +++ b/packages/@emulators/aws/src/routes/cognito-oauth.ts @@ -0,0 +1,221 @@ +import { createHash, randomBytes } from "node:crypto"; +import { + constantTimeSecretEqual, + renderCardPage, + renderUserButton, + type Context, + type RouteContext, +} from "@emulators/core"; +import { getCognitoStore } from "../cognito/store.js"; +import { + CognitoError, + checkUser, + getClient, + getPool, + getUser, + issueTokens, + issuer, + refreshSession, + signingKey, + verifyToken, +} from "../cognito/helpers.js"; + +export function cognitoOAuthRoutes({ app, store, baseUrl }: RouteContext) { + const cs = () => getCognitoStore(store); + const root = "/cognito-idp/:poolId"; + const oauthError = (c: Context, error: string, description: string, status: 400 | 401 = 400) => + c.json({ error, error_description: description }, status); + + app.get(`${root}/.well-known/openid-configuration`, (c) => { + const pool = cs().pools.findOneBy("pool_id", c.req.param("poolId")); + if (!pool) return c.json({ error: "not_found" }, 404); + const url = issuer(baseUrl, pool.pool_id); + return c.json({ + issuer: url, + authorization_endpoint: `${url}/oauth2/authorize`, + token_endpoint: `${url}/oauth2/token`, + userinfo_endpoint: `${url}/oauth2/userInfo`, + jwks_uri: `${url}/.well-known/jwks.json`, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + subject_types_supported: ["public"], + id_token_signing_alg_values_supported: ["RS256"], + token_endpoint_auth_methods_supported: ["none", "client_secret_basic", "client_secret_post"], + scopes_supported: ["openid", "email", "profile", "aws.cognito.signin.user.admin"], + code_challenge_methods_supported: ["S256"], + }); + }); + app.get(`${root}/.well-known/jwks.json`, (c) => { + const pool = cs().pools.findOneBy("pool_id", c.req.param("poolId")); + if (!pool) return c.json({ error: "not_found" }, 404); + return c.json({ keys: [signingKey(store, pool.pool_id).jwk] }); + }); + + for (const method of ["GET", "POST"]) + app.on(method, `${root}/oauth2/authorize`, async (c) => { + try { + const pool = getPool(store, c.req.param("poolId")); + const params = method === "GET" ? new URL(c.req.url).searchParams : new URLSearchParams(await c.req.text()); + const client = getClient(store, params.get("client_id") ?? ""); + if (client.pool_id !== pool.pool_id) + return oauthError(c, "invalid_client", "Client does not belong to this user pool."); + const redirect = params.get("redirect_uri") ?? ""; + if (!client.redirect_uris.includes(redirect)) + return oauthError(c, "invalid_request", "redirect_uri must exactly match a registered URI."); + if (params.get("response_type") !== "code") + return oauthError(c, "unsupported_response_type", "Only the authorization code flow is simulated."); + const scopes = [...new Set((params.get("scope") ?? "openid").split(" ").filter(Boolean))]; + if (scopes.some((scope) => !client.scopes.includes(scope))) + return oauthError(c, "invalid_scope", "A requested scope is not allowed for this client."); + const challenge = params.get("code_challenge"); + if ( + (challenge && (!/^[A-Za-z0-9_-]{43}$/.test(challenge) || params.get("code_challenge_method") !== "S256")) || + (!challenge && params.has("code_challenge_method")) + ) { + return oauthError(c, "invalid_request", "PKCE requires an S256 code challenge."); + } + if (method === "GET") { + const users = cs() + .users.findBy("pool_id", pool.pool_id) + .filter((user) => user.enabled && user.status === "CONFIRMED"); + const fields = Object.fromEntries([...params].filter(([name]) => name !== "username")); + const buttons = users + .map((user) => + renderUserButton({ + login: user.username, + letter: user.username[0].toUpperCase(), + name: user.attributes.name, + email: user.attributes.email, + formAction: `${issuer(baseUrl, pool.pool_id)}/oauth2/authorize`, + hiddenFields: { ...fields, username: user.username }, + }), + ) + .join(""); + return c.html( + renderCardPage( + "Sign in with Cognito", + "Choose a local test user", + buttons || '

Seed a confirmed user or complete sign-up to sign in.

', + "AWS", + ), + ); + } + const user = getUser(store, pool.pool_id, params.get("username") ?? ""); + checkUser(user); + const code = cs().codes.insert({ + token: randomBytes(32).toString("base64url"), + pool_id: pool.pool_id, + client_id: client.client_id, + username: user.username, + redirect_uri: redirect, + scope: scopes.join(" "), + nonce: params.get("nonce") ?? undefined, + code_challenge: challenge ?? undefined, + expires_at: Date.now() + 5 * 60_000, + }); + const location = new URL(redirect); + location.searchParams.set("code", code.token); + if (params.has("state")) location.searchParams.set("state", params.get("state")!); + return c.redirect(location.toString()); + } catch (error) { + if (!(error instanceof CognitoError)) throw error; + return oauthError(c, "invalid_request", error.message); + } + }); + + app.post(`${root}/oauth2/token`, async (c) => { + c.header("Cache-Control", "no-store"); + c.header("Pragma", "no-cache"); + try { + const pool = getPool(store, c.req.param("poolId")); + const params = new URLSearchParams(await c.req.text()); + let clientId = params.get("client_id") ?? ""; + let secret = params.get("client_secret") ?? ""; + const authorization = c.req.header("Authorization"); + if (authorization?.startsWith("Basic ")) { + const credentials = Buffer.from(authorization.slice(6), "base64").toString(); + const separator = credentials.indexOf(":"); + if (separator < 0) return oauthError(c, "invalid_client", "Invalid client credentials.", 401); + const basicId = decodeURIComponent(credentials.slice(0, separator)); + if (clientId && clientId !== basicId) + return oauthError(c, "invalid_client", "Conflicting client credentials.", 401); + clientId = basicId; + secret = decodeURIComponent(credentials.slice(separator + 1)); + } + const client = getClient(store, clientId); + if ( + client.pool_id !== pool.pool_id || + (client.client_secret && !constantTimeSecretEqual(secret, client.client_secret)) + ) { + return oauthError(c, "invalid_client", "Invalid client credentials.", 401); + } + let tokens: Awaited>; + let scope: string; + if (params.get("grant_type") === "authorization_code") { + const code = cs().codes.findOneBy("token", params.get("code") ?? ""); + if ( + !code || + code.client_id !== client.client_id || + code.pool_id !== pool.pool_id || + code.redirect_uri !== params.get("redirect_uri") || + code.expires_at <= Date.now() + ) { + return oauthError(c, "invalid_grant", "Invalid or expired authorization code."); + } + if (code.code_challenge) { + const verifier = params.get("code_verifier") ?? ""; + const challenge = createHash("sha256").update(verifier).digest("base64url"); + if (!/^[A-Za-z0-9._~-]{43,128}$/.test(verifier) || !constantTimeSecretEqual(challenge, code.code_challenge)) { + return oauthError(c, "invalid_grant", "Invalid PKCE verifier."); + } + } + const user = getUser(store, pool.pool_id, code.username); + checkUser(user); + cs().codes.delete(code.id); + scope = code.scope; + tokens = await issueTokens(store, baseUrl, client, user, { scope, nonce: code.nonce }); + } else if (params.get("grant_type") === "refresh_token") { + const session = refreshSession(store, client, params.get("refresh_token") ?? ""); + scope = session.scope; + tokens = await issueTokens(store, baseUrl, client, getUser(store, pool.pool_id, session.username), { session }); + } else return oauthError(c, "unsupported_grant_type", "Only authorization_code and refresh_token are simulated."); + return c.json({ + access_token: tokens.AccessToken, + ...(scope.split(" ").includes("openid") ? { id_token: tokens.IdToken } : {}), + ...(tokens.RefreshToken ? { refresh_token: tokens.RefreshToken } : {}), + token_type: tokens.TokenType, + expires_in: tokens.ExpiresIn, + scope, + }); + } catch (error) { + if (error instanceof URIError) return oauthError(c, "invalid_client", "Invalid client credentials.", 401); + if (!(error instanceof CognitoError)) throw error; + return oauthError( + c, + error.code === "ResourceNotFoundException" ? "invalid_client" : "invalid_grant", + error.message, + ); + } + }); + + app.get(`${root}/oauth2/userInfo`, async (c) => { + try { + const pool = getPool(store, c.req.param("poolId")); + const token = c.req.header("Authorization")?.replace(/^Bearer\s+/i, "") ?? ""; + const { user, session } = await verifyToken(store, baseUrl, token, "access", pool.pool_id); + const scopes = session.scope.split(" "); + if (!scopes.includes("openid")) return oauthError(c, "invalid_token", "Token requires openid scope.", 401); + return c.json({ + sub: user.sub, + username: user.username, + ...(scopes.includes("email") && user.attributes.email + ? { email: user.attributes.email, email_verified: user.attributes.email_verified === "true" } + : {}), + ...(scopes.includes("profile") && user.attributes.name ? { name: user.attributes.name } : {}), + }); + } catch (error) { + if (!(error instanceof CognitoError)) throw error; + return oauthError(c, "invalid_token", error.message, 401); + } + }); +} diff --git a/packages/@emulators/aws/src/routes/inspector.ts b/packages/@emulators/aws/src/routes/inspector.ts index 150d6505..8595e0b9 100644 --- a/packages/@emulators/aws/src/routes/inspector.ts +++ b/packages/@emulators/aws/src/routes/inspector.ts @@ -1,5 +1,6 @@ import type { RouteContext, InspectorTab } from "@emulators/core"; import { renderInspectorPage } from "@emulators/core"; +import { getCognitoStore } from "../cognito/store.js"; import { getAwsStore } from "../store.js"; import { escapeXml } from "../helpers.js"; @@ -9,6 +10,7 @@ const TABS: InspectorTab[] = [ { id: "s3", label: "S3", href: "/_inspector?tab=s3" }, { id: "sqs", label: "SQS", href: "/_inspector?tab=sqs" }, { id: "iam", label: "IAM", href: "/_inspector?tab=iam" }, + { id: "cognito", label: "Cognito", href: "/_inspector?tab=cognito" }, ]; export function inspectorRoutes(ctx: RouteContext): void { @@ -132,6 +134,24 @@ export function inspectorRoutes(ctx: RouteContext): void { `; } + if (tab === "cognito") { + const cognito = getCognitoStore(store); + const rows = cognito.users + .all() + .map( + (user) => ` + ${escapeXml(user.pool_id)}${escapeXml(user.username)} + ${escapeXml(user.status)}${user.enabled ? "Yes" : "No"} + ${user.code_expires_at && user.code_expires_at > Date.now() ? escapeXml(user.confirmation_code ?? "") : ""} + `, + ) + .join(""); + contentHtml = `

Cognito Users (${cognito.users.all().length})

+

${cognito.pools.all().length} user pools, ${cognito.identityPools.all().length} identity pools, ${cognito.identities.all().length} identities

+ + ${rows || ''}
PoolUsernameStatusEnabledLocal verification code
No users
`; + } + return c.html(renderInspectorPage("Inspector", TABS, tab, contentHtml, SERVICE_LABEL)); }); } diff --git a/packages/emulate/src/index.ts b/packages/emulate/src/index.ts index 7a4db9b1..a8c5bac6 100644 --- a/packages/emulate/src/index.ts +++ b/packages/emulate/src/index.ts @@ -75,6 +75,14 @@ AWS API coverage: S3 uploads and downloads preserve arbitrary binary payloads, including raw byte lengths and ETags. SQS supports current AWS SDK clients using AWS JSON 1.0 at /sqs, alongside XML Query requests. Queue operations also accept the returned QueueUrl as the endpoint. + Cognito User Pools use /cognito-idp and Identity Pools use /cognito-identity with the AWS SDK v3. + Seed aws.cognito.user_pools and identity_pools; defaults are us-east-1_emulate and app client emulateclient. + Sign-up, confirmation, password/reset/refresh flows, new-password challenges, and admin user operations are supported. + Local confirmation codes default to 123456 and appear at /_inspector?tab=cognito; no email/SMS is sent. + RS256 tokens, discovery, and JWKS use the issuer BASE_URL/cognito-idp/USER_POOL_ID. + That issuer serves /oauth2/authorize (local user picker), /oauth2/token (code/refresh + S256 PKCE), and /oauth2/userInfo. + Identity Pools support GetId and GetCredentialsForIdentity with validated User Pool ID tokens. + SRP, MFA, external federation, pool/client management APIs, and IAM policy/signature evaluation are not simulated. Google Calendar discovery: GET /discovery/v1/apis/calendar/v3/rest returns the public discovery document for the emulated Calendar v3 surface. diff --git a/packages/emulate/src/registry.ts b/packages/emulate/src/registry.ts index 9697fb15..4891200c 100644 --- a/packages/emulate/src/registry.ts +++ b/packages/emulate/src/registry.ts @@ -394,7 +394,7 @@ export const SERVICE_REGISTRY: Record = { aws: { label: "AWS cloud service emulator", endpoints: - "S3 (buckets, objects), SQS (queues, messages), IAM (users, roles, access keys), STS (assume role, caller identity)", + "S3 (buckets, objects), SQS (queues, messages), Cognito (User Pools, OAuth, JWTs, Identity Pools), IAM (users, roles, access keys), STS (assume role, caller identity)", async load() { const mod = await import("@emulators/aws"); return { plugin: mod.awsPlugin, seedFromConfig: mod.seedFromConfig }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5c6456a..a8abc8c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -583,7 +583,16 @@ importers: '@emulators/core': specifier: workspace:* version: link:../core + jose: + specifier: ^6.2.12 + version: 6.2.12 devDependencies: + '@aws-sdk/client-cognito-identity': + specifier: ^3.1150.0 + version: 3.1150.0 + '@aws-sdk/client-cognito-identity-provider': + specifier: ^3.1150.0 + version: 3.1150.0 '@aws-sdk/client-s3': specifier: ^3.1031.0 version: 3.1031.0 @@ -962,6 +971,14 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + '@aws-sdk/client-cognito-identity-provider@3.1150.0': + resolution: {integrity: sha512-MdUO7+ctiATD6fRtcnMp6RWNlVo7u/qGKyLHo8owNKK9kcsKC1Rgh2xeztvE+pMIVaitoszLaIDIKYn4U2hGhQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/client-cognito-identity@3.1150.0': + resolution: {integrity: sha512-iGHzqKDOz6OJ8u/iqNaaMi/+d2FqjcEbWcJLYSrbEmsbBAm6xJO6GAZoMbXIjGVhbnyZ+gWN2BUz51iol/C7Sg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/client-s3@3.1031.0': resolution: {integrity: sha512-HflwKSpDl2pwPiH116n9dy1pKW+5yEoiGFor6NO1yADgge+QY5LqluBiHscD7k/o9ib+dgxf6Vg4WsctcRT3OA==} engines: {node: '>=20.0.0'} @@ -978,6 +995,10 @@ packages: resolution: {integrity: sha512-ViOBLuGSSyPAmkKGJ6KJXtVN7MOEp3rnI7uH+kWxnHccvo8C9cvunEAnN5/oaZH1laQfsTBw7IOnmNkWlFZl2A==} engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.978.3': + resolution: {integrity: sha512-nslpzUCTI9b2IQMWSd6mSAXldBexPc8rr0d3dXUutXYFLFaTdsHa2GD7sJdsn3PhyjK0O5K8ypd6zi+RXXPVlg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/crc64-nvme@3.972.7': resolution: {integrity: sha512-QUagVVBbC8gODCF6e1aV0mE2TXWB9Opz4k8EJFdNrujUVQm5R4AjJa1mpOqzwOuROBzqJU9zawzig7M96L8Ejg==} engines: {node: '>=20.0.0'} @@ -990,6 +1011,10 @@ packages: resolution: {integrity: sha512-PxlFZiF5IPUsPnuVSqjM9jlmRwZhL0xx40ccWfZiLQS8TRnHQpegpK1uAidatWuEqbJtbbY97A2Sezikz2AMUw==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.74': + resolution: {integrity: sha512-rsRJcW2iFUhCgclyD6OSbMEaPl8Eu0qSeRZb2uuCK1ViZ66hl5wOe/p6Vr6MO3b+ydyKgFeqza2p6tZThvILlw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.28': resolution: {integrity: sha512-+1DwCjjpo1WoiZTN08yGitI3nUwZUSQWVWFrW4C46HqZwACjcUQ7C66tnKPBTVxrEYYDOP11A6Afmu1L6ylt3g==} engines: {node: '>=20.0.0'} @@ -998,6 +1023,10 @@ packages: resolution: {integrity: sha512-Q/uIDy8eAVN7JgSKDg16F/f+hmNJj/ZyLJN8AkW2ofVdDgJVh8fO/A26iZ7YsvM2/k76hsf/u2lLAtyXsK8Ziw==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.76': + resolution: {integrity: sha512-pnKdh0Br7OJPj+7qKjzgTfDTFW0RhEMKXNDR2jv9CAM4oHuG/4z3hRh8KtutLfpmItuRtDbKBmwWkDqHwwAWXw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.972.30': resolution: {integrity: sha512-Fg1oJcoijwOZjTxdbx+ubqbQl8YEQ4Cwhjw6TWzQjuDEvQYNhnCXW2pN7eKtdTrdE4a6+5TVKGSm2I+i2BKIQg==} engines: {node: '>=20.0.0'} @@ -1006,6 +1035,10 @@ packages: resolution: {integrity: sha512-KoyjZ9vdjuxEQ8obAH06xKP8H0WKqGhwUZLMqAOiS7G0xgUAht4X8f0ASLC/vhNLCWGblZOD3VQHWyTROGJHLg==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.973.19': + resolution: {integrity: sha512-kcjvAxqQEV8yuwkX58RelNn3ne65o7JgRWf3Z/gVvbi7Xuw1qSDLJlatrLD76+40cmRxmNkLxBoyp/vAQChQ5g==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.30': resolution: {integrity: sha512-nchIrrI/7dgjG1bW/DEWOJc00K9n+kkl6B8Mk0KO6d4GfWBOXlVr9uHp7CJR9FIrjmov5SGjHXG2q9XAtkRw6Q==} engines: {node: '>=20.0.0'} @@ -1014,6 +1047,10 @@ packages: resolution: {integrity: sha512-xF9A0gaO7k+1dYPFoo69OQatA9Ov0QuDZfqE8H0FLyIoOph+mlEC+I1Qo9UULupGuDfSYh+kVpfcDSxSYf/2fg==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.81': + resolution: {integrity: sha512-CeQHSbE2CnVdaHIJ/NayRaCecIER+Pm9faUPkmRATO54rCoMTEPSbVieGHzEwvm1dZM2HIs6qQZCznhayNTWJQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.31': resolution: {integrity: sha512-99OHVQ6eZ5DTxiOWgHdjBMvLqv7xoY4jLK6nZ1NcNSQbAnYZkQNIHi/VqInc9fnmg7of9si/z+waE6YL9OQIlw==} engines: {node: '>=20.0.0'} @@ -1022,6 +1059,10 @@ packages: resolution: {integrity: sha512-CrFcML/xImiSlquH4W3V6Npz3aSkPXHDVciAU1JOBN0vqjmkcz9H9up2sCHQqN76J/WrT+TJ580L8h2joksQrg==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.86': + resolution: {integrity: sha512-WNttSyr6e8innPtBSty//CdnqJcgZN6eAbtw3v/CR2IObz6MYZEo8eusdtuCHJ2ujwFqC2bSPHVyhXGHx8HruQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.26': resolution: {integrity: sha512-jibxNld3m+vbmQwn98hcQ+fLIVrx3cQuhZlSs1/hix48SjDS5/pjMLwpmtLD/lFnd6ve1AL4o1bZg3X1WRa2SQ==} engines: {node: '>=20.0.0'} @@ -1030,6 +1071,10 @@ packages: resolution: {integrity: sha512-tyNnT4aVuOEXmKKKZPExBi828vRd9heshobBsKF0bov5EORHSJc1AGsHOMRP57sDt+dIuVASUOywaixiX26t4g==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.74': + resolution: {integrity: sha512-Yj6kZFJQdbR+PygTkqdVLPHHpdDpOS9GCYVcDCuweCRRig9LF24ELQBMfyUw1vspvrZJUfnOGVyMjhaL094ahw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.972.30': resolution: {integrity: sha512-honYIM17F/+QSWJRE84T4u//ofqEi7rLbnwmIpu7fgFX5PML78wbtdSAy5Xwyve3TLpE9/f9zQx0aBVxSjAOPw==} engines: {node: '>=20.0.0'} @@ -1038,6 +1083,10 @@ packages: resolution: {integrity: sha512-gxKTWaUk2gSbBOH4m3NPRfEKcGXeDAOgHr7nNG5JktR1xZLo0R+DpMjbcleoHbB0uJREOG7hjIRE635IuBWsRQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.973.18': + resolution: {integrity: sha512-FnpPhdY4qUGtX/8X6eKxh+uD+9Y19i9FYibgk4SBe0M935s9vocHkHzaxaMNk+YkHmvzgdu8cHA5SCoeU+kkew==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.30': resolution: {integrity: sha512-CyL4oWUlONQRN2SsYMVrA9Z3i3QfLWTQctI8tuKbjNGCVVDCnJf/yMbSJCOZgpPFRtxh7dgQwvpqwmJm+iytmw==} engines: {node: '>=20.0.0'} @@ -1046,6 +1095,10 @@ packages: resolution: {integrity: sha512-/jn0Gs/qm6fs6nTIA5yncHayybz42SATZ3rNxCEBYPJ9v69PLrDRgk07E9xTT2gpRcNmDXXbi27ZbuU06N0Z6Q==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.80': + resolution: {integrity: sha512-TJF25Ndwy2HxcVj6oxMgp8qVlJAawlExHYBXqhuC6Zt2m+A7WpdkjBD6QglNbiArlteYljZHEY3R7KKmno6ISA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-bucket-endpoint@3.972.10': resolution: {integrity: sha512-Vbc2frZH7wXlMNd+ZZSXUEs/l1Sv8Jj4zUnIfwrYF5lwaLdXHZ9xx4U3rjUcaye3HRhFVc+E5DbBxpRAbB16BA==} engines: {node: '>=20.0.0'} @@ -1098,6 +1151,10 @@ packages: resolution: {integrity: sha512-bovCKvDsLocNTzaj6VrrMVJ2BGROqO0JlwcGseO67a+I9Bo+08nUJhSQfZUIx4+A4a7I4101GEJd/AYm7dQ68w==} engines: {node: '>=20.0.0'} + '@aws-sdk/nested-clients@3.997.48': + resolution: {integrity: sha512-B01jdXJtAW/XOiUzGdI2mz67WyEHbGEc9X6Euzw3bjLAa+txTqdZDER4X2FsIEcXzV2ABqudVZ5thali8tCCzQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/region-config-resolver@3.972.12': resolution: {integrity: sha512-QQI43Mxd53nBij0pm8HXC+t4IOC6gnhhZfzxE0OATQyO6QfPV4e+aTIRRuAJKA6Nig/cR8eLwPryqYTX9ZrjAQ==} engines: {node: '>=20.0.0'} @@ -1122,6 +1179,10 @@ packages: resolution: {integrity: sha512-CAwYyA8eVk1w6h8YgLvkjIOakWPwURaUFrP7BRVgkxTFUM+O87thyKsGKJ6rVPbP8eoEV10cBWQ5YfaYl3qSVQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1150.0': + resolution: {integrity: sha512-wDU36ofhabZYu+d2KSriyDbwzxNoaOp+4dG0p3qkabk7HeALR5nIWyQbjoyJZLn3iqWkv/xNdyUfbfevDVDzzg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.973.8': resolution: {integrity: sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==} engines: {node: '>=20.0.0'} @@ -8358,6 +8419,9 @@ packages: jose@5.10.0: resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} + jose@6.2.2: resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} @@ -11484,20 +11548,20 @@ snapshots: '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.974.6 tslib: 2.8.1 '@aws-crypto/crc32c@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.974.6 tslib: 2.8.1 '@aws-crypto/sha1-browser@5.2.0': dependencies: '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.974.6 '@aws-sdk/util-locate-window': 3.965.5 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -11507,7 +11571,7 @@ snapshots: '@aws-crypto/sha256-js': 5.2.0 '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.974.6 '@aws-sdk/util-locate-window': 3.965.5 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -11515,7 +11579,7 @@ snapshots: '@aws-crypto/sha256-js@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.974.6 tslib: 2.8.1 '@aws-crypto/supports-web-crypto@5.2.0': @@ -11524,10 +11588,32 @@ snapshots: '@aws-crypto/util@5.2.0': dependencies: - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.974.6 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 + '@aws-sdk/client-cognito-identity-provider@3.1150.0': + dependencies: + '@aws-sdk/core': 3.978.3 + '@aws-sdk/credential-provider-node': 3.972.86 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.2 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/client-cognito-identity@3.1150.0': + dependencies: + '@aws-sdk/core': 3.978.3 + '@aws-sdk/credential-provider-node': 3.972.86 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.2 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/client-s3@3.1031.0': dependencies: '@aws-crypto/sha1-browser': 5.2.0 @@ -11602,15 +11688,15 @@ snapshots: '@aws-sdk/core@3.974.0': dependencies: - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.974.6 '@aws-sdk/xml-builder': 3.972.18 - '@smithy/core': 3.23.15 + '@smithy/core': 3.35.2 '@smithy/node-config-provider': 4.3.14 '@smithy/property-provider': 4.2.14 '@smithy/protocol-http': 5.3.14 '@smithy/signature-v4': 5.3.14 '@smithy/smithy-client': 4.12.11 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 '@smithy/util-base64': 4.3.2 '@smithy/util-middleware': 4.2.14 '@smithy/util-utf8': 4.2.2 @@ -11627,17 +11713,28 @@ snapshots: bowser: 2.14.1 tslib: 2.8.1 + '@aws-sdk/core@3.978.3': + dependencies: + '@aws-sdk/types': 3.974.6 + '@aws-sdk/xml-builder': 3.972.41 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.35.2 + '@smithy/signature-v4': 5.7.4 + '@smithy/types': 4.19.0 + bowser: 2.14.1 + tslib: 2.8.1 + '@aws-sdk/crc64-nvme@3.972.7': dependencies: - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws-sdk/credential-provider-env@3.972.26': dependencies: - '@aws-sdk/core': 3.974.0 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/core': 3.978.2 + '@aws-sdk/types': 3.974.6 '@smithy/property-provider': 4.2.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws-sdk/credential-provider-env@3.972.73': @@ -11648,16 +11745,24 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.972.74': + dependencies: + '@aws-sdk/core': 3.978.3 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.28': dependencies: - '@aws-sdk/core': 3.974.0 - '@aws-sdk/types': 3.973.8 - '@smithy/fetch-http-handler': 5.3.17 - '@smithy/node-http-handler': 4.5.3 + '@aws-sdk/core': 3.978.2 + '@aws-sdk/types': 3.974.6 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 '@smithy/property-provider': 4.2.14 '@smithy/protocol-http': 5.3.14 '@smithy/smithy-client': 4.12.11 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 '@smithy/util-stream': 4.5.23 tslib: 2.8.1 @@ -11671,21 +11776,31 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.76': + dependencies: + '@aws-sdk/core': 3.978.3 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.2 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-ini@3.972.30': dependencies: - '@aws-sdk/core': 3.974.0 - '@aws-sdk/credential-provider-env': 3.972.26 - '@aws-sdk/credential-provider-http': 3.972.28 + '@aws-sdk/core': 3.978.2 + '@aws-sdk/credential-provider-env': 3.972.73 + '@aws-sdk/credential-provider-http': 3.972.75 '@aws-sdk/credential-provider-login': 3.972.30 - '@aws-sdk/credential-provider-process': 3.972.26 - '@aws-sdk/credential-provider-sso': 3.972.30 - '@aws-sdk/credential-provider-web-identity': 3.972.30 + '@aws-sdk/credential-provider-process': 3.972.73 + '@aws-sdk/credential-provider-sso': 3.973.17 + '@aws-sdk/credential-provider-web-identity': 3.972.79 '@aws-sdk/nested-clients': 3.996.20 - '@aws-sdk/types': 3.973.8 - '@smithy/credential-provider-imds': 4.2.14 + '@aws-sdk/types': 3.974.6 + '@smithy/credential-provider-imds': 4.5.2 '@smithy/property-provider': 4.2.14 '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 transitivePeerDependencies: - aws-crt @@ -11706,18 +11821,32 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-ini@3.973.19': + dependencies: + '@aws-sdk/core': 3.978.3 + '@aws-sdk/credential-provider-env': 3.972.74 + '@aws-sdk/credential-provider-http': 3.972.76 + '@aws-sdk/credential-provider-login': 3.972.81 + '@aws-sdk/credential-provider-process': 3.972.74 + '@aws-sdk/credential-provider-sso': 3.973.18 + '@aws-sdk/credential-provider-web-identity': 3.972.80 + '@aws-sdk/nested-clients': 3.997.48 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.2 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-login@3.972.30': dependencies: - '@aws-sdk/core': 3.974.0 - '@aws-sdk/nested-clients': 3.996.20 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/core': 3.978.2 + '@aws-sdk/nested-clients': 3.997.47 + '@aws-sdk/types': 3.974.6 '@smithy/property-provider': 4.2.14 '@smithy/protocol-http': 5.3.14 '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt '@aws-sdk/credential-provider-login@3.972.80': dependencies: @@ -11728,6 +11857,15 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-login@3.972.81': + dependencies: + '@aws-sdk/core': 3.978.3 + '@aws-sdk/nested-clients': 3.997.48 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-node@3.972.31': dependencies: '@aws-sdk/credential-provider-env': 3.972.26 @@ -11736,11 +11874,11 @@ snapshots: '@aws-sdk/credential-provider-process': 3.972.26 '@aws-sdk/credential-provider-sso': 3.972.30 '@aws-sdk/credential-provider-web-identity': 3.972.30 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.974.6 '@smithy/credential-provider-imds': 4.2.14 '@smithy/property-provider': 4.2.14 '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 transitivePeerDependencies: - aws-crt @@ -11759,13 +11897,27 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-node@3.972.86': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.74 + '@aws-sdk/credential-provider-http': 3.972.76 + '@aws-sdk/credential-provider-ini': 3.973.19 + '@aws-sdk/credential-provider-process': 3.972.74 + '@aws-sdk/credential-provider-sso': 3.973.18 + '@aws-sdk/credential-provider-web-identity': 3.972.80 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.2 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.972.26': dependencies: - '@aws-sdk/core': 3.974.0 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/core': 3.978.2 + '@aws-sdk/types': 3.974.6 '@smithy/property-provider': 4.2.14 '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws-sdk/credential-provider-process@3.972.73': @@ -11776,15 +11928,23 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.972.74': + dependencies: + '@aws-sdk/core': 3.978.3 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-sso@3.972.30': dependencies: - '@aws-sdk/core': 3.974.0 + '@aws-sdk/core': 3.978.2 '@aws-sdk/nested-clients': 3.996.20 '@aws-sdk/token-providers': 3.1031.0 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.974.6 '@smithy/property-provider': 4.2.14 '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 transitivePeerDependencies: - aws-crt @@ -11799,14 +11959,24 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-sso@3.973.18': + dependencies: + '@aws-sdk/core': 3.978.3 + '@aws-sdk/nested-clients': 3.997.48 + '@aws-sdk/token-providers': 3.1150.0 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-web-identity@3.972.30': dependencies: - '@aws-sdk/core': 3.974.0 + '@aws-sdk/core': 3.978.2 '@aws-sdk/nested-clients': 3.996.20 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.974.6 '@smithy/property-provider': 4.2.14 '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 transitivePeerDependencies: - aws-crt @@ -11820,21 +11990,30 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-web-identity@3.972.80': + dependencies: + '@aws-sdk/core': 3.978.3 + '@aws-sdk/nested-clients': 3.997.48 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/middleware-bucket-endpoint@3.972.10': dependencies: - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.974.6 '@aws-sdk/util-arn-parser': 3.972.3 '@smithy/node-config-provider': 4.3.14 '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 '@smithy/util-config-provider': 4.2.2 tslib: 2.8.1 '@aws-sdk/middleware-expect-continue@3.972.10': dependencies: - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.974.6 '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws-sdk/middleware-flexible-checksums@3.974.8': @@ -11842,13 +12021,13 @@ snapshots: '@aws-crypto/crc32': 5.2.0 '@aws-crypto/crc32c': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/core': 3.974.0 + '@aws-sdk/core': 3.978.2 '@aws-sdk/crc64-nvme': 3.972.7 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.974.6 '@smithy/is-array-buffer': 4.2.2 '@smithy/node-config-provider': 4.3.14 '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 '@smithy/util-middleware': 4.2.14 '@smithy/util-stream': 4.5.23 '@smithy/util-utf8': 4.2.2 @@ -11856,42 +12035,42 @@ snapshots: '@aws-sdk/middleware-host-header@3.972.10': dependencies: - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.974.6 '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws-sdk/middleware-location-constraint@3.972.10': dependencies: - '@aws-sdk/types': 3.973.8 - '@smithy/types': 4.14.1 + '@aws-sdk/types': 3.974.6 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws-sdk/middleware-logger@3.972.10': dependencies: - '@aws-sdk/types': 3.973.8 - '@smithy/types': 4.14.1 + '@aws-sdk/types': 3.974.6 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws-sdk/middleware-recursion-detection@3.972.11': dependencies: - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.974.6 '@aws/lambda-invoke-store': 0.2.4 '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws-sdk/middleware-sdk-s3@3.972.29': dependencies: - '@aws-sdk/core': 3.974.0 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/core': 3.978.2 + '@aws-sdk/types': 3.974.6 '@aws-sdk/util-arn-parser': 3.972.3 - '@smithy/core': 3.23.15 + '@smithy/core': 3.35.2 '@smithy/node-config-provider': 4.3.14 '@smithy/protocol-http': 5.3.14 '@smithy/signature-v4': 5.3.14 '@smithy/smithy-client': 4.12.11 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 '@smithy/util-config-provider': 4.2.2 '@smithy/util-middleware': 4.2.14 '@smithy/util-stream': 4.5.23 @@ -11907,18 +12086,18 @@ snapshots: '@aws-sdk/middleware-ssec@3.972.10': dependencies: - '@aws-sdk/types': 3.973.8 - '@smithy/types': 4.14.1 + '@aws-sdk/types': 3.974.6 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws-sdk/middleware-user-agent@3.972.30': dependencies: - '@aws-sdk/core': 3.974.0 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/core': 3.978.2 + '@aws-sdk/types': 3.974.6 '@aws-sdk/util-endpoints': 3.996.7 - '@smithy/core': 3.23.15 + '@smithy/core': 3.35.2 '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 '@smithy/util-retry': 4.3.2 tslib: 2.8.1 @@ -11926,19 +12105,19 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.0 + '@aws-sdk/core': 3.978.2 '@aws-sdk/middleware-host-header': 3.972.10 '@aws-sdk/middleware-logger': 3.972.10 '@aws-sdk/middleware-recursion-detection': 3.972.11 '@aws-sdk/middleware-user-agent': 3.972.30 '@aws-sdk/region-config-resolver': 3.972.12 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.974.6 '@aws-sdk/util-endpoints': 3.996.7 '@aws-sdk/util-user-agent-browser': 3.972.10 '@aws-sdk/util-user-agent-node': 3.973.16 '@smithy/config-resolver': 4.4.16 - '@smithy/core': 3.23.15 - '@smithy/fetch-http-handler': 5.3.17 + '@smithy/core': 3.35.2 + '@smithy/fetch-http-handler': 5.8.0 '@smithy/hash-node': 4.2.14 '@smithy/invalid-dependency': 4.2.14 '@smithy/middleware-content-length': 4.2.14 @@ -11947,10 +12126,10 @@ snapshots: '@smithy/middleware-serde': 4.2.18 '@smithy/middleware-stack': 4.2.14 '@smithy/node-config-provider': 4.3.14 - '@smithy/node-http-handler': 4.5.3 + '@smithy/node-http-handler': 4.12.1 '@smithy/protocol-http': 5.3.14 '@smithy/smithy-client': 4.12.11 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 '@smithy/url-parser': 4.2.14 '@smithy/util-base64': 4.3.2 '@smithy/util-body-length-browser': 4.2.2 @@ -11976,12 +12155,23 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@aws-sdk/nested-clients@3.997.48': + dependencies: + '@aws-sdk/core': 3.978.3 + '@aws-sdk/signature-v4-multi-region': 3.996.48 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.2 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/region-config-resolver@3.972.12': dependencies: - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.974.6 '@smithy/config-resolver': 4.4.16 '@smithy/node-config-provider': 4.3.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws-sdk/s3-presigned-post@3.1031.0': @@ -12001,10 +12191,10 @@ snapshots: '@aws-sdk/signature-v4-multi-region@3.996.17': dependencies: '@aws-sdk/middleware-sdk-s3': 3.972.29 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.974.6 '@smithy/protocol-http': 5.3.14 '@smithy/signature-v4': 5.3.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws-sdk/signature-v4-multi-region@3.996.48': @@ -12016,15 +12206,13 @@ snapshots: '@aws-sdk/token-providers@3.1031.0': dependencies: - '@aws-sdk/core': 3.974.0 - '@aws-sdk/nested-clients': 3.996.20 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/core': 3.978.2 + '@aws-sdk/nested-clients': 3.997.47 + '@aws-sdk/types': 3.974.6 '@smithy/property-provider': 4.2.14 '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt '@aws-sdk/token-providers@3.1149.0': dependencies: @@ -12035,9 +12223,18 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@aws-sdk/token-providers@3.1150.0': + dependencies: + '@aws-sdk/core': 3.978.3 + '@aws-sdk/nested-clients': 3.997.48 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/types@3.973.8': dependencies: - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws-sdk/types@3.974.6': @@ -12051,17 +12248,17 @@ snapshots: '@aws-sdk/util-endpoints@3.996.7': dependencies: - '@aws-sdk/types': 3.973.8 - '@smithy/types': 4.14.1 + '@aws-sdk/types': 3.974.6 + '@smithy/types': 4.19.0 '@smithy/url-parser': 4.2.14 '@smithy/util-endpoints': 3.4.1 tslib: 2.8.1 '@aws-sdk/util-format-url@3.972.10': dependencies: - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.974.6 '@smithy/querystring-builder': 4.2.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws-sdk/util-locate-window@3.965.5': @@ -12070,23 +12267,23 @@ snapshots: '@aws-sdk/util-user-agent-browser@3.972.10': dependencies: - '@aws-sdk/types': 3.973.8 - '@smithy/types': 4.14.1 + '@aws-sdk/types': 3.974.6 + '@smithy/types': 4.19.0 bowser: 2.14.1 tslib: 2.8.1 '@aws-sdk/util-user-agent-node@3.973.16': dependencies: '@aws-sdk/middleware-user-agent': 3.972.30 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.974.6 '@smithy/node-config-provider': 4.3.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 '@smithy/util-config-provider': 4.2.2 tslib: 2.8.1 '@aws-sdk/xml-builder@3.972.18': dependencies: - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 fast-xml-parser: 5.5.8 tslib: 2.8.1 @@ -15780,7 +15977,7 @@ snapshots: '@smithy/config-resolver@4.4.16': dependencies: '@smithy/node-config-provider': 4.3.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 '@smithy/util-config-provider': 4.2.2 '@smithy/util-endpoints': 3.4.1 '@smithy/util-middleware': 4.2.14 @@ -15789,7 +15986,7 @@ snapshots: '@smithy/core@3.23.15': dependencies: '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 '@smithy/url-parser': 4.2.14 '@smithy/util-base64': 4.3.2 '@smithy/util-body-length-browser': 4.2.2 @@ -15808,7 +16005,7 @@ snapshots: dependencies: '@smithy/node-config-provider': 4.3.14 '@smithy/property-provider': 4.2.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 '@smithy/url-parser': 4.2.14 tslib: 2.8.1 @@ -15821,38 +16018,38 @@ snapshots: '@smithy/eventstream-codec@4.2.14': dependencies: '@aws-crypto/crc32': 5.2.0 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 '@smithy/util-hex-encoding': 4.2.2 tslib: 2.8.1 '@smithy/eventstream-serde-browser@4.2.14': dependencies: '@smithy/eventstream-serde-universal': 4.2.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@smithy/eventstream-serde-config-resolver@4.3.14': dependencies: - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@smithy/eventstream-serde-node@4.2.14': dependencies: '@smithy/eventstream-serde-universal': 4.2.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@smithy/eventstream-serde-universal@4.2.14': dependencies: '@smithy/eventstream-codec': 4.2.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@smithy/fetch-http-handler@5.3.17': dependencies: '@smithy/protocol-http': 5.3.14 '@smithy/querystring-builder': 4.2.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 '@smithy/util-base64': 4.3.2 tslib: 2.8.1 @@ -15866,25 +16063,25 @@ snapshots: dependencies: '@smithy/chunked-blob-reader': 5.2.2 '@smithy/chunked-blob-reader-native': 4.2.3 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@smithy/hash-node@4.2.14': dependencies: - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 '@smithy/util-buffer-from': 4.2.2 '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 '@smithy/hash-stream-node@4.2.14': dependencies: - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 '@smithy/invalid-dependency@4.2.14': dependencies: - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@smithy/is-array-buffer@2.2.0': @@ -15897,35 +16094,35 @@ snapshots: '@smithy/md5-js@4.2.14': dependencies: - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 '@smithy/middleware-content-length@4.2.14': dependencies: '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@smithy/middleware-endpoint@4.4.30': dependencies: - '@smithy/core': 3.23.15 + '@smithy/core': 3.35.2 '@smithy/middleware-serde': 4.2.18 '@smithy/node-config-provider': 4.3.14 '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 '@smithy/url-parser': 4.2.14 '@smithy/util-middleware': 4.2.14 tslib: 2.8.1 '@smithy/middleware-retry@4.5.3': dependencies: - '@smithy/core': 3.23.15 + '@smithy/core': 3.35.2 '@smithy/node-config-provider': 4.3.14 '@smithy/protocol-http': 5.3.14 '@smithy/service-error-classification': 4.2.14 '@smithy/smithy-client': 4.12.11 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 '@smithy/util-middleware': 4.2.14 '@smithy/util-retry': 4.3.2 '@smithy/uuid': 1.1.2 @@ -15933,21 +16130,21 @@ snapshots: '@smithy/middleware-serde@4.2.18': dependencies: - '@smithy/core': 3.23.15 + '@smithy/core': 3.35.2 '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@smithy/middleware-stack@4.2.14': dependencies: - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@smithy/node-config-provider@4.3.14': dependencies: '@smithy/property-provider': 4.2.14 '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@smithy/node-http-handler@4.12.1': @@ -15960,44 +16157,44 @@ snapshots: dependencies: '@smithy/protocol-http': 5.3.14 '@smithy/querystring-builder': 4.2.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@smithy/property-provider@4.2.14': dependencies: - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@smithy/protocol-http@5.3.14': dependencies: - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@smithy/querystring-builder@4.2.14': dependencies: - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 '@smithy/util-uri-escape': 4.2.2 tslib: 2.8.1 '@smithy/querystring-parser@4.2.14': dependencies: - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@smithy/service-error-classification@4.2.14': dependencies: - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 '@smithy/shared-ini-file-loader@4.4.9': dependencies: - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@smithy/signature-v4@5.3.14': dependencies: '@smithy/is-array-buffer': 4.2.2 '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 '@smithy/util-hex-encoding': 4.2.2 '@smithy/util-middleware': 4.2.14 '@smithy/util-uri-escape': 4.2.2 @@ -16012,11 +16209,11 @@ snapshots: '@smithy/smithy-client@4.12.11': dependencies: - '@smithy/core': 3.23.15 + '@smithy/core': 3.35.2 '@smithy/middleware-endpoint': 4.4.30 '@smithy/middleware-stack': 4.2.14 '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 '@smithy/util-stream': 4.5.23 tslib: 2.8.1 @@ -16031,7 +16228,7 @@ snapshots: '@smithy/url-parser@4.2.14': dependencies: '@smithy/querystring-parser': 4.2.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@smithy/util-base64@4.3.2': @@ -16066,7 +16263,7 @@ snapshots: dependencies: '@smithy/property-provider': 4.2.14 '@smithy/smithy-client': 4.12.11 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@smithy/util-defaults-mode-node@4.2.52': @@ -16076,13 +16273,13 @@ snapshots: '@smithy/node-config-provider': 4.3.14 '@smithy/property-provider': 4.2.14 '@smithy/smithy-client': 4.12.11 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@smithy/util-endpoints@3.4.1': dependencies: '@smithy/node-config-provider': 4.3.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@smithy/util-hex-encoding@4.2.2': @@ -16091,20 +16288,20 @@ snapshots: '@smithy/util-middleware@4.2.14': dependencies: - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@smithy/util-retry@4.3.2': dependencies: '@smithy/service-error-classification': 4.2.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@smithy/util-stream@4.5.23': dependencies: - '@smithy/fetch-http-handler': 5.3.17 - '@smithy/node-http-handler': 4.5.3 - '@smithy/types': 4.14.1 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.19.0 '@smithy/util-base64': 4.3.2 '@smithy/util-buffer-from': 4.2.2 '@smithy/util-hex-encoding': 4.2.2 @@ -16127,7 +16324,7 @@ snapshots: '@smithy/util-waiter@4.2.16': dependencies: - '@smithy/types': 4.14.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@smithy/uuid@1.1.2': @@ -19681,6 +19878,8 @@ snapshots: jose@5.10.0: {} + jose@6.2.12: {} + jose@6.2.2: {} joycon@3.1.1: {} diff --git a/skills/aws/SKILL.md b/skills/aws/SKILL.md index 36568e98..0edab7ea 100644 --- a/skills/aws/SKILL.md +++ b/skills/aws/SKILL.md @@ -1,12 +1,12 @@ --- name: aws -description: Emulated AWS cloud services (S3, SQS, IAM, STS) for local development and testing. Use when the user needs to interact with AWS API endpoints locally, test S3 bucket and object operations, emulate SQS queues and messages, manage IAM users/roles/access keys, test STS assume role, or work without hitting real AWS APIs. Triggers include "AWS emulator", "emulate AWS", "mock S3", "local SQS", "test IAM", "emulate S3", "AWS locally", "STS assume role", or any task requiring local AWS service emulation. +description: Emulated AWS cloud services (S3, SQS, Cognito, IAM, STS) for local development and testing. Use when the user needs to interact with AWS API endpoints locally, test S3 bucket and object operations, emulate SQS queues and messages, test Cognito sign-up, sign-in, JWTs, or Identity Pools, manage IAM users/roles/access keys, test STS assume role, or work without hitting real AWS APIs. Triggers include "AWS emulator", "emulate AWS", "mock S3", "local SQS", "test IAM", "emulate S3", "AWS locally", "STS assume role", or any task requiring local AWS service emulation. allowed-tools: Bash(npx emulate:*), Bash(curl:*) --- # AWS Emulator -S3, SQS, IAM, and STS emulation with AWS SDK-compatible S3 paths. S3 uploads and downloads preserve arbitrary binary payloads, including raw byte lengths and ETags. SQS supports AWS JSON 1.0 and XML Query protocols; IAM and STS use XML Query. All state is in-memory. +S3, SQS, Cognito, IAM, and STS emulation with AWS SDK-compatible S3 paths. S3 uploads and downloads preserve arbitrary binary payloads, including raw byte lengths and ETags. SQS supports AWS JSON 1.0 and XML Query protocols; IAM and STS use XML Query. All state is in-memory. ## Start @@ -308,6 +308,56 @@ curl -X POST http://localhost:4006/iam/ \ -d "Action=DeleteRole&RoleName=my-role" ``` +### Cognito + +Use `CognitoIdentityProviderClient` with endpoint `/cognito-idp` and `CognitoIdentityClient` with `/cognito-identity`. Both accept AWS JSON 1.1 and work with the official AWS SDK v3: + +```typescript +import { CognitoIdentityProviderClient, InitiateAuthCommand } from "@aws-sdk/client-cognito-identity-provider"; + +const cognito = new CognitoIdentityProviderClient({ + endpoint: "http://localhost:4007/cognito-idp", + region: "us-east-1", + credentials: { accessKeyId: "local-access-key", secretAccessKey: "local-secret" }, +}); +const result = await cognito.send(new InitiateAuthCommand({ + ClientId: "emulateclient", + AuthFlow: "USER_PASSWORD_AUTH", + AuthParameters: { USERNAME: "developer", PASSWORD: "LocalPass123!" }, +})); +``` + +A default User Pool `us-east-1_emulate`, public app client `emulateclient`, and Identity Pool `us-east-1:00000000-0000-4000-8000-000000000001` are created without users. Seed a confirmed account for the example above: + +```yaml +aws: + cognito: + user_pools: + - id: us-east-1_emulate + confirmation_code: "123456" + users: + - username: developer + password: LocalPass123! + attributes: + email: dev@example.com + email_verified: "true" + name: Developer +``` + +User pools also accept `name` and `clients` with `client_id`, optional `client_secret`, exact `redirect_uris`, and allowed `scopes`. Users accept `status` (`CONFIRMED`, `UNCONFIRMED`, or `FORCE_CHANGE_PASSWORD`) and `enabled`. Passwords require at least eight characters and are stored as salted hashes. Confidential clients require Cognito's HMAC `SecretHash` or `SECRET_HASH` for SDK flows. + +- `SignUp`, `ConfirmSignUp`, `ResendConfirmationCode`, `ForgotPassword`, and `ConfirmForgotPassword` model account confirmation and recovery. Codes expire after ten minutes and use the pool's `confirmation_code` fixture, default `123456`. No email or SMS is sent. Inspect active codes at `/_inspector?tab=cognito`; seeded unconfirmed users can request a code with `ResendConfirmationCode`. +- `InitiateAuth` supports `USER_PASSWORD_AUTH`, `REFRESH_TOKEN_AUTH`, and `REFRESH_TOKEN`. `AdminInitiateAuth` also supports `ADMIN_USER_PASSWORD_AUTH`. Admin-created temporary passwords return `NEW_PASSWORD_REQUIRED`, completed with `RespondToAuthChallenge` or `AdminRespondToAuthChallenge`. Challenge sessions expire after three minutes and are bound to the user and client. +- `GetUser`, `ChangePassword`, `GlobalSignOut`, and `RevokeToken` use issued tokens. `AdminCreateUser` (with `TemporaryPassword`), `AdminGetUser`, `AdminConfirmSignUp`, `AdminSetUserPassword`, `AdminEnableUser`, `AdminDisableUser`, `AdminDeleteUser`, and paginated `ListUsers` support test setup and administration. + +Tokens are RS256 JWTs with Cognito `token_use` claims. ID tokens identify the app client in `aud`; access tokens use `client_id`. ID and access tokens expire after one hour; refresh tokens after thirty days. Sign-out, revocation, disabling, deletion, and password resets invalidate affected sessions. Users, sessions, identities, and signing keys participate in snapshots, restore, and reset. + +The local issuer is `http://localhost:4007/cognito-idp/USER_POOL_ID`. Discovery and public keys are served at `ISSUER/.well-known/openid-configuration` and `ISSUER/.well-known/jwks.json`. Configure token verifiers with this local issuer and JWKS. `ISSUER/oauth2/authorize` presents a local user picker, `ISSUER/oauth2/token` exchanges authorization codes or refresh tokens, and `ISSUER/oauth2/userInfo` returns scoped profile claims. Authorization codes expire after five minutes, are single-use, and bind the client, exact redirect URI, and optional S256 PKCE challenge. Client authentication accepts `client_secret_basic` or `client_secret_post`. The default client permits `http://localhost:3000/callback`. + +Identity Pools support `GetId` and `GetCredentialsForIdentity`. Supply the ID token in `Logins` under `cognito-idp.REGION.amazonaws.com/USER_POOL_ID`. The default Identity Pool accepts the default User Pool and app client. Custom `aws.cognito.identity_pools` entries take `id`, optional `name`, `providers: [{ user_pool_id, client_id }]`, and `allow_unauthenticated_identities` (default `false`). Identities remain stable for the same authenticated subject. Credentials expire after one hour; login signatures, expiry, audience, and identity ownership are checked. + +SRP, MFA, external identity providers, Lambda triggers, pool/client management APIs, `ListUsers` filters, and Identity Pool role selection are not simulated. Administrative requests follow the AWS emulator's permissive credential behavior: SigV4 signatures and IAM policies are not evaluated. Identity Pool credentials are generated test credentials, without role or policy evaluation. + ### STS All STS operations use `POST /sts/` with `Action` as a form-urlencoded parameter.