Skip to content

fix: derive kid from JWK thumbprint when the key omits one - #35

Open
Karavil wants to merge 2 commits into
better-auth:mainfrom
Karavil:fix/derive-kid-from-jwk-thumbprint
Open

fix: derive kid from JWK thumbprint when the key omits one#35
Karavil wants to merge 2 commits into
better-auth:mainfrom
Karavil:fix/derive-kid-from-jwk-thumbprint

Conversation

@Karavil

@Karavil Karavil commented Aug 4, 2026

Copy link
Copy Markdown

The failure

A host generates an Ed25519 keypair, exports the public JWK, and registers dynamically. kid is OPTIONAL in a JWK (RFC 7517 §4.5), so the exported key does not carry one. The host identifies itself the way §4.2 prescribes, with iss set to the RFC 7638 thumbprint. Registration succeeds. Every authenticated request after that returns 401 AGENT_NOT_FOUND, and no retry ever recovers.

register  -> host row written, kid = null    ok
status    -> findHostById(thumbprint)        miss
          -> findHostByKid(thumbprint)       miss, column is null
          -> 401 AGENT_NOT_FOUND             permanent

Every host write path stored publicKey.kid ?? null, while findHostByIdOrKid resolves iss against the id and kid columns. Nothing recomputes a thumbprint at read time, so the read path silently depends on a write-side convention: the SDK stamps kid = thumbprint at keygen (packages/sdk/src/crypto.ts), which is what puts the thumbprint in the column for the lookup to find. A host built with the SDK therefore always works, and one that legitimately omits the member never can. That is why the existing suite never caught it -- generateTestKeypair produces kid-less JWKs, but no test asserted the stored kid or re-authenticated afterwards.

The change

resolveHostKid() returns an explicit kid when the JWK has one and derives the RFC 7638 thumbprint otherwise. RFC 7638 §3.1 blesses the thumbprint as a kid value, and rotate-key.ts already derived it for the host id under §8.7 -- this applies the same convention consistently at the remaining write sites: /agent/register, /agent/claim, /host/create, /host/enroll, /host/update, and /host/rotate-key.

A JWK carrying an explicit kid keeps it unchanged, so existing hosts are unaffected.

Tests

Four cases in kidless-host-jwk.test.ts: a kid-less dynamic registration persists the thumbprint and authenticates on a follow-up request, an explicit kid survives untouched, and /host/create and /host/enroll derive it too. Reverting the register.ts change alone fails the first case with AGENT_NOT_FOUND and leaves the explicit-kid case passing.

Full suite: 260 passed, 15 files. tsc --noEmit clean.

Note on an adjacent issue

Visible in the same lookup and sent separately as #36: when iss names no known host, the middleware raises AGENT_NOT_FOUND rather than HOST_NOT_FOUND, which exists and is used elsewhere. It sends people looking at the agent record when the host row is what is missing. The two are independent and either can merge first.

`kid` is OPTIONAL in a JWK (RFC 7517 §4.5), but every host write path
persisted `publicKey.kid ?? null`. A client that legitimately omits the
member was stored with `kid = null`, and since the middleware resolves a
host by `findHostById(iss) ?? findHostByKid(iss)`, its thumbprint `iss`
matched neither lookup. Every authenticated request then failed with
AGENT_NOT_FOUND, permanently.

RFC 7638 §3.1 blesses the JWK thumbprint as a `kid` value, and the SDK
already stamps `kid = thumbprint` at keygen — which is why the gap is
invisible to hosts built with it. Deriving the same value server-side
when `kid` is absent keeps the stored lookup key aligned with how a host
identifies itself.

Applies to /agent/register, /agent/claim, /host/create, /host/enroll,
/host/update, and /host/rotate-key. A JWK that carries an explicit `kid`
keeps it unchanged.
@Karavil
Karavil marked this pull request as ready for review August 4, 2026 02:25
Replaces the duplicated getTestInstance setup and the `as any` casts with
createTestContext, and adds a getHost helper so host row assertions no
longer need non-null assertions. Behaviour and coverage are unchanged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant