diff --git a/history/2026-09-05-011726-scope-intent-assessment.md b/history/2026-09-05-011726-scope-intent-assessment.md index 30063c1..7036d5c 100644 --- a/history/2026-09-05-011726-scope-intent-assessment.md +++ b/history/2026-09-05-011726-scope-intent-assessment.md @@ -106,9 +106,16 @@ configured correctly, which is the assumption it was meant to remove. Verification holds regardless of how the token arrived, so the absence of HTTPS validation here is a choice, not an oversight. +Both verification paths — the upstream adapter and the caller-side +`authclient` — now run on one implementation in `internal/jose`, so the strict +RS256/JWKS rules cannot drift apart or be fixed in only one copy. Claim policy +stays with each caller, whose issuer, audience and nonce bindings genuinely +differ. + Verified the tests catch the original defect: with the unverified parse restored, -all thirteen rejection cases plus the algorithm and rotation cases fail. Complete -profile conformance is still not claimed. +all thirteen rejection cases plus the algorithm and rotation cases fail, and with +the shared signature check disabled both callers' suites fail. Complete profile +conformance is still not claimed. ### G02 — Machine-readable contract and discovery lag the runtime diff --git a/src/internal/authclient/client_test.go b/src/internal/authclient/client_test.go index c7e98ba..dfce398 100644 --- a/src/internal/authclient/client_test.go +++ b/src/internal/authclient/client_test.go @@ -259,11 +259,13 @@ func TestNonceMismatchAndCancelledLogin(t *testing.T) { } // The caller-side verifier moved onto internal/jose (KEY-WP-0019-T05). The -// existing tamper case appends to the encoded signature, which fails on the -// segment's shape before any key is used, so it would still pass if the -// signature check were removed entirely. These cases sign a structurally -// valid token with a key the provider does not publish, and hand the client a -// key set it must refuse — the two ways a broken verifier actually shows up. +// existing tamper case proves the signature is checked at all; these two prove +// the parts of the move that a byte-level tamper cannot reach. A token signed +// by an unpublished key under a kid the provider does publish fails only if key +// selection is bound to the published set, not merely if the bytes are intact. +// A key set carrying an undersized modulus must deny the login rather than fall +// through to the claims, which is ParseJWKS strictness being applied on this +// path and not only in the adapter. func TestVerifyRejectsForeignSignatureAndUnusableKeySet(t *testing.T) { c, d, _ := provider(t) ctx := context.Background() diff --git a/workplans/KEY-WP-0019-upstream-provider-token-verification.md b/workplans/KEY-WP-0019-upstream-provider-token-verification.md index 9e78541..dfeb922 100644 --- a/workplans/KEY-WP-0019-upstream-provider-token-verification.md +++ b/workplans/KEY-WP-0019-upstream-provider-token-verification.md @@ -143,3 +143,13 @@ Also added direct tests for `internal/jose` (17 cases). It is now the single verifier behind both paths, so testing it only through its callers would leave its edges — duplicate key ids, `crit`, even exponents, undersized moduli — to be covered by accident. + +Follow-on evidence for the same task: "existing tests pass unchanged" shows the +move preserved behaviour, not that the behaviour is checked, so the check was +made explicit. Disabling the RSA comparison in `jose.Verify` fails both callers' +suites, which establishes the shared verifier is load-bearing on each path. +Added two caller-side cases the existing tamper case cannot reach, since it +alters signature bytes for a key that is legitimately published: a structurally +valid token signed by an unpublished key under a published kid, which fails only +if key selection is bound to the key set, and an undersized modulus in the +published set, which must deny rather than fall through to the claims.