@zackbart/connecta 0.28.0 → 0.28.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/CHANGELOG.md +173 -0
- package/README.md +8 -5
- package/dist/artifacts/connector.js +1 -1
- package/dist/auth/downstream-oauth.js +147 -28
- package/dist/auth/static-oauth.js +504 -0
- package/dist/connectors/api-connector.d.ts +168 -0
- package/dist/connectors/api-connector.js +127 -0
- package/dist/connectors/api.d.ts +8 -71
- package/dist/connectors/api.js +10 -99
- package/dist/connectors/guarded-fetch.js +37 -3
- package/dist/connectors/remote-mcp.js +7 -32
- package/dist/index.d.ts +1 -1
- package/dist/oauth-partition.js +17 -0
- package/dist/providers/basecamp.d.ts +65 -0
- package/dist/providers/basecamp.js +445 -0
- package/dist/providers/breeze.d.ts +34 -0
- package/dist/providers/breeze.js +1402 -0
- package/dist/providers/ccb.d.ts +103 -0
- package/dist/providers/ccb.js +1570 -0
- package/dist/providers/cloudflare.js +1 -1
- package/dist/providers/linear.js +1 -1
- package/dist/providers/mixpanel.js +1 -1
- package/dist/providers/notion.js +1 -1
- package/dist/providers/overflow.d.ts +60 -0
- package/dist/providers/overflow.js +1572 -0
- package/dist/providers/planning-center.d.ts +59 -0
- package/dist/providers/planning-center.js +2039 -0
- package/dist/providers/revenuecat.js +1 -1
- package/dist/providers/stripe.js +1 -1
- package/dist/providers/tithely.d.ts +49 -0
- package/dist/providers/tithely.js +1034 -0
- package/dist/providers/vercel.js +1 -1
- package/dist/routes/oauth.js +29 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +5 -3
- package/documentation/auth.md +120 -6
- package/ethos.md +3 -3
- package/package.json +25 -1
- package/templates/node/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,179 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this package are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.28.1 — 2026-10-05
|
|
6
|
+
|
|
7
|
+
This patch adds six maintained connections for church operations and the
|
|
8
|
+
downstream OAuth they needed. Planning Center, Overflow, Tithe.ly, Breeze ChMS,
|
|
9
|
+
and Church Community Builder (Pushpay ChMS) are hand-written `api()` surfaces
|
|
10
|
+
over their whole APIs, with every money-moving write behind approval; Basecamp
|
|
11
|
+
proxies 37signals' hosted MCP server. Church Community Builder accepts only
|
|
12
|
+
OAuth, so `api()` connectors can now declare a static OAuth 2.0
|
|
13
|
+
authorization-code grant that reuses the `remoteMcp()` machinery. Each
|
|
14
|
+
connection is a new opt-in provider subpath and the OAuth grant is opt-in per
|
|
15
|
+
connector: existing imports, connector declarations, credentials, and
|
|
16
|
+
deployment behavior do not change. Two fixes a deployment may notice: a forced
|
|
17
|
+
OAuth restart no longer reuses a client registration the provider has deleted
|
|
18
|
+
([#611](https://github.com/zackbart/connecta/issues/611)), and a failed OAuth
|
|
19
|
+
code exchange is now logged as fixed text rather than the provider's message.
|
|
20
|
+
Internally, the guarded transport can repeat a query key for an array value,
|
|
21
|
+
and `api()`'s builder moved into its own module so OAuth stays out of bundles
|
|
22
|
+
that never use it.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **Downstream OAuth on `api()` connectors.** A hand-written connector can
|
|
27
|
+
declare `oauth: { authorizationEndpoint, tokenEndpoint, clientId,
|
|
28
|
+
clientSecret?, tokenEndpointAuthMethod?, scope?, pkce?, authorizationParams?,
|
|
29
|
+
tokenRequestHeaders?, apiOrigins }` for a REST API that accepts only the authorization code grant,
|
|
30
|
+
such as Church Community Builder. Nothing is discovered or registered: the
|
|
31
|
+
endpoints and the provider-issued client are configuration, and the client
|
|
32
|
+
secret comes from the deployment's environment and is never stored. Handlers
|
|
33
|
+
call `ctx.oauth.fetch(url, init)`, which sends the calling owner's token as a
|
|
34
|
+
bearer to a declared origin only, answers a 401 with one coordinated refresh
|
|
35
|
+
and one replay, and otherwise fails `auth_required`. The grant is the
|
|
36
|
+
`remoteMcp()` grant underneath — the same sealed epochs, shared and personal
|
|
37
|
+
ownership, rotating-refresh coordination, dead-versus-outage verdicts,
|
|
38
|
+
`/oauth/callback/<id>`, `authorize_connector`, and operator Connect,
|
|
39
|
+
Restart, and Disconnect. `oauth` and `credential` are exclusive on one
|
|
40
|
+
connector. See [downstream OAuth on `api()`](./documentation/auth.md#downstream-oauth-on-api).
|
|
41
|
+
- **Church Community Builder (Pushpay ChMS) connection.**
|
|
42
|
+
`@zackbart/connecta/providers/ccb` exports `ccb(id, { purpose, environment,
|
|
43
|
+
mode, clientId, clientSecret, access?, scopes?, subdomain? })`, a
|
|
44
|
+
hand-written v2 REST connection over `api()`'s OAuth grant. `environment`
|
|
45
|
+
(`production` or `sandbox`) and `mode` (`system`, one church-wide grant an
|
|
46
|
+
administrator approves; `identity`, each person signing in as themselves
|
|
47
|
+
with a personal grant) are required, because CCB credentials do not cross
|
|
48
|
+
environments and the two modes reach different data. Twenty-four named reads
|
|
49
|
+
cover people and families, groups and participants, events and attendance,
|
|
50
|
+
forms and responses, processes and queues, scheduling, notes, and giving
|
|
51
|
+
metrics and pledges, with projections that drop allergies, giving
|
|
52
|
+
identifiers, and prayer requests unless `raw: true`. `ccb_api_get` and
|
|
53
|
+
`ccb_api_search` reach every GET and advanced search read-only, and
|
|
54
|
+
`ccb_api_mutate` ships only when a write scope is requested. A default
|
|
55
|
+
60-per-minute admission window per endpoint transcribes CCB's documented
|
|
56
|
+
limit, and `npm run providers:check -- --provider ccb` compares the 41
|
|
57
|
+
touched endpoints with Pushpay's published OpenAPI document. Clients are
|
|
58
|
+
issued by hand through Pushpay's API access request form.
|
|
59
|
+
- **Maintained Planning Center connection.**
|
|
60
|
+
`@zackbart/connecta/providers/planning-center` exports `planningCenter(id,
|
|
61
|
+
options)`, a hand-written `api()` surface over Planning Center Online's REST
|
|
62
|
+
API with no SDK and no new dependency. Named tools cover the jobs church
|
|
63
|
+
staff run — People search, profiles, contact records, lists, workflows and
|
|
64
|
+
card actions, notes, custom fields, and form submissions; Services plans,
|
|
65
|
+
teams, schedules, songs, and scheduling; Groups memberships and events;
|
|
66
|
+
Check-Ins attendance; Calendar occurrences; Registrations signups; and Giving
|
|
67
|
+
donations, funds, batches, pledges, and recurring gifts as reads only — with
|
|
68
|
+
JSON:API flattened into lean records and `raw: true` beside them.
|
|
69
|
+
`pco_api_get` (read-only) and `pco_api_mutate` (always approval-gated) reach
|
|
70
|
+
every other endpoint, including every write that moves money. The credential
|
|
71
|
+
is a personal access token as two labeled fields, and the Test action names
|
|
72
|
+
the person and organization it acts as. Every request sends a reviewed
|
|
73
|
+
`X-PCO-API-Version` per product and a configurable `User-Agent`, and the
|
|
74
|
+
default call budget transcribes Planning Center's 100 requests per 20
|
|
75
|
+
seconds. `npm run providers:check -- --provider planning-center` compares
|
|
76
|
+
the touched endpoints with Planning Center's per-product OpenAPI documents
|
|
77
|
+
and reports a newly published version of any pinned product. Planning
|
|
78
|
+
Center's own hosted MCP server was considered and is not wrapped: its writes
|
|
79
|
+
stop at People.
|
|
80
|
+
- **Maintained Overflow connection.** `@zackbart/connecta/providers/overflow`
|
|
81
|
+
ships 16 projected reads over contributions, deposits and their line items
|
|
82
|
+
and totals, refunds, chargebacks, donors, recurring gifts, payment methods,
|
|
83
|
+
campaigns, locations, Tap events, and webhook delivery logs, plus a
|
|
84
|
+
read-only GET hatch and a destructive JSON-mutation hatch that carries every
|
|
85
|
+
Overflow write — charges, payment authorization, refunds, recurring-gift
|
|
86
|
+
changes, and donor edits — to the host's approval prompt. `environment`
|
|
87
|
+
(`"production"` or `"staging"`) is required with no default, the credential
|
|
88
|
+
is one operator slot with `clientId` and `apiKey` fields, list rows keep
|
|
89
|
+
donor contact details and webhook payloads out unless `raw: true`, and the
|
|
90
|
+
default admission budget transcribes Overflow's 120 requests per minute per
|
|
91
|
+
client. `npm run providers:check -- --provider overflow` compares all 38
|
|
92
|
+
operations with the OpenAPI document Overflow publishes on staging.
|
|
93
|
+
- A maintained Tithe.ly giving connection, `tithely(id, { purpose, environment })`
|
|
94
|
+
from `@zackbart/connecta/providers/tithely`: a hand-written `api()` surface
|
|
95
|
+
over Tithe.ly's v1 REST API. `environment` (`"live"` for tithe.ly, `"test"`
|
|
96
|
+
for tithelydev.com) is required with no default, because Tithe.ly's key pairs
|
|
97
|
+
are environment-specific and carry nothing that says which. The credential
|
|
98
|
+
slot takes the public and private keys as two fields, refuses an obviously
|
|
99
|
+
swapped pair without a request, and its Test action reports the organization
|
|
100
|
+
the keys reach; API access is by request through support@tithe.ly. Named
|
|
101
|
+
read-only tools cover organizations and their funds, donor accounts, charges
|
|
102
|
+
by organization or donor and created-date range, recurring gifts, and
|
|
103
|
+
payment-method metadata, with amounts as integer cents, dates as ISO 8601,
|
|
104
|
+
`hasMore` paging, and organization projections that omit payout bank and
|
|
105
|
+
legal-contact details. Every write moves money or donor payment state and is
|
|
106
|
+
reached only through the always-destructive, form-encoded
|
|
107
|
+
`tithely_api_mutate`; `tithely_api_get` reaches every v1 read. No call budget
|
|
108
|
+
is declared, since Tithe.ly publishes no rate limit.
|
|
109
|
+
`npm run providers:check -- --provider tithely` digests the OpenAPI snippet
|
|
110
|
+
each reference page embeds, for every documented v1 operation.
|
|
111
|
+
- A maintained Breeze ChMS connection (Tithely Church Management since
|
|
112
|
+
2025-11-11) at `@zackbart/connecta/providers/breeze`:
|
|
113
|
+
`breeze(id, { subdomain, purpose })` binds one church's
|
|
114
|
+
`<subdomain>.breezechms.com` — validated as a single hostname label at
|
|
115
|
+
construction, so no argument can steer the API key elsewhere — with an
|
|
116
|
+
operator `Api-Key` slot tested against the account summary. Named reads cover
|
|
117
|
+
people, profile fields, tags, events, calendars, attendance, forms,
|
|
118
|
+
volunteers, the account log, and giving (`list_contributions`, `list_funds`,
|
|
119
|
+
which call endpoints Breeze stopped documenting in 2023 and still serves);
|
|
120
|
+
named writes add and update people, assign and unassign tags, and record a
|
|
121
|
+
check-in. Because Breeze sends writes as GET too, the escape hatches split by
|
|
122
|
+
endpoint rather than method: `breeze_api_get` admits only a reviewed
|
|
123
|
+
allowlist of read endpoints and their documented parameters, and the
|
|
124
|
+
destructive `breeze_api_mutate` reaches everything else. No admission budget
|
|
125
|
+
is declared, since Breeze documents no rate limit; drift is review-only,
|
|
126
|
+
since it publishes no machine-readable specification.
|
|
127
|
+
- **`basecamp()` — a maintained Basecamp hosted-MCP connection** at
|
|
128
|
+
`@zackbart/connecta/providers/basecamp`, proxying
|
|
129
|
+
`https://mcp.basecamp.com/mcp` — a server 37signals runs but has not
|
|
130
|
+
documented or announced. OAuth only, and `clientMetadataUrl` is required:
|
|
131
|
+
Basecamp restricts dynamic client registration for HTTPS callbacks, so a
|
|
132
|
+
deployment hosts a Client ID Metadata Document listing
|
|
133
|
+
`<publicUrl>/oauth/callback/<id>`, and construction throws without one. A
|
|
134
|
+
grant reaches exactly one Basecamp account, chosen at consent, so the guide
|
|
135
|
+
leads with that, names `get_me` to confirm it, and a second account is a
|
|
136
|
+
second connector. All 227 tools in the live catalog are classified (105
|
|
137
|
+
read-only, 34 additive, 88 destructive); the four verdicts that part from the
|
|
138
|
+
server's own annotations, and `create_stream_ticket`'s agreement with its
|
|
139
|
+
read-only claim, are argued beside their rows. No access mode ships: the MCP
|
|
140
|
+
client requests the scope Basecamp's challenge names, so a `read` option
|
|
141
|
+
would be a label rather than a limit. Personal access tokens are not offered,
|
|
142
|
+
because nothing shows this endpoint accepts one. No default `callAdmission`:
|
|
143
|
+
Basecamp's limits are dynamic, multi-tier, and metered at its own server's
|
|
144
|
+
IP. With no public setup page or inventory, `npm run providers:check` reads
|
|
145
|
+
the server's OAuth discovery instead — the resource names the endpoint and
|
|
146
|
+
the authorization server still accepts metadata-document clients — and
|
|
147
|
+
catalog drift is runtime-only.
|
|
148
|
+
|
|
149
|
+
### Fixed
|
|
150
|
+
|
|
151
|
+
- **A forced OAuth restart can no longer wedge on a purged client
|
|
152
|
+
registration** ([#611](https://github.com/zackbart/connecta/issues/611)).
|
|
153
|
+
Restart already carried an issuer-bound dynamic registration into the new
|
|
154
|
+
epoch; it carried it unconditionally, and a provider that had forgotten the
|
|
155
|
+
client refuses it at consent without ever redirecting back, so every later
|
|
156
|
+
Restart reused the same dead client and only Disconnect escaped. A carried
|
|
157
|
+
client is now carried again only once a grant in its epoch proves the
|
|
158
|
+
provider still knows it, so two restarts in a row with no consent between
|
|
159
|
+
them register at most once more. A URL-based client (`clientMetadataUrl`)
|
|
160
|
+
and a client whose secret has expired are never carried. Continue no longer
|
|
161
|
+
hands back a pending consent URL whose client a callback's `invalid_client`
|
|
162
|
+
has dropped; it starts a flow that registers instead. Static `api()` OAuth
|
|
163
|
+
clients are configuration and unaffected.
|
|
164
|
+
- **A refresh rotation saved after its request was cancelled keeps its
|
|
165
|
+
issuer.** When the token endpoint answered after the owning request left,
|
|
166
|
+
connecta persisted the rotated tokens itself but unbound, and the next
|
|
167
|
+
issuer-aware read adopted them for whichever authorization server the
|
|
168
|
+
connector named by then — so a connector repointed at another server would
|
|
169
|
+
send the rotated refresh token there. The recovery save now stamps the issuer
|
|
170
|
+
the refresh answered to, as the SDK's own save does, and a repointed connector
|
|
171
|
+
fences the grant instead.
|
|
172
|
+
- **A failed OAuth code exchange no longer writes the provider's message to the
|
|
173
|
+
operator log.** The SDK quotes a token endpoint's `error_description` or raw
|
|
174
|
+
body, and a provider that echoes a `client_secret_post` request put the client
|
|
175
|
+
secret, code, and verifier in the log. The callback now logs fixed text and,
|
|
176
|
+
when the error carries one, a standard OAuth error code from an allowlist.
|
|
177
|
+
|
|
5
178
|
## 0.28.0 — 2026-10-04
|
|
6
179
|
|
|
7
180
|
This minor release removes program pauses ([#672](https://github.com/zackbart/connecta/issues/672)).
|
package/README.md
CHANGED
|
@@ -64,11 +64,14 @@ Fifty issues in, one small object out. Your context window notices.
|
|
|
64
64
|
services in a config file; your client never changes.
|
|
65
65
|
- **Wrap any HTTP API by hand.** A few lines per tool. No OpenAPI conversion —
|
|
66
66
|
generated tool sprawl is the problem, not the fix.
|
|
67
|
-
- **Use maintained connections** for
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
67
|
+
- **Use maintained connections** for Basecamp, Breeze ChMS, Church Community
|
|
68
|
+
Builder, Cloudflare, Linear, Mixpanel, Notion, Overflow, Planning Center,
|
|
69
|
+
RevenueCat, Stripe, Tithe.ly, and Vercel: known endpoints, auth defaults, and
|
|
70
|
+
vetted read/write classifications, imported one at a time. Cloudflare,
|
|
71
|
+
Notion, and Vercel each let the deployment choose their hand-written API
|
|
72
|
+
interface or official hosted MCP. Planning Center, Overflow, Tithe.ly,
|
|
73
|
+
Breeze, and Church Community Builder are hand-written over their whole APIs,
|
|
74
|
+
with every money-moving write behind approval.
|
|
72
75
|
- **Let the agent work in code.** Search, chain, filter, join, and reduce
|
|
73
76
|
inside the sandbox instead of round-tripping every call through the model.
|
|
74
77
|
- **Teach undeclared result shapes by using them.** Successful read-only calls
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Who made each version comes from the caller core attached to the context,
|
|
8
8
|
// never from arguments.
|
|
9
|
-
import { api } from "../connectors/api.js";
|
|
9
|
+
import { apiConnector as api } from "../connectors/api-connector.js";
|
|
10
10
|
import { callerOf } from "../connector-caller.js";
|
|
11
11
|
import { ConnectorCallError } from "../errors.js";
|
|
12
12
|
import { resolveTheme } from "../branding.js";
|
|
@@ -2,7 +2,30 @@ import { UnauthorizedError } from "@modelcontextprotocol/client";
|
|
|
2
2
|
import { Deferred, Effect } from "effect";
|
|
3
3
|
import { retryAfterMs } from "../connectors/guarded-fetch.js";
|
|
4
4
|
import { ConnectorCallError } from "../errors.js";
|
|
5
|
+
import { attachOAuthPartition, oauthPartitionFor, retainOAuthPartition, } from "../oauth-partition.js";
|
|
6
|
+
import { inheritOAuthSealer } from "../oauth-sealing.js";
|
|
5
7
|
import { detach, fromSignal, runEdge } from "../runtime/run.js";
|
|
8
|
+
/** RFC 6749 section 3.3: one or more scope tokens, separated by single spaces. */
|
|
9
|
+
const OAUTH_SCOPE = /^[\x21\x23-\x5B\x5D-\x7E]+(?: [\x21\x23-\x5B\x5D-\x7E]+)*$/;
|
|
10
|
+
/** Refuse, at construction, a configured scope no authorization server could parse. */
|
|
11
|
+
export function assertOAuthScope(connectorId, scope) {
|
|
12
|
+
if (scope !== undefined && !OAUTH_SCOPE.test(scope)) {
|
|
13
|
+
throw new Error(`[connecta] connector "${connectorId}" OAuth scope must contain space-separated scope tokens.`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The context an explicit authorization start runs under: allowed to begin
|
|
18
|
+
* consent, with a request scope of its own, carrying the registry's sealer and
|
|
19
|
+
* owner partition across the copy. Only `startAuth` builds one; status reads
|
|
20
|
+
* and calls never do, which is what keeps them from starting authorization.
|
|
21
|
+
*/
|
|
22
|
+
export function authorizingContext(ctx) {
|
|
23
|
+
return attachOAuthPartition(inheritOAuthSealer(ctx, {
|
|
24
|
+
...ctx,
|
|
25
|
+
requestScope: ctx.requestScope ?? ctx,
|
|
26
|
+
allowAuthorization: true,
|
|
27
|
+
}), oauthPartitionFor(ctx));
|
|
28
|
+
}
|
|
6
29
|
/** A 256-bit random opaque value, hex-encoded — used for the OAuth `state`. */
|
|
7
30
|
function randomState() {
|
|
8
31
|
const bytes = new Uint8Array(32);
|
|
@@ -18,7 +41,7 @@ function timingSafeEqual(a, b) {
|
|
|
18
41
|
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
19
42
|
return diff === 0;
|
|
20
43
|
}
|
|
21
|
-
const LEGACY_GENERATION = "legacy";
|
|
44
|
+
export const LEGACY_GENERATION = "legacy";
|
|
22
45
|
const ACTIVE_GENERATION_PREFIX = "v2:";
|
|
23
46
|
const RESETTING_GENERATION_PREFIX = "reset:";
|
|
24
47
|
const DISCONNECTED_GENERATION_PREFIX = "disconnected:";
|
|
@@ -592,6 +615,9 @@ export class OAuthRefreshCoordinator {
|
|
|
592
615
|
// reaches the token endpoint.
|
|
593
616
|
if (requestSignal?.aborted)
|
|
594
617
|
throw aborted(requestSignal);
|
|
618
|
+
// The authorization server this redemption answers to, fixed now: a
|
|
619
|
+
// recovery save outlives the SDK flow that would have stamped it.
|
|
620
|
+
const issuer = provider.refreshIssuer(generation);
|
|
595
621
|
const flight = {
|
|
596
622
|
outcome: Deferred.makeUnsafe(),
|
|
597
623
|
stopObservingOwnerAbort: () => { },
|
|
@@ -601,7 +627,7 @@ export class OAuthRefreshCoordinator {
|
|
|
601
627
|
recoveryPending: false,
|
|
602
628
|
settled: false,
|
|
603
629
|
releasePartition: this.retainWork(),
|
|
604
|
-
persist: (tokens) => provider.saveAcceptedRefreshTokens(tokens, generation, flight),
|
|
630
|
+
persist: (tokens) => provider.saveAcceptedRefreshTokens(tokens, generation, flight, issuer),
|
|
605
631
|
};
|
|
606
632
|
this.flights.set(generation, flight);
|
|
607
633
|
this.advanceStateRevision();
|
|
@@ -790,6 +816,25 @@ export class OAuthRefreshCoordinator {
|
|
|
790
816
|
this.settle(generation, flight, { status: "retired" });
|
|
791
817
|
}
|
|
792
818
|
}
|
|
819
|
+
/**
|
|
820
|
+
* One refresh coordinator per owner partition, for one connector runtime.
|
|
821
|
+
* Long-lived enough for distinct request scopes to join one token redemption;
|
|
822
|
+
* it owns no client, transport, or request state. Production contexts carry a
|
|
823
|
+
* stable registry partition; hand-written contexts coordinate by their shared
|
|
824
|
+
* storage object instead.
|
|
825
|
+
*/
|
|
826
|
+
export function refreshCoordinatorsByPartition() {
|
|
827
|
+
const coordinators = new WeakMap();
|
|
828
|
+
return (ctx) => {
|
|
829
|
+
const partition = oauthPartitionFor(ctx) ?? ctx.storage;
|
|
830
|
+
let coordinator = coordinators.get(partition);
|
|
831
|
+
if (!coordinator) {
|
|
832
|
+
coordinator = new OAuthRefreshCoordinator(() => retainOAuthPartition(partition));
|
|
833
|
+
coordinators.set(partition, coordinator);
|
|
834
|
+
}
|
|
835
|
+
return coordinator;
|
|
836
|
+
};
|
|
837
|
+
}
|
|
793
838
|
function storedOAuthValue(value) {
|
|
794
839
|
if (!value || typeof value !== "object")
|
|
795
840
|
return false;
|
|
@@ -798,6 +843,7 @@ function storedOAuthValue(value) {
|
|
|
798
843
|
typeof candidate.generation === "string" &&
|
|
799
844
|
(candidate.issuer === undefined || typeof candidate.issuer === "string") &&
|
|
800
845
|
(candidate.binding === undefined || typeof candidate.binding === "string") &&
|
|
846
|
+
(candidate.carried === undefined || candidate.carried === true) &&
|
|
801
847
|
"value" in candidate);
|
|
802
848
|
}
|
|
803
849
|
function legacyStoredOAuthValue(value) {
|
|
@@ -1012,6 +1058,11 @@ export class KvOAuthProvider {
|
|
|
1012
1058
|
this.refreshCoordinator?.fail(owned.generation, owned.flight, error);
|
|
1013
1059
|
}
|
|
1014
1060
|
}
|
|
1061
|
+
/** @internal The issuer this flow's issuer-aware read bound its refresh to. */
|
|
1062
|
+
refreshIssuer(generation) {
|
|
1063
|
+
const basis = this.refreshBasis;
|
|
1064
|
+
return basis?.generation === generation ? basis.issuer : undefined;
|
|
1065
|
+
}
|
|
1015
1066
|
/** True when another request saved a refresh result after this flow's read. */
|
|
1016
1067
|
refreshBasisChanged(current, generation) {
|
|
1017
1068
|
const basis = this.refreshBasis;
|
|
@@ -1226,6 +1277,7 @@ export class KvOAuthProvider {
|
|
|
1226
1277
|
generation,
|
|
1227
1278
|
...(parsed.issuer !== undefined ? { issuer: parsed.issuer } : {}),
|
|
1228
1279
|
...(parsed.binding !== undefined ? { binding: parsed.binding } : {}),
|
|
1280
|
+
...(parsed.carried === true ? { carried: true } : {}),
|
|
1229
1281
|
// Only a finite number is a time; anything else reads as untimed.
|
|
1230
1282
|
...(typeof parsed.writtenAt === "number" &&
|
|
1231
1283
|
Number.isFinite(parsed.writtenAt)
|
|
@@ -1421,6 +1473,7 @@ export class KvOAuthProvider {
|
|
|
1421
1473
|
this.refreshBasis = {
|
|
1422
1474
|
accessToken: tokens.access_token,
|
|
1423
1475
|
generation: refreshGeneration,
|
|
1476
|
+
issuer: ctx.issuer,
|
|
1424
1477
|
...(tokens.refresh_token !== undefined
|
|
1425
1478
|
? { refreshToken: tokens.refresh_token }
|
|
1426
1479
|
: {}),
|
|
@@ -1438,9 +1491,16 @@ export class KvOAuthProvider {
|
|
|
1438
1491
|
await this.persistTokens(tokens, ctx, false, owned);
|
|
1439
1492
|
}
|
|
1440
1493
|
}
|
|
1441
|
-
/**
|
|
1442
|
-
|
|
1443
|
-
|
|
1494
|
+
/**
|
|
1495
|
+
* @internal Commit an already redeemed rotation even after its request
|
|
1496
|
+
* leaves. A token response names no issuer, so the one the refreshed grant
|
|
1497
|
+
* was bound to is stamped here as the SDK's own save would: an unbound
|
|
1498
|
+
* rotation would read as legacy state and bind to whichever authorization
|
|
1499
|
+
* server the connector names next.
|
|
1500
|
+
*/
|
|
1501
|
+
async saveAcceptedRefreshTokens(tokens, generation, flight, issuer) {
|
|
1502
|
+
const stamped = issuer !== undefined ? { ...tokens, issuer } : tokens;
|
|
1503
|
+
await this.persistAcceptedTokens(stamped, issuer !== undefined ? { issuer } : undefined, { generation, flight });
|
|
1444
1504
|
}
|
|
1445
1505
|
persistAcceptedTokens(tokens, ctx, owned) {
|
|
1446
1506
|
// The SDK can save the same response while cancellation recovery is
|
|
@@ -1542,13 +1602,27 @@ export class KvOAuthProvider {
|
|
|
1542
1602
|
* direction to tolerate clock skew between isolates. A URL with no write
|
|
1543
1603
|
* time — stored by an earlier release, or under a pre-envelope generation —
|
|
1544
1604
|
* is treated as stale, so the caller starts a fresh flow instead.
|
|
1605
|
+
*
|
|
1606
|
+
* The URL must also still name the epoch's client. A callback whose code
|
|
1607
|
+
* exchange the server refused with `invalid_client` leaves the URL behind
|
|
1608
|
+
* but drops the client; handing that URL back would send the operator to a
|
|
1609
|
+
* consent the server has already refused, where a fresh flow registers.
|
|
1545
1610
|
*/
|
|
1546
1611
|
async reusablePendingAuthorizationUrl() {
|
|
1547
1612
|
const pending = await this.readValue("oauth:pending", (raw) => raw);
|
|
1548
|
-
if (pending?.writtenAt === undefined
|
|
1613
|
+
if (pending?.writtenAt === undefined ||
|
|
1614
|
+
Math.abs(Date.now() - pending.writtenAt) >= PENDING_AUTHORIZATION_MAX_AGE_MS) {
|
|
1615
|
+
return undefined;
|
|
1616
|
+
}
|
|
1617
|
+
let clientId;
|
|
1618
|
+
try {
|
|
1619
|
+
clientId = new URL(pending.value).searchParams.get("client_id");
|
|
1620
|
+
}
|
|
1621
|
+
catch {
|
|
1549
1622
|
return undefined;
|
|
1550
|
-
|
|
1551
|
-
|
|
1623
|
+
}
|
|
1624
|
+
const client = await this.clientInformation();
|
|
1625
|
+
return clientId !== null && client?.client_id === clientId
|
|
1552
1626
|
? pending.value
|
|
1553
1627
|
: undefined;
|
|
1554
1628
|
}
|
|
@@ -1616,24 +1690,13 @@ export class KvOAuthProvider {
|
|
|
1616
1690
|
async performResetAuthorization(operatorDisconnected, preserveClient) {
|
|
1617
1691
|
const nonce = crypto.randomUUID();
|
|
1618
1692
|
const previous = await this.generation();
|
|
1619
|
-
// Only an explicitly forced restart may carry a registration forward
|
|
1620
|
-
//
|
|
1621
|
-
//
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
}
|
|
1627
|
-
catch {
|
|
1628
|
-
return {};
|
|
1629
|
-
}
|
|
1630
|
-
})
|
|
1631
|
-
: undefined;
|
|
1632
|
-
const reusableClient = priorClient?.generation === previous &&
|
|
1633
|
-
priorClient.issuer !== undefined &&
|
|
1634
|
-
priorClient.binding === this.clientBinding &&
|
|
1635
|
-
typeof priorClient.value?.client_id === "string"
|
|
1636
|
-
? priorClient
|
|
1693
|
+
// Only an explicitly forced restart may carry a registration forward, and
|
|
1694
|
+
// only one this connector registered: an operator disconnect, an issuer
|
|
1695
|
+
// mismatch, and every unforced reset discard it. Discovery is never
|
|
1696
|
+
// copied: the SDK must select the authorization server again, then its
|
|
1697
|
+
// issuer-aware clientInformation hook checks the issuer.
|
|
1698
|
+
const reusableClient = preserveClient && !operatorDisconnected && this.clientBinding
|
|
1699
|
+
? await this.carriableClient(previous)
|
|
1637
1700
|
: undefined;
|
|
1638
1701
|
const now = Date.now();
|
|
1639
1702
|
const inherited = await this.cleanupBacklog(previous, now);
|
|
@@ -1678,10 +1741,17 @@ export class KvOAuthProvider {
|
|
|
1678
1741
|
await this.storage.set("oauth:generation", active);
|
|
1679
1742
|
this.refreshCoordinator?.retire(previous);
|
|
1680
1743
|
if (reusableClient && !this.signal?.aborted) {
|
|
1681
|
-
// readValue opened the old physical key;
|
|
1744
|
+
// readValue opened the old physical key; the write seals the plaintext
|
|
1682
1745
|
// under the new one. Copying ciphertext would fail its AAD check.
|
|
1683
1746
|
this.captureGeneration(active);
|
|
1684
|
-
await this.
|
|
1747
|
+
await this.storeInGeneration("oauth:client", active, () => JSON.stringify({
|
|
1748
|
+
connectaOAuthVersion: STORED_VALUE_VERSION,
|
|
1749
|
+
generation: active,
|
|
1750
|
+
issuer: reusableClient.issuer,
|
|
1751
|
+
binding: reusableClient.binding,
|
|
1752
|
+
carried: true,
|
|
1753
|
+
value: reusableClient.value,
|
|
1754
|
+
}));
|
|
1685
1755
|
}
|
|
1686
1756
|
// Delete the generation just retired: its values, then the lineage it
|
|
1687
1757
|
// published, which the new epoch has copied. Then retry the newest few
|
|
@@ -1713,6 +1783,55 @@ export class KvOAuthProvider {
|
|
|
1713
1783
|
if (failure)
|
|
1714
1784
|
throw failure.reason;
|
|
1715
1785
|
}
|
|
1786
|
+
/**
|
|
1787
|
+
* The registration a forced restart may copy out of `previous`, or nothing.
|
|
1788
|
+
*
|
|
1789
|
+
* It must have been registered in that epoch for this authorization server
|
|
1790
|
+
* (an issuer stamp) under this connector's exact configuration, redirect
|
|
1791
|
+
* URI, and client metadata (the binding), and it is read through this
|
|
1792
|
+
* owner's own storage, so another principal's partition is never a source.
|
|
1793
|
+
* Three registrations are never carried:
|
|
1794
|
+
*
|
|
1795
|
+
* - A URL-based client (`clientMetadataUrl`). Nothing was registered, so
|
|
1796
|
+
* nothing is saved by copying it, and leaving it behind makes the SDK ask
|
|
1797
|
+
* the freshly discovered metadata whether the server still accepts one.
|
|
1798
|
+
* - One whose `client_secret_expires_at` has passed (RFC 7591: zero means
|
|
1799
|
+
* never). The token endpoint would refuse it.
|
|
1800
|
+
* - One a previous restart carried that has not since earned a grant. The
|
|
1801
|
+
* SDK builds a consent URL locally and sends nothing that could answer
|
|
1802
|
+
* `invalid_client`, and RFC 6749 section 4.1.2.1 forbids an authorization
|
|
1803
|
+
* server from redirecting a client it does not recognize, so a forgotten
|
|
1804
|
+
* registration never reaches the callback. Tokens in the epoch are the
|
|
1805
|
+
* only proof the server still knows the client. Without them the second
|
|
1806
|
+
* restart registers again, which bounds a forgotten registration to one
|
|
1807
|
+
* refused consent rather than a restart loop only a disconnect escapes.
|
|
1808
|
+
*/
|
|
1809
|
+
async carriableClient(previous) {
|
|
1810
|
+
const prior = await this.readValue("oauth:client", (raw) => {
|
|
1811
|
+
try {
|
|
1812
|
+
return JSON.parse(raw);
|
|
1813
|
+
}
|
|
1814
|
+
catch {
|
|
1815
|
+
return {};
|
|
1816
|
+
}
|
|
1817
|
+
});
|
|
1818
|
+
const clientId = prior?.value?.client_id;
|
|
1819
|
+
const expiresAt = prior?.value?.client_secret_expires_at;
|
|
1820
|
+
if (prior?.generation !== previous ||
|
|
1821
|
+
prior.issuer === undefined ||
|
|
1822
|
+
prior.binding === undefined ||
|
|
1823
|
+
prior.binding !== this.clientBinding ||
|
|
1824
|
+
typeof clientId !== "string" ||
|
|
1825
|
+
clientId === this.clientMetadataUrl ||
|
|
1826
|
+
(typeof expiresAt === "number" && expiresAt > 0 && expiresAt * 1000 <= Date.now())) {
|
|
1827
|
+
return undefined;
|
|
1828
|
+
}
|
|
1829
|
+
const carriable = { ...prior, issuer: prior.issuer, binding: prior.binding };
|
|
1830
|
+
if (!prior.carried)
|
|
1831
|
+
return carriable;
|
|
1832
|
+
const earnedGrant = (await this.storage.get(oauthValueStorageKey("oauth:tokens", previous))) !== null;
|
|
1833
|
+
return earnedGrant ? carriable : undefined;
|
|
1834
|
+
}
|
|
1716
1835
|
async invalidateCredentials(scope) {
|
|
1717
1836
|
const endsRefresh = scope === "all" || scope === "tokens";
|
|
1718
1837
|
try {
|