//, slice 4 - the CLIENT's half of the binding rule: what this browser may claim about its key. // // THE MIRROR OF THE SERVER, AND THE SYMMETRY IS THE POINT. `auth/src/utils/dpopBinding.ts` says a // thumbprint may only be REGISTERED by an act that required credentials, and a refresh may only re-stamp // one already registered. This module is the same sentence from the other side: **a new key may be created // only on the sign-in path; the refresh path may only READ one.** // // THE MISTAKE THIS EXISTS TO PREVENT: minting a fresh key when the stored one has gone missing. It looks // like graceful recovery and it is a lockout - the server refuses an unregistered key on a refresh // (`unregistered-key-on-refresh`), that refusal THROWS inside token generation, and the user is signed out // by a path that reports nothing useful. Spencer's own requirement is the opposite: **a key lost mid-session // must sign the user out cleanly**, which is the only way anybody ever perceives this feature. // // AND THE ROLLOUT DIRECTION, which is why the token is the oracle rather than the key store. Every // session that existed before this ships carries an access token with NO `dpop_jkt` claim. Reading the // claim tells us which world a session is in, so a pre-rollout session keeps refreshing exactly as it does // today and nobody is signed out at the deploy. /** What the refresh path should do about this session's binding. */ export type DpopRefreshPlan = /** No binding on this session - refresh without a thumbprint, exactly as before this feature existed. */ | { kind: 'unbound' } /** Bound, and we hold the key — hand this thumbprint over so the new token carries it too. */ | { kind: 'bound'; jkt: string } /** The token is bound to a key this browser can no longer produce ⇒ end the session cleanly. */ | { kind: 'key-lost' }; /** * The `dpop_jkt` claim, read out of an access token. * * EVERY failure answers `undefined` rather than throwing, and the direction is deliberate: `undefined` * routes to `unbound`, which refreshes as before. A throw here would turn an unparseable token into a * lockout, and this runs on the path whose entire job is keeping a legitimate user signed in. */ export function readDpopJkt(accessToken: string | null | undefined): string | undefined { if (!accessToken) return undefined; const segments = accessToken.split('.'); // A JWS is exactly three segments. Not `>= 2`: a two-segment string is not a token, and decoding // whatever sits in position 1 of an arbitrary string is how a garbage value becomes a "claim". if (segments.length !== 3) return undefined; try { const payload = JSON.parse( atob(segments[1].replace(/-/g, '+').replace(/_/g, '/')), ) as Record; const jkt = payload.dpop_jkt; // Only a non-empty string counts. An empty string would compare equal to nothing and would // travel as a "binding" the server must then refuse as malformed. return typeof jkt === 'string' && jkt.length > 0 ? jkt : undefined; } catch { return undefined; } } /** * What to do on a refresh, given what the token claims and what this browser actually holds. * * THE SESSION'S BINDING NEVER CHANGES MID-LIFE. A token that arrived unbound refreshes unbound even when * a key is sitting in storage. Upgrading it would be a state transition nothing else in the system models, * and it is the shape of the attack the server's refresh rule exists to stop - a token-holder improving * their own binding without presenting credentials. The way to become bound is to sign in. */ export function planClientRefresh(input: { tokenJkt: string | undefined; storedJkt: string | undefined; }): DpopRefreshPlan { if (input.tokenJkt === undefined) { return { kind: 'unbound' }; } if (input.storedJkt === input.tokenJkt) { return { kind: 'bound', jkt: input.tokenJkt }; } // BOTH remaining cases are `key-lost`, and collapsing them is correct rather than lazy: // storage empty (cleared site data, a fresh profile) and storage holding a DIFFERENT key (the store was // cleared and a later sign-in made a new one while an old token lingered) are the same fact - **we // cannot sign for what this token is bound to.** Sending the different key instead is the trap: the // server either refuses it, or - if that key happens to be registered from another sign-in - silently // moves the session onto a key it was not issued for. return { kind: 'key-lost' }; } /** * Add this browser's thumbprint to the `ClientMetadata` of a Cognito call, when there is one to add. * * THE KEY NAME IS A CONTRACT WITH THE PRE-TOKEN TRIGGER - it reads `clientMetadata.dpop_jkt` * (`auth/src/handlers/pre-token-generation-trigger.ts`). Nothing compiles that coupling; a rename on either * side makes every sign-in silently UNBOUND, which is a state the server tolerates during rollout and * therefore reports as normal. * * OMITS THE KEY ENTIRELY when unbound rather than sending an empty or undefined value. `ClientMetadata` * is a string map; a present-but-empty value reaches `planDpopBinding` as a malformed thumbprint, and that * refusal THROWS and fails token generation - turning "this browser has no key" into "this user cannot * sign in". */ export function withDpopThumbprint( metadata: Record, jkt: string | null | undefined, ): Record { if (typeof jkt !== 'string' || jkt.length === 0) return metadata; return { ...metadata, dpop_jkt: jkt }; }