@panaversity/ksor 0.0.19 → 0.0.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/dist/cli.mjs +29 -1
- package/docs/authorization.md +197 -0
- package/docs/index.md +4 -0
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# @panaversity/ksor
|
|
2
2
|
|
|
3
|
+
## 0.0.20
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- f25e963: A token from another authorization server is refused, not reported as an outage
|
|
8
|
+
|
|
9
|
+
An unknown key id raises the same error whether the cause is key-rotation lag or
|
|
10
|
+
a token minted by an entirely different authorization server. Both were treated
|
|
11
|
+
as transient, so a client presenting a credential that can never work got `503
|
|
12
|
+
service unavailable` — and retried it, forever, while the misconfiguration read
|
|
13
|
+
as an outage in every dashboard.
|
|
14
|
+
|
|
15
|
+
Reproduced across two real servers: a door configured for Ory Hydra, presented
|
|
16
|
+
with a genuine Keycloak token, answered 503. It now answers 401, before it
|
|
17
|
+
fetches a key at all.
|
|
18
|
+
|
|
19
|
+
The check runs only when `KSOR_SSO_ISSUER` is set, because only then has the
|
|
20
|
+
operator stated what the issuer should be. It reads the issuer from an unverified
|
|
21
|
+
payload, which is sound for exactly one purpose — refusing. A token that passes
|
|
22
|
+
it still has its signature verified in full, so a lie there buys nothing.
|
|
23
|
+
|
|
24
|
+
Genuine rotation lag is still transient, still uncached, and a valid bearer is
|
|
25
|
+
still re-admitted the instant the key set catches up.
|
|
26
|
+
|
|
27
|
+
**New: `docs/authorization.md`**, shipped in the package — two worked recipes for
|
|
28
|
+
putting a record behind an authorization server, both executed against real
|
|
29
|
+
servers rather than written from their documentation, plus what an agent does to
|
|
30
|
+
obtain a token and what each refusal means.
|
|
31
|
+
|
|
3
32
|
## 0.0.19
|
|
4
33
|
|
|
5
34
|
### Patch Changes
|
package/dist/cli.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import { bodyLimit } from "hono/body-limit";
|
|
|
15
15
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
16
16
|
import { parseArgs } from "node:util";
|
|
17
17
|
import { readFile, readdir, stat } from "node:fs/promises";
|
|
18
|
-
//#region ../content-gateway/dist/main-
|
|
18
|
+
//#region ../content-gateway/dist/main-9UsxyoZa.mjs
|
|
19
19
|
/**
|
|
20
20
|
* A connection could not be ESTABLISHED in time — retryable.
|
|
21
21
|
*
|
|
@@ -3508,6 +3508,27 @@ const POS_TTL_S = 60;
|
|
|
3508
3508
|
function isBadToken(err) {
|
|
3509
3509
|
return err instanceof errors.JWTExpired || err instanceof errors.JWTClaimValidationFailed || err instanceof errors.JWTInvalid || err instanceof errors.JWSInvalid || err instanceof errors.JWSSignatureVerificationFailed || err instanceof errors.JOSEAlgNotAllowed || err instanceof errors.JOSENotSupported;
|
|
3510
3510
|
}
|
|
3511
|
+
/**
|
|
3512
|
+
* The `iss` a token CLAIMS, read without verifying anything.
|
|
3513
|
+
*
|
|
3514
|
+
* Sound for exactly one purpose: REFUSING. A token that passes this check still
|
|
3515
|
+
* has its signature verified in full, so an attacker gains nothing by lying here
|
|
3516
|
+
* — the worst they achieve is being refused for a different reason. It must
|
|
3517
|
+
* never be used to admit anything.
|
|
3518
|
+
*
|
|
3519
|
+
* Returns null when the payload is not readable JSON, which sends the token down
|
|
3520
|
+
* the ordinary path rather than inventing a verdict about it.
|
|
3521
|
+
*/
|
|
3522
|
+
function claimedIssuer(token) {
|
|
3523
|
+
try {
|
|
3524
|
+
const payload = token.split(".")[1];
|
|
3525
|
+
if (payload === void 0 || payload === "") return null;
|
|
3526
|
+
const iss = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")).iss;
|
|
3527
|
+
return typeof iss === "string" && iss !== "" ? iss : null;
|
|
3528
|
+
} catch {
|
|
3529
|
+
return null;
|
|
3530
|
+
}
|
|
3531
|
+
}
|
|
3511
3532
|
function describeError(err) {
|
|
3512
3533
|
return err instanceof Error ? `${err.name}: ${err.message}` : String(err);
|
|
3513
3534
|
}
|
|
@@ -3571,6 +3592,13 @@ function createVerify(config, deps, jwksOf) {
|
|
|
3571
3592
|
if (hit !== void 0 && hit.until > now()) return hit.identity;
|
|
3572
3593
|
let claims = null;
|
|
3573
3594
|
if (token.split(".").length === 3) {
|
|
3595
|
+
if (config.issuer !== null) {
|
|
3596
|
+
const claimed = claimedIssuer(token);
|
|
3597
|
+
if (claimed !== null && claimed !== config.issuer) {
|
|
3598
|
+
reject(key);
|
|
3599
|
+
throw new TokenVerifyError(`token iss ${JSON.stringify(claimed)} is not this record's authorization server ${JSON.stringify(config.issuer)}`, { transient: false });
|
|
3600
|
+
}
|
|
3601
|
+
}
|
|
3574
3602
|
try {
|
|
3575
3603
|
claims = await verifyJwt(token);
|
|
3576
3604
|
} catch (err) {
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Authorization
|
|
3
|
+
status: draft
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Putting the record behind an authorization server
|
|
7
|
+
|
|
8
|
+
`ksor serve` refuses to boot unauthenticated on a public bind. That is the whole
|
|
9
|
+
posture, and it means the last step of a deployment is standing up an
|
|
10
|
+
authorization server and pointing the door at it.
|
|
11
|
+
|
|
12
|
+
This page is two worked recipes, both executed against real servers rather than
|
|
13
|
+
written from their documentation, plus what an agent does to obtain a token. The
|
|
14
|
+
mechanism is standard OAuth 2.0 — nothing here is specific to either product, and
|
|
15
|
+
that is the point: two different implementations are shown because a single one
|
|
16
|
+
proves nothing about neutrality.
|
|
17
|
+
|
|
18
|
+
## What the door needs
|
|
19
|
+
|
|
20
|
+
Three variables, and one more you should set even though it is optional:
|
|
21
|
+
|
|
22
|
+
| variable | what it is | where the value comes from |
|
|
23
|
+
| ---------------------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
|
24
|
+
| `KSOR_SSO_URL` | your authorization server's base URL | the AS itself; for OIDC it is the issuer, the URL whose `/.well-known/openid-configuration` answers |
|
|
25
|
+
| `KSOR_MCP_RESOURCE_URL` | **this record's** canonical URL — the identifier a token must be audienced at | you choose it; it is the public URL agents reach the door on |
|
|
26
|
+
| `KSOR_JWT_ALLOWED_AUDIENCES` | which audiences are accepted, comma-separated | normally exactly `KSOR_MCP_RESOURCE_URL` |
|
|
27
|
+
| `KSOR_SSO_ISSUER` | the issuer to enforce | the `issuer` field of the AS's discovery document |
|
|
28
|
+
|
|
29
|
+
`KSOR_MCP_RESOURCE_URL` is **not** a place the door fetches anything from. It is
|
|
30
|
+
the name of this resource, in the RFC 8707 sense: a token minted for a different
|
|
31
|
+
resource is refused even when the signature is perfect and the issuer is right.
|
|
32
|
+
That is what stops a token issued for some other service being replayed at your
|
|
33
|
+
record.
|
|
34
|
+
|
|
35
|
+
The door finds the signing keys by discovery, in this order, and says at boot
|
|
36
|
+
which one it used:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
1. KSOR_JWKS_URL you stated the URI outright
|
|
40
|
+
2. /.well-known/oauth-authorization-server RFC 8414 metadata
|
|
41
|
+
3. /.well-known/openid-configuration OIDC discovery
|
|
42
|
+
4. <KSOR_SSO_URL>/api/auth/jwks a vendor default, reported as a GUESS
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**Set `KSOR_SSO_ISSUER`.** Without it, a token from a _different_ authorization
|
|
46
|
+
server produces an unknown key id, which is indistinguishable from key-rotation
|
|
47
|
+
lag — so the door answers `503`, the client retries a credential that can never
|
|
48
|
+
work, and a misconfiguration reads as an outage. With the issuer declared, that
|
|
49
|
+
same token is refused `401` before any key is fetched.
|
|
50
|
+
|
|
51
|
+
## Recipe: Keycloak
|
|
52
|
+
|
|
53
|
+
Run it:
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
docker run -d --name kc -p 8180:8080 \
|
|
57
|
+
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
|
|
58
|
+
quay.io/keycloak/keycloak:26.0 start-dev
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Get an admin token, then create a client for the agent. `client_credentials` is
|
|
62
|
+
the machine-to-machine shape — an agent is not a person and has no browser:
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
KC=http://127.0.0.1:8180
|
|
66
|
+
ADM=$(curl -s -X POST "$KC/realms/master/protocol/openid-connect/token" \
|
|
67
|
+
-d client_id=admin-cli -d username=admin -d password=admin -d grant_type=password \
|
|
68
|
+
| jq -r .access_token)
|
|
69
|
+
|
|
70
|
+
curl -s -X POST "$KC/admin/realms/master/clients" -H "Authorization: Bearer $ADM" \
|
|
71
|
+
-H 'Content-Type: application/json' -d '{
|
|
72
|
+
"clientId":"ksor-agent","protocol":"openid-connect","publicClient":false,
|
|
73
|
+
"serviceAccountsEnabled":true,"standardFlowEnabled":false,"secret":"agent-secret"}'
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Keycloak does not put your resource in the token's `aud` on its own. Add an
|
|
77
|
+
audience mapper — this is the step people miss, and its absence looks exactly
|
|
78
|
+
like a rejected token:
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
CID=$(curl -s "$KC/admin/realms/master/clients?clientId=ksor-agent" \
|
|
82
|
+
-H "Authorization: Bearer $ADM" | jq -r '.[0].id')
|
|
83
|
+
|
|
84
|
+
curl -s -X POST "$KC/admin/realms/master/clients/$CID/protocol-mappers/models" \
|
|
85
|
+
-H "Authorization: Bearer $ADM" -H 'Content-Type: application/json' -d '{
|
|
86
|
+
"name":"ksor-resource-audience","protocol":"openid-connect",
|
|
87
|
+
"protocolMapper":"oidc-audience-mapper",
|
|
88
|
+
"config":{"included.custom.audience":"https://records.example.com/mcp",
|
|
89
|
+
"access.token.claim":"true"}}'
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Point the door at it:
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
export KSOR_SSO_URL=http://127.0.0.1:8180/realms/master
|
|
96
|
+
export KSOR_SSO_ISSUER=http://127.0.0.1:8180/realms/master
|
|
97
|
+
export KSOR_MCP_RESOURCE_URL=https://records.example.com/mcp
|
|
98
|
+
export KSOR_JWT_ALLOWED_AUDIENCES=https://records.example.com/mcp
|
|
99
|
+
ksor serve --instance instance.md
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The boot block tells you whether discovery worked:
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
auth bearer tokens, verified against the record's authorization server
|
|
106
|
+
keys openid-configuration — http://127.0.0.1:8180/realms/master/protocol/openid-connect/certs
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
If that line says `guess` instead of naming a discovery document, the AS did not
|
|
110
|
+
publish metadata where the door looked, and you should set `KSOR_JWKS_URL`
|
|
111
|
+
yourself rather than rely on the vendor default.
|
|
112
|
+
|
|
113
|
+
## Recipe: Ory Hydra
|
|
114
|
+
|
|
115
|
+
A different implementation, and a different way of asking for the audience —
|
|
116
|
+
which is the neutrality proof. Hydra takes the RFC 8707 `audience` parameter on
|
|
117
|
+
the token request, so no mapper is involved:
|
|
118
|
+
|
|
119
|
+
```sh
|
|
120
|
+
docker run -d --name hydra -p 4444:4444 -p 4445:4445 \
|
|
121
|
+
-e DSN=memory -e URLS_SELF_ISSUER=http://127.0.0.1:4444 \
|
|
122
|
+
-e SECRETS_SYSTEM=change-me-0000000000000000000000 \
|
|
123
|
+
-e STRATEGIES_ACCESS_TOKEN=jwt \
|
|
124
|
+
oryd/hydra:v2.2.0 serve all --dev
|
|
125
|
+
|
|
126
|
+
curl -s -X POST http://127.0.0.1:4445/admin/clients -H 'Content-Type: application/json' -d '{
|
|
127
|
+
"client_id":"ksor-agent","client_secret":"agent-secret",
|
|
128
|
+
"grant_types":["client_credentials"],"token_endpoint_auth_method":"client_secret_post",
|
|
129
|
+
"audience":["https://records.example.com/mcp"],"access_token_strategy":"jwt"}'
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Only the two SSO variables change:
|
|
133
|
+
|
|
134
|
+
```sh
|
|
135
|
+
export KSOR_SSO_URL=http://127.0.0.1:4444
|
|
136
|
+
export KSOR_SSO_ISSUER=http://127.0.0.1:4444
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Hydra publishes its keys at `/.well-known/jwks.json` rather than Keycloak's
|
|
140
|
+
`/protocol/openid-connect/certs`. Nothing in ksor knows that; discovery reads it
|
|
141
|
+
from the metadata document, which is why the door works against both unmodified.
|
|
142
|
+
|
|
143
|
+
## What an agent does
|
|
144
|
+
|
|
145
|
+
Ask the token endpoint for a token audienced at the record, then send it as an
|
|
146
|
+
ordinary bearer:
|
|
147
|
+
|
|
148
|
+
```sh
|
|
149
|
+
# Hydra — the audience is a request parameter
|
|
150
|
+
curl -s -X POST http://127.0.0.1:4444/oauth2/token \
|
|
151
|
+
-d grant_type=client_credentials -d client_id=ksor-agent -d client_secret=agent-secret \
|
|
152
|
+
-d audience=https://records.example.com/mcp
|
|
153
|
+
|
|
154
|
+
# Keycloak — the audience comes from the mapper, so the request is plain
|
|
155
|
+
curl -s -X POST "$KC/realms/master/protocol/openid-connect/token" \
|
|
156
|
+
-d client_id=ksor-agent -d client_secret=agent-secret -d grant_type=client_credentials
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
With the MCP TypeScript SDK:
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
const transport = new StreamableHTTPClientTransport(new URL("https://records.example.com/mcp"), {
|
|
163
|
+
requestInit: { headers: { authorization: `Bearer ${accessToken}` } },
|
|
164
|
+
});
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
A client that does not know where to authenticate can find out: an unauthorized
|
|
168
|
+
request answers `401` with a pointer to this record's metadata, per RFC 9728.
|
|
169
|
+
|
|
170
|
+
```
|
|
171
|
+
www-authenticate: Bearer resource_metadata="https://records.example.com/.well-known/oauth-protected-resource/mcp"
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## What the door refuses, and how it says so
|
|
175
|
+
|
|
176
|
+
| what you send | answer |
|
|
177
|
+
| ---------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
|
178
|
+
| no token | `401`, with the `resource_metadata` pointer |
|
|
179
|
+
| a malformed or unsigned token | `401`, `error="invalid_token"` |
|
|
180
|
+
| a token for a **different resource** | `401` — the audience binding, and the reason it exists |
|
|
181
|
+
| a token from a **different issuer** (with `KSOR_SSO_ISSUER` set) | `401`, before any key is fetched |
|
|
182
|
+
| a token from a different issuer (issuer NOT set) | `503` — indistinguishable from rotation lag, which is why you should set it |
|
|
183
|
+
| an expired token | `401` |
|
|
184
|
+
| a valid token | the record answers, and the answer carries its citations |
|
|
185
|
+
|
|
186
|
+
A genuine key-rotation lag stays a `503` on purpose: it is transient, retrying is
|
|
187
|
+
the right response, and the refusal is never cached — a valid bearer is
|
|
188
|
+
re-admitted the instant the key set catches up.
|
|
189
|
+
|
|
190
|
+
## Before a public bind
|
|
191
|
+
|
|
192
|
+
- Auth configured as above, **or** `KSOR_ALLOW_PUBLIC_UNAUTHENTICATED=1` set
|
|
193
|
+
deliberately — the door will not come up on a public address without one of
|
|
194
|
+
them, and the second is a decision, not a default.
|
|
195
|
+
- `KSOR_ALLOWED_HOSTS` set to the host you serve on.
|
|
196
|
+
- `KSOR_SNAPSHOT_KEYS` shared across every replica. Unset means a key per
|
|
197
|
+
process, so a citation minted by one replica fails on another.
|
package/docs/index.md
CHANGED
|
@@ -32,6 +32,10 @@ instead of their training memory. The corpus grows with each implemented verb.
|
|
|
32
32
|
export the manifest the site build reads), `ksor calibrate` (measure the
|
|
33
33
|
abstention floor) and `ksor gc` (reap retired generations). Only `ksor dev` and `ksor build` remain designed, not
|
|
34
34
|
implemented: each prints an honest notice and exits `2`.
|
|
35
|
+
- **[authorization.md](./authorization.md)** — putting the record behind an
|
|
36
|
+
authorization server, with worked recipes for two of them, executed rather
|
|
37
|
+
than written. `ksor serve` refuses to boot unauthenticated on a public bind,
|
|
38
|
+
so this is the last step of a deployment, not an optional hardening pass.
|
|
35
39
|
- Exit codes are a contract: `1` refused (first stderr line is a stable
|
|
36
40
|
slug such as `error: bad-name`, followed by a remedy), `2` designed but
|
|
37
41
|
not implemented, `3` the environment cannot run ksor
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@panaversity/ksor",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.20",
|
|
4
4
|
"description": "Knowledge System of Record — compile governed markdown into a static site for people and an MCP server for AI agents, with citations and measured abstention.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"abstention",
|
|
@@ -66,8 +66,8 @@
|
|
|
66
66
|
"tsdown": "0.22.14",
|
|
67
67
|
"typescript": "7.0.2",
|
|
68
68
|
"vitest": "^4.1.10",
|
|
69
|
-
"@panaversity/ksor-content": "0.0.0",
|
|
70
|
-
"@panaversity/ksor-content
|
|
69
|
+
"@panaversity/ksor-content-gateway": "0.0.0",
|
|
70
|
+
"@panaversity/ksor-content": "0.0.0"
|
|
71
71
|
},
|
|
72
72
|
"engines": {
|
|
73
73
|
"node": ">=24"
|