@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 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-DDeyGVjK.mjs
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.19",
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-gateway": "0.0.0"
69
+ "@panaversity/ksor-content-gateway": "0.0.0",
70
+ "@panaversity/ksor-content": "0.0.0"
71
71
  },
72
72
  "engines": {
73
73
  "node": ">=24"