diff --git a/docs/services/cognito/README.md b/docs/services/cognito/README.md index 5682fa5f5..45c3f98d9 100644 --- a/docs/services/cognito/README.md +++ b/docs/services/cognito/README.md @@ -2752,6 +2752,18 @@ try { Cognito. Neither fires for `REFRESH_TOKEN_AUTH`, as neither does on real Cognito, where `PreTokenGeneration` does. +A sign-in at the hosted domain runs `PreAuthentication` too, under the same +`PreAuthentication_Authentication` source real Cognito reports it from `/login`. It runs for a +password and for a passkey, once per sign-in, before the password is checked. A handler that throws +refuses the sign-in, and a served domain draws that refusal on the sign-in form the way it draws a +wrong password. A browser coming back on the managed login session it already holds runs nothing, +because the `PreAuthentication` docs say the trigger does not activate on the renewal of a session +that already exists. + +`PostAuthentication` stays unfired at the hosted domain. AWS names it for a federated sign-in and +leaves it out of the table for a local user at managed login, and what real Cognito does there was +not checked against a live account. + ### Federated sign-in triggers A user arriving from an identity provider runs the pool's triggers too, and which ones it runs @@ -4672,6 +4684,8 @@ Sim Cognito currently supports: - The triggers a federated sign-in runs, being `PreSignUp` and `PostConfirmation` on a first sign-in, `PreAuthentication` and `PostAuthentication` on every one after it, and `PreTokenGeneration` under `TokenGeneration_HostedAuth` when the code is exchanged +- The `PreAuthentication` trigger a local user's managed login sign-in runs, for a password and for + a passkey alike, with a handler's refusal drawn on the sign-in form - A `REGIONAL` web ACL in front of the pool, attached by `AssociateWebACL` on simulated WAFv2 and evaluated against every request the hosted domain and the two `.well-known` documents answer - App client OAuth settings: `AllowedOAuthFlowsUserPoolClient`, `AllowedOAuthFlows`, @@ -4931,6 +4945,11 @@ Current documented limitations: `EXTERNAL_PROVIDER` and never passes through `UNCONFIRMED`, so there is no confirmation for a handler to skip. `autoVerifyEmail` and `autoVerifyPhone` are applied there as they are for a sign-up. +- A local user signing in at managed login runs `PreAuthentication` and leaves `PostAuthentication` + unfired. The documented table names the first for `/login` and the second only for a federated + sign-in, and what real Cognito does with the second there was not checked against a live account. +- A browser signed in from the managed login session it was already holding runs no sign-in trigger, + which is what the `PreAuthentication` docs say of a renewed session. - A code exchanged at the token endpoint reports `TokenGeneration_HostedAuth` where the sign-in happened at an identity provider, and `TokenGeneration_Authentication` where the pool signed in one of its own users. Real Cognito reports the same two. A browser signed in from the managed diff --git a/src/service/cognito/command/hosted/sim-cognito-hosted-commands.ts b/src/service/cognito/command/hosted/sim-cognito-hosted-commands.ts index d8ec91a49..92d011fd3 100644 --- a/src/service/cognito/command/hosted/sim-cognito-hosted-commands.ts +++ b/src/service/cognito/command/hosted/sim-cognito-hosted-commands.ts @@ -52,6 +52,7 @@ export class SimCognitoHostedCommands { }), challenge, clock, + triggers, }), clock, }); diff --git a/src/service/cognito/command/hosted/sim-cognito-hosted-passkey-sign-in.ts b/src/service/cognito/command/hosted/sim-cognito-hosted-passkey-sign-in.ts index c30a933d3..6b657c968 100644 --- a/src/service/cognito/command/hosted/sim-cognito-hosted-passkey-sign-in.ts +++ b/src/service/cognito/command/hosted/sim-cognito-hosted-passkey-sign-in.ts @@ -4,6 +4,7 @@ import { requireSimCognitoReadyToSignIn, requireSimCognitoSignInUser, } from "../../user-pool/auth/sim-cognito-sign-in.js"; +import type { SimCognitoUserPoolTriggers } from "../../user-pool/trigger/sim-cognito-user-pool-triggers.js"; import type { SimCognitoUserPoolClient } from "../../user-pool/client/sim-cognito-user-pool-client.js"; import type { SimCognitoUserPool } from "../../user-pool/sim-cognito-user-pool.js"; import type { SimCognitoUser } from "../../user-pool/user/sim-cognito-user.js"; @@ -33,6 +34,7 @@ interface SimCognitoHostedPasskeySignInProperties { */ readonly challenge: SimCognitoFirstFactorChallenge; readonly clock: SimClock; + readonly triggers: SimCognitoUserPoolTriggers; } /** @@ -58,22 +60,36 @@ interface SimCognitoHostedPasskeySignInProperties { export class SimCognitoHostedPasskeySignIn { private readonly challenge: SimCognitoFirstFactorChallenge; private readonly clock: SimClock; + private readonly triggers: SimCognitoUserPoolTriggers; constructor(properties: SimCognitoHostedPasskeySignInProperties) { this.challenge = properties.challenge; this.clock = properties.clock; + this.triggers = properties.triggers; } /** * Ask this user for a passkey, or refuse where the pool allows none and * where the user has registered none. + * + * `PreAuthentication` runs here rather than where the credential comes back, + * because this is the request that starts the sign-in. A `USER_AUTH` + * sign-in runs it in the same place, on the `InitiateAuth` that asks for a + * factor, and the challenge response that answers runs it no second time. + * Only a request that has been asked holds a session to answer with, so + * every passkey sign-in passes through here exactly once. */ - ask( + async ask( pool: SimCognitoUserPool, client: SimCognitoUserPoolClient, username: string, - ): never { - const user = this.signingIn(pool, client, username); + ): Promise { + const user = requireSimCognitoSignInUser(pool, client, username); + + await this.triggers.preAuthentication({ pool, client, user }); + + requireSimCognitoReadyToSignIn(user); + const asked = this.challenge.issue( { pool, client, user }, simCognitoWebAuthnChallenge, diff --git a/src/service/cognito/command/hosted/sim-cognito-hosted-password-sign-in.ts b/src/service/cognito/command/hosted/sim-cognito-hosted-password-sign-in.ts index 415c860be..fadc19b63 100644 --- a/src/service/cognito/command/hosted/sim-cognito-hosted-password-sign-in.ts +++ b/src/service/cognito/command/hosted/sim-cognito-hosted-password-sign-in.ts @@ -7,6 +7,7 @@ import { } from "../../user-pool/auth/sim-cognito-sign-in.js"; import type { SimCognitoUserPoolClient } from "../../user-pool/client/sim-cognito-user-pool-client.js"; import type { SimCognitoUserPool } from "../../user-pool/sim-cognito-user-pool.js"; +import type { SimCognitoUserPoolTriggers } from "../../user-pool/trigger/sim-cognito-user-pool-triggers.js"; import type { SimCognitoUser } from "../../user-pool/user/sim-cognito-user.js"; import { simCognitoChallengeFactor } from "../auth/sim-cognito-mfa-factor-choice.js"; import type { SimCognitoHostedCredentials } from "./sim-cognito-hosted-credentials.js"; @@ -26,17 +27,30 @@ import type { SimCognitoHostedCredentials } from "./sim-cognito-hosted-credentia * reach one is refused here with a message saying which. */ export class SimCognitoHostedPasswordSignIn { + private readonly triggers: SimCognitoUserPoolTriggers; + + constructor(properties: { readonly triggers: SimCognitoUserPoolTriggers }) { + this.triggers = properties.triggers; + } + /** * The user these credentials sign in, having checked the password. + * + * `PreAuthentication` runs once the user is known and before the password is + * checked, which is where the API sign-ins run it and where real managed + * login reports it from `/login`. A wrong password reaches the handler too, + * because the trigger is given the user to decide about. */ - signIn( + async signIn( pool: SimCognitoUserPool, client: SimCognitoUserPoolClient, credentials: SimCognitoHostedCredentials, - ): SimCognitoUser { + ): Promise { const { username, password } = credentials; const user = requireSimCognitoSignInUser(pool, client, username); + await this.triggers.preAuthentication({ pool, client, user }); + requireSimCognitoSignIn(user, password); requireSimCognitoConfirmed(user); requireSimCognitoPasswordSet(user); diff --git a/src/service/cognito/command/hosted/sim-cognito-hosted-sign-in.ts b/src/service/cognito/command/hosted/sim-cognito-hosted-sign-in.ts index 65a64aec4..36ab26f3b 100644 --- a/src/service/cognito/command/hosted/sim-cognito-hosted-sign-in.ts +++ b/src/service/cognito/command/hosted/sim-cognito-hosted-sign-in.ts @@ -3,6 +3,7 @@ import { SimCognitoManagedLoginRequired } from "../../error/sim-cognito-managed- import type { SimCognitoUserPoolClient } from "../../user-pool/client/sim-cognito-user-pool-client.js"; import type { SimCognitoFederatedSignIn } from "../../user-pool/idp/sim-cognito-federated-sign-in.js"; import type { SimCognitoUserPool } from "../../user-pool/sim-cognito-user-pool.js"; +import type { SimCognitoUserPoolTriggers } from "../../user-pool/trigger/sim-cognito-user-pool-triggers.js"; import type { SimCognitoFirstFactorChallenge } from "../auth/sim-cognito-first-factor-challenge.js"; import { SimCognitoAuthorizeRequest } from "./sim-cognito-authorize-request.js"; import { SimCognitoBrowserSession } from "./sim-cognito-browser-session.js"; @@ -21,6 +22,12 @@ interface SimCognitoHostedSignInProperties { */ readonly challenge: SimCognitoFirstFactorChallenge; readonly clock: SimClock; + + /** + * The trigger runner the API sign-ins use, which a sign-in at this endpoint + * runs the pool's `PreAuthentication` through. + */ + readonly triggers: SimCognitoUserPoolTriggers; } /** @@ -37,16 +44,20 @@ export class SimCognitoHostedSignIn { private readonly federatedSignIn: SimCognitoFederatedSignIn; private readonly clock: SimClock; private readonly request = new SimCognitoAuthorizeRequest(); - private readonly passwordSignIn = new SimCognitoHostedPasswordSignIn(); + private readonly passwordSignIn: SimCognitoHostedPasswordSignIn; private readonly passkeySignIn: SimCognitoHostedPasskeySignIn; private readonly browserSession = new SimCognitoBrowserSession(); constructor(properties: SimCognitoHostedSignInProperties) { this.federatedSignIn = properties.federatedSignIn; this.clock = properties.clock; + this.passwordSignIn = new SimCognitoHostedPasswordSignIn({ + triggers: properties.triggers, + }); this.passkeySignIn = new SimCognitoHostedPasskeySignIn({ challenge: properties.challenge, clock: properties.clock, + triggers: properties.triggers, }); } @@ -89,12 +100,12 @@ export class SimCognitoHostedSignIn { * the sign-in form is shown for. The serving layer answers with that page, * from this refusal. */ - private localSignIn( + private async localSignIn( pool: SimCognitoUserPool, client: SimCognitoUserPoolClient, input: SimCognitoAuthorizeInput, presentedSession: string | undefined, - ): SimCognitoHostedSignedIn { + ): Promise { const now = this.clock.now(); const { username, credential } = input; @@ -112,7 +123,7 @@ export class SimCognitoHostedSignIn { } if (username !== undefined && input.passkey !== undefined) { - this.passkeySignIn.ask(pool, client, username); + await this.passkeySignIn.ask(pool, client, username); } const credentials = SimCognitoHostedCredentials.in(input); @@ -127,7 +138,7 @@ export class SimCognitoHostedSignIn { return returning; } - const user = this.passwordSignIn.signIn(pool, client, credentials); + const user = await this.passwordSignIn.signIn(pool, client, credentials); return this.browserSession.start(pool, user, now); } diff --git a/src/service/cognito/command/hosted/sim-cognito-managed-login-triggers.iso.test.ts b/src/service/cognito/command/hosted/sim-cognito-managed-login-triggers.iso.test.ts new file mode 100644 index 000000000..1f08df93f --- /dev/null +++ b/src/service/cognito/command/hosted/sim-cognito-managed-login-triggers.iso.test.ts @@ -0,0 +1,201 @@ +import { + assertArrayEquals, + assertIdentical, + assertNonNullable, + assertStringIncludes, + assertThrowsErrorAsync, +} from "@kensio/smartass"; +import { describe, it } from "vitest"; + +import { DEFAULT_SIM_AWS_ACCOUNT_ID } from "../../../aws/sim-aws-account.js"; +import { + simCognitoCallbackUrl, + simCognitoHosted, + simCognitoLocalPassword, + simCognitoLocalUser, + simCognitoLocalUsername, + type SimCognitoHostedSetUp, +} from "../../../../../test/cognito/federation-fixture.js"; +import { + simCognitoPasskeyPosted, + simCognitoWithHostedPasskey, +} from "../../../../../test/cognito/hosted-passkey-fixture.js"; +import { + simCognitoAuthorizeParameters, + simCognitoPostForm, +} from "../../../../../test/cognito/managed-login-fixture.js"; +import { recordingTriggerHandler } from "../../../../../test/cognito/trigger-handler-fixture.js"; +import { triggerFunctionArnIn } from "../../../../../test/cognito/trigger-fixture.js"; + +/** + * The ARN of the trigger function in the region the hosted fixture builds in. + */ +const functionArn = triggerFunctionArnIn( + "eu-west-2", + DEFAULT_SIM_AWS_ACCOUNT_ID, +); + +/** + * The two fields managed login's form takes, beside the parameters the request + * arrived on. + */ +function signInInput( + setUp: SimCognitoHostedSetUp, + overrides: Record = {}, +): Record { + return { + response_type: "code", + client_id: setUp.clientId, + redirect_uri: simCognitoCallbackUrl, + username: simCognitoLocalUsername, + password: simCognitoLocalPassword, + ...overrides, + }; +} + +/** + * The `triggerSource` of every event a handler recorded, in the order it ran. + */ +function sourcesOf(events: readonly unknown[]): readonly string[] { + return events.map( + (event) => (event as { triggerSource: string }).triggerSource, + ); +} + +/** + * A pool with a hosted domain whose `PreAuthentication` trigger records what it + * is given, and a user of its own to sign in. + */ +async function poolRecordingPreAuthentication( + events: unknown[], +): Promise { + const setUp = await simCognitoHosted({ + triggers: { PreAuthentication: functionArn }, + handler: recordingTriggerHandler(events), + }); + + await simCognitoLocalUser(setUp); + + return setUp; +} + +describe("The PreAuthentication trigger a sim Cognito managed login sign-in runs", () => { + it("runs for a password sign-in at the authorize endpoint", async () => { + // Given a pool whose PreAuthentication trigger records what it is given. + const events: unknown[] = []; + const setUp = await poolRecordingPreAuthentication(events); + + // When one of its own users signs in at the authorize endpoint. + await setUp.cognito.hostedAuthorize( + setUp.cognito.userPool(setUp.userPoolId), + signInInput(setUp), + ); + + // Then the handler ran under the source real Cognito reports from + // `/login`, on the user signing in. + assertArrayEquals(sourcesOf(events), ["PreAuthentication_Authentication"]); + assertIdentical( + (events[0] as { userName: string }).userName, + simCognitoLocalUsername, + ); + }); + + it("runs for a sign-in the pool goes on to refuse", async () => { + // Given the same pool. + const events: unknown[] = []; + const setUp = await poolRecordingPreAuthentication(events); + + // When the password is wrong. + await assertThrowsErrorAsync(async () => { + await setUp.cognito.hostedAuthorize( + setUp.cognito.userPool(setUp.userPoolId), + signInInput(setUp, { password: "WrongPassword!" }), + ); + }); + + // Then the handler still ran, because the trigger is given the user to + // decide about and runs before the password is checked, which is the order + // the API sign-ins use. + assertArrayEquals(sourcesOf(events), ["PreAuthentication_Authentication"]); + }); + + it("runs once for a passkey sign-in, on the request that asks for one", async () => { + // Given a pool that allows a passkey at the first prompt, with a user + // holding one. + const events: unknown[] = []; + const setUp = await simCognitoWithHostedPasskey({ + triggers: { PreAuthentication: functionArn }, + handler: recordingTriggerHandler(events), + }); + // The fixture registers the passkey through an API sign-in, which runs the + // trigger itself. + events.length = 0; + + // When the browser asks for a passkey and presents one. + const presented = await simCognitoPasskeyPosted( + setUp, + simCognitoLocalUsername, + ); + + // Then the sign-in completed, and the trigger ran once rather than on both + // requests. The challenge response answers a sign-in already started, and + // answering one runs the trigger no second time on the API path either. + assertIdentical(presented.status, 302); + assertArrayEquals(sourcesOf(events), ["PreAuthentication_Authentication"]); + }); + + it("stays unfired for a browser signing in from its managed login session", async () => { + // Given a browser that has signed in once and holds the session for it. + const events: unknown[] = []; + const setUp = await poolRecordingPreAuthentication(events); + const pool = setUp.cognito.userPool(setUp.userPoolId); + const first = await setUp.cognito.hostedAuthorize(pool, signInInput(setUp)); + const session = first.session.startedSession; + assertNonNullable(session); + events.length = 0; + + // When it comes back to a plain authorize request carrying that session. + const returning = await setUp.cognito.hostedAuthorize( + pool, + { + response_type: "code", + client_id: setUp.clientId, + redirect_uri: simCognitoCallbackUrl, + }, + session, + ); + + // Then it is signed in without the trigger running. The PreAuthentication + // docs say it does not activate on the renewal of a session that already + // exists, and this is that renewal. + assertIdentical(returning.session.outcome, "reused"); + assertArrayEquals(sourcesOf(events), []); + }); + + it("refuses the sign-in on the form where the handler throws", async () => { + // Given a pool whose PreAuthentication trigger refuses everybody. + const setUp = await simCognitoHosted({ + triggers: { PreAuthentication: functionArn }, + handler: () => { + throw new Error("Only example.com may sign in."); + }, + }); + + await simCognitoLocalUser(setUp); + + // When the sign-in form is posted on the served domain. + const response = await simCognitoPostForm(setUp, "/oauth2/authorize", { + ...simCognitoAuthorizeParameters(setUp), + username: simCognitoLocalUsername, + password: simCognitoLocalPassword, + }); + + // Then the browser gets the form back carrying what the handler threw, + // which is where real managed login shows a trigger's refusal. + assertIdentical(response.status, 200); + + const page = await response.text(); + assertStringIncludes(page, "Only example.com may sign in."); + assertStringIncludes(page, 'name="password"'); + }); +}); diff --git a/test/cognito/hosted-passkey-fixture.ts b/test/cognito/hosted-passkey-fixture.ts index 46ed0d425..25bb00849 100644 --- a/test/cognito/hosted-passkey-fixture.ts +++ b/test/cognito/hosted-passkey-fixture.ts @@ -24,6 +24,7 @@ import { import { assertTypeString } from "@kensio/smartass"; import { SimAwsHttp } from "../../src/serve/http/sim-aws-http.js"; +import type { SimLambdaHandler } from "../../src/service/lambda/function/sim-lambda-handler.type.js"; import { simCognitoCallbackUrl, simCognitoHosted, @@ -49,6 +50,12 @@ export interface SimCognitoHostedPasskeyOptions { /** The name the user is created and signs in by, `alice` by default. */ readonly username?: string; + + /** The `LambdaConfig` the pool is created with, none by default. */ + readonly triggers?: Readonly>; + + /** What the function behind those triggers does. */ + readonly handler?: SimLambdaHandler; } /** @@ -62,6 +69,8 @@ export async function simCognitoWithHostedPasskey( const byAttribute = usernameAttributes !== undefined; const setUp = await simCognitoHosted({ ...(byAttribute && { usernameAttributes }), + ...(options.triggers !== undefined && { triggers: options.triggers }), + ...(options.handler !== undefined && { handler: options.handler }), }); // A pool signing users in by an attribute puts the name a user is created @@ -70,12 +79,16 @@ export async function simCognitoWithHostedPasskey( username, ...(byAttribute && { attributes: [] }), }); + // The LambdaConfig goes back in because an update replaces the pool's + // settings whole, here and on real Cognito, so a request that left it out + // would take the pool's triggers off it. await setUp.cognito.updateUserPool( new UpdateUserPoolCommand({ UserPoolId: setUp.userPoolId, Policies: { SignInPolicy: { AllowedFirstAuthFactors: ["PASSWORD", "WEB_AUTHN"] }, }, + ...(options.triggers !== undefined && { LambdaConfig: options.triggers }), }), );