@kinnet/mcp-identity
v0.4.1
Published
Library half of the MCP credential shim: probe an RFC 9728 resource document, bind its claimed kinnet participant id to a signed discovery record, select the token mode, and mint and cache a self-issued pnc1 chain token.
Downloads
289
Maintainers
Readme
@kinnet/mcp-identity
The library half of the MCP credential shim: what lets one kinnet identity walk into an MCP server as itself, instead of as a per-server OAuth account.
The kinnet CLI and the kinnet-mcp stdio shim are its only intended consumers; the shapes
below are the contract they rely on.
Five functions, in the order a shim calls them.
1. probeResourceMetadata(resourceUrl, options?)
Fetches the server's RFC 9728 protected-resource document, trying the path-inserted location
(https://host/.well-known/oauth-protected-resource/mcp for https://host/mcp) and then the
root one. A kinnet-aware server adds two top-level members to the standard ones:
{
"resource": "https://resource.example/mcp",
"authorization_servers": ["https://id.kinnet.example"],
"scopes_supported": ["notes/read", "notes/write", "notes/share", "notes/publish"],
"bearer_methods_supported": ["header"],
"kinnet_participant_id": "pk_z…", // the id a token's `aud` would be bound to — a CLAIM
"kinnet_token_formats": ["pnc1"] // "pnc1" ⇒ the chain door is open
}The document is parsed loosely (unknown members are kept, because RFC 9728 is an extensible
registry) but fetched strictly: redirect: "error", a 64 KiB ceiling enforced while reading
rather than after, a deadline, and strict JSON parsing. Failures are an McpIdentityError with
a reason: insecure_url, unreachable, too_large, document_malformed, document_invalid.
A location that fails to answer (network error, refused redirect, 404, 5xx) is a reason to try the next one. A location that answers with an unusable document is a hard failure — the server has told us what it publishes, and a second lookup would only let a broken document hide.
https always; http only on a loopback host (localhost, 127.0.0.1, [::1]), the same
rule the IdP applies to redirect URIs.
2. bindResourceParticipant({ metadata, resourceUrl, discovery }) — the binding rule
kinnet_participant_id is served unauthenticated at the server's own origin, so it is a claim,
not evidence. Without a cross-check, a hostile server X publishes the resource R's participant
id, the shim mints a chain token with aud = R, X takes it as a bearer and replays it at R as
R's own caller.
So the claim is checked against a record that participant signed, read from the shim's OWN
configured discovery and never from a URL the server supplied: ParticipantProfile.verifiedDomains
(spec 017) — the participant's signed assertion that it operates a service at that host. X cannot
make R's profile say x.example.
The matching rule, one verifiedDomains entry at a time (verifiedDomainMatches, exported and
pure):
- an entry is
hostorhost:port, lowercase; an IPv6 literal is bracketed ([::1]:8787); - the URL's host must equal the entry's host, case-insensitively and exactly. No suffix
matching, no prefix matching, no wildcards, no subdomain inference:
resource.examplematches neitherevil-resource.examplenorresource.example.attackernorsub.resource.example, all of which an attacker can register; - an entry with a port must equal the URL's effective port (explicit, or the scheme's
default, so
resource.example:443matcheshttps://resource.example/mcp); an entry with no port matches any port.
The profile itself is verified before a word of it is trusted: the participant's key log is
replayed under replayKeyLogStatesFor (bound to the id it was fetched for, so a hostile
discovery host cannot serve one participant's log at another's path), and the profile's scalar
signature is lifted into a one-member signature set and checked against any state the log
ever committed (spec 015 S5, 017 READER) — a rotation must not orphan a profile published
before it.
The verdict is data, not an exception:
{ ok: true, participantId } |
{ ok: false, reason: "no_participant_id" | "insecure_scheme" | "profile_unavailable"
| "profile_signature_invalid" | "host_not_asserted" }discovery is anything with getKeyLog and getProfile — a DiscoveryClient from
@kinnet/discovery-client satisfies it structurally.
3. selectMode({ metadata, binding, kinnetIssuer })
In order:
kinnet_participant_idpresent andkinnet_token_formatsincludes"pnc1"and the binding holds ⇒{ mode: "A" }— self-issue. Needs no IdP to be reachable.- else
authorization_serversincludes the configured kinnet issuer (exact string compare after trimming trailing slashes) ⇒{ mode: "C", issuer }— go through the IdP. - else
{ mode: "none", reason: "not_kinnet_aware" }— the harness's own OAuth applies and the shim has no job.
A server that offers the chain door but fails the binding check is
{ mode: "none", reason: "binding_failed" } and is never downgraded to C. A downgrade would
hide exactly the attack the binding check exists for behind a working OAuth login.
4. mintChainToken({ issuer, parent?, resourceParticipantId, abilities, ttlSeconds?, now? })
Mints the mode-A token: a leaf grant with audienceId = the resource's participant id (a
participant audience, not a key) and caveats.aud naming that same id, encoded as pnc1. +
base64url(JSON chain). Byte-compatible with what the IdP issues and what a resource verifies
today.
Two lanes, one function, because the leaf is the same record in both and only its parentage differs:
- Service lane (no
parent) — the identity acts for itself, so the leaf is a self-issued root (issueGrant),subjectId=issuerId= the service, andchainis one link. - Machine lane (
parent) — the identity is a machine participant acting for a human, so the leaf is delegated from the custody-signedhuman → machinegrant (issueDelegatedGrant),issuerId= the machine,proof=canonicalDigest(parent), andsubjectIdstays the human: one chain carries one participant's authority however many hands it passes through (spec 009 rule 4), so the resource sees who is asking rather than merely which program asked.chainis[leaf, parent]— leaf first, the orderencodeChainAccessTokendefines and every verifier reads.
Everything downstream — the encoding, the cache, the resource's verification — is unchanged by the extra link.
No ceilings are applied here. The resource enforces its own — on every link expiresAt present
and every ability at least two / segments and never notes/admin, and on the leaf a remaining
lifetime of at most 7 days — and a minter that silently clamped a caller's TTL would hand back a
token whose lifetime nobody asked for. An over-long TTL produces a valid, signed, refused token,
which is the honest outcome.
Attenuation is not checked here either: a DelegationError from @kinnet/trust propagates
unwrapped. A leaf asking for an ability the parent never held, or for a verifier outside the
parent's aud, is a caller bug that the trust package already names precisely — which rule, and
which value offended. Re-throwing it as an McpIdentityError would replace a diagnosis with the
word invalid_argument.
selectParentGrant(grants, { machineId, resourceParticipantId, abilities, now? })
Which held human → machine grant backs a token for a given server. A machine accumulates one
grant per server the human consented to at kinnet mcp add, each signed in custody under a
passkey ceremony; this answers "is there already consent for this server and these
abilities?", which is what decides whether the next mcp add is silent or costs a browser tap.
Returns the first grant in input order satisfying all four rules, or null. First-match is
deliberate: precedence between two eligible grants (newest, narrowest, last consented) is a
policy the caller holds. Sorting is the caller's job; choosing is this function's.
audienceId === machineId— authority is exercised only by the principal it was delegated to (spec 009 rule 3), andissueDelegatedGrantwould refuse to sign under anything else.abilitiesliterally contains every requested ability — exact string membership, not prefix cover. See below; this is the load-bearing rule.caveats.aud, if present, includesresourceParticipantId.audnarrows only along a chain (spec 011), so a leaf can never name a server its parent did not — a grant consented for prod is not a grant for staging. A malformedaudis a non-match, not a throw: a grant nobody here can read is a grant nobody here should spend, and one corrupt entry must not stop the rest of the pile from being searched.- Unexpired.
expiresAtmay legally be absent on a participant-audience grant (spec 011 makes it mandatory only for a key audience), even though the device-grant flow always sets one at 90 days; absent means "does not expire", not "invalid".
Why ability matching is literal and not prefix-covered. Spec 009 rule 5 lets a verifier
prefix-cover notes/read out of a parent holding notes — but the bearer door a kinnet-aware
resource puts in front of a pnc1. chain applies its two-segment/no-admin rule to every link
and matches the leaf's abilities by exact string against its allowlist, so an umbrella parent
such as ["notes"] is refused there. Prefix cover
is for verifiers deciding a chain; a bearer chain must show explicit consent at each
link. A selector using abilityCovers would hand back a grant that mints a perfectly valid,
signed token no resource accepts — and would silently defeat the per-server consent unit the
whole design rests on.
This function only selects. Whether the resulting two-link chain is acceptable is the door's judgement, and the door port in this package's suite is what asserts it. Pure: no I/O, no signature checks (these are the machine's own grants, verified when they were stored), no throwing on malformed input.
createTokenCache({ mint, refreshFraction?, now? })
Holds one resource's token in memory only — a pnc1. chain is a bearer credential carrying
the caller's full consented authority, and a copy on disk would be a second copy of that
authority with a lifetime nobody manages.
get()serves the cached token until 80% of its lifetime has passed, then re-mints. Re-minting before expiry means a request never carries a token that dies in flight.- Concurrent
get()s on a cold or stale cache share one mint (single-flight). - A failed mint rejects the callers waiting on it and leaves the cache untouched; the next
get()tries again. invalidate()drops the token — including against a mint that was already in the air, which resolves its own callers but no longer repopulates the cache.peek()reports the cached token and its timings without minting.
Testing
pnpm --filter @kinnet/mcp-identity test. Discovery is not stubbed: the suite stands up a real
@kinnet/discovery-api app over a memory store and publishes real signed records through
@kinnet/discovery-client. The suite carries a port of the door a kinnet-aware resource
puts in front of a pnc1. bearer — verifyGrantChain with verifierId + requireAud, then
the chain[0].audienceId equality step, then the resource's ceilings — so minted tokens are
asserted against what a resource actually enforces rather than against what the minter believes
it produced.
Those ceilings are per-link, following the policy a chain-verifying resource applies: on
every link of the chain expiresAt must be present and parseable and every ability must have at
least two non-empty / segments and never be notes/admin. Only the remaining-lifetime cap
(7 days) is leaf-only — an upstream link is a standing consent measured in months, and a
resource's own 90-day upstream cap is its policy number, deliberately not modelled in the
oracle.
License
Free to use, not open source (yet). The package is published under a use-only license: install
it and build on it, commercially included, but the software may not be modified or
redistributed — see LICENSE for the exact terms. The implementation ships minified with full
TypeScript declarations. The intent is to open the source as the network matures.
