@agentforge-io/core 2.0.14 → 2.0.15

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.
@@ -36,6 +36,24 @@ export interface OAuth2ProviderConfig {
36
36
  * instead of letting an empty string poison the encrypted-token row.
37
37
  */
38
38
  tokenExtractor?: (json: unknown) => TokenSet;
39
+ /**
40
+ * Where to put the client credentials on the token exchange request.
41
+ *
42
+ * - `'body'` (default) — appends `client_id` + `client_secret` to the
43
+ * form-encoded body. Works for the majority of providers (Google,
44
+ * HubSpot, ClickUp, Slack, GitHub).
45
+ *
46
+ * - `'basic'` — sends them as `Authorization: Basic
47
+ * base64(client_id:client_secret)` instead. Required by Notion
48
+ * (their `/v1/oauth/token` rejects body credentials with
49
+ * `invalid_client`), Spotify, and a handful of other providers
50
+ * whose docs say "Basic Auth" explicitly.
51
+ *
52
+ * RFC 6749 §2.3.1 allows both forms but recommends Basic when both are
53
+ * supported. We default to body because it was the legacy behavior and
54
+ * every existing connector relies on it.
55
+ */
56
+ tokenAuth?: 'body' | 'basic';
39
57
  }
40
58
  export interface AuthorizeUrlResult {
41
59
  url: string;
@@ -40,44 +40,62 @@ class OAuth2Service {
40
40
  grant_type: 'authorization_code',
41
41
  code,
42
42
  redirect_uri: redirectUri,
43
- client_id: cfg.clientId,
44
- client_secret: cfg.clientSecret,
45
43
  });
44
+ // Body-auth providers carry credentials in the form payload; basic-auth
45
+ // providers (Notion, Spotify) carry them in the Authorization header
46
+ // and reject body credentials with `invalid_client`. See
47
+ // OAuth2ProviderConfig.tokenAuth for the per-provider toggle.
48
+ if ((cfg.tokenAuth ?? 'body') === 'body') {
49
+ body.set('client_id', cfg.clientId);
50
+ body.set('client_secret', cfg.clientSecret);
51
+ }
46
52
  if (pkceVerifier)
47
53
  body.set('code_verifier', pkceVerifier);
48
- return this.postToken(cfg.tokenUrl, body, cfg.tokenExtractor);
54
+ return this.postToken(cfg, body);
49
55
  }
50
56
  async refresh(cfg, refreshToken) {
51
57
  const body = new URLSearchParams({
52
58
  grant_type: 'refresh_token',
53
59
  refresh_token: refreshToken,
54
- client_id: cfg.clientId,
55
- client_secret: cfg.clientSecret,
56
60
  });
57
- return this.postToken(cfg.tokenUrl, body, cfg.tokenExtractor);
61
+ if ((cfg.tokenAuth ?? 'body') === 'body') {
62
+ body.set('client_id', cfg.clientId);
63
+ body.set('client_secret', cfg.clientSecret);
64
+ }
65
+ return this.postToken(cfg, body);
58
66
  }
59
- async postToken(tokenUrl, body, extractor) {
60
- const res = await this.fetchImpl(tokenUrl, {
67
+ async postToken(cfg, body) {
68
+ const headers = {
69
+ 'content-type': 'application/x-www-form-urlencoded',
70
+ accept: 'application/json',
71
+ };
72
+ // Basic-auth providers need credentials in the Authorization header
73
+ // instead of the body. RFC 6749 §2.3.1 — both forms are spec-legal
74
+ // but providers vary on which they accept.
75
+ if (cfg.tokenAuth === 'basic') {
76
+ const credentials = Buffer.from(`${cfg.clientId}:${cfg.clientSecret}`).toString('base64');
77
+ headers.authorization = `Basic ${credentials}`;
78
+ }
79
+ const res = await this.fetchImpl(cfg.tokenUrl, {
61
80
  method: 'POST',
62
- headers: {
63
- 'content-type': 'application/x-www-form-urlencoded',
64
- accept: 'application/json',
65
- },
81
+ headers,
66
82
  body: body.toString(),
67
83
  });
68
84
  if (!res.ok) {
69
85
  const text = await res.text().catch(() => '');
70
- throw new Error(`OAuth2 token endpoint ${tokenUrl} returned ${res.status}: ${text}`);
86
+ throw new Error(`OAuth2 token endpoint ${cfg.tokenUrl} returned ${res.status}: ${text}`);
71
87
  }
72
88
  const json = (await res.json());
73
- const tokens = extractor ? extractor(json) : defaultExtractor(json);
89
+ const tokens = cfg.tokenExtractor
90
+ ? cfg.tokenExtractor(json)
91
+ : defaultExtractor(json);
74
92
  // Guard against accidentally persisting an empty-string token — that
75
93
  // would crash the cipher with "Received undefined" downstream and the
76
94
  // operator would chase a confusing stack trace. Better to fail fast
77
95
  // here with a descriptive error so the connector author knows their
78
96
  // extractor (or the default) missed the token.
79
97
  if (!tokens.accessToken) {
80
- throw new Error(`OAuth2 token endpoint ${tokenUrl} returned a response without an access token. ` +
98
+ throw new Error(`OAuth2 token endpoint ${cfg.tokenUrl} returned a response without an access token. ` +
81
99
  'If the provider uses a non-standard envelope, supply `tokenExtractor` on the ConnectorDefinition.');
82
100
  }
83
101
  return tokens;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentforge-io/core",
3
- "version": "2.0.14",
3
+ "version": "2.0.15",
4
4
  "description": "Framework-free AI runtime SDK. Owns: agent loop (Anthropic), conversations, tools, streaming, agent-job queue, SdkHooks. Identity, billing, infra (email/uploads/secrets) live in the host's modules — not here.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",