@dewee/identity 0.0.0-stage → 0.1.0-beta.1
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 +95 -2
- package/dist/assertion.d.ts +64 -0
- package/dist/assertion.js +155 -0
- package/dist/browser.d.ts +3 -0
- package/dist/browser.js +2 -0
- package/dist/errors.d.ts +66 -0
- package/dist/errors.js +174 -0
- package/dist/exchange.d.ts +51 -0
- package/dist/exchange.js +74 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +15 -0
- package/dist/job-access.d.ts +26 -0
- package/dist/job-access.js +182 -0
- package/dist/lifecycle.d.ts +50 -0
- package/dist/lifecycle.js +67 -0
- package/dist/links.d.ts +99 -0
- package/dist/links.js +140 -0
- package/dist/transport.d.ts +40 -0
- package/dist/transport.js +103 -0
- package/dist/verify-trusted-user.d.ts +76 -0
- package/dist/verify-trusted-user.js +173 -0
- package/package.json +53 -4
package/dist/links.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { createAssertion } from "./assertion.js";
|
|
2
|
+
import { DeweeIdentityError, invalidArgument } from "./errors.js";
|
|
3
|
+
import { IDENTITY_SESSION_HEADER } from "./exchange.js";
|
|
4
|
+
import { assertServer, gatewayRequest, normalizeBaseUrl, requireString } from "./transport.js";
|
|
5
|
+
/** Path of the hosted Connect page that receives the partner handoff. */
|
|
6
|
+
export const CONNECT_RETURN_PATH = "/connect/return";
|
|
7
|
+
const TXN_PATTERN = /^[A-Za-z0-9_-]{16,256}$/;
|
|
8
|
+
function expectedReturnTo(connectBaseUrl, allowInsecureHttp) {
|
|
9
|
+
return `${normalizeBaseUrl(connectBaseUrl, "connectBaseUrl", allowInsecureHttp)}${CONNECT_RETURN_PATH}`;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Checks the two values Dewee sends to your connect start URL. `return_to` must be exactly your
|
|
13
|
+
* Dewee Connect page: anything else is refused, so a crafted link can never make your server hand
|
|
14
|
+
* a signed assertion to another site.
|
|
15
|
+
*/
|
|
16
|
+
export function checkConnectRequest(input, opts) {
|
|
17
|
+
const expected = expectedReturnTo(opts.connectBaseUrl, opts.allowInsecureHttp);
|
|
18
|
+
if (typeof input.deweeTxn !== "string" || !TXN_PATTERN.test(input.deweeTxn)) {
|
|
19
|
+
throw invalidArgument("dewee_txn is missing or malformed");
|
|
20
|
+
}
|
|
21
|
+
if (typeof input.returnTo !== "string" || input.returnTo === "") {
|
|
22
|
+
throw invalidArgument("return_to is missing");
|
|
23
|
+
}
|
|
24
|
+
let returnTo;
|
|
25
|
+
try {
|
|
26
|
+
returnTo = new URL(input.returnTo);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
throw invalidArgument("return_to is not a URL");
|
|
30
|
+
}
|
|
31
|
+
const normalized = `${returnTo.origin}${returnTo.pathname}`;
|
|
32
|
+
if (normalized !== expected || returnTo.search !== "" || returnTo.hash !== "" || returnTo.username !== "") {
|
|
33
|
+
throw invalidArgument("return_to is not your Dewee Connect page; refusing to hand off");
|
|
34
|
+
}
|
|
35
|
+
return { deweeTxn: input.deweeTxn, returnTo: expected };
|
|
36
|
+
}
|
|
37
|
+
/** Reads and checks `dewee_txn` and `return_to` from the URL of a request to your connect start URL. */
|
|
38
|
+
export function readConnectRequest(requestUrl, opts) {
|
|
39
|
+
let url;
|
|
40
|
+
try {
|
|
41
|
+
url = new URL(requestUrl.toString(), "http://placeholder.invalid");
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
throw invalidArgument("request URL is malformed");
|
|
45
|
+
}
|
|
46
|
+
return checkConnectRequest({ deweeTxn: url.searchParams.get("dewee_txn"), returnTo: url.searchParams.get("return_to") }, opts);
|
|
47
|
+
}
|
|
48
|
+
const CSP_NONCE_PATTERN = /^[A-Za-z0-9+/=_-]{8,256}$/;
|
|
49
|
+
function escapeHtml(value) {
|
|
50
|
+
return value
|
|
51
|
+
.replaceAll("&", "&")
|
|
52
|
+
.replaceAll("<", "<")
|
|
53
|
+
.replaceAll(">", ">")
|
|
54
|
+
.replaceAll('"', """)
|
|
55
|
+
.replaceAll("'", "'");
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Builds the hosted-link handoff: signs a handoff assertion for the signed-in user, bound to this
|
|
59
|
+
* ceremony (`nonce` = `dewee_txn`), and returns the POST form that sends the user back to Dewee
|
|
60
|
+
* Connect. Dewee then shows the consent screen and the chat proof step.
|
|
61
|
+
*/
|
|
62
|
+
export async function createConnectRedirect(opts) {
|
|
63
|
+
assertServer();
|
|
64
|
+
if (opts === null || typeof opts !== "object")
|
|
65
|
+
throw invalidArgument("options are required");
|
|
66
|
+
const checked = checkConnectRequest({ deweeTxn: opts.deweeTxn, returnTo: opts.returnTo }, opts);
|
|
67
|
+
if (opts.cspNonce !== undefined && (typeof opts.cspNonce !== "string" || !CSP_NONCE_PATTERN.test(opts.cspNonce))) {
|
|
68
|
+
throw invalidArgument("cspNonce must be 8 to 256 base64 characters");
|
|
69
|
+
}
|
|
70
|
+
const scriptNonce = opts.cspNonce === undefined ? "" : ` nonce="${opts.cspNonce}"`;
|
|
71
|
+
const assertion = await createAssertion({
|
|
72
|
+
...opts.signer,
|
|
73
|
+
purpose: "handoff",
|
|
74
|
+
subject: opts.subject,
|
|
75
|
+
nonce: checked.deweeTxn,
|
|
76
|
+
ttlSeconds: opts.ttlSeconds,
|
|
77
|
+
});
|
|
78
|
+
const action = checked.returnTo;
|
|
79
|
+
const location = `${action}?${new URLSearchParams({ assertion }).toString()}`;
|
|
80
|
+
const html = "<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"referrer\" content=\"no-referrer\">" +
|
|
81
|
+
"<title>Continuing to Dewee</title></head><body>" +
|
|
82
|
+
`<form id="dewee-connect" method="post" action="${escapeHtml(action)}">` +
|
|
83
|
+
`<input type="hidden" name="assertion" value="${escapeHtml(assertion)}">` +
|
|
84
|
+
"<button type=\"submit\">Continue</button></form>" +
|
|
85
|
+
`<script${scriptNonce}>document.getElementById("dewee-connect").submit();</script></body></html>`;
|
|
86
|
+
return { action, method: "POST", fields: { assertion }, html, location };
|
|
87
|
+
}
|
|
88
|
+
const IDEMPOTENCY_KEY_PATTERN = /^[\x21-\x7E]{1,255}$/;
|
|
89
|
+
/**
|
|
90
|
+
* Starts a web-first link for a signed-in user (`POST /v1/identity/links`): Dewee returns a proof
|
|
91
|
+
* code the user sends from the chat account to link. The user never types an id; Dewee checks both
|
|
92
|
+
* sides and links on its own.
|
|
93
|
+
*/
|
|
94
|
+
export async function startLink(opts) {
|
|
95
|
+
assertServer();
|
|
96
|
+
if (opts === null || typeof opts !== "object")
|
|
97
|
+
throw invalidArgument("options are required");
|
|
98
|
+
if (typeof opts.sessionToken !== "string" || opts.sessionToken === "" || opts.sessionToken.length > 512) {
|
|
99
|
+
throw invalidArgument("sessionToken is required");
|
|
100
|
+
}
|
|
101
|
+
const headers = { [IDENTITY_SESSION_HEADER]: opts.sessionToken };
|
|
102
|
+
if (opts.idempotencyKey !== undefined) {
|
|
103
|
+
if (!IDEMPOTENCY_KEY_PATTERN.test(opts.idempotencyKey)) {
|
|
104
|
+
throw invalidArgument("idempotencyKey must be 1 to 255 printable ASCII characters without spaces");
|
|
105
|
+
}
|
|
106
|
+
headers["Idempotency-Key"] = opts.idempotencyKey;
|
|
107
|
+
}
|
|
108
|
+
const path = "/v1/identity/links";
|
|
109
|
+
const { body } = await gatewayRequest(opts, {
|
|
110
|
+
method: "POST",
|
|
111
|
+
path,
|
|
112
|
+
headers,
|
|
113
|
+
body: { kind: "web_first" },
|
|
114
|
+
});
|
|
115
|
+
const expiresAt = new Date(requireString(body, "expires_at", path));
|
|
116
|
+
if (Number.isNaN(expiresAt.getTime())) {
|
|
117
|
+
throw new DeweeIdentityError({ code: "INVALID_RESPONSE", message: `INVALID_RESPONSE: ${path} answer has a bad expires_at` });
|
|
118
|
+
}
|
|
119
|
+
const targets = Array.isArray(body.deep_links) ? body.deep_links : [];
|
|
120
|
+
return {
|
|
121
|
+
transactionId: requireString(body, "transaction_id", path),
|
|
122
|
+
kind: String(body.kind ?? "web_first"),
|
|
123
|
+
state: String(body.state ?? ""),
|
|
124
|
+
proofCode: requireString(body, "proof_code", path),
|
|
125
|
+
expiresAt,
|
|
126
|
+
created: body.created !== false,
|
|
127
|
+
deepLinks: targets
|
|
128
|
+
.filter((t) => t !== null && typeof t === "object")
|
|
129
|
+
.map((t) => {
|
|
130
|
+
const target = {
|
|
131
|
+
channelType: String(t.channel_type ?? ""),
|
|
132
|
+
channelInstanceId: String(t.channel_instance_id ?? ""),
|
|
133
|
+
name: String(t.name ?? ""),
|
|
134
|
+
};
|
|
135
|
+
if (typeof t.display_name === "string" && t.display_name !== "")
|
|
136
|
+
target.displayName = t.display_name;
|
|
137
|
+
return target;
|
|
138
|
+
}),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Refuses to run in a browser. The SDK signs with partner secrets and handles session tokens; both
|
|
3
|
+
* belong on the partner's server, never in a page.
|
|
4
|
+
*/
|
|
5
|
+
export declare function assertServer(): void;
|
|
6
|
+
/** Options shared by every call that talks to the gateway. */
|
|
7
|
+
export interface GatewayOptions {
|
|
8
|
+
/** Gateway origin, for example `https://dewee.example.com`. A path prefix is kept. */
|
|
9
|
+
gatewayUrl: string;
|
|
10
|
+
/**
|
|
11
|
+
* Allow plain `http://` to a host that is not loopback, for a private network such as a compose
|
|
12
|
+
* file. Off by default: credentials then only travel over https (or to localhost).
|
|
13
|
+
*/
|
|
14
|
+
allowInsecureHttp?: boolean;
|
|
15
|
+
/** Request timeout in milliseconds (default 10000). */
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
/** A fetch implementation; defaults to the global fetch. */
|
|
18
|
+
fetch?: typeof fetch;
|
|
19
|
+
/** Cancels the request. */
|
|
20
|
+
signal?: AbortSignal;
|
|
21
|
+
}
|
|
22
|
+
/** Validates a gateway or connect base URL and returns it as origin + path, without a trailing slash. */
|
|
23
|
+
export declare function normalizeBaseUrl(raw: string, name: string, allowInsecureHttp?: boolean): string;
|
|
24
|
+
export interface GatewayRequest {
|
|
25
|
+
method: "GET" | "POST" | "DELETE";
|
|
26
|
+
path: string;
|
|
27
|
+
headers?: Record<string, string>;
|
|
28
|
+
body?: unknown;
|
|
29
|
+
}
|
|
30
|
+
export interface GatewayResponse<T> {
|
|
31
|
+
status: number;
|
|
32
|
+
body: T;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Sends one request and returns the parsed JSON of a 2xx answer. Any other answer becomes a typed
|
|
36
|
+
* {@link DeweeIdentityError}. Request headers and bodies are never put in an error.
|
|
37
|
+
*/
|
|
38
|
+
export declare function gatewayRequest<T>(opts: GatewayOptions, req: GatewayRequest): Promise<GatewayResponse<T>>;
|
|
39
|
+
/** Reads a required string field of a gateway answer. */
|
|
40
|
+
export declare function requireString(body: Record<string, unknown>, field: string, path: string): string;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { DeweeIdentityError, errorFromResponse, invalidArgument } from "./errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* Refuses to run in a browser. The SDK signs with partner secrets and handles session tokens; both
|
|
4
|
+
* belong on the partner's server, never in a page.
|
|
5
|
+
*/
|
|
6
|
+
export function assertServer() {
|
|
7
|
+
if (typeof globalThis.window !== "undefined") {
|
|
8
|
+
throw new DeweeIdentityError({
|
|
9
|
+
code: "SERVER_ONLY",
|
|
10
|
+
message: "@dewee/identity runs on your server only: it holds application secrets. Call your own backend from the browser instead.",
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
|
15
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
16
|
+
/** Validates a gateway or connect base URL and returns it as origin + path, without a trailing slash. */
|
|
17
|
+
export function normalizeBaseUrl(raw, name, allowInsecureHttp = false) {
|
|
18
|
+
if (typeof raw !== "string" || raw.trim() === "") {
|
|
19
|
+
throw invalidArgument(`${name} is required`);
|
|
20
|
+
}
|
|
21
|
+
let url;
|
|
22
|
+
try {
|
|
23
|
+
url = new URL(raw.trim());
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw invalidArgument(`${name} must be an absolute URL`);
|
|
27
|
+
}
|
|
28
|
+
if (url.username !== "" || url.password !== "") {
|
|
29
|
+
throw invalidArgument(`${name} must not carry credentials`);
|
|
30
|
+
}
|
|
31
|
+
if (url.search !== "" || url.hash !== "") {
|
|
32
|
+
throw invalidArgument(`${name} must not carry a query or fragment`);
|
|
33
|
+
}
|
|
34
|
+
const loopback = LOOPBACK_HOSTS.has(url.hostname);
|
|
35
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && (loopback || allowInsecureHttp))) {
|
|
36
|
+
throw invalidArgument(`${name} must be https (http only for localhost, or with allowInsecureHttp)`);
|
|
37
|
+
}
|
|
38
|
+
return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Sends one request and returns the parsed JSON of a 2xx answer. Any other answer becomes a typed
|
|
42
|
+
* {@link DeweeIdentityError}. Request headers and bodies are never put in an error.
|
|
43
|
+
*/
|
|
44
|
+
export async function gatewayRequest(opts, req) {
|
|
45
|
+
const base = normalizeBaseUrl(opts.gatewayUrl, "gatewayUrl", opts.allowInsecureHttp);
|
|
46
|
+
const url = `${base}${req.path}`;
|
|
47
|
+
const doFetch = opts.fetch ?? globalThis.fetch;
|
|
48
|
+
if (typeof doFetch !== "function") {
|
|
49
|
+
throw invalidArgument("no fetch implementation is available; pass options.fetch");
|
|
50
|
+
}
|
|
51
|
+
const timeout = AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
52
|
+
const signal = opts.signal ? AbortSignal.any([opts.signal, timeout]) : timeout;
|
|
53
|
+
const headers = { Accept: "application/json", ...req.headers };
|
|
54
|
+
let payload;
|
|
55
|
+
if (req.body !== undefined) {
|
|
56
|
+
headers["Content-Type"] = "application/json";
|
|
57
|
+
payload = JSON.stringify(req.body);
|
|
58
|
+
}
|
|
59
|
+
let res;
|
|
60
|
+
try {
|
|
61
|
+
res = await doFetch(url, { method: req.method, headers, body: payload, signal, redirect: "error" });
|
|
62
|
+
}
|
|
63
|
+
catch (cause) {
|
|
64
|
+
const name = cause instanceof Error ? cause.name : "Error";
|
|
65
|
+
throw new DeweeIdentityError({
|
|
66
|
+
code: "TRANSPORT_ERROR",
|
|
67
|
+
message: `TRANSPORT_ERROR: ${req.method} ${req.path} failed (${name})`,
|
|
68
|
+
cause,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
const text = await res.text().catch(() => "");
|
|
72
|
+
let body;
|
|
73
|
+
if (text !== "") {
|
|
74
|
+
try {
|
|
75
|
+
body = JSON.parse(text);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
body = undefined;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (!res.ok) {
|
|
82
|
+
throw errorFromResponse(res.status, body, res.headers.get("Retry-After"));
|
|
83
|
+
}
|
|
84
|
+
if (body === undefined || body === null || typeof body !== "object") {
|
|
85
|
+
throw new DeweeIdentityError({
|
|
86
|
+
code: "INVALID_RESPONSE",
|
|
87
|
+
status: res.status,
|
|
88
|
+
message: `INVALID_RESPONSE: ${req.method} ${req.path} answered ${res.status} without a JSON object`,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return { status: res.status, body: body };
|
|
92
|
+
}
|
|
93
|
+
/** Reads a required string field of a gateway answer. */
|
|
94
|
+
export function requireString(body, field, path) {
|
|
95
|
+
const v = body[field];
|
|
96
|
+
if (typeof v !== "string" || v === "") {
|
|
97
|
+
throw new DeweeIdentityError({
|
|
98
|
+
code: "INVALID_RESPONSE",
|
|
99
|
+
message: `INVALID_RESPONSE: ${path} answer has no ${field}`,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return v;
|
|
103
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** JWS `typ` of the trusted user token Dewee attaches to MCP calls. */
|
|
2
|
+
export declare const TRUSTED_USER_TOKEN_TYPE = "dewee-tuc+jwt";
|
|
3
|
+
/** Longest token lifetime (exp - iat) Dewee issues. */
|
|
4
|
+
export declare const TRUSTED_USER_TOKEN_TTL_SECONDS = 60;
|
|
5
|
+
/** How far ahead of this server's clock a token's iat may lie. */
|
|
6
|
+
export declare const TRUSTED_USER_CLOCK_SKEW_SECONDS = 5;
|
|
7
|
+
/** Issuer prefix of a gateway without a public URL: `urn:dewee:tenant:<tenant id>`. */
|
|
8
|
+
export declare const TRUSTED_USER_ISSUER_PREFIX = "urn:dewee:tenant:";
|
|
9
|
+
/** The verified user of one MCP call. */
|
|
10
|
+
export interface TrustedUser {
|
|
11
|
+
/** Your stable user id (the `sub` your assertions carried). */
|
|
12
|
+
subject: string;
|
|
13
|
+
principalId: string;
|
|
14
|
+
tenantId: string;
|
|
15
|
+
applicationId: string;
|
|
16
|
+
grantVersion: number;
|
|
17
|
+
authnLevel: string;
|
|
18
|
+
verificationMethod: string;
|
|
19
|
+
scopes: string[];
|
|
20
|
+
issuer: string;
|
|
21
|
+
/** The MCP server id the token was minted for. */
|
|
22
|
+
audience: string;
|
|
23
|
+
issuedAt: Date;
|
|
24
|
+
expiresAt: Date;
|
|
25
|
+
jti: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Remembers accepted token ids until they expire, so a captured token cannot be replayed. Use a
|
|
29
|
+
* shared store (Redis `SET NX EX`, a database row) when your MCP server runs more than one
|
|
30
|
+
* process.
|
|
31
|
+
*/
|
|
32
|
+
export interface ReplayGuard {
|
|
33
|
+
/** Records `jti` until `expiresAt` (unix seconds). Returns false when it was already recorded. */
|
|
34
|
+
claim(jti: string, expiresAt: number, now: number): boolean | Promise<boolean>;
|
|
35
|
+
}
|
|
36
|
+
/** In-process {@link ReplayGuard}. Refuses (fails closed) when full of unexpired ids. */
|
|
37
|
+
export declare class MemoryReplayGuard implements ReplayGuard {
|
|
38
|
+
private readonly maxEntries;
|
|
39
|
+
private readonly seen;
|
|
40
|
+
constructor(maxEntries?: number);
|
|
41
|
+
claim(jti: string, expiresAt: number, now: number): boolean;
|
|
42
|
+
get size(): number;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* What your MCP server expects of a token. Every field is required: a check that leaves the issuer,
|
|
46
|
+
* tenant or application out is refused rather than letting that claim go unchecked.
|
|
47
|
+
*/
|
|
48
|
+
export interface VerifyTrustedUserOptions {
|
|
49
|
+
/** The application's MCP context secret (shown once by `dewee identity setup`). */
|
|
50
|
+
mcpContextSecret: string;
|
|
51
|
+
/** The id of this MCP server in Dewee; tokens for any other server are refused. */
|
|
52
|
+
serverId: string;
|
|
53
|
+
/** The gateway's issuer: its public URL without a trailing slash, or `urn:dewee:tenant:<tenant id>`. */
|
|
54
|
+
issuer: string;
|
|
55
|
+
/** The tenant your application belongs to. */
|
|
56
|
+
tenantId: string;
|
|
57
|
+
/** Your Dewee application id. */
|
|
58
|
+
applicationId: string;
|
|
59
|
+
/** Replay protection; defaults to one in-process guard shared by every call. */
|
|
60
|
+
replayGuard?: ReplayGuard;
|
|
61
|
+
/** Clock override, for tests. */
|
|
62
|
+
now?: Date;
|
|
63
|
+
}
|
|
64
|
+
/** Where a trusted user token travels: `params._meta.dewee.trusted_user`. */
|
|
65
|
+
export declare function extractTrustedUserToken(meta: unknown): string | undefined;
|
|
66
|
+
/**
|
|
67
|
+
* Verifies the trusted user token of one MCP call, as `identity.VerifyTrustedUserToken` does in the
|
|
68
|
+
* gateway: HS256 only, `typ` `dewee-tuc+jwt`, the signature, `aud` = this server, the pinned `iss`,
|
|
69
|
+
* `tenant_id` and `application_id`, an `iat` at most 5 s ahead, a lifetime of at most 60 s that has
|
|
70
|
+
* not ended, a subject and principal, and a `jti` accepted only once.
|
|
71
|
+
*
|
|
72
|
+
* Accepts `params._meta`, the whole `params`, or the token itself. Any refusal is a
|
|
73
|
+
* {@link DeweeIdentityError} with code `IDENTITY_REQUIRED` and a bounded `reason`; answer the call
|
|
74
|
+
* with an error and never fall back to an argument or header naming a user.
|
|
75
|
+
*/
|
|
76
|
+
export declare function verifyTrustedUser(meta: unknown, opts: VerifyTrustedUserOptions): Promise<TrustedUser>;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { compactVerify, decodeProtectedHeader } from "jose";
|
|
2
|
+
import { MIN_SECRET_LENGTH, requireUuid } from "./assertion.js";
|
|
3
|
+
import { DeweeIdentityError, invalidArgument } from "./errors.js";
|
|
4
|
+
import { assertServer } from "./transport.js";
|
|
5
|
+
/** JWS `typ` of the trusted user token Dewee attaches to MCP calls. */
|
|
6
|
+
export const TRUSTED_USER_TOKEN_TYPE = "dewee-tuc+jwt";
|
|
7
|
+
/** Longest token lifetime (exp - iat) Dewee issues. */
|
|
8
|
+
export const TRUSTED_USER_TOKEN_TTL_SECONDS = 60;
|
|
9
|
+
/** How far ahead of this server's clock a token's iat may lie. */
|
|
10
|
+
export const TRUSTED_USER_CLOCK_SKEW_SECONDS = 5;
|
|
11
|
+
/** Issuer prefix of a gateway without a public URL: `urn:dewee:tenant:<tenant id>`. */
|
|
12
|
+
export const TRUSTED_USER_ISSUER_PREFIX = "urn:dewee:tenant:";
|
|
13
|
+
const MAX_TOKEN_LENGTH = 8 * 1024;
|
|
14
|
+
/** In-process {@link ReplayGuard}. Refuses (fails closed) when full of unexpired ids. */
|
|
15
|
+
export class MemoryReplayGuard {
|
|
16
|
+
maxEntries;
|
|
17
|
+
seen = new Map();
|
|
18
|
+
constructor(maxEntries = 100_000) {
|
|
19
|
+
this.maxEntries = maxEntries;
|
|
20
|
+
if (!Number.isInteger(maxEntries) || maxEntries < 1)
|
|
21
|
+
throw invalidArgument("maxEntries must be a positive integer");
|
|
22
|
+
}
|
|
23
|
+
claim(jti, expiresAt, now) {
|
|
24
|
+
const until = this.seen.get(jti);
|
|
25
|
+
if (until !== undefined && until > now)
|
|
26
|
+
return false;
|
|
27
|
+
if (this.seen.size >= this.maxEntries) {
|
|
28
|
+
for (const [id, exp] of this.seen) {
|
|
29
|
+
if (exp <= now)
|
|
30
|
+
this.seen.delete(id);
|
|
31
|
+
}
|
|
32
|
+
if (this.seen.size >= this.maxEntries)
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
this.seen.set(jti, expiresAt);
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
get size() {
|
|
39
|
+
return this.seen.size;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const defaultReplayGuard = new MemoryReplayGuard();
|
|
43
|
+
/** Where a trusted user token travels: `params._meta.dewee.trusted_user`. */
|
|
44
|
+
export function extractTrustedUserToken(meta) {
|
|
45
|
+
if (typeof meta === "string")
|
|
46
|
+
return meta;
|
|
47
|
+
if (meta === null || typeof meta !== "object")
|
|
48
|
+
return undefined;
|
|
49
|
+
const m = meta;
|
|
50
|
+
const inner = m._meta !== null && typeof m._meta === "object" ? m._meta : m;
|
|
51
|
+
const dewee = inner.dewee;
|
|
52
|
+
if (dewee === null || typeof dewee !== "object")
|
|
53
|
+
return undefined;
|
|
54
|
+
const token = dewee.trusted_user;
|
|
55
|
+
return typeof token === "string" ? token : undefined;
|
|
56
|
+
}
|
|
57
|
+
function refuse(reason) {
|
|
58
|
+
return new DeweeIdentityError({
|
|
59
|
+
code: "IDENTITY_REQUIRED",
|
|
60
|
+
status: 401,
|
|
61
|
+
reason,
|
|
62
|
+
message: `IDENTITY_REQUIRED: trusted user token refused (${reason})`,
|
|
63
|
+
remediation: { action: "send_connect_link", audience: "end_user" },
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
function nonEmpty(value, name) {
|
|
67
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
68
|
+
throw invalidArgument(`${name} is required: a trusted user check must name the server, issuer, tenant and application`);
|
|
69
|
+
}
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Verifies the trusted user token of one MCP call, as `identity.VerifyTrustedUserToken` does in the
|
|
74
|
+
* gateway: HS256 only, `typ` `dewee-tuc+jwt`, the signature, `aud` = this server, the pinned `iss`,
|
|
75
|
+
* `tenant_id` and `application_id`, an `iat` at most 5 s ahead, a lifetime of at most 60 s that has
|
|
76
|
+
* not ended, a subject and principal, and a `jti` accepted only once.
|
|
77
|
+
*
|
|
78
|
+
* Accepts `params._meta`, the whole `params`, or the token itself. Any refusal is a
|
|
79
|
+
* {@link DeweeIdentityError} with code `IDENTITY_REQUIRED` and a bounded `reason`; answer the call
|
|
80
|
+
* with an error and never fall back to an argument or header naming a user.
|
|
81
|
+
*/
|
|
82
|
+
export async function verifyTrustedUser(meta, opts) {
|
|
83
|
+
assertServer();
|
|
84
|
+
if (opts === null || typeof opts !== "object")
|
|
85
|
+
throw invalidArgument("options are required");
|
|
86
|
+
const secret = nonEmpty(opts.mcpContextSecret, "mcpContextSecret");
|
|
87
|
+
const key = new TextEncoder().encode(secret);
|
|
88
|
+
if (key.length < MIN_SECRET_LENGTH) {
|
|
89
|
+
throw invalidArgument(`mcpContextSecret must be at least ${MIN_SECRET_LENGTH} bytes`);
|
|
90
|
+
}
|
|
91
|
+
const serverId = nonEmpty(opts.serverId, "serverId");
|
|
92
|
+
const issuer = nonEmpty(opts.issuer, "issuer");
|
|
93
|
+
const tenantId = requireUuid(nonEmpty(opts.tenantId, "tenantId"), "tenantId");
|
|
94
|
+
const applicationId = requireUuid(nonEmpty(opts.applicationId, "applicationId"), "applicationId");
|
|
95
|
+
const guard = opts.replayGuard ?? defaultReplayGuard;
|
|
96
|
+
const token = extractTrustedUserToken(meta);
|
|
97
|
+
if (token === undefined || token === "")
|
|
98
|
+
throw refuse("missing");
|
|
99
|
+
if (token.length > MAX_TOKEN_LENGTH || token.split(".").length !== 3)
|
|
100
|
+
throw refuse("malformed");
|
|
101
|
+
let header;
|
|
102
|
+
try {
|
|
103
|
+
header = decodeProtectedHeader(token);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
throw refuse("malformed");
|
|
107
|
+
}
|
|
108
|
+
if (header.alg !== "HS256")
|
|
109
|
+
throw refuse("algorithm");
|
|
110
|
+
if (header.typ !== TRUSTED_USER_TOKEN_TYPE)
|
|
111
|
+
throw refuse("type");
|
|
112
|
+
let payload;
|
|
113
|
+
try {
|
|
114
|
+
({ payload } = await compactVerify(token, key, { algorithms: ["HS256"] }));
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
throw refuse("signature");
|
|
118
|
+
}
|
|
119
|
+
let c;
|
|
120
|
+
try {
|
|
121
|
+
const parsed = JSON.parse(new TextDecoder().decode(payload));
|
|
122
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
123
|
+
throw new Error("not an object");
|
|
124
|
+
c = parsed;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
throw refuse("malformed");
|
|
128
|
+
}
|
|
129
|
+
const str = (k) => (typeof c[k] === "string" ? c[k] : "");
|
|
130
|
+
const int = (k) => (Number.isSafeInteger(c[k]) ? c[k] : undefined);
|
|
131
|
+
const iat = int("iat");
|
|
132
|
+
const exp = int("exp");
|
|
133
|
+
if (iat === undefined || exp === undefined)
|
|
134
|
+
throw refuse("malformed");
|
|
135
|
+
const now = Math.floor((opts.now ?? new Date()).getTime() / 1000);
|
|
136
|
+
if (str("aud") === "" || str("aud") !== serverId)
|
|
137
|
+
throw refuse("audience");
|
|
138
|
+
if (str("iss") !== issuer)
|
|
139
|
+
throw refuse("issuer");
|
|
140
|
+
if (str("tenant_id") !== tenantId)
|
|
141
|
+
throw refuse("tenant");
|
|
142
|
+
if (str("application_id") !== applicationId)
|
|
143
|
+
throw refuse("application");
|
|
144
|
+
if (iat > now + TRUSTED_USER_CLOCK_SKEW_SECONDS)
|
|
145
|
+
throw refuse("not_yet_valid");
|
|
146
|
+
if (exp <= iat || exp - iat > TRUSTED_USER_TOKEN_TTL_SECONDS)
|
|
147
|
+
throw refuse("lifetime");
|
|
148
|
+
if (now >= exp)
|
|
149
|
+
throw refuse("expired");
|
|
150
|
+
if (str("sub").trim() === "" || str("principal_id") === "")
|
|
151
|
+
throw refuse("subject");
|
|
152
|
+
const jti = str("jti");
|
|
153
|
+
if (jti === "")
|
|
154
|
+
throw refuse("jti");
|
|
155
|
+
if (!(await guard.claim(`${applicationId}:${jti}`, exp, now)))
|
|
156
|
+
throw refuse("replay");
|
|
157
|
+
const scopes = Array.isArray(c.scopes) ? c.scopes.filter((s) => typeof s === "string") : [];
|
|
158
|
+
return {
|
|
159
|
+
subject: str("sub"),
|
|
160
|
+
principalId: str("principal_id"),
|
|
161
|
+
tenantId,
|
|
162
|
+
applicationId,
|
|
163
|
+
grantVersion: int("grant_version") ?? 0,
|
|
164
|
+
authnLevel: str("authn_level"),
|
|
165
|
+
verificationMethod: str("verification_method"),
|
|
166
|
+
scopes,
|
|
167
|
+
issuer,
|
|
168
|
+
audience: serverId,
|
|
169
|
+
issuedAt: new Date(iat * 1000),
|
|
170
|
+
expiresAt: new Date(exp * 1000),
|
|
171
|
+
jti,
|
|
172
|
+
};
|
|
173
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,55 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dewee/identity",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-beta.1",
|
|
4
|
+
"description": "Server SDK for Dewee Connect: partner assertions, identity session exchange and trusted user verification for MCP servers.",
|
|
5
|
+
"license": "CC-BY-NC-4.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/nextlevelbuilder/dewee.git",
|
|
9
|
+
"directory": "packages/dewee-identity"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"sideEffects": false,
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20.3"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
"./browser": {
|
|
18
|
+
"types": "./dist/browser.d.ts",
|
|
19
|
+
"import": "./dist/browser.js"
|
|
20
|
+
},
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"import": "./dist/index.js"
|
|
24
|
+
},
|
|
25
|
+
"./package.json": "./package.json"
|
|
26
|
+
},
|
|
27
|
+
"main": "./dist/index.js",
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"README.md"
|
|
32
|
+
],
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public",
|
|
35
|
+
"tag": "beta"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "node scripts/clean-dist.mjs && tsc -p tsconfig.build.json",
|
|
39
|
+
"typecheck": "tsc --noEmit",
|
|
40
|
+
"lint": "pnpm run build && publint --strict",
|
|
41
|
+
"test": "vitest run",
|
|
42
|
+
"test:integration": "vitest run --config vitest.integration.config.ts",
|
|
43
|
+
"fixtures:generate": "pnpm run build && node scripts/generate-fixtures.mjs",
|
|
44
|
+
"prepack": "pnpm run build"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"jose": "^6.2.13"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^24.13.3",
|
|
51
|
+
"publint": "^0.3.25",
|
|
52
|
+
"typescript": "^6.0.3",
|
|
53
|
+
"vitest": "^4.1.9"
|
|
54
|
+
}
|
|
55
|
+
}
|