@hasna/connectors 1.3.46 → 1.4.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 +0 -10
- package/bin/index.js +571 -11667
- package/bin/mcp.js +314 -10277
- package/bin/serve.js +1852 -11815
- package/connectors/x/src/api/oauth.test.ts +205 -0
- package/dist/db/database.d.ts +2 -1
- package/dist/db/sqlite-adapter.d.ts +43 -0
- package/dist/index.js +128 -9652
- package/dist/no-cloud-boundary.test.d.ts +1 -0
- package/package.json +1 -2
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
exchangeCodeForTokens,
|
|
4
|
+
refreshAccessToken,
|
|
5
|
+
revokeToken,
|
|
6
|
+
type OAuth2Config,
|
|
7
|
+
} from './oauth';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Regression tests for https://github.com/hasna/connectors/issues/1
|
|
11
|
+
*
|
|
12
|
+
* X rejects `Authorization: Basic <client_id:client_secret>` on
|
|
13
|
+
* POST /2/oauth2/token when the app is registered as a *public* client:
|
|
14
|
+
* {"error":"unauthorized_client",
|
|
15
|
+
* "error_description":"Missing valid authorization header"}
|
|
16
|
+
*
|
|
17
|
+
* The connector therefore must always authenticate the client with POST body
|
|
18
|
+
* parameters (`client_id`, plus `client_secret` when one is configured) and
|
|
19
|
+
* must never fall back to an Authorization header on the token/revoke
|
|
20
|
+
* endpoints.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
interface CapturedRequest {
|
|
24
|
+
url: string;
|
|
25
|
+
method?: string;
|
|
26
|
+
headers: Record<string, string>;
|
|
27
|
+
body: URLSearchParams;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const realFetch = globalThis.fetch;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Normalise every shape `RequestInit.headers` can take (plain object, entry
|
|
34
|
+
* array, `Headers` instance) into a lower-cased record. Going through
|
|
35
|
+
* `new Headers(...)` matters: if the implementation ever switched to a
|
|
36
|
+
* `Headers` object, a naive `Object.entries()` would yield `[]` and the
|
|
37
|
+
* "no Authorization header" assertions below would pass vacuously.
|
|
38
|
+
*/
|
|
39
|
+
function normaliseHeaders(init?: RequestInit): Record<string, string> {
|
|
40
|
+
const out: Record<string, string> = {};
|
|
41
|
+
if (!init?.headers) return out;
|
|
42
|
+
new Headers(init.headers).forEach((value, key) => {
|
|
43
|
+
out[key.toLowerCase()] = value;
|
|
44
|
+
});
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function captureTokenRequest(): { calls: CapturedRequest[] } {
|
|
49
|
+
const calls: CapturedRequest[] = [];
|
|
50
|
+
|
|
51
|
+
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
|
|
52
|
+
const headers = normaliseHeaders(init);
|
|
53
|
+
|
|
54
|
+
calls.push({
|
|
55
|
+
url: String(input),
|
|
56
|
+
method: init?.method,
|
|
57
|
+
headers,
|
|
58
|
+
body: new URLSearchParams(String(init?.body ?? '')),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return new Response(
|
|
62
|
+
JSON.stringify({
|
|
63
|
+
access_token: 'ACCESS_TOKEN',
|
|
64
|
+
refresh_token: 'NEW_REFRESH_TOKEN',
|
|
65
|
+
expires_in: 7200,
|
|
66
|
+
scope: 'tweet.read tweet.write offline.access',
|
|
67
|
+
token_type: 'bearer',
|
|
68
|
+
}),
|
|
69
|
+
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
|
70
|
+
);
|
|
71
|
+
}) as typeof fetch;
|
|
72
|
+
|
|
73
|
+
return { calls };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const PUBLIC_CLIENT: OAuth2Config = {
|
|
77
|
+
clientId: 'PUBLIC_CLIENT_ID',
|
|
78
|
+
redirectUri: 'http://localhost:8888/callback',
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const CONFIDENTIAL_CLIENT: OAuth2Config = {
|
|
82
|
+
clientId: 'CONFIDENTIAL_CLIENT_ID',
|
|
83
|
+
clientSecret: 'CONFIDENTIAL_CLIENT_SECRET',
|
|
84
|
+
redirectUri: 'http://localhost:8888/callback',
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
afterEach(() => {
|
|
88
|
+
globalThis.fetch = realFetch;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('OAuth 2.0 token requests - public client (issue #1)', () => {
|
|
92
|
+
test('exchangeCodeForTokens sends client_id in the body and no auth header', async () => {
|
|
93
|
+
const { calls } = captureTokenRequest();
|
|
94
|
+
|
|
95
|
+
const tokens = await exchangeCodeForTokens(
|
|
96
|
+
PUBLIC_CLIENT,
|
|
97
|
+
'AUTH_CODE',
|
|
98
|
+
'CODE_VERIFIER'
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
expect(calls).toHaveLength(1);
|
|
102
|
+
const req = calls[0]!;
|
|
103
|
+
expect(req.url).toBe('https://api.twitter.com/2/oauth2/token');
|
|
104
|
+
expect(req.method).toBe('POST');
|
|
105
|
+
expect(req.headers['authorization']).toBeUndefined();
|
|
106
|
+
expect(req.headers['content-type']).toBe(
|
|
107
|
+
'application/x-www-form-urlencoded'
|
|
108
|
+
);
|
|
109
|
+
expect(req.body.get('client_id')).toBe('PUBLIC_CLIENT_ID');
|
|
110
|
+
expect(req.body.get('client_secret')).toBeNull();
|
|
111
|
+
expect(req.body.get('grant_type')).toBe('authorization_code');
|
|
112
|
+
expect(req.body.get('code')).toBe('AUTH_CODE');
|
|
113
|
+
expect(req.body.get('code_verifier')).toBe('CODE_VERIFIER');
|
|
114
|
+
expect(req.body.get('redirect_uri')).toBe(
|
|
115
|
+
'http://localhost:8888/callback'
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
expect(tokens.accessToken).toBe('ACCESS_TOKEN');
|
|
119
|
+
expect(tokens.refreshToken).toBe('NEW_REFRESH_TOKEN');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('refreshAccessToken sends client_id in the body and no auth header', async () => {
|
|
123
|
+
const { calls } = captureTokenRequest();
|
|
124
|
+
|
|
125
|
+
await refreshAccessToken(PUBLIC_CLIENT, 'OLD_REFRESH_TOKEN');
|
|
126
|
+
|
|
127
|
+
expect(calls).toHaveLength(1);
|
|
128
|
+
const req = calls[0]!;
|
|
129
|
+
expect(req.url).toBe('https://api.twitter.com/2/oauth2/token');
|
|
130
|
+
expect(req.headers['authorization']).toBeUndefined();
|
|
131
|
+
expect(req.body.get('grant_type')).toBe('refresh_token');
|
|
132
|
+
expect(req.body.get('refresh_token')).toBe('OLD_REFRESH_TOKEN');
|
|
133
|
+
expect(req.body.get('client_id')).toBe('PUBLIC_CLIENT_ID');
|
|
134
|
+
expect(req.body.get('client_secret')).toBeNull();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('revokeToken sends client_id in the body and no auth header', async () => {
|
|
138
|
+
const { calls } = captureTokenRequest();
|
|
139
|
+
|
|
140
|
+
await revokeToken(PUBLIC_CLIENT, 'SOME_ACCESS_TOKEN', 'access_token');
|
|
141
|
+
|
|
142
|
+
expect(calls).toHaveLength(1);
|
|
143
|
+
const req = calls[0]!;
|
|
144
|
+
expect(req.url).toBe('https://api.twitter.com/2/oauth2/revoke');
|
|
145
|
+
expect(req.headers['authorization']).toBeUndefined();
|
|
146
|
+
expect(req.body.get('token')).toBe('SOME_ACCESS_TOKEN');
|
|
147
|
+
expect(req.body.get('token_type_hint')).toBe('access_token');
|
|
148
|
+
expect(req.body.get('client_id')).toBe('PUBLIC_CLIENT_ID');
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('OAuth 2.0 token requests - configured client secret (issue #1)', () => {
|
|
153
|
+
// This is the exact trigger of issue #1: a client_secret is present in the
|
|
154
|
+
// connector config (env var or ~/.hasna/connectors/connect-x/credentials.json)
|
|
155
|
+
// while the X app itself is registered as a public client. Sending Basic auth
|
|
156
|
+
// in that situation is what produced "Missing valid authorization header".
|
|
157
|
+
test('never falls back to Basic auth when a client secret is configured', async () => {
|
|
158
|
+
const { calls } = captureTokenRequest();
|
|
159
|
+
|
|
160
|
+
await exchangeCodeForTokens(
|
|
161
|
+
CONFIDENTIAL_CLIENT,
|
|
162
|
+
'AUTH_CODE',
|
|
163
|
+
'CODE_VERIFIER'
|
|
164
|
+
);
|
|
165
|
+
await refreshAccessToken(CONFIDENTIAL_CLIENT, 'OLD_REFRESH_TOKEN');
|
|
166
|
+
await revokeToken(CONFIDENTIAL_CLIENT, 'SOME_ACCESS_TOKEN');
|
|
167
|
+
|
|
168
|
+
expect(calls).toHaveLength(3);
|
|
169
|
+
for (const req of calls) {
|
|
170
|
+
expect(req.headers['authorization']).toBeUndefined();
|
|
171
|
+
expect(req.body.get('client_id')).toBe('CONFIDENTIAL_CLIENT_ID');
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('still authenticates the client via client_secret_post', async () => {
|
|
176
|
+
const { calls } = captureTokenRequest();
|
|
177
|
+
|
|
178
|
+
await exchangeCodeForTokens(
|
|
179
|
+
CONFIDENTIAL_CLIENT,
|
|
180
|
+
'AUTH_CODE',
|
|
181
|
+
'CODE_VERIFIER'
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
expect(calls[0]!.body.get('client_secret')).toBe(
|
|
185
|
+
'CONFIDENTIAL_CLIENT_SECRET'
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('OAuth 2.0 token requests - error surfacing', () => {
|
|
191
|
+
test('exchangeCodeForTokens surfaces the X error payload', async () => {
|
|
192
|
+
globalThis.fetch = (async () =>
|
|
193
|
+
new Response(
|
|
194
|
+
JSON.stringify({
|
|
195
|
+
error: 'unauthorized_client',
|
|
196
|
+
error_description: 'Missing valid authorization header',
|
|
197
|
+
}),
|
|
198
|
+
{ status: 400 }
|
|
199
|
+
)) as typeof fetch;
|
|
200
|
+
|
|
201
|
+
await expect(
|
|
202
|
+
exchangeCodeForTokens(PUBLIC_CLIENT, 'AUTH_CODE', 'CODE_VERIFIER')
|
|
203
|
+
).rejects.toThrow(/Token exchange failed:.*unauthorized_client/s);
|
|
204
|
+
});
|
|
205
|
+
});
|
package/dist/db/database.d.ts
CHANGED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Database as BunDatabase, type SQLQueryBindings, type Statement } from "bun:sqlite";
|
|
2
|
+
/** Result of a mutating statement, mirroring `bun:sqlite`'s `Statement.run()`. */
|
|
3
|
+
export interface RunResult {
|
|
4
|
+
changes: number;
|
|
5
|
+
lastInsertRowid: number | bigint;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Statement bindings. `bun:sqlite` accepts both the spread form
|
|
9
|
+
* (`run(sql, a, b)`) and a single array of values (`run(sql, [a, b])`), and
|
|
10
|
+
* call sites in `src/db` use both, so both are allowed here.
|
|
11
|
+
*/
|
|
12
|
+
export type Bindings = SQLQueryBindings | SQLQueryBindings[];
|
|
13
|
+
/** A prepared statement handle returned by {@link SqliteAdapter.prepare}. */
|
|
14
|
+
export interface PreparedStatement {
|
|
15
|
+
run(...params: Bindings[]): RunResult;
|
|
16
|
+
get(...params: Bindings[]): unknown;
|
|
17
|
+
all(...params: Bindings[]): unknown[];
|
|
18
|
+
finalize(): void;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Thin synchronous wrapper over `bun:sqlite`.
|
|
22
|
+
*
|
|
23
|
+
* This is the local-only storage engine for the connectors database. It was
|
|
24
|
+
* previously imported from `@hasna/cloud`, which is retired and unsupported;
|
|
25
|
+
* the class is kept API-compatible with that import so the rest of `src/db`
|
|
26
|
+
* and its tests are unchanged.
|
|
27
|
+
*
|
|
28
|
+
* Parameters are forwarded to `bun:sqlite` verbatim, so both the spread form
|
|
29
|
+
* (`run(sql, a, b)`) and the array form (`run(sql, [a, b])`) keep working.
|
|
30
|
+
*/
|
|
31
|
+
export declare class SqliteAdapter {
|
|
32
|
+
private readonly db;
|
|
33
|
+
constructor(path: string);
|
|
34
|
+
run(sql: string, ...params: Bindings[]): RunResult;
|
|
35
|
+
get(sql: string, ...params: Bindings[]): unknown;
|
|
36
|
+
all(sql: string, ...params: Bindings[]): unknown[];
|
|
37
|
+
exec(sql: string): void;
|
|
38
|
+
query(sql: string): Statement;
|
|
39
|
+
prepare(sql: string): PreparedStatement;
|
|
40
|
+
close(): void;
|
|
41
|
+
transaction<T>(fn: () => T): T;
|
|
42
|
+
get raw(): BunDatabase;
|
|
43
|
+
}
|