import { AuthenticationDetails, CognitoUser, CognitoUserPool, type CognitoUserSession, } from 'amazon-cognito-identity-js'; import type { AuthenticationResultType } from '@aws-sdk/client-cognito-identity-provider'; import { memoryCognitoStorage } from './cognitoLibrarySession'; /** * A PASSWORD SIGN-IN THAT KEEPS ITS DPoP BINDING, BY BEING TWO-STEP. * * THE DEFECT THIS CLOSES, measured rather than supposed. `InitiateAuth` with the password inline mints * the tokens in ONE step, and Cognito does not forward that call's `ClientMetadata` to the pre-token * trigger - so the token comes back with no `dpop_jkt` and the session is unbound, while the screen * looks perfect. Counted in the trigger's own log over fourteen hours: 2,364 unbound mints against 13 * bound ones, and the 13 are the emailed-code sign-ins, which are two-step and therefore pass through * `RespondToAuthChallenge` - the call that does carry the thumbprint. * * SRP IS THAT SAME SHAPE FOR A PASSWORD. `InitiateAuth` returns a `PASSWORD_VERIFIER` challenge and the * tokens are minted by the response to it, so the metadata reaches the trigger by the route that already * works. Proven end to end before this was written: a probe sending a deliberately invalid thumbprint was * refused by name - `PreTokenGenerationV2_0 failed with error DPoP binding refused: malformed-thumbprint` * - and a trigger can only refuse a value it received. * * THE ONE-LINE ALTERNATIVE IS THE TRAP. Putting `PASSWORD` in `AuthParameters` turns the screen green * and silently unbinds every session. The binding is not negotiable; the password is a convenience. */ /** * NEVER PASS ValidationData, AND THIS IS THE WHOLE REASON THIS WRAPPER EXISTS RATHER THAN A CALL SITE. * * The library reads `getValidationData().length !== 0 ? getValidationData() : getClientMetadata()`, so * supplying BOTH silently discards the ClientMetadata - a successful sign-in and an unbound token, which * is indistinguishable from the defect being fixed. `organizationId` travels as validationData on the * PreAuthentication trigger today, so putting it there is the natural mistake to make. */ export interface SrpPasswordSignInInput { userPoolId: string; clientId: string; username: string; password: string; /** Reaches the pre-token trigger verbatim. The thumbprint belongs in here, never in ValidationData. */ clientMetadata: Record; /** Asked before every request the library sends, its own challenge answers included; false stops the flow there. */ mayContinue?: () => boolean; } /** * Shapes the library's session as the SDK's `AuthenticationResultType`, because every caller downstream * already speaks that shape. Translating here keeps the library at this one boundary instead of letting a * second session type spread through the app. */ const asAuthenticationResult = (session: CognitoUserSession): AuthenticationResultType => ({ AccessToken: session.getAccessToken().getJwtToken(), IdToken: session.getIdToken().getJwtToken(), RefreshToken: session.getRefreshToken().getToken(), // The library exposes the access token's own expiry rather than a duration; the difference from now is // what the rest of the app stores. Floored, so a rounded-up value can never outlive the token. ExpiresIn: Math.max(0, Math.floor(session.getAccessToken().getExpiration() - Date.now() / 1000)), TokenType: 'Bearer', }); export async function srpPasswordSignIn( input: SrpPasswordSignInInput, ): Promise { // THE LIBRARY IS GIVEN A STORAGE THAT FORGETS, and it has to be given to BOTH: a `CognitoUser` // constructed without one falls back to `localStorage` regardless of what its pool holds // (`CognitoUser.js:84`), so passing it to the pool alone leaves the token cache exactly where it was. // Left to itself the library writes a second copy of the whole session - refresh token included, and // a refresh token lasts seven days - under keys nothing in this app reads and the sign-out sweep does // not cover, because they are in the library's namespace rather than the portal's. See // cognitoLibrarySession.ts for the measurement. const storage = memoryCognitoStorage(); const pool = new CognitoUserPool({ UserPoolId: input.userPoolId, ClientId: input.clientId, Storage: storage, }); const mayContinue = input.mayContinue; if (mayContinue) { const client = (pool as unknown as { client: { request: (op: string, params: unknown, callback: (error: unknown, data?: unknown) => void) => void } }).client; const send = client.request.bind(client); client.request = (op, params, callback) => (mayContinue() ? send(op, params, callback) : callback(new Error('The sign-in was stopped.'))); } const user = new CognitoUser({ Username: input.username, Pool: pool, Storage: storage }); const details = new AuthenticationDetails({ Username: input.username, Password: input.password, ClientMetadata: input.clientMetadata, }); user.setAuthenticationFlowType('USER_SRP_AUTH'); return new Promise((resolve, reject) => { user.authenticateUser(details, { onSuccess: (session) => resolve(asAuthenticationResult(session)), onFailure: (error) => reject(error), /* * A pool-level password reset or an admin-created account lands here. It is REJECTED rather * than left hanging: the promise must settle, and a sign-in that neither resolves nor throws * is the blank bounce this whole area has been cleaning up. The emailed-code path remains the * way in for such an account. */ newPasswordRequired: () => reject(new Error('This account needs a new password before it can be used.')), }); }); }