toolnexus 0.23.0 → 0.25.0
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/README.md +139 -1
- package/dist/acp.d.ts +37 -6
- package/dist/acp.js +239 -52
- package/dist/agents/loop.js +4 -1
- package/dist/agents/runtime.js +4 -1
- package/dist/client.d.ts +55 -9
- package/dist/client.js +154 -41
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/mcp.d.ts +32 -7
- package/dist/mcp.js +125 -36
- package/dist/mcp2026/auth.d.ts +137 -0
- package/dist/mcp2026/auth.js +446 -0
- package/dist/mcp2026/client.d.ts +48 -0
- package/dist/mcp2026/client.js +160 -0
- package/dist/mcp2026/protocol.d.ts +54 -0
- package/dist/mcp2026/protocol.js +113 -0
- package/dist/mcp2026/skills.d.ts +5 -0
- package/dist/mcp2026/skills.js +25 -0
- package/dist/mcp2026/source.d.ts +86 -0
- package/dist/mcp2026/source.js +372 -0
- package/dist/mcp2026/stdio.d.ts +24 -0
- package/dist/mcp2026/stdio.js +99 -0
- package/dist/mcp2026/toolsearch.d.ts +5 -0
- package/dist/mcp2026/toolsearch.js +58 -0
- package/dist/mcphandler.d.ts +169 -0
- package/dist/mcphandler.js +1145 -0
- package/dist/mcpheader.d.ts +5 -0
- package/dist/mcpheader.js +18 -0
- package/dist/mcpserve.d.ts +24 -15
- package/dist/mcpserve.js +105 -2
- package/dist/mcpserverauth.d.ts +48 -0
- package/dist/mcpserverauth.js +203 -0
- package/dist/media.d.ts +126 -0
- package/dist/media.js +540 -0
- package/dist/skill.d.ts +21 -0
- package/dist/skill.js +44 -0
- package/dist/toolconcurrency.d.ts +16 -0
- package/dist/toolconcurrency.js +59 -0
- package/dist/toolkit.d.ts +23 -1
- package/dist/toolkit.js +26 -4
- package/dist/types.d.ts +26 -1
- package/package.json +5 -3
- package/src/acp.ts +258 -55
- package/src/agents/loop.ts +4 -1
- package/src/agents/runtime.ts +4 -1
- package/src/client.ts +196 -48
- package/src/index.ts +4 -0
- package/src/mcp.ts +162 -43
- package/src/mcp2026/auth.ts +473 -0
- package/src/mcp2026/client.ts +161 -0
- package/src/mcp2026/protocol.ts +135 -0
- package/src/mcp2026/skills.ts +22 -0
- package/src/mcp2026/source.ts +407 -0
- package/src/mcp2026/stdio.ts +102 -0
- package/src/mcp2026/toolsearch.ts +56 -0
- package/src/mcphandler.ts +1252 -0
- package/src/mcpheader.ts +19 -0
- package/src/mcpserve.ts +115 -2
- package/src/mcpserverauth.ts +216 -0
- package/src/media.ts +604 -0
- package/src/skill.ts +50 -0
- package/src/toolconcurrency.ts +67 -0
- package/src/toolkit.ts +35 -5
- package/src/types.ts +27 -1
package/src/mcpheader.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Internal (not exported from index): MCP 2026-07-28 header helpers shared by the handler and McpServerAuth. */
|
|
2
|
+
|
|
3
|
+
/** Decodes the `=?base64?<std-base64>?=` sentinel (SPEC §2.3); an absent header is not ok. */
|
|
4
|
+
export function decodeHeaderValue(v: string): [string, boolean] {
|
|
5
|
+
if (v.startsWith("=?base64?") && v.endsWith("?=") && v.length >= 11) {
|
|
6
|
+
const b = v.slice(9, -2)
|
|
7
|
+
if (b.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(b)) return ["", false]
|
|
8
|
+
return [Buffer.from(b, "base64").toString("utf8"), true]
|
|
9
|
+
}
|
|
10
|
+
return [v, v !== ""]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** GET a JSON document (200 only). */
|
|
14
|
+
export async function getJson(f: typeof fetch, url: string): Promise<any> {
|
|
15
|
+
const r = await f(url, { headers: { Accept: "application/json" } })
|
|
16
|
+
if (r.status !== 200) throw new Error(`GET ${url}: HTTP ${r.status}`)
|
|
17
|
+
return r.json()
|
|
18
|
+
}
|
|
19
|
+
|
package/src/mcpserve.ts
CHANGED
|
@@ -12,7 +12,11 @@
|
|
|
12
12
|
* server alongside the A2A routes (see serve.ts), built on the same
|
|
13
13
|
* `@modelcontextprotocol/sdk` the client side already uses.
|
|
14
14
|
*/
|
|
15
|
+
import http from "node:http"
|
|
16
|
+
import type { AddressInfo } from "node:net"
|
|
17
|
+
import { randomBytes, timingSafeEqual } from "node:crypto"
|
|
15
18
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
|
19
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"
|
|
16
20
|
import { ListToolsRequestSchema, CallToolRequestSchema, McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"
|
|
17
21
|
import type { Tool, ToolSource, ToolResult } from "./types.js"
|
|
18
22
|
import type { ContentPart } from "./content.js"
|
|
@@ -66,7 +70,11 @@ export function buildMcpServer(tools: Tool[], cfg?: MCPServeConfig, onCall?: OnC
|
|
|
66
70
|
)
|
|
67
71
|
|
|
68
72
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
69
|
-
tools: tools.map((t) =>
|
|
73
|
+
tools: tools.map((t) =>
|
|
74
|
+
t.outputSchema
|
|
75
|
+
? { name: t.name, description: t.description, inputSchema: t.inputSchema, outputSchema: t.outputSchema }
|
|
76
|
+
: { name: t.name, description: t.description, inputSchema: t.inputSchema },
|
|
77
|
+
),
|
|
70
78
|
}))
|
|
71
79
|
|
|
72
80
|
server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
|
|
@@ -89,12 +97,36 @@ export function buildMcpServer(tools: Tool[], cfg?: MCPServeConfig, onCall?: OnC
|
|
|
89
97
|
// Host callback errors are isolated.
|
|
90
98
|
}
|
|
91
99
|
}
|
|
92
|
-
return
|
|
100
|
+
return toolResultToMcp(result) as any
|
|
93
101
|
})
|
|
94
102
|
|
|
95
103
|
return server
|
|
96
104
|
}
|
|
97
105
|
|
|
106
|
+
/**
|
|
107
|
+
* §7C / §1. Map a ToolResult to an MCP `CallToolResult`: `output` → the text block (or, when
|
|
108
|
+
* `output` is empty and `structured` is set, the JSON text of `structured`), `parts` → extra
|
|
109
|
+
* blocks, `structured` → `structuredContent`, `meta` → `_meta`. With neither set the shape is
|
|
110
|
+
* byte-identical to the pre-§7C.1 result. `omitFalseIsError` drops `isError:false` (the
|
|
111
|
+
* hand-written §7C.1 handler wire, like Go's omitempty); the SDK serve path keeps it.
|
|
112
|
+
*/
|
|
113
|
+
export function toolResultToMcp(result: ToolResult, omitFalseIsError = false): Record<string, unknown> {
|
|
114
|
+
let text = result.output ?? ""
|
|
115
|
+
if (result.structured !== undefined && result.structured !== null && text === "") {
|
|
116
|
+
try {
|
|
117
|
+
text = JSON.stringify(result.structured)
|
|
118
|
+
} catch {
|
|
119
|
+
// leave the text empty when structured is not serialisable
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const out: Record<string, unknown> = { content: [{ type: "text", text }, ...partBlocks(result.parts)] }
|
|
123
|
+
if (!omitFalseIsError) out.isError = result.isError
|
|
124
|
+
else if (result.isError) out.isError = true
|
|
125
|
+
if (result.structured !== undefined && result.structured !== null) out.structuredContent = result.structured
|
|
126
|
+
if (result.meta) out._meta = result.meta
|
|
127
|
+
return out
|
|
128
|
+
}
|
|
129
|
+
|
|
98
130
|
/**
|
|
99
131
|
* §7C. A served tool's `ToolResult.parts` become MCP content blocks appended after the
|
|
100
132
|
* text part, in order — image ⇒ image content, audio ⇒ audio content, file ⇒ an embedded
|
|
@@ -116,3 +148,84 @@ function partBlocks(parts: ContentPart[] | undefined): any[] {
|
|
|
116
148
|
}
|
|
117
149
|
return out
|
|
118
150
|
}
|
|
151
|
+
|
|
152
|
+
/** A loopback MCP endpoint for a local agent (SPEC §8C, {@link Toolkit.asMcpServer}). */
|
|
153
|
+
export interface LocalMcpServer {
|
|
154
|
+
/** `http://127.0.0.1:<port>/mcp`. */
|
|
155
|
+
url: string
|
|
156
|
+
/** 64 lowercase hex chars; required as `Authorization: Bearer <token>`. Never logged. */
|
|
157
|
+
token: string
|
|
158
|
+
/** Stops the listener. Idempotent. */
|
|
159
|
+
close(): Promise<void>
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Serves `tools` (filtered by `cfg.tools`) as the stateless §7C streamable-HTTP MCP endpoint on
|
|
164
|
+
* `127.0.0.1:<ephemeral>/mcp` behind a fresh bearer token. Other paths ⇒ 404; missing/wrong
|
|
165
|
+
* token ⇒ 401 + `WWW-Authenticate: Bearer` (constant-time compare); a non-POST ⇒ 405.
|
|
166
|
+
*/
|
|
167
|
+
export async function serveLocalMcp(tools: Tool[], cfg?: MCPServeConfig): Promise<LocalMcpServer> {
|
|
168
|
+
const exposed = exposedMcpTools(tools, cfg)
|
|
169
|
+
const token = randomBytes(32).toString("hex")
|
|
170
|
+
const want = Buffer.from("Bearer " + token)
|
|
171
|
+
const server = http.createServer((req, res) => {
|
|
172
|
+
const u = req.url ?? ""
|
|
173
|
+
if (u !== "/mcp" && !u.startsWith("/mcp?")) {
|
|
174
|
+
res.writeHead(404).end("not found")
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
const got = Buffer.from(String(req.headers.authorization ?? ""))
|
|
178
|
+
if (got.length !== want.length || !timingSafeEqual(got, want)) {
|
|
179
|
+
res.writeHead(401, { "WWW-Authenticate": "Bearer" }).end("unauthorized")
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
if (req.method !== "POST") {
|
|
183
|
+
res.writeHead(405, { Allow: "POST" }).end("method not allowed")
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
void (async () => {
|
|
187
|
+
const body = await readBody(req)
|
|
188
|
+
let parsed: unknown
|
|
189
|
+
try {
|
|
190
|
+
parsed = body ? JSON.parse(body) : undefined
|
|
191
|
+
} catch {
|
|
192
|
+
parsed = undefined
|
|
193
|
+
}
|
|
194
|
+
const mcp = buildMcpServer(exposed, cfg)
|
|
195
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
|
|
196
|
+
res.on("close", () => {
|
|
197
|
+
void transport.close()
|
|
198
|
+
void mcp.close()
|
|
199
|
+
})
|
|
200
|
+
await mcp.connect(transport)
|
|
201
|
+
await transport.handleRequest(req, res, parsed)
|
|
202
|
+
})().catch(() => {
|
|
203
|
+
if (!res.headersSent) res.writeHead(500).end("mcp error")
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
await new Promise<void>((resolve, reject) => {
|
|
207
|
+
server.once("error", reject)
|
|
208
|
+
server.listen(0, "127.0.0.1", () => resolve())
|
|
209
|
+
})
|
|
210
|
+
const port = (server.address() as AddressInfo).port
|
|
211
|
+
let closing: Promise<void> | undefined
|
|
212
|
+
return {
|
|
213
|
+
url: `http://127.0.0.1:${port}/mcp`,
|
|
214
|
+
token,
|
|
215
|
+
close: () =>
|
|
216
|
+
(closing ??= new Promise<void>((resolve) => {
|
|
217
|
+
server.close(() => resolve())
|
|
218
|
+
server.closeAllConnections()
|
|
219
|
+
})),
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function readBody(req: http.IncomingMessage): Promise<string> {
|
|
224
|
+
return new Promise((resolve, reject) => {
|
|
225
|
+
let data = ""
|
|
226
|
+
req.setEncoding("utf8")
|
|
227
|
+
req.on("data", (c: string) => (data += c))
|
|
228
|
+
req.on("end", () => resolve(data))
|
|
229
|
+
req.on("error", reject)
|
|
230
|
+
})
|
|
231
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server authorization (SPEC §2A.2, §7C.2): an `mcpHandler` as an OAuth 2.1 protected
|
|
3
|
+
* resource. `McpServerAuth` is a plain helper producing the handler's `authenticate` hook and the
|
|
4
|
+
* RFC 9728 metadata listener; the host mounts both. Identity, tenants and token storage stay the
|
|
5
|
+
* host's.
|
|
6
|
+
*
|
|
7
|
+
* const auth = new McpServerAuth({ issuer: "https://login.example", scopes: { "*": ["mcp"] } })
|
|
8
|
+
* const mcp = toolkit.mcpHandler({ protocolVersions: ["2026-07-28"], authenticate: auth.authenticate })
|
|
9
|
+
* const meta = auth.metadata() // mount at /.well-known/oauth-protected-resource[/mcp]
|
|
10
|
+
*/
|
|
11
|
+
import { createPublicKey, verify as cryptoVerify, type KeyObject } from "node:crypto"
|
|
12
|
+
import type { IncomingMessage, ServerResponse } from "node:http"
|
|
13
|
+
import { McpAuthError } from "./mcphandler.js"
|
|
14
|
+
import { decodeHeaderValue, getJson } from "./mcpheader.js"
|
|
15
|
+
|
|
16
|
+
/** The verified caller; under McpServerAuth, `identityFrom(ctx)` returns it. */
|
|
17
|
+
export interface McpPrincipal {
|
|
18
|
+
subject: string
|
|
19
|
+
scopes: string[]
|
|
20
|
+
claims?: Record<string, unknown>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface McpServerAuthOptions {
|
|
24
|
+
/** The authorization server's issuer (advertised in the PRM, and the JWT `iss` required). */
|
|
25
|
+
issuer: string
|
|
26
|
+
/** Canonical resource URI (PRM `resource`, JWT `aud`). Omit ⇒ `<scheme>://<Host><resourcePath>` per request. */
|
|
27
|
+
audience?: string
|
|
28
|
+
/** Where the handler is mounted. Default "/mcp". */
|
|
29
|
+
resourcePath?: string
|
|
30
|
+
/** "*" is required on every request (and advertised); a tool name adds scopes for that tools/call. */
|
|
31
|
+
scopes?: Record<string, string[]>
|
|
32
|
+
/** Replaces the default RS256/JWKS verifier (opaque tokens, introspection). Throw ⇒ invalid_token. */
|
|
33
|
+
verify?: (req: IncomingMessage, token: string, audience: string) => Promise<McpPrincipal> | McpPrincipal
|
|
34
|
+
/** Fetches AS metadata and JWKS. Default: global fetch with a 10 s timeout. */
|
|
35
|
+
fetch?: typeof fetch
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const PRM_PATH = "/.well-known/oauth-protected-resource"
|
|
39
|
+
|
|
40
|
+
function header(req: IncomingMessage, name: string): string {
|
|
41
|
+
const v = req.headers[name.toLowerCase()]
|
|
42
|
+
return Array.isArray(v) ? (v[0] ?? "") : (v ?? "")
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function origin(req: IncomingMessage): string {
|
|
46
|
+
const scheme = (req.socket as { encrypted?: boolean }).encrypted ? "https" : "http"
|
|
47
|
+
return `${scheme}://${header(req, "Host")}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Protects an `mcpHandler` with bearer tokens (SPEC §2A.2). */
|
|
51
|
+
export class McpServerAuth {
|
|
52
|
+
readonly issuer: string
|
|
53
|
+
readonly audience?: string
|
|
54
|
+
readonly resourcePath: string
|
|
55
|
+
readonly scopes: Record<string, string[]>
|
|
56
|
+
private readonly verifyHook?: McpServerAuthOptions["verify"]
|
|
57
|
+
private readonly fetchImpl: typeof fetch
|
|
58
|
+
private keys?: Map<string, KeyObject>
|
|
59
|
+
private fetched = 0
|
|
60
|
+
|
|
61
|
+
constructor(opts: McpServerAuthOptions) {
|
|
62
|
+
this.issuer = opts.issuer
|
|
63
|
+
this.audience = opts.audience || undefined
|
|
64
|
+
this.resourcePath = opts.resourcePath || "/mcp"
|
|
65
|
+
this.scopes = opts.scopes ?? {}
|
|
66
|
+
this.verifyHook = opts.verify
|
|
67
|
+
this.fetchImpl = opts.fetch ?? ((u, init) => fetch(u, { ...init, signal: AbortSignal.timeout(10_000) }))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private audienceFor(req: IncomingMessage): string {
|
|
71
|
+
return this.audience ?? origin(req) + this.resourcePath
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The RFC 9728 metadata URL the challenge names. */
|
|
75
|
+
metadataUrl(req: IncomingMessage): string {
|
|
76
|
+
if (this.audience) {
|
|
77
|
+
try {
|
|
78
|
+
const u = new URL(this.audience)
|
|
79
|
+
if (u.host) {
|
|
80
|
+
const path = /^[^:]+:\/\/[^/?#]*\//.test(this.audience) ? u.pathname : ""
|
|
81
|
+
return `${u.protocol}//${u.host}${PRM_PATH}${path}`
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
// fall through to the request-derived URL
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return origin(req) + PRM_PATH + this.resourcePath
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Request listener serving the protected resource metadata at the two PRM paths; anything else 404. */
|
|
91
|
+
metadata(): (req: IncomingMessage, res: ServerResponse) => void {
|
|
92
|
+
return (req, res) => {
|
|
93
|
+
const path = (req.url ?? "/").split("?")[0]
|
|
94
|
+
if (req.method !== "GET" || (path !== PRM_PATH && path !== PRM_PATH + this.resourcePath)) {
|
|
95
|
+
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }).end("404 page not found\n")
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
res.writeHead(200, { "Content-Type": "application/json" })
|
|
99
|
+
res.end(
|
|
100
|
+
JSON.stringify({
|
|
101
|
+
resource: this.audienceFor(req),
|
|
102
|
+
authorization_servers: [this.issuer],
|
|
103
|
+
scopes_supported: this.scopes["*"] ?? [],
|
|
104
|
+
bearer_methods_supported: ["header"],
|
|
105
|
+
}) + "\n",
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private challenge(req: IncomingMessage, status: number, scopes: string[] | undefined, err: string): McpAuthError {
|
|
111
|
+
let v = `Bearer resource_metadata="${this.metadataUrl(req)}"`
|
|
112
|
+
if (scopes && scopes.length > 0) v += `, scope="${scopes.join(" ")}"`
|
|
113
|
+
if (err) v += `, error="${err}"`
|
|
114
|
+
return new McpAuthError(status, v, err)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The `mcpHandler` `authenticate` hook (bound). Missing token ⇒ 401; invalid ⇒ 401
|
|
119
|
+
* error="invalid_token"; a missing scope ⇒ 403 error="insufficient_scope" naming the full needed
|
|
120
|
+
* set. Per-tool scopes come from the Mcp-Method / Mcp-Name headers (the 2026-07-28 wire).
|
|
121
|
+
*/
|
|
122
|
+
readonly authenticate = async (req: IncomingMessage): Promise<McpPrincipal> => {
|
|
123
|
+
const authz = header(req, "Authorization")
|
|
124
|
+
const tok = authz.startsWith("Bearer ") ? authz.slice(7) : ""
|
|
125
|
+
if (tok.trim() === "") throw this.challenge(req, 401, this.scopes["*"], "")
|
|
126
|
+
let p: McpPrincipal
|
|
127
|
+
try {
|
|
128
|
+
const aud = this.audienceFor(req)
|
|
129
|
+
p = this.verifyHook ? await this.verifyHook(req, tok, aud) : await this.verifyJwt(tok, aud)
|
|
130
|
+
} catch {
|
|
131
|
+
throw this.challenge(req, 401, this.scopes["*"], "invalid_token")
|
|
132
|
+
}
|
|
133
|
+
const need = [...(this.scopes["*"] ?? [])]
|
|
134
|
+
if (header(req, "Mcp-Method") === "tools/call") {
|
|
135
|
+
const [name, ok] = decodeHeaderValue(header(req, "Mcp-Name"))
|
|
136
|
+
if (ok) for (const s of this.scopes[name] ?? []) if (!need.includes(s)) need.push(s)
|
|
137
|
+
}
|
|
138
|
+
const have = p.scopes ?? []
|
|
139
|
+
if (need.some((s) => !have.includes(s))) throw this.challenge(req, 403, need, "insufficient_scope")
|
|
140
|
+
return p
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private async verifyJwt(tok: string, aud: string): Promise<McpPrincipal> {
|
|
144
|
+
const parts = tok.split(".")
|
|
145
|
+
if (parts.length !== 3) throw new Error("malformed jwt")
|
|
146
|
+
const hdr = decodeSeg(parts[0]!)
|
|
147
|
+
const claims = decodeSeg(parts[1]!)
|
|
148
|
+
if (!hdr || !claims || hdr.alg !== "RS256") throw new Error("bad jwt header or claims")
|
|
149
|
+
const key = await this.key(typeof hdr.kid === "string" ? hdr.kid : "")
|
|
150
|
+
if (!/^[A-Za-z0-9_-]*$/.test(parts[2]!)) throw new Error("bad signature encoding")
|
|
151
|
+
const sig = Buffer.from(parts[2]!, "base64url")
|
|
152
|
+
if (!cryptoVerify("RSA-SHA256", Buffer.from(parts[0] + "." + parts[1]), key, sig)) throw new Error("bad signature")
|
|
153
|
+
if (claims.iss !== this.issuer) throw new Error("wrong issuer")
|
|
154
|
+
const a = claims.aud
|
|
155
|
+
if (!(typeof a === "string" ? a === aud : Array.isArray(a) && a.includes(aud))) throw new Error("wrong audience")
|
|
156
|
+
const exp = typeof claims.exp === "number" ? Math.trunc(claims.exp) : 0
|
|
157
|
+
if (exp <= Math.floor(Date.now() / 1000)) throw new Error("expired")
|
|
158
|
+
const scope = typeof claims.scope === "string" ? claims.scope : ""
|
|
159
|
+
return {
|
|
160
|
+
subject: typeof claims.sub === "string" ? claims.sub : "",
|
|
161
|
+
scopes: scope.split(/\s+/).filter(Boolean),
|
|
162
|
+
claims,
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The JWKS key for kid; an unknown kid refetches at most every 30 s. */
|
|
167
|
+
private async key(kid: string): Promise<KeyObject> {
|
|
168
|
+
const k = this.keys?.get(kid)
|
|
169
|
+
if (k) return k
|
|
170
|
+
if (this.keys && Date.now() - this.fetched < 30_000) throw new Error("unknown kid")
|
|
171
|
+
const md = await discoverAs(this.fetchImpl, this.issuer)
|
|
172
|
+
const jwks = await getJson(this.fetchImpl, String(md.jwks_uri ?? ""))
|
|
173
|
+
const keys = new Map<string, KeyObject>()
|
|
174
|
+
for (const j of Array.isArray(jwks?.keys) ? jwks.keys : []) {
|
|
175
|
+
if (j?.kty !== "RSA" || typeof j.n !== "string" || typeof j.e !== "string") continue
|
|
176
|
+
try {
|
|
177
|
+
keys.set(String(j.kid ?? ""), createPublicKey({ key: { kty: "RSA", n: j.n, e: j.e }, format: "jwk" }))
|
|
178
|
+
} catch {
|
|
179
|
+
// skip a malformed key
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
this.keys = keys
|
|
183
|
+
this.fetched = Date.now()
|
|
184
|
+
const got = keys.get(kid)
|
|
185
|
+
if (!got) throw new Error("unknown kid")
|
|
186
|
+
return got
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function decodeSeg(s: string): Record<string, any> | undefined {
|
|
191
|
+
if (!/^[A-Za-z0-9_-]+$/.test(s)) return undefined
|
|
192
|
+
try {
|
|
193
|
+
const v = JSON.parse(Buffer.from(s, "base64url").toString("utf8"))
|
|
194
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : undefined
|
|
195
|
+
} catch {
|
|
196
|
+
return undefined
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** AS metadata in SPEC §2A.1 step 3 order; the metadata `issuer` must equal the issuer. */
|
|
201
|
+
async function discoverAs(f: typeof fetch, issuer: string): Promise<Record<string, unknown>> {
|
|
202
|
+
const u = new URL(issuer)
|
|
203
|
+
const org = `${u.protocol}//${u.host}`
|
|
204
|
+
const path = (/^[^:]+:\/\/[^/?#]*\//.test(issuer) ? u.pathname : "").replace(/\/$/, "")
|
|
205
|
+
const cands = [`${org}/.well-known/oauth-authorization-server${path}`, `${org}/.well-known/openid-configuration${path}`]
|
|
206
|
+
if (path) cands.push(`${org}${path}/.well-known/openid-configuration`)
|
|
207
|
+
for (const c of cands) {
|
|
208
|
+
try {
|
|
209
|
+
const md = await getJson(f, c)
|
|
210
|
+
if (md && md.issuer === issuer) return md
|
|
211
|
+
} catch {
|
|
212
|
+
// next candidate
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
throw new Error("authorization server metadata not found for " + issuer)
|
|
216
|
+
}
|