From f1f35ae81051142ab85a35665a074cb186f25a3f Mon Sep 17 00:00:00 2001 From: Danny Morrow Date: Wed, 22 Jul 2026 11:21:16 -0400 Subject: [PATCH 1/3] fix(auth): read device metadata with the caller's username during sign-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ConfirmDevice` stores device metadata under `signedInData.inputUsername` (the username the caller signed in with), but the sign-in flow read it back using `parameters["USERNAME"]` — the value Cognito echoes, which is the sub for pools configured with alias sign-in. On those pools the read never matched the write, so `DEVICE_KEY` was omitted from the password-verifier response. Cognito treated every sign-in as a new device: it returned fresh `NewDeviceMetadata`, `ConfirmDevice` stored it under the email again, and the next sign-in repeated the cycle — MFA on every sign-in and a new device record per attempt (one user had accumulated 50). Pools where the username *is* the email are unaffected, since the two values coincide there. Reads now use the same value as the write: - VerifyPasswordSRP: `inputUsername` - VerifySignInChallenge (both call sites): `challenge.inputUsername ?? username` Nothing sent to Cognito changes — `username` still supplies `USERNAME` on every request; only the keychain lookups moved. Known gap: the DeviceSRP actions (InitiateAuthDeviceSRP, VerifyDevicePasswordSRP) still look up by the echoed username. That path only executes once Cognito starts accepting the device key, so it is now reachable and tracked separately. Co-Authored-By: Claude Opus 4.8 --- .../Actions/SignIn/SRPAuth/VerifyPasswordSRP.swift | 7 ++++++- .../Actions/SignIn/VerifySignInChallenge.swift | 7 +++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SRPAuth/VerifyPasswordSRP.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SRPAuth/VerifyPasswordSRP.swift index 59d5560d35..61c09d55a5 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SRPAuth/VerifyPasswordSRP.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SRPAuth/VerifyPasswordSRP.swift @@ -53,8 +53,13 @@ struct VerifyPasswordSRP: Action { credentialStoreClient: environment.authEnvironment().credentialsClient ) + // Device metadata is stored under the username the caller signed in with + // (`ConfirmDevice` uses `signedInData.inputUsername`), so it must be read back with + // the same value. `username` here is `parameters["USERNAME"]`, which for pools with + // alias sign-in is the sub — looking metadata up with it never matches what was + // written, so DEVICE_KEY would be omitted and every sign-in would register a new device. deviceMetadata = await DeviceMetadataHelper.getDeviceMetadata( - for: username, + for: inputUsername, with: environment ) let signature = try signature( diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/VerifySignInChallenge.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/VerifySignInChallenge.swift index 07753ef845..c9ac89565f 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/VerifySignInChallenge.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/VerifySignInChallenge.swift @@ -75,8 +75,11 @@ struct VerifySignInChallenge: Action { credentialStoreClient: environment.authEnvironment().credentialsClient ) + // Read with the username the caller signed in with — `ConfirmDevice` writes the + // metadata under `inputUsername`, and `challenge.username` is the value Cognito + // returned (the sub, for pools using alias sign-in). deviceMetadata = await DeviceMetadataHelper.getDeviceMetadata( - for: username, + for: challenge.inputUsername ?? username, with: environment ) @@ -139,7 +142,7 @@ struct VerifySignInChallenge: Action { ) async throws { let newDeviceMetadata = await DeviceMetadataHelper.getDeviceMetadata( - for: username, + for: challenge.inputUsername ?? username, with: environment ) if challenge.challenge == .password { From e08bbb526d1b5e330ff46ebb6ba413fee68b02d6 Mon Sep 17 00:00:00 2001 From: Danny Morrow Date: Wed, 22 Jul 2026 18:37:44 -0400 Subject: [PATCH 2/3] fix(auth): delete device metadata with the same username used to read it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 122689ec1 changed the sign-in flow to read device metadata with the caller's `inputUsername` (matching how `ConfirmDevice` writes it), but left the `deviceNotFound` recovery paths deleting under `username` — the value Cognito echoes, which is the sub for pools with alias sign-in. When a stored device key is stale, Cognito rejects the password verifier with `ResourceNotFoundException`. The recovery path then deletes the wrong keychain entry, leaving the real one in place, so the retry re-reads the same stale key, Cognito rejects it again, and sign-in loops indefinitely. Read and delete now use the same value in both actions: - VerifyPasswordSRP: `inputUsername` - VerifySignInChallenge: `challenge.inputUsername ?? username` Co-Authored-By: Claude Opus 4.8 --- .../Actions/SignIn/SRPAuth/VerifyPasswordSRP.swift | 7 +++++-- .../Actions/SignIn/VerifySignInChallenge.swift | 9 +++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SRPAuth/VerifyPasswordSRP.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SRPAuth/VerifyPasswordSRP.swift index 61c09d55a5..ee941aac4e 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SRPAuth/VerifyPasswordSRP.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SRPAuth/VerifyPasswordSRP.swift @@ -105,8 +105,11 @@ struct VerifyPasswordSRP: Action { await dispatcher.send(event) } catch let error where deviceNotFound(error: error, deviceMetadata: deviceMetadata) { logVerbose("\(#fileID) Received device not found \(error)", environment: environment) - // Remove the saved device details and retry password verify - await DeviceMetadataHelper.removeDeviceMetaData(for: username, with: environment) + // Remove the saved device details and retry password verify. This must use the same + // value the metadata was read with above — deleting under `username` (the echoed sub) + // leaves the real entry in place, so the retry re-reads the same stale device key, + // Cognito rejects it again, and sign-in loops here indefinitely. + await DeviceMetadataHelper.removeDeviceMetaData(for: inputUsername, with: environment) let event = SignInEvent(eventType: .retryRespondPasswordVerifier(stateData, authResponse, clientMetadata)) logVerbose( "\(#fileID) Sending event \(event)", diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/VerifySignInChallenge.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/VerifySignInChallenge.swift index c9ac89565f..df7b9d6832 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/VerifySignInChallenge.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/VerifySignInChallenge.swift @@ -110,8 +110,13 @@ struct VerifySignInChallenge: Action { await dispatcher.send(responseEvent) } catch let error where deviceNotFound(error: error, deviceMetadata: deviceMetadata) { logVerbose("\(#fileID) Received device not found \(error)", environment: environment) - // Remove the saved device details and retry verify challenge - await DeviceMetadataHelper.removeDeviceMetaData(for: username, with: environment) + // Remove the saved device details and retry verify challenge. Must match the value the + // metadata was read with above — deleting under `username` (the echoed sub) leaves the + // real entry in place and the retry loops on the same stale device key. + await DeviceMetadataHelper.removeDeviceMetaData( + for: challenge.inputUsername ?? username, + with: environment + ) let event = SignInChallengeEvent( eventType: .retryVerifyChallengeAnswer(confirmSignEventData, currentSignInStep) ) From 25e5d1675e6dfd23560941f700634ca16390565f Mon Sep 17 00:00:00 2001 From: Danny Morrow Date: Wed, 22 Jul 2026 19:17:08 -0400 Subject: [PATCH 3/3] fix(auth): use inputUsername for device metadata in the device SRP flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once a device is remembered, Cognito answers the password verifier with DEVICE_SRP_AUTH instead of an MFA challenge, routing sign-in through InitiateAuthDeviceSRP / VerifyDevicePasswordSRP. Both looked the stored device metadata up by the Cognito-echoed username (the sub, on pools with alias sign-in) rather than the `inputUsername` it was written under, so the lookup missed and the request omitted DEVICE_KEY — Cognito then rejected sign-in with "Missing required parameter DEVICE_KEY". - UserPoolSignInHelper: carry `inputUsername ?? username` into `.initiateDeviceSRP` so InitiateAuthDeviceSRP reads (and re-sends) under the right key. - VerifyDevicePasswordSRP: read metadata with `inputUsername`; the request still sends `username` (the echoed value), which is what Cognito expects. Completes the alias-pool device-remembering fix (see prior two commits); the DEVICE_SRP_AUTH path is only reachable once DEVICE_KEY is accepted, so it could not be exercised until the earlier read/delete fixes landed. Co-Authored-By: Claude Opus 4.8 --- .../SignIn/DeviceSRPAuth/VerifyDevicePasswordSRP.swift | 6 +++++- .../Support/Helpers/UserPoolSignInHelper.swift | 8 +++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/DeviceSRPAuth/VerifyDevicePasswordSRP.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/DeviceSRPAuth/VerifyDevicePasswordSRP.swift index 844d513a43..f322824256 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/DeviceSRPAuth/VerifyDevicePasswordSRP.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/DeviceSRPAuth/VerifyDevicePasswordSRP.swift @@ -40,8 +40,12 @@ struct VerifyDevicePasswordSRP: Action { credentialStoreClient: environment.authEnvironment().credentialsClient ) + // Read with the username the caller signed in with — `ConfirmDevice` writes the + // metadata under `inputUsername` (here `stateData.username`). `username` is + // `parameters["USERNAME"]`, the sub echoed by Cognito on alias pools, and is still the + // correct value to send back in the request below. let deviceMetadata = await DeviceMetadataHelper.getDeviceMetadata( - for: username, + for: inputUsername, with: environment ) guard diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Helpers/UserPoolSignInHelper.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Helpers/UserPoolSignInHelper.swift index 06f4b6c5c0..415d22d4b6 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Helpers/UserPoolSignInHelper.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Helpers/UserPoolSignInHelper.swift @@ -146,7 +146,13 @@ struct UserPoolSignInHelper: DefaultLogger { eventType: .respondPasswordVerifier(srpStateData, response, [:]) ) case .deviceSrpAuth: - return SignInEvent(eventType: .initiateDeviceSRP(username, response)) + // Carry the caller's `inputUsername` into the device SRP flow. It has to look + // the stored device metadata back up, and that's keyed on the username the + // caller signed in with (see `ConfirmDevice`). `username` here is the value + // Cognito echoed — the sub, on pools with alias sign-in — so using it makes the + // lookup miss and the request omits DEVICE_KEY, which Cognito rejects with + // "Missing required parameter DEVICE_KEY". + return SignInEvent(eventType: .initiateDeviceSRP(inputUsername ?? username, response)) case .webAuthn: let signInData = WebAuthnSignInData( username: username,