driggsby 0.0.1 → 0.1.2
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/LICENSE +8 -0
- package/README.md +65 -0
- package/dist/auth/browser.js +31 -0
- package/dist/auth/config.js +23 -0
- package/dist/auth/discovery.js +42 -0
- package/dist/auth/dpop.js +44 -0
- package/dist/auth/login.js +125 -0
- package/dist/auth/loopback.js +157 -0
- package/dist/auth/oauth.js +113 -0
- package/dist/auth/pkce.js +12 -0
- package/dist/auth/url-security.js +16 -0
- package/dist/broker/authentication.js +49 -0
- package/dist/broker/client.js +148 -0
- package/dist/broker/daemon.js +64 -0
- package/dist/broker/installation.js +142 -0
- package/dist/broker/ipc.js +12 -0
- package/dist/broker/launch.js +34 -0
- package/dist/broker/lock.js +84 -0
- package/dist/broker/remote-mcp.js +121 -0
- package/dist/broker/remote-session.js +103 -0
- package/dist/broker/secret-store.js +30 -0
- package/dist/broker/server.js +177 -0
- package/dist/broker/session.js +31 -0
- package/dist/broker/test-support.js +258 -0
- package/dist/broker/types.js +1 -0
- package/dist/cli/commands/broker-daemon.js +4 -0
- package/dist/cli/commands/login.js +31 -0
- package/dist/cli/commands/logout.js +16 -0
- package/dist/cli/commands/status.js +14 -0
- package/dist/cli/format.js +23 -0
- package/dist/index.js +39 -0
- package/dist/lib/json-file.js +36 -0
- package/dist/lib/runtime-paths.js +62 -0
- package/dist/shim/server.js +103 -0
- package/package.json +57 -3
- package/pyproject.toml +0 -11
- package/src/driggsby/__init__.py +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
UNLICENSED
|
|
2
|
+
|
|
3
|
+
Copyright (c) The Good Software Company.
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
This package and its source are proprietary. No license rights are granted to
|
|
7
|
+
use, copy, modify, or distribute this software except as otherwise agreed in
|
|
8
|
+
writing by The Good Software Company.
|
package/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# driggsby
|
|
2
|
+
|
|
3
|
+
`driggsby` installs the local Driggsby CLI and MCP broker.
|
|
4
|
+
|
|
5
|
+
It gives local AI clients a single shared path into Driggsby:
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
Codex / Claude Code / other local MCP client
|
|
9
|
+
|
|
|
10
|
+
v
|
|
11
|
+
driggsby mcp-server
|
|
12
|
+
|
|
|
13
|
+
v
|
|
14
|
+
shared local broker
|
|
15
|
+
|
|
|
16
|
+
v
|
|
17
|
+
remote Driggsby MCP
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install -g driggsby
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Node `20+` is required.
|
|
27
|
+
|
|
28
|
+
## Commands
|
|
29
|
+
|
|
30
|
+
```text
|
|
31
|
+
driggsby login
|
|
32
|
+
driggsby status
|
|
33
|
+
driggsby mcp-server
|
|
34
|
+
driggsby logout
|
|
35
|
+
driggsby broker-daemon
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Typical local setup:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
driggsby login
|
|
42
|
+
codex mcp add driggsby -- driggsby mcp-server
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
You can also use `npx -y driggsby ...` if you do not want a global install.
|
|
46
|
+
|
|
47
|
+
## What It Does
|
|
48
|
+
|
|
49
|
+
- opens a browser-based Driggsby sign-in flow
|
|
50
|
+
- keeps one local broker session on the machine
|
|
51
|
+
- exposes a local stdio MCP server for local AI clients
|
|
52
|
+
- forwards supported Driggsby MCP tools through the broker
|
|
53
|
+
|
|
54
|
+
## Security
|
|
55
|
+
|
|
56
|
+
- publishes from GitHub Actions using npm trusted publishing and OIDC
|
|
57
|
+
- does not require a long-lived npm publish token for releases
|
|
58
|
+
- keeps local broker session material in the platform secret store
|
|
59
|
+
|
|
60
|
+
## License
|
|
61
|
+
|
|
62
|
+
This package is currently published as `UNLICENSED`.
|
|
63
|
+
|
|
64
|
+
No license rights are granted beyond what The Good Software Company agrees to in
|
|
65
|
+
writing.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
export async function openBrowserUrl(url) {
|
|
3
|
+
const command = browserCommandForPlatform(process.platform, url);
|
|
4
|
+
if (command === null) {
|
|
5
|
+
return false;
|
|
6
|
+
}
|
|
7
|
+
return await new Promise((resolve) => {
|
|
8
|
+
const child = spawn(command.file, command.args, {
|
|
9
|
+
detached: true,
|
|
10
|
+
stdio: "ignore",
|
|
11
|
+
windowsHide: true,
|
|
12
|
+
});
|
|
13
|
+
child.once("error", () => {
|
|
14
|
+
resolve(false);
|
|
15
|
+
});
|
|
16
|
+
child.once("spawn", () => {
|
|
17
|
+
child.unref();
|
|
18
|
+
resolve(true);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
function browserCommandForPlatform(platform, url) {
|
|
23
|
+
switch (platform) {
|
|
24
|
+
case "darwin":
|
|
25
|
+
return { file: "open", args: [url] };
|
|
26
|
+
case "win32":
|
|
27
|
+
return { file: "cmd", args: ["/c", "start", "", url] };
|
|
28
|
+
default:
|
|
29
|
+
return { file: "xdg-open", args: [url] };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { assertBrokerRemoteUrl } from "./url-security.js";
|
|
2
|
+
const DEFAULT_REMOTE_BASE_URL = "https://app.driggsby.com";
|
|
3
|
+
const DEFAULT_SCOPE = "driggsby.read";
|
|
4
|
+
const DEFAULT_LOGIN_TIMEOUT_MS = 5 * 60 * 1_000;
|
|
5
|
+
const DEFAULT_CLIENT_NAME = "Driggsby Local Broker";
|
|
6
|
+
export function resolveBrokerAuthConfig(env = process.env) {
|
|
7
|
+
const remoteBaseUrl = normalizeBaseUrl(env["DRIGGSBY_REMOTE_BASE_URL"] ?? DEFAULT_REMOTE_BASE_URL);
|
|
8
|
+
return {
|
|
9
|
+
clientName: DEFAULT_CLIENT_NAME,
|
|
10
|
+
loginTimeoutMs: DEFAULT_LOGIN_TIMEOUT_MS,
|
|
11
|
+
protectedResourceMetadataUrl: new URL("/.well-known/oauth-protected-resource", remoteBaseUrl).toString(),
|
|
12
|
+
requestedScope: DEFAULT_SCOPE,
|
|
13
|
+
remoteBaseUrl,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function normalizeBaseUrl(rawValue) {
|
|
17
|
+
const parsed = new URL(rawValue);
|
|
18
|
+
assertBrokerRemoteUrl(parsed.toString(), "The Driggsby remote base URL");
|
|
19
|
+
parsed.pathname = "/";
|
|
20
|
+
parsed.search = "";
|
|
21
|
+
parsed.hash = "";
|
|
22
|
+
return parsed.toString().replace(/\/$/, "");
|
|
23
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const protectedResourceMetadataSchema = z.object({
|
|
3
|
+
authorization_server: z.url(),
|
|
4
|
+
authorization_servers: z.array(z.url()),
|
|
5
|
+
resource: z.url(),
|
|
6
|
+
scopes_supported: z.array(z.string().min(1)),
|
|
7
|
+
});
|
|
8
|
+
const authorizationServerMetadataSchema = z.object({
|
|
9
|
+
authorization_endpoint: z.url(),
|
|
10
|
+
code_challenge_methods_supported: z.array(z.string().min(1)),
|
|
11
|
+
dpop_signing_alg_values_supported: z.array(z.string().min(1)),
|
|
12
|
+
grant_types_supported: z.array(z.string().min(1)),
|
|
13
|
+
issuer: z.url(),
|
|
14
|
+
registration_endpoint: z.url(),
|
|
15
|
+
response_types_supported: z.array(z.string().min(1)),
|
|
16
|
+
scopes_supported: z.array(z.string().min(1)),
|
|
17
|
+
token_endpoint: z.url(),
|
|
18
|
+
token_endpoint_auth_methods_supported: z.array(z.string().min(1)),
|
|
19
|
+
});
|
|
20
|
+
export async function fetchProtectedResourceMetadata(url, fetchImpl = fetch) {
|
|
21
|
+
const response = await fetchImpl(url, {
|
|
22
|
+
headers: {
|
|
23
|
+
accept: "application/json",
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
throw new Error("Driggsby sign-in could not load the protected-resource metadata.");
|
|
28
|
+
}
|
|
29
|
+
return protectedResourceMetadataSchema.parse(await response.json());
|
|
30
|
+
}
|
|
31
|
+
export async function fetchAuthorizationServerMetadata(issuer, fetchImpl = fetch) {
|
|
32
|
+
const url = new URL("/.well-known/oauth-authorization-server", issuer).toString();
|
|
33
|
+
const response = await fetchImpl(url, {
|
|
34
|
+
headers: {
|
|
35
|
+
accept: "application/json",
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
if (!response.ok) {
|
|
39
|
+
throw new Error("Driggsby sign-in could not load the authorization-server metadata.");
|
|
40
|
+
}
|
|
41
|
+
return authorizationServerMetadataSchema.parse(await response.json());
|
|
42
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { calculateJwkThumbprint, exportJWK, generateKeyPair, importJWK, SignJWT, } from "jose";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
export async function generateDpopKeyMaterial() {
|
|
4
|
+
const { privateKey, publicKey } = await generateKeyPair("ES256", {
|
|
5
|
+
extractable: true,
|
|
6
|
+
});
|
|
7
|
+
const privateJwk = await exportJWK(privateKey);
|
|
8
|
+
const publicJwk = await exportJWK(publicKey);
|
|
9
|
+
const thumbprint = await calculateJwkThumbprint(publicJwk, "sha256");
|
|
10
|
+
return {
|
|
11
|
+
algorithm: "ES256",
|
|
12
|
+
createdAt: new Date().toISOString(),
|
|
13
|
+
privateJwk,
|
|
14
|
+
publicJwk,
|
|
15
|
+
thumbprint,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export async function createDpopProof(options) {
|
|
19
|
+
const signingKey = await importJWK(options.privateJwk, "ES256");
|
|
20
|
+
const payload = {
|
|
21
|
+
htm: options.httpMethod.toUpperCase(),
|
|
22
|
+
htu: normalizeDpopTargetUrl(options.targetUrl),
|
|
23
|
+
};
|
|
24
|
+
if (options.accessToken !== undefined) {
|
|
25
|
+
payload["ath"] = createHash("sha256")
|
|
26
|
+
.update(options.accessToken)
|
|
27
|
+
.digest("base64url");
|
|
28
|
+
}
|
|
29
|
+
return await new SignJWT(payload)
|
|
30
|
+
.setProtectedHeader({
|
|
31
|
+
alg: "ES256",
|
|
32
|
+
typ: "dpop+jwt",
|
|
33
|
+
jwk: options.publicJwk,
|
|
34
|
+
})
|
|
35
|
+
.setIssuedAt()
|
|
36
|
+
.setJti(randomUUID())
|
|
37
|
+
.sign(signingKey);
|
|
38
|
+
}
|
|
39
|
+
export function normalizeDpopTargetUrl(targetUrl) {
|
|
40
|
+
const url = new URL(targetUrl);
|
|
41
|
+
url.search = "";
|
|
42
|
+
url.hash = "";
|
|
43
|
+
return url.toString();
|
|
44
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { writeBrokerRemoteSession, } from "../broker/session.js";
|
|
2
|
+
import { ensureBrokerInstallation, readBrokerDpopKeyPair, } from "../broker/installation.js";
|
|
3
|
+
import { openBrowserUrl } from "./browser.js";
|
|
4
|
+
import { resolveBrokerAuthConfig, } from "./config.js";
|
|
5
|
+
import { fetchAuthorizationServerMetadata, fetchProtectedResourceMetadata, } from "./discovery.js";
|
|
6
|
+
import { createDpopProof } from "./dpop.js";
|
|
7
|
+
import { startLoopbackAuthListener } from "./loopback.js";
|
|
8
|
+
import { buildAuthorizationUrl, createOAuthState, exchangeAuthorizationCode, registerBrokerClient, } from "./oauth.js";
|
|
9
|
+
import { generatePkcePair } from "./pkce.js";
|
|
10
|
+
import { assertBrokerRemoteUrl } from "./url-security.js";
|
|
11
|
+
export async function loginBroker(runtimePaths, dependencies) {
|
|
12
|
+
const config = dependencies.config ?? resolveBrokerAuthConfig();
|
|
13
|
+
const fetchImpl = dependencies.fetchImpl ?? fetch;
|
|
14
|
+
const openUrl = dependencies.openUrl ?? openBrowserUrl;
|
|
15
|
+
const metadata = await ensureBrokerInstallation(runtimePaths, dependencies.secretStore);
|
|
16
|
+
const protectedResourceMetadata = await fetchProtectedResourceMetadata(config.protectedResourceMetadataUrl, fetchImpl);
|
|
17
|
+
const authorizationServerMetadata = await fetchAuthorizationServerMetadata(protectedResourceMetadata.authorization_server, fetchImpl);
|
|
18
|
+
validateRemoteMetadata(authorizationServerMetadata, protectedResourceMetadata);
|
|
19
|
+
const listener = await startLoopbackAuthListener(config.loginTimeoutMs);
|
|
20
|
+
try {
|
|
21
|
+
const registration = await registerBrokerClient({
|
|
22
|
+
clientName: config.clientName,
|
|
23
|
+
dpopBoundAccessTokens: true,
|
|
24
|
+
metadata: authorizationServerMetadata,
|
|
25
|
+
redirectUri: listener.redirectUri,
|
|
26
|
+
requestedScope: config.requestedScope,
|
|
27
|
+
}, fetchImpl);
|
|
28
|
+
const pkce = generatePkcePair();
|
|
29
|
+
const state = createOAuthState();
|
|
30
|
+
const signInUrl = buildAuthorizationUrl({
|
|
31
|
+
clientId: registration.clientId,
|
|
32
|
+
metadata: authorizationServerMetadata,
|
|
33
|
+
pkceChallenge: pkce.challenge,
|
|
34
|
+
redirectUri: listener.redirectUri,
|
|
35
|
+
requestedScope: config.requestedScope,
|
|
36
|
+
resource: protectedResourceMetadata,
|
|
37
|
+
state,
|
|
38
|
+
});
|
|
39
|
+
const browserOpened = await openUrl(signInUrl);
|
|
40
|
+
dependencies.onBrowserPrompt?.({
|
|
41
|
+
browserOpened,
|
|
42
|
+
signInUrl,
|
|
43
|
+
});
|
|
44
|
+
const callback = await listener.waitForResult();
|
|
45
|
+
if (!callback.ok) {
|
|
46
|
+
throw new Error(callback.value.errorDescription ??
|
|
47
|
+
"Driggsby sign-in was not completed.");
|
|
48
|
+
}
|
|
49
|
+
if (callback.value.state !== state) {
|
|
50
|
+
throw new Error("Driggsby sign-in returned an invalid state value. Try again.");
|
|
51
|
+
}
|
|
52
|
+
const tokenPair = await exchangeAuthorizationCode({
|
|
53
|
+
clientId: registration.clientId,
|
|
54
|
+
code: callback.value.code,
|
|
55
|
+
codeVerifier: pkce.verifier,
|
|
56
|
+
dpopProof: await tokenEndpointDpopProof(runtimePaths, dependencies.secretStore, metadata.brokerId, authorizationServerMetadata.token_endpoint),
|
|
57
|
+
metadata: authorizationServerMetadata,
|
|
58
|
+
redirectUri: listener.redirectUri,
|
|
59
|
+
resource: protectedResourceMetadata,
|
|
60
|
+
}, fetchImpl);
|
|
61
|
+
const session = {
|
|
62
|
+
schemaVersion: 1,
|
|
63
|
+
accessToken: tokenPair.accessToken,
|
|
64
|
+
accessTokenExpiresAt: tokenPair.accessTokenExpiresAt,
|
|
65
|
+
authenticatedAt: new Date().toISOString(),
|
|
66
|
+
clientId: registration.clientId,
|
|
67
|
+
issuer: authorizationServerMetadata.issuer,
|
|
68
|
+
redirectUri: listener.redirectUri,
|
|
69
|
+
refreshToken: tokenPair.refreshToken,
|
|
70
|
+
resource: protectedResourceMetadata.resource,
|
|
71
|
+
scope: tokenPair.scope,
|
|
72
|
+
tokenType: tokenPair.tokenType,
|
|
73
|
+
};
|
|
74
|
+
await writeBrokerRemoteSession(dependencies.secretStore, metadata.brokerId, session);
|
|
75
|
+
return {
|
|
76
|
+
browserOpened,
|
|
77
|
+
brokerId: metadata.brokerId,
|
|
78
|
+
dpopThumbprint: metadata.dpop.thumbprint,
|
|
79
|
+
session,
|
|
80
|
+
signInUrl,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
await listener.close();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function validateRemoteMetadata(authorizationServerMetadata, protectedResourceMetadata) {
|
|
88
|
+
assertBrokerRemoteUrl(protectedResourceMetadata.authorization_server, "The Driggsby authorization server URL");
|
|
89
|
+
assertBrokerRemoteUrl(protectedResourceMetadata.resource, "The Driggsby MCP resource URL");
|
|
90
|
+
assertBrokerRemoteUrl(authorizationServerMetadata.issuer, "The Driggsby issuer URL");
|
|
91
|
+
assertBrokerRemoteUrl(authorizationServerMetadata.authorization_endpoint, "The Driggsby authorization endpoint");
|
|
92
|
+
assertBrokerRemoteUrl(authorizationServerMetadata.registration_endpoint, "The Driggsby registration endpoint");
|
|
93
|
+
assertBrokerRemoteUrl(authorizationServerMetadata.token_endpoint, "The Driggsby token endpoint");
|
|
94
|
+
if (!authorizationServerMetadata.code_challenge_methods_supported.includes("S256")) {
|
|
95
|
+
throw new Error("Driggsby sign-in requires S256 PKCE support.");
|
|
96
|
+
}
|
|
97
|
+
if (!authorizationServerMetadata.dpop_signing_alg_values_supported.includes("ES256")) {
|
|
98
|
+
throw new Error("Driggsby sign-in requires ES256 DPoP support.");
|
|
99
|
+
}
|
|
100
|
+
if (!authorizationServerMetadata.grant_types_supported.includes("authorization_code")) {
|
|
101
|
+
throw new Error("Driggsby sign-in requires authorization code grant support.");
|
|
102
|
+
}
|
|
103
|
+
if (!authorizationServerMetadata.response_types_supported.includes("code")) {
|
|
104
|
+
throw new Error("Driggsby sign-in requires code response support.");
|
|
105
|
+
}
|
|
106
|
+
if (!authorizationServerMetadata.token_endpoint_auth_methods_supported.includes("none")) {
|
|
107
|
+
throw new Error("Driggsby sign-in requires public client token exchange support.");
|
|
108
|
+
}
|
|
109
|
+
if (!authorizationServerMetadata.scopes_supported.includes("driggsby.read") ||
|
|
110
|
+
!protectedResourceMetadata.scopes_supported.includes("driggsby.read")) {
|
|
111
|
+
throw new Error("Driggsby sign-in requires the driggsby.read scope to be available.");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async function tokenEndpointDpopProof(runtimePaths, secretStore, brokerId, tokenEndpoint) {
|
|
115
|
+
const dpopKeyPair = await readBrokerDpopKeyPair(runtimePaths, secretStore, brokerId);
|
|
116
|
+
if (dpopKeyPair === null) {
|
|
117
|
+
throw new Error("The local broker DPoP key is missing. Run `npx -y driggsby login` again.");
|
|
118
|
+
}
|
|
119
|
+
return await createDpopProof({
|
|
120
|
+
httpMethod: "POST",
|
|
121
|
+
privateJwk: dpopKeyPair.privateJwk,
|
|
122
|
+
publicJwk: dpopKeyPair.publicJwk,
|
|
123
|
+
targetUrl: tokenEndpoint,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
export async function startLoopbackAuthListener(timeoutMs) {
|
|
3
|
+
let settled = false;
|
|
4
|
+
let resolveResult = null;
|
|
5
|
+
let rejectResult = null;
|
|
6
|
+
const sockets = new Set();
|
|
7
|
+
const resultPromise = new Promise((resolve, reject) => {
|
|
8
|
+
resolveResult = resolve;
|
|
9
|
+
rejectResult = reject;
|
|
10
|
+
});
|
|
11
|
+
const server = http.createServer((request, response) => {
|
|
12
|
+
if (request.url === undefined) {
|
|
13
|
+
respondWithHtml(response, "Driggsby sign-in failed.", false);
|
|
14
|
+
settleError({
|
|
15
|
+
error: "invalid_request",
|
|
16
|
+
errorDescription: "The browser callback was missing a request URL.",
|
|
17
|
+
});
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const callbackUrl = new URL(request.url, "http://127.0.0.1");
|
|
21
|
+
const code = callbackUrl.searchParams.get("code");
|
|
22
|
+
const state = callbackUrl.searchParams.get("state");
|
|
23
|
+
const error = callbackUrl.searchParams.get("error");
|
|
24
|
+
const errorDescription = callbackUrl.searchParams.get("error_description");
|
|
25
|
+
if (error !== null) {
|
|
26
|
+
respondWithHtml(response, "Driggsby sign-in was not completed. You can close this tab.", false);
|
|
27
|
+
const authorizationError = {
|
|
28
|
+
error,
|
|
29
|
+
};
|
|
30
|
+
if (errorDescription !== null) {
|
|
31
|
+
Object.assign(authorizationError, {
|
|
32
|
+
errorDescription,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
if (state !== null) {
|
|
36
|
+
Object.assign(authorizationError, {
|
|
37
|
+
state,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
settleError(authorizationError);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (code === null || state === null) {
|
|
44
|
+
respondWithHtml(response, "Driggsby sign-in failed.", false);
|
|
45
|
+
settleError({
|
|
46
|
+
error: "invalid_request",
|
|
47
|
+
errorDescription: "The browser callback was missing the authorization code or state.",
|
|
48
|
+
});
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
respondWithHtml(response, "Driggsby is connected. You can return to your MCP client now.", true);
|
|
52
|
+
settleSuccess({
|
|
53
|
+
code,
|
|
54
|
+
state,
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
server.on("connection", (socket) => {
|
|
58
|
+
sockets.add(socket);
|
|
59
|
+
socket.on("close", () => {
|
|
60
|
+
sockets.delete(socket);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
await new Promise((resolve, reject) => {
|
|
64
|
+
server.once("error", reject);
|
|
65
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
66
|
+
});
|
|
67
|
+
const address = server.address();
|
|
68
|
+
if (address === null || typeof address === "string") {
|
|
69
|
+
await closeServer(server, sockets);
|
|
70
|
+
throw new Error("Driggsby could not start the local login callback listener.");
|
|
71
|
+
}
|
|
72
|
+
const timeoutHandle = setTimeout(() => {
|
|
73
|
+
void closeServer(server, sockets);
|
|
74
|
+
rejectResult?.(new Error("Driggsby sign-in timed out before the browser finished connecting."));
|
|
75
|
+
}, timeoutMs);
|
|
76
|
+
timeoutHandle.unref();
|
|
77
|
+
return {
|
|
78
|
+
redirectUri: `http://127.0.0.1:${address.port}/callback`,
|
|
79
|
+
async close() {
|
|
80
|
+
clearTimeoutIfPresent(timeoutHandle);
|
|
81
|
+
await closeServer(server, sockets);
|
|
82
|
+
},
|
|
83
|
+
async waitForResult() {
|
|
84
|
+
return await resultPromise;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
function settleSuccess(value) {
|
|
88
|
+
if (settled) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
settled = true;
|
|
92
|
+
clearTimeoutIfPresent(timeoutHandle);
|
|
93
|
+
void closeServer(server, sockets);
|
|
94
|
+
resolveResult?.({
|
|
95
|
+
ok: true,
|
|
96
|
+
value,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
function settleError(value) {
|
|
100
|
+
if (settled) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
settled = true;
|
|
104
|
+
clearTimeoutIfPresent(timeoutHandle);
|
|
105
|
+
void closeServer(server, sockets);
|
|
106
|
+
resolveResult?.({
|
|
107
|
+
ok: false,
|
|
108
|
+
value,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async function closeServer(server, sockets) {
|
|
113
|
+
if (!server.listening) {
|
|
114
|
+
destroyTrackedSockets(sockets);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
await new Promise((resolve, reject) => {
|
|
118
|
+
server.close((error) => {
|
|
119
|
+
if (error) {
|
|
120
|
+
reject(error);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
resolve();
|
|
124
|
+
});
|
|
125
|
+
server.closeIdleConnections();
|
|
126
|
+
destroyTrackedSockets(sockets);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
function destroyTrackedSockets(sockets) {
|
|
130
|
+
for (const socket of sockets) {
|
|
131
|
+
socket.destroy();
|
|
132
|
+
}
|
|
133
|
+
sockets.clear();
|
|
134
|
+
}
|
|
135
|
+
function clearTimeoutIfPresent(timeoutHandle) {
|
|
136
|
+
if (timeoutHandle !== undefined) {
|
|
137
|
+
clearTimeout(timeoutHandle);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function respondWithHtml(response, message, success) {
|
|
141
|
+
response.statusCode = success ? 200 : 400;
|
|
142
|
+
response.setHeader("content-type", "text/html; charset=utf-8");
|
|
143
|
+
response.end(`<!doctype html>
|
|
144
|
+
<html lang="en">
|
|
145
|
+
<head>
|
|
146
|
+
<meta charset="utf-8" />
|
|
147
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
148
|
+
<title>Driggsby</title>
|
|
149
|
+
</head>
|
|
150
|
+
<body>
|
|
151
|
+
<main>
|
|
152
|
+
<h1>${success ? "Driggsby connected" : "Driggsby sign-in stopped"}</h1>
|
|
153
|
+
<p>${message}</p>
|
|
154
|
+
</main>
|
|
155
|
+
</body>
|
|
156
|
+
</html>`);
|
|
157
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
const registrationResponseSchema = z.object({
|
|
4
|
+
client_id: z.string().min(1),
|
|
5
|
+
scope: z.string().min(1),
|
|
6
|
+
});
|
|
7
|
+
const tokenResponseSchema = z.object({
|
|
8
|
+
access_token: z.string().min(1),
|
|
9
|
+
expires_in: z.number().int().positive(),
|
|
10
|
+
refresh_token: z.string().min(1).optional(),
|
|
11
|
+
scope: z.string().min(1),
|
|
12
|
+
token_type: z.enum(["Bearer", "DPoP"]),
|
|
13
|
+
});
|
|
14
|
+
export async function registerBrokerClient(options, fetchImpl = fetch) {
|
|
15
|
+
const response = await fetchImpl(options.metadata.registration_endpoint, {
|
|
16
|
+
body: JSON.stringify({
|
|
17
|
+
client_name: options.clientName,
|
|
18
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
19
|
+
redirect_uris: [options.redirectUri],
|
|
20
|
+
response_types: ["code"],
|
|
21
|
+
scope: options.requestedScope,
|
|
22
|
+
dpop_bound_access_tokens: options.dpopBoundAccessTokens,
|
|
23
|
+
token_endpoint_auth_method: "none",
|
|
24
|
+
}),
|
|
25
|
+
headers: {
|
|
26
|
+
accept: "application/json",
|
|
27
|
+
"content-type": "application/json",
|
|
28
|
+
},
|
|
29
|
+
method: "POST",
|
|
30
|
+
});
|
|
31
|
+
if (!response.ok) {
|
|
32
|
+
throw new Error("Driggsby sign-in could not register the local broker with the remote service.");
|
|
33
|
+
}
|
|
34
|
+
const parsed = registrationResponseSchema.parse(await response.json());
|
|
35
|
+
return {
|
|
36
|
+
clientId: parsed.client_id,
|
|
37
|
+
scope: parsed.scope,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function buildAuthorizationUrl(options) {
|
|
41
|
+
const url = new URL(options.metadata.authorization_endpoint);
|
|
42
|
+
url.searchParams.set("client_id", options.clientId);
|
|
43
|
+
url.searchParams.set("redirect_uri", options.redirectUri);
|
|
44
|
+
url.searchParams.set("response_type", "code");
|
|
45
|
+
url.searchParams.set("scope", options.requestedScope);
|
|
46
|
+
url.searchParams.set("resource", options.resource.resource);
|
|
47
|
+
url.searchParams.set("state", options.state);
|
|
48
|
+
url.searchParams.set("code_challenge", options.pkceChallenge);
|
|
49
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
50
|
+
return url.toString();
|
|
51
|
+
}
|
|
52
|
+
export async function exchangeAuthorizationCode(options, fetchImpl = fetch) {
|
|
53
|
+
const response = await fetchImpl(options.metadata.token_endpoint, {
|
|
54
|
+
body: new URLSearchParams({
|
|
55
|
+
client_id: options.clientId,
|
|
56
|
+
code: options.code,
|
|
57
|
+
code_verifier: options.codeVerifier,
|
|
58
|
+
grant_type: "authorization_code",
|
|
59
|
+
redirect_uri: options.redirectUri,
|
|
60
|
+
resource: options.resource.resource,
|
|
61
|
+
}),
|
|
62
|
+
headers: {
|
|
63
|
+
accept: "application/json",
|
|
64
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
65
|
+
DPoP: options.dpopProof,
|
|
66
|
+
},
|
|
67
|
+
method: "POST",
|
|
68
|
+
});
|
|
69
|
+
if (!response.ok) {
|
|
70
|
+
throw new Error("Driggsby sign-in could not exchange the authorization code for tokens.");
|
|
71
|
+
}
|
|
72
|
+
return parseTokenExchangeResult(await response.json(), true);
|
|
73
|
+
}
|
|
74
|
+
export async function refreshAccessToken(options, fetchImpl = fetch) {
|
|
75
|
+
const response = await fetchImpl(options.metadata.token_endpoint, {
|
|
76
|
+
body: new URLSearchParams({
|
|
77
|
+
client_id: options.clientId,
|
|
78
|
+
grant_type: "refresh_token",
|
|
79
|
+
refresh_token: options.refreshToken,
|
|
80
|
+
resource: options.resource,
|
|
81
|
+
}),
|
|
82
|
+
headers: {
|
|
83
|
+
accept: "application/json",
|
|
84
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
85
|
+
DPoP: options.dpopProof,
|
|
86
|
+
},
|
|
87
|
+
method: "POST",
|
|
88
|
+
});
|
|
89
|
+
if (!response.ok) {
|
|
90
|
+
throw new Error("Driggsby could not refresh the broker session with the remote service.");
|
|
91
|
+
}
|
|
92
|
+
const refreshedTokens = parseTokenExchangeResult(await response.json(), false);
|
|
93
|
+
return {
|
|
94
|
+
...refreshedTokens,
|
|
95
|
+
refreshToken: refreshedTokens.refreshToken || options.refreshToken,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
export function createOAuthState() {
|
|
99
|
+
return randomUUID();
|
|
100
|
+
}
|
|
101
|
+
function parseTokenExchangeResult(payload, requireRefreshToken) {
|
|
102
|
+
const parsed = tokenResponseSchema.parse(payload);
|
|
103
|
+
if (requireRefreshToken && parsed.refresh_token === undefined) {
|
|
104
|
+
throw new Error("Driggsby could not continue because the remote service did not return a refresh token.");
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
accessToken: parsed.access_token,
|
|
108
|
+
accessTokenExpiresAt: new Date(Date.now() + parsed.expires_in * 1_000).toISOString(),
|
|
109
|
+
refreshToken: parsed.refresh_token ?? "",
|
|
110
|
+
scope: parsed.scope,
|
|
111
|
+
tokenType: parsed.token_type,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
export function generatePkcePair() {
|
|
3
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
4
|
+
const challenge = createHash("sha256")
|
|
5
|
+
.update(verifier)
|
|
6
|
+
.digest("base64url");
|
|
7
|
+
return {
|
|
8
|
+
challenge,
|
|
9
|
+
method: "S256",
|
|
10
|
+
verifier,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function assertBrokerRemoteUrl(rawUrl, context) {
|
|
2
|
+
const url = new URL(rawUrl);
|
|
3
|
+
if (url.protocol === "https:") {
|
|
4
|
+
return;
|
|
5
|
+
}
|
|
6
|
+
if (url.protocol === "http:" && isLoopbackHostname(url.hostname)) {
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
throw new Error(`${context} must use https, except for local loopback development.`);
|
|
10
|
+
}
|
|
11
|
+
function isLoopbackHostname(hostname) {
|
|
12
|
+
return (hostname === "localhost" ||
|
|
13
|
+
hostname === "::1" ||
|
|
14
|
+
hostname === "[::1]" ||
|
|
15
|
+
hostname.startsWith("127."));
|
|
16
|
+
}
|