@retinue/agentkit 0.2.0 → 0.3.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 +1 -1
- package/dist/adapters/audio/openai.d.ts +36 -0
- package/dist/adapters/audio/openai.js +136 -0
- package/dist/adapters/bullmq/lock.d.ts +26 -2
- package/dist/adapters/memory/connections.d.ts +10 -0
- package/dist/adapters/memory/connections.js +87 -0
- package/dist/adapters/memory/graph.d.ts +17 -0
- package/dist/adapters/memory/graph.js +267 -0
- package/dist/adapters/memory/index.d.ts +3 -0
- package/dist/adapters/memory/index.js +3 -0
- package/dist/adapters/memory/rate-limit.d.ts +14 -0
- package/dist/adapters/memory/rate-limit.js +28 -0
- package/dist/adapters/memory/runtime.js +0 -0
- package/dist/adapters/postgres/connections.d.ts +15 -0
- package/dist/adapters/postgres/connections.js +134 -0
- package/dist/adapters/postgres/graph.d.ts +24 -0
- package/dist/adapters/postgres/graph.js +325 -0
- package/dist/adapters/postgres/index.d.ts +2 -0
- package/dist/adapters/postgres/index.js +2 -0
- package/dist/adapters/postgres/migrations.js +198 -0
- package/dist/adapters/postgres/run-store.js +26 -2
- package/dist/adapters/postgres/schema.d.ts +61 -2
- package/dist/adapters/postgres/schema.js +95 -10
- package/dist/adapters/redis/index.d.ts +1 -0
- package/dist/adapters/redis/index.js +1 -0
- package/dist/adapters/redis/rate-limit.d.ts +46 -0
- package/dist/adapters/redis/rate-limit.js +55 -0
- package/dist/adapters/supabase/index.d.ts +15 -0
- package/dist/adapters/supabase/index.js +15 -0
- package/dist/adapters/supabase/rls.js +33 -0
- package/dist/agents/agent.d.ts +78 -1
- package/dist/agents/agent.js +118 -5
- package/dist/agents/define.js +24 -4
- package/dist/agents/engine.d.ts +20 -0
- package/dist/agents/engine.js +177 -6
- package/dist/audio/index.d.ts +151 -0
- package/dist/audio/index.js +183 -0
- package/dist/connections/cipher.d.ts +103 -0
- package/dist/connections/cipher.js +141 -0
- package/dist/connections/index.d.ts +156 -0
- package/dist/connections/index.js +72 -0
- package/dist/connections/oauth/client.d.ts +84 -0
- package/dist/connections/oauth/client.js +141 -0
- package/dist/connections/oauth/index.d.ts +166 -0
- package/dist/connections/oauth/index.js +226 -0
- package/dist/connections/oauth/service.d.ts +90 -0
- package/dist/connections/oauth/service.js +132 -0
- package/dist/connections/pause.d.ts +68 -0
- package/dist/connections/pause.js +97 -0
- package/dist/connections/resolver.d.ts +59 -0
- package/dist/connections/resolver.js +138 -0
- package/dist/connections/resume.d.ts +64 -0
- package/dist/connections/resume.js +99 -0
- package/dist/connections/vault-cipher.d.ts +95 -0
- package/dist/connections/vault-cipher.js +206 -0
- package/dist/core/content-parts.d.ts +18 -2
- package/dist/core/content-parts.js +1 -0
- package/dist/core/context.d.ts +25 -0
- package/dist/core/errors.d.ts +1 -1
- package/dist/core/errors.js +15 -0
- package/dist/core/events.d.ts +44 -2
- package/dist/core/events.js +31 -2
- package/dist/core/validation.js +19 -0
- package/dist/entries/connections.d.ts +10 -0
- package/dist/entries/connections.js +10 -0
- package/dist/entries/knowledge.d.ts +8 -0
- package/dist/entries/knowledge.js +8 -0
- package/dist/entries/mcp-server.d.ts +9 -0
- package/dist/entries/mcp-server.js +9 -0
- package/dist/entries/runtime.d.ts +10 -0
- package/dist/entries/runtime.js +8 -0
- package/dist/entries/testing.d.ts +28 -0
- package/dist/entries/testing.js +28 -0
- package/dist/graphql/resolvers.d.ts +3 -1
- package/dist/graphql/resolvers.js +14 -0
- package/dist/knowledge/communities.d.ts +166 -0
- package/dist/knowledge/communities.js +377 -0
- package/dist/knowledge/graph-global.d.ts +153 -0
- package/dist/knowledge/graph-global.js +246 -0
- package/dist/knowledge/graph-retrieval.d.ts +150 -0
- package/dist/knowledge/graph-retrieval.js +303 -0
- package/dist/knowledge/graph.d.ts +223 -0
- package/dist/knowledge/graph.js +369 -0
- package/dist/knowledge/index.d.ts +24 -0
- package/dist/knowledge/index.js +36 -1
- package/dist/knowledge/retrieval.d.ts +26 -1
- package/dist/knowledge/retrieval.js +77 -1
- package/dist/mcp-server/index.d.ts +168 -0
- package/dist/mcp-server/index.js +175 -0
- package/dist/models/extraction.d.ts +73 -0
- package/dist/models/extraction.js +118 -0
- package/dist/models/index.d.ts +40 -1
- package/dist/models/index.js +22 -1
- package/dist/models/pricing.d.ts +2 -0
- package/dist/models/pricing.js +17 -1
- package/dist/models/provider-factory.js +9 -7
- package/dist/models/streaming.d.ts +89 -0
- package/dist/models/streaming.js +179 -2
- package/dist/persistence/index.d.ts +230 -0
- package/dist/persistence/index.js +2 -0
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.js +12 -0
- package/dist/runtime/worker.d.ts +36 -1
- package/dist/runtime/worker.js +37 -4
- package/dist/security/checklist.js +46 -0
- package/dist/server/bin.d.ts +19 -0
- package/dist/server/bin.js +226 -0
- package/dist/server/boot.d.ts +20 -2
- package/dist/server/boot.js +29 -3
- package/dist/server/cli.d.ts +13 -0
- package/dist/server/cli.js +7 -4
- package/dist/server/doctor.d.ts +74 -0
- package/dist/server/doctor.js +280 -0
- package/dist/skills/context.d.ts +61 -0
- package/dist/skills/context.js +107 -0
- package/dist/skills/index.d.ts +1 -0
- package/dist/skills/index.js +1 -0
- package/dist/telemetry/spans.js +5 -0
- package/dist/testing/conformance/artifact-exports.d.ts +20 -0
- package/dist/testing/conformance/artifact-exports.js +172 -0
- package/dist/testing/conformance/artifacts.d.ts +11 -0
- package/dist/testing/conformance/artifacts.js +316 -0
- package/dist/testing/conformance/audio.d.ts +38 -0
- package/dist/testing/conformance/audio.js +145 -0
- package/dist/testing/conformance/capability.d.ts +23 -0
- package/dist/testing/conformance/capability.js +27 -0
- package/dist/testing/conformance/checkpoint-store.d.ts +15 -0
- package/dist/testing/conformance/checkpoint-store.js +72 -0
- package/dist/testing/conformance/connections.d.ts +14 -0
- package/dist/testing/conformance/connections.js +171 -0
- package/dist/testing/conformance/conversation-store.d.ts +10 -0
- package/dist/testing/conformance/conversation-store.js +60 -0
- package/dist/testing/conformance/evaluation.d.ts +10 -0
- package/dist/testing/conformance/evaluation.js +295 -0
- package/dist/testing/conformance/files.d.ts +19 -0
- package/dist/testing/conformance/files.js +454 -0
- package/dist/testing/conformance/flows.d.ts +16 -0
- package/dist/testing/conformance/flows.js +193 -0
- package/dist/testing/conformance/graph.d.ts +22 -0
- package/dist/testing/conformance/graph.js +500 -0
- package/dist/testing/conformance/hitl.d.ts +25 -0
- package/dist/testing/conformance/hitl.js +523 -0
- package/dist/testing/conformance/index.d.ts +163 -0
- package/dist/testing/conformance/index.js +321 -0
- package/dist/testing/conformance/invariants.d.ts +23 -0
- package/dist/testing/conformance/invariants.js +80 -0
- package/dist/testing/conformance/knowledge.d.ts +41 -0
- package/dist/testing/conformance/knowledge.js +592 -0
- package/dist/testing/conformance/parents.d.ts +50 -0
- package/dist/testing/conformance/parents.js +39 -0
- package/dist/testing/conformance/rate-limit.d.ts +25 -0
- package/dist/testing/conformance/rate-limit.js +71 -0
- package/dist/testing/conformance/records.d.ts +36 -0
- package/dist/testing/conformance/records.js +400 -0
- package/dist/testing/conformance/rollups.d.ts +30 -0
- package/dist/testing/conformance/rollups.js +425 -0
- package/dist/testing/conformance/run-coordinator.d.ts +27 -0
- package/dist/testing/conformance/run-coordinator.js +150 -0
- package/dist/testing/conformance/run-event-log.d.ts +9 -0
- package/dist/testing/conformance/run-event-log.js +138 -0
- package/dist/testing/conformance/run-store.d.ts +12 -0
- package/dist/testing/conformance/run-store.js +294 -0
- package/dist/testing/conformance/session-state.d.ts +38 -0
- package/dist/testing/conformance/session-state.js +198 -0
- package/dist/testing/conformance/usage-limits.d.ts +10 -0
- package/dist/testing/conformance/usage-limits.js +309 -0
- package/dist/testing/conformance.d.ts +8 -0
- package/dist/testing/conformance.js +8 -0
- package/dist/testing/memory-backend.d.ts +39 -0
- package/dist/testing/memory-backend.js +41 -0
- package/dist/testing/pglite.d.ts +55 -0
- package/dist/testing/pglite.js +78 -0
- package/dist/testing/stub-model.d.ts +58 -0
- package/dist/testing/stub-model.js +71 -0
- package/dist/testing/supabase-storage-double.d.ts +35 -0
- package/dist/testing/supabase-storage-double.js +101 -0
- package/dist/toolkit/http.d.ts +27 -0
- package/dist/toolkit/http.js +48 -0
- package/dist/toolkit/index.d.ts +27 -1
- package/dist/toolkit/index.js +24 -1
- package/dist/toolkit/mime.d.ts +97 -0
- package/dist/toolkit/mime.js +233 -0
- package/dist/toolkit/ssrf.d.ts +139 -0
- package/dist/toolkit/ssrf.js +349 -0
- package/dist/toolkit/vendor.d.ts +91 -0
- package/dist/toolkit/vendor.js +153 -0
- package/dist/tools/credentials.d.ts +265 -6
- package/dist/tools/credentials.js +318 -3
- package/dist/tools/define.d.ts +14 -0
- package/dist/tools/define.js +3 -0
- package/dist/tools/index.d.ts +21 -0
- package/dist/tools/index.js +7 -0
- package/dist/tools/library/audio.d.ts +63 -0
- package/dist/tools/library/audio.js +148 -0
- package/dist/tools/library/index.d.ts +8 -0
- package/dist/tools/library/index.js +7 -0
- package/dist/tools/registry.d.ts +33 -0
- package/dist/tools/registry.js +72 -2
- package/dist/usage/index.d.ts +21 -0
- package/dist/usage/index.js +1 -0
- package/dist/usage/rate-limit.d.ts +122 -0
- package/dist/usage/rate-limit.js +131 -0
- package/dist/usage/recorder.js +3 -0
- package/package.json +29 -2
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which OAuth app a tenant connects through — REQ-063 (#259), task #263.
|
|
3
|
+
*
|
|
4
|
+
* Two ways to reach a provider, and both must work.
|
|
5
|
+
*
|
|
6
|
+
* **The deployment's shared app.** One registration per provider; every tenant consents to it. Simplest for the
|
|
7
|
+
* customer — nothing to register — and the path most will take.
|
|
8
|
+
*
|
|
9
|
+
* **The tenant's own app.** A customer who already has a registered Slack app, GitHub App or Google Cloud
|
|
10
|
+
* project uses their own client id and secret. This is not a nicety. Meta's app review is per app, so a shared
|
|
11
|
+
* app's approved use case may not cover a customer's; X's access tier is per app, so a customer paying for a
|
|
12
|
+
* higher tier gains nothing from a shared one on a lower; and an enterprise whose security team will not
|
|
13
|
+
* approve a third-party app in their Google tenant has no other route.
|
|
14
|
+
*
|
|
15
|
+
* ## The fallback is explicit, never silent
|
|
16
|
+
*
|
|
17
|
+
* `resolveOAuthClient` reports **which** app it chose. A caller that logs or displays it can see the difference;
|
|
18
|
+
* one that ignores it behaves as before. The reason is AC-7: removing a tenant's app configuration must not
|
|
19
|
+
* quietly migrate their live connections to the shared app, because that is a credential swap nobody
|
|
20
|
+
* authorised — the tokens were issued by a client that no longer participates, and the shared app cannot
|
|
21
|
+
* refresh or revoke them.
|
|
22
|
+
*/
|
|
23
|
+
import { AgentPlatformError } from "../../core/errors.js";
|
|
24
|
+
import { assertSecureEndpoint, isAllowedRedirect } from "./index.js";
|
|
25
|
+
/**
|
|
26
|
+
* A tenant's registration, stored as an `oauth-app` row.
|
|
27
|
+
*
|
|
28
|
+
* The client secret is sealed by the same `SecretCipher` as every token — AC-2 — because it is exactly as much
|
|
29
|
+
* a secret and exactly as much the tenant's. The client id, redirect URI and scopes are metadata, readable,
|
|
30
|
+
* because a settings screen should render without a key.
|
|
31
|
+
*/
|
|
32
|
+
export const registerTenantOAuthApp = async (input) => {
|
|
33
|
+
if (input.redirectUris.length === 0)
|
|
34
|
+
throw new AgentPlatformError({
|
|
35
|
+
code: "invalid_input",
|
|
36
|
+
message: "a tenant's OAuth app must declare at least one redirect URI, or no flow could ever complete",
|
|
37
|
+
retryable: false,
|
|
38
|
+
});
|
|
39
|
+
return input.store.create({
|
|
40
|
+
tenantId: input.context.tenantId,
|
|
41
|
+
connection: {
|
|
42
|
+
id: input.id ?? `${input.provider}-app`,
|
|
43
|
+
kind: "oauth-app",
|
|
44
|
+
provider: input.provider,
|
|
45
|
+
...(input.label === undefined ? {} : { label: input.label }),
|
|
46
|
+
mode: "oauth2",
|
|
47
|
+
scheme: "bearer",
|
|
48
|
+
metadata: {
|
|
49
|
+
clientId: input.clientId,
|
|
50
|
+
redirectUris: input.redirectUris.join(" "),
|
|
51
|
+
...(input.scopes === undefined ? {} : { scopes: input.scopes.join(" ") }),
|
|
52
|
+
},
|
|
53
|
+
sealed: await input.cipher.seal(input.clientSecret),
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* The client this tenant connects through — theirs if registered, otherwise the deployment's.
|
|
59
|
+
*
|
|
60
|
+
* A tenant's own app brings its **own** redirect URIs, and they are still allowlisted: this is the obvious place
|
|
61
|
+
* that check gets loosened into a wildcard to make BYO work, and the whole of #262's second defence would go
|
|
62
|
+
* with it. The registration's URIs simply *become* the allowlist for that tenant's flow — an allowlist chosen by
|
|
63
|
+
* the customer, not a pattern.
|
|
64
|
+
*/
|
|
65
|
+
export const resolveOAuthClient = async (input) => {
|
|
66
|
+
const registrations = await input.store.list({
|
|
67
|
+
tenantId: input.context.tenantId,
|
|
68
|
+
provider: input.config.provider,
|
|
69
|
+
kind: "oauth-app",
|
|
70
|
+
});
|
|
71
|
+
const registration = registrations[0];
|
|
72
|
+
if (registration === undefined) {
|
|
73
|
+
return {
|
|
74
|
+
clientId: input.config.clientId,
|
|
75
|
+
...(input.config.clientSecret === undefined ? {} : { clientSecret: input.config.clientSecret }),
|
|
76
|
+
redirectUris: input.config.redirectUris,
|
|
77
|
+
scopes: input.config.scopes,
|
|
78
|
+
source: "deployment",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const clientId = registration.metadata?.clientId;
|
|
82
|
+
const redirectUris = (registration.metadata?.redirectUris ?? "").split(" ").filter(Boolean);
|
|
83
|
+
if (clientId === undefined || redirectUris.length === 0) {
|
|
84
|
+
// A half-written registration is refused rather than silently falling back: falling back here is exactly
|
|
85
|
+
// the credential swap AC-7 forbids, and it would happen at the worst moment — mid-migration.
|
|
86
|
+
throw new AgentPlatformError({
|
|
87
|
+
code: "invalid_input",
|
|
88
|
+
message: `this workspace's ${input.config.provider} app registration is incomplete (needs metadata.clientId and ` +
|
|
89
|
+
"metadata.redirectUris). Refusing rather than falling back to the shared app: that would issue tokens " +
|
|
90
|
+
"from a different client than the one your existing connections were created with.",
|
|
91
|
+
retryable: false,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
for (const uri of redirectUris)
|
|
95
|
+
assertSecureEndpoint("a tenant redirect URI", uri);
|
|
96
|
+
const scopes = (registration.metadata?.scopes ?? "").split(" ").filter(Boolean);
|
|
97
|
+
return {
|
|
98
|
+
clientId,
|
|
99
|
+
clientSecret: await input.cipher.open(registration.sealed),
|
|
100
|
+
redirectUris,
|
|
101
|
+
// A tenant's app may be approved for a different scope set — Meta's review is per app.
|
|
102
|
+
scopes: scopes.length > 0 ? scopes : input.config.scopes,
|
|
103
|
+
source: "tenant",
|
|
104
|
+
registrationId: registration.id,
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
/** The provider config a flow should run with, for this tenant. */
|
|
108
|
+
export const configForTenant = (base, client) => ({
|
|
109
|
+
...base,
|
|
110
|
+
clientId: client.clientId,
|
|
111
|
+
...(client.clientSecret === undefined ? {} : { clientSecret: client.clientSecret }),
|
|
112
|
+
// Still an allowlist, and still matched exactly — see the note on `resolveOAuthClient`.
|
|
113
|
+
redirectUris: client.redirectUris,
|
|
114
|
+
scopes: client.scopes,
|
|
115
|
+
});
|
|
116
|
+
/**
|
|
117
|
+
* Refuses to use a connection with a client that did not issue it — AC-4.
|
|
118
|
+
*
|
|
119
|
+
* A token obtained through one app is not usable through another: refresh and revocation both authenticate as
|
|
120
|
+
* the client, so a connection whose client changed underneath it fails at the provider with a message about an
|
|
121
|
+
* invalid client — which reads as "your integration is broken" and sends nobody to the actual cause.
|
|
122
|
+
*
|
|
123
|
+
* Failing loudly here, naming both clients, is the difference between a five-minute fix and an afternoon.
|
|
124
|
+
*/
|
|
125
|
+
export const assertClientMatches = (connection, client) => {
|
|
126
|
+
const issuedBy = connection.metadata?.clientId;
|
|
127
|
+
// A connection created before client ids were recorded cannot be checked. Refusing those would break every
|
|
128
|
+
// existing connection to enforce a property they predate.
|
|
129
|
+
if (issuedBy === undefined || issuedBy === client.clientId)
|
|
130
|
+
return;
|
|
131
|
+
throw new AgentPlatformError({
|
|
132
|
+
code: "capability_unavailable",
|
|
133
|
+
message: `this ${connection.provider} connection was created with OAuth client "${issuedBy}" and this workspace ` +
|
|
134
|
+
`now resolves to "${client.clientId}". Refresh and revocation both authenticate as the client, so it ` +
|
|
135
|
+
"cannot be used — reconnect, or restore the previous app registration.",
|
|
136
|
+
retryable: false,
|
|
137
|
+
});
|
|
138
|
+
};
|
|
139
|
+
/** Whether a redirect is allowed for this tenant's resolved client. Exactly matched, as always. */
|
|
140
|
+
export const isAllowedRedirectForClient = (client, candidate) => isAllowedRedirect(client.redirectUris, candidate);
|
|
141
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth 2.0, authorization-code with PKCE — REQ-063 (#259), task #262.
|
|
3
|
+
*
|
|
4
|
+
* Shipped so a deployment mounts a flow rather than implementing one. That matters beyond convenience: this is
|
|
5
|
+
* the piece where a mistake is *exploitable* rather than merely broken, and twenty deployments writing it
|
|
6
|
+
* themselves is twenty chances at the same three mistakes.
|
|
7
|
+
*
|
|
8
|
+
* ## The three ways an OAuth callback is attacked
|
|
9
|
+
*
|
|
10
|
+
* Each has its own defence here and its own sabotage test. None is theoretical; all three are routine findings.
|
|
11
|
+
*
|
|
12
|
+
* **1. No `state`, or a `state` not bound to the session.** An attacker completes their own consent and gets
|
|
13
|
+
* their code delivered into the victim's session — the victim's tenant ends up holding an attacker-controlled
|
|
14
|
+
* connection, which is a *login CSRF* and is worse than it sounds: every subsequent action the agent takes
|
|
15
|
+
* against that provider is the attacker's account. So `state` is single-use, TTL-bounded, and bound to the
|
|
16
|
+
* tenant *and* the principal who started it, verified before the code is touched.
|
|
17
|
+
*
|
|
18
|
+
* **2. An unallowlisted `redirect_uri`.** Any reflection of a caller-supplied redirect turns the callback into
|
|
19
|
+
* an open redirect and the code into a token somebody else holds. Redirect URIs come from configuration and are
|
|
20
|
+
* matched **exactly** — no prefix matching, which `https://app.example.com.evil.tld` defeats, and no wildcard
|
|
21
|
+
* subdomains.
|
|
22
|
+
*
|
|
23
|
+
* **3. No PKCE.** With a public client, or an intercepted code, the verifier is what stops the exchange. Used
|
|
24
|
+
* everywhere the provider supports it rather than only where a client secret is absent, because "we have a
|
|
25
|
+
* secret so we do not need PKCE" is an argument about one threat and PKCE defends another.
|
|
26
|
+
*
|
|
27
|
+
* ## What is deliberately not here
|
|
28
|
+
*
|
|
29
|
+
* No transport. This produces a URL, consumes a callback, and returns a `Connection` — a host mounts it on its
|
|
30
|
+
* own router with its own authentication, exactly as `./mcp-server` leaves the transport to the host. A
|
|
31
|
+
* convenience wrapper would have to guess at the authentication, and guessing about authentication is how a
|
|
32
|
+
* surface ends up open.
|
|
33
|
+
*/
|
|
34
|
+
import type { ExecutionContext } from "../../core/context.js";
|
|
35
|
+
/** How long an authorization attempt may stay open. Long enough to read a consent screen, not to leave open. */
|
|
36
|
+
export declare const DEFAULT_STATE_TTL_MS: number;
|
|
37
|
+
export type OAuthProviderConfig = {
|
|
38
|
+
readonly provider: string;
|
|
39
|
+
readonly authorizationUrl: string;
|
|
40
|
+
readonly tokenUrl: string;
|
|
41
|
+
readonly clientId: string;
|
|
42
|
+
/** Absent for a public client. PKCE is used either way. */
|
|
43
|
+
readonly clientSecret?: string;
|
|
44
|
+
readonly scopes: readonly string[];
|
|
45
|
+
/**
|
|
46
|
+
* Every redirect URI this provider may return to. **Matched exactly.**
|
|
47
|
+
*
|
|
48
|
+
* A list rather than one value, because a deployment legitimately has several environments — and a list is
|
|
49
|
+
* still an allowlist. What it must never become is a prefix or a pattern.
|
|
50
|
+
*/
|
|
51
|
+
readonly redirectUris: readonly string[];
|
|
52
|
+
/** Some providers need extra authorization parameters (`access_type=offline`, `prompt=consent`). */
|
|
53
|
+
readonly extraAuthorizationParams?: Readonly<Record<string, string>>;
|
|
54
|
+
/** Providers that reject PKCE outright. Absent means use it — the safe default. */
|
|
55
|
+
readonly usePkce?: boolean;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* One in-flight authorization attempt.
|
|
59
|
+
*
|
|
60
|
+
* The verifier is here and **never leaves the server**: sending it to the browser would defeat PKCE entirely,
|
|
61
|
+
* since the whole point is that only the party that began the flow can finish it.
|
|
62
|
+
*/
|
|
63
|
+
export type OAuthAttempt = {
|
|
64
|
+
readonly state: string;
|
|
65
|
+
readonly provider: string;
|
|
66
|
+
readonly tenantId: string;
|
|
67
|
+
/** Who began it. A code must not be redeemable by a different person in the same tenant. */
|
|
68
|
+
readonly principalId: string;
|
|
69
|
+
readonly redirectUri: string;
|
|
70
|
+
readonly codeVerifier?: string;
|
|
71
|
+
readonly expiresAt: number;
|
|
72
|
+
/** Where to send the person afterwards. Validated by the host, not reflected from the request. */
|
|
73
|
+
readonly returnTo?: string;
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* Where in-flight attempts live.
|
|
77
|
+
*
|
|
78
|
+
* A port, because the answer differs by deployment shape: one process can hold a `Map`, and several behind a
|
|
79
|
+
* load balancer cannot — the callback may land on a different instance from the one that started the flow.
|
|
80
|
+
*
|
|
81
|
+
* `consume` is **take-once**: it returns the attempt and removes it in the same step. A `get` followed by a
|
|
82
|
+
* `delete` is a replay window, and a replay window is the first attack in the list above.
|
|
83
|
+
*
|
|
84
|
+
* **The store does not judge expiry.** The flow writes `expiresAt` from its own clock and checks it with the
|
|
85
|
+
* same one; a store that also checked would be a *second* clock deciding one fact, and injecting a clock into
|
|
86
|
+
* one and not the other yields attempts that are expired the instant they are written. Found by a test doing
|
|
87
|
+
* exactly that. The store still drops stale entries as housekeeping — that is a memory concern, not a security
|
|
88
|
+
* decision.
|
|
89
|
+
*/
|
|
90
|
+
export interface OAuthAttemptStore {
|
|
91
|
+
put(attempt: OAuthAttempt): Promise<void>;
|
|
92
|
+
/** Returns and removes. `null` only for a state this store does not have. */
|
|
93
|
+
consume(state: string): Promise<OAuthAttempt | null>;
|
|
94
|
+
}
|
|
95
|
+
/** Single-process store. Adequate for one instance; see the port's note for why that is a real limit. */
|
|
96
|
+
export declare const createMemoryOAuthAttemptStore: (now?: () => number) => OAuthAttemptStore;
|
|
97
|
+
/** RFC 7636 S256. Plain is not offered: it is PKCE in name only. */
|
|
98
|
+
export declare const codeChallengeOf: (verifier: string) => string;
|
|
99
|
+
/**
|
|
100
|
+
* Exact match, and the reason it is a function rather than an `includes` is that it must stay one.
|
|
101
|
+
*
|
|
102
|
+
* `https://app.example.com.evil.tld/callback` starts with `https://app.example.com`, so a prefix check accepts
|
|
103
|
+
* an attacker's host. A `URL`-based comparison would also be wrong in a subtler way: it normalises, so a
|
|
104
|
+
* trailing slash or a default port could make two different strings compare equal and widen the allowlist by
|
|
105
|
+
* accident.
|
|
106
|
+
*/
|
|
107
|
+
export declare const isAllowedRedirect: (allowlist: readonly string[], candidate: string) => boolean;
|
|
108
|
+
export type StartInput = {
|
|
109
|
+
readonly context: ExecutionContext;
|
|
110
|
+
readonly redirectUri: string;
|
|
111
|
+
/** Extra scopes beyond the provider's defaults — a tenant's own app may need different ones (#263). */
|
|
112
|
+
readonly scopes?: readonly string[];
|
|
113
|
+
readonly returnTo?: string;
|
|
114
|
+
};
|
|
115
|
+
export type OAuthFlowDeps = {
|
|
116
|
+
readonly config: OAuthProviderConfig;
|
|
117
|
+
readonly attempts: OAuthAttemptStore;
|
|
118
|
+
readonly now?: () => number;
|
|
119
|
+
readonly randomBytesOf?: (size: number) => Buffer;
|
|
120
|
+
readonly stateTtlMs?: number;
|
|
121
|
+
/** Injected so the exchange is testable without a provider. */
|
|
122
|
+
readonly fetchImpl?: typeof fetch;
|
|
123
|
+
};
|
|
124
|
+
export type TokenResponse = {
|
|
125
|
+
readonly accessToken: string;
|
|
126
|
+
readonly refreshToken?: string;
|
|
127
|
+
readonly expiresAt?: string;
|
|
128
|
+
readonly grantedScopes?: readonly string[];
|
|
129
|
+
/** Everything else the provider returned that is not a secret — Atlassian's cloud id, Slack's team id. */
|
|
130
|
+
readonly metadata?: Readonly<Record<string, string>>;
|
|
131
|
+
};
|
|
132
|
+
/**
|
|
133
|
+
* Refuses a provider endpoint that is not HTTPS — checked at construction.
|
|
134
|
+
*
|
|
135
|
+
* The token request carries the **client secret and the authorization code** in its body. Over cleartext, both
|
|
136
|
+
* are readable by anything on the path, and the failure is completely silent: the flow works.
|
|
137
|
+
*
|
|
138
|
+
* `localhost` is allowed over HTTP, because a provider emulator on a developer's machine is a real and harmless
|
|
139
|
+
* case, and refusing it would push people to disable the check entirely — which is how a check stops applying
|
|
140
|
+
* in production too.
|
|
141
|
+
*/
|
|
142
|
+
export declare const assertSecureEndpoint: (label: string, value: string) => void;
|
|
143
|
+
export declare const createOAuthFlow: (deps: OAuthFlowDeps) => {
|
|
144
|
+
/** The URL to send a person to, and the attempt recorded server-side. */
|
|
145
|
+
start(input: StartInput): Promise<{
|
|
146
|
+
readonly url: string;
|
|
147
|
+
readonly state: string;
|
|
148
|
+
}>;
|
|
149
|
+
/**
|
|
150
|
+
* Verifies the callback and exchanges the code.
|
|
151
|
+
*
|
|
152
|
+
* **The state is verified before the code is touched.** Every check that can be made without contacting the
|
|
153
|
+
* provider is made first, so an attacker probing the callback never causes an outbound request.
|
|
154
|
+
*/
|
|
155
|
+
callback(input: {
|
|
156
|
+
readonly state: string;
|
|
157
|
+
readonly code: string;
|
|
158
|
+
readonly context: ExecutionContext;
|
|
159
|
+
}): Promise<{
|
|
160
|
+
readonly tokens: TokenResponse;
|
|
161
|
+
readonly attempt: OAuthAttempt;
|
|
162
|
+
}>;
|
|
163
|
+
};
|
|
164
|
+
/** Constant-time state comparison, for a caller checking one against another outside the store. */
|
|
165
|
+
export declare const stateEquals: (a: string, b: string) => boolean;
|
|
166
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth 2.0, authorization-code with PKCE — REQ-063 (#259), task #262.
|
|
3
|
+
*
|
|
4
|
+
* Shipped so a deployment mounts a flow rather than implementing one. That matters beyond convenience: this is
|
|
5
|
+
* the piece where a mistake is *exploitable* rather than merely broken, and twenty deployments writing it
|
|
6
|
+
* themselves is twenty chances at the same three mistakes.
|
|
7
|
+
*
|
|
8
|
+
* ## The three ways an OAuth callback is attacked
|
|
9
|
+
*
|
|
10
|
+
* Each has its own defence here and its own sabotage test. None is theoretical; all three are routine findings.
|
|
11
|
+
*
|
|
12
|
+
* **1. No `state`, or a `state` not bound to the session.** An attacker completes their own consent and gets
|
|
13
|
+
* their code delivered into the victim's session — the victim's tenant ends up holding an attacker-controlled
|
|
14
|
+
* connection, which is a *login CSRF* and is worse than it sounds: every subsequent action the agent takes
|
|
15
|
+
* against that provider is the attacker's account. So `state` is single-use, TTL-bounded, and bound to the
|
|
16
|
+
* tenant *and* the principal who started it, verified before the code is touched.
|
|
17
|
+
*
|
|
18
|
+
* **2. An unallowlisted `redirect_uri`.** Any reflection of a caller-supplied redirect turns the callback into
|
|
19
|
+
* an open redirect and the code into a token somebody else holds. Redirect URIs come from configuration and are
|
|
20
|
+
* matched **exactly** — no prefix matching, which `https://app.example.com.evil.tld` defeats, and no wildcard
|
|
21
|
+
* subdomains.
|
|
22
|
+
*
|
|
23
|
+
* **3. No PKCE.** With a public client, or an intercepted code, the verifier is what stops the exchange. Used
|
|
24
|
+
* everywhere the provider supports it rather than only where a client secret is absent, because "we have a
|
|
25
|
+
* secret so we do not need PKCE" is an argument about one threat and PKCE defends another.
|
|
26
|
+
*
|
|
27
|
+
* ## What is deliberately not here
|
|
28
|
+
*
|
|
29
|
+
* No transport. This produces a URL, consumes a callback, and returns a `Connection` — a host mounts it on its
|
|
30
|
+
* own router with its own authentication, exactly as `./mcp-server` leaves the transport to the host. A
|
|
31
|
+
* convenience wrapper would have to guess at the authentication, and guessing about authentication is how a
|
|
32
|
+
* surface ends up open.
|
|
33
|
+
*/
|
|
34
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
35
|
+
import { AgentPlatformError } from "../../core/errors.js";
|
|
36
|
+
/** How long an authorization attempt may stay open. Long enough to read a consent screen, not to leave open. */
|
|
37
|
+
export const DEFAULT_STATE_TTL_MS = 10 * 60 * 1000;
|
|
38
|
+
/** Single-process store. Adequate for one instance; see the port's note for why that is a real limit. */
|
|
39
|
+
export const createMemoryOAuthAttemptStore = (now = Date.now) => {
|
|
40
|
+
const attempts = new Map();
|
|
41
|
+
return {
|
|
42
|
+
async put(attempt) {
|
|
43
|
+
// Expired entries are dropped on write rather than by a timer: a timer keeps the process alive and needs
|
|
44
|
+
// clearing, and the set is small by construction.
|
|
45
|
+
for (const [key, value] of attempts)
|
|
46
|
+
if (value.expiresAt <= now())
|
|
47
|
+
attempts.delete(key);
|
|
48
|
+
attempts.set(attempt.state, attempt);
|
|
49
|
+
},
|
|
50
|
+
async consume(state) {
|
|
51
|
+
const attempt = attempts.get(state);
|
|
52
|
+
// Deleted whether or not it is expired: a state presented once is spent, and leaving an expired one
|
|
53
|
+
// behind is a row that can be presented again after a clock correction. Expiry itself is the *flow's*
|
|
54
|
+
// judgement — see the port's note.
|
|
55
|
+
attempts.delete(state);
|
|
56
|
+
return attempt ?? null;
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
const base64url = (buffer) => buffer.toString("base64url");
|
|
61
|
+
/** RFC 7636 S256. Plain is not offered: it is PKCE in name only. */
|
|
62
|
+
export const codeChallengeOf = (verifier) => base64url(createHash("sha256").update(verifier).digest());
|
|
63
|
+
function refuse(message, code = "invalid_input") {
|
|
64
|
+
// A function declaration, not an arrow: TypeScript narrows on a `never`-returning *declaration* used as a
|
|
65
|
+
// statement, and an arrow assigned to a const does not narrow the code after the call. Without this the
|
|
66
|
+
// callback below reads as "attempt is possibly null" everywhere after the guard.
|
|
67
|
+
throw new AgentPlatformError({ code, message, retryable: false });
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Exact match, and the reason it is a function rather than an `includes` is that it must stay one.
|
|
71
|
+
*
|
|
72
|
+
* `https://app.example.com.evil.tld/callback` starts with `https://app.example.com`, so a prefix check accepts
|
|
73
|
+
* an attacker's host. A `URL`-based comparison would also be wrong in a subtler way: it normalises, so a
|
|
74
|
+
* trailing slash or a default port could make two different strings compare equal and widen the allowlist by
|
|
75
|
+
* accident.
|
|
76
|
+
*/
|
|
77
|
+
export const isAllowedRedirect = (allowlist, candidate) => allowlist.some((allowed) => allowed === candidate);
|
|
78
|
+
/**
|
|
79
|
+
* Refuses a provider endpoint that is not HTTPS — checked at construction.
|
|
80
|
+
*
|
|
81
|
+
* The token request carries the **client secret and the authorization code** in its body. Over cleartext, both
|
|
82
|
+
* are readable by anything on the path, and the failure is completely silent: the flow works.
|
|
83
|
+
*
|
|
84
|
+
* `localhost` is allowed over HTTP, because a provider emulator on a developer's machine is a real and harmless
|
|
85
|
+
* case, and refusing it would push people to disable the check entirely — which is how a check stops applying
|
|
86
|
+
* in production too.
|
|
87
|
+
*/
|
|
88
|
+
export const assertSecureEndpoint = (label, value) => {
|
|
89
|
+
let url;
|
|
90
|
+
try {
|
|
91
|
+
url = new URL(value);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
refuse(`${label} is not a URL`);
|
|
95
|
+
}
|
|
96
|
+
const local = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1";
|
|
97
|
+
if (url.protocol !== "https:" && !local) {
|
|
98
|
+
refuse(`${label} must be https — the token request carries the client secret and the authorization code in its ` +
|
|
99
|
+
"body, and over cleartext both are readable by anything on the path while the flow still works.");
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
export const createOAuthFlow = (deps) => {
|
|
103
|
+
assertSecureEndpoint("authorizationUrl", deps.config.authorizationUrl);
|
|
104
|
+
assertSecureEndpoint("tokenUrl", deps.config.tokenUrl);
|
|
105
|
+
const now = deps.now ?? Date.now;
|
|
106
|
+
const random = deps.randomBytesOf ?? randomBytes;
|
|
107
|
+
const ttl = deps.stateTtlMs ?? DEFAULT_STATE_TTL_MS;
|
|
108
|
+
const usePkce = deps.config.usePkce ?? true;
|
|
109
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
110
|
+
return {
|
|
111
|
+
/** The URL to send a person to, and the attempt recorded server-side. */
|
|
112
|
+
async start(input) {
|
|
113
|
+
if (!isAllowedRedirect(deps.config.redirectUris, input.redirectUri)) {
|
|
114
|
+
// Refused before anything is stored, so an attacker probing redirects leaves no attempts behind.
|
|
115
|
+
refuse(`redirect_uri "${input.redirectUri}" is not in this provider's allowlist. It is matched exactly — a ` +
|
|
116
|
+
"prefix match would accept https://app.example.com.evil.tld.");
|
|
117
|
+
}
|
|
118
|
+
const state = base64url(random(32));
|
|
119
|
+
const codeVerifier = usePkce ? base64url(random(32)) : undefined;
|
|
120
|
+
await deps.attempts.put({
|
|
121
|
+
state,
|
|
122
|
+
provider: deps.config.provider,
|
|
123
|
+
tenantId: String(input.context.tenantId),
|
|
124
|
+
principalId: String(input.context.principalId),
|
|
125
|
+
redirectUri: input.redirectUri,
|
|
126
|
+
...(codeVerifier === undefined ? {} : { codeVerifier }),
|
|
127
|
+
expiresAt: now() + ttl,
|
|
128
|
+
...(input.returnTo === undefined ? {} : { returnTo: input.returnTo }),
|
|
129
|
+
});
|
|
130
|
+
const url = new URL(deps.config.authorizationUrl);
|
|
131
|
+
url.searchParams.set("response_type", "code");
|
|
132
|
+
url.searchParams.set("client_id", deps.config.clientId);
|
|
133
|
+
url.searchParams.set("redirect_uri", input.redirectUri);
|
|
134
|
+
url.searchParams.set("scope", (input.scopes ?? deps.config.scopes).join(" "));
|
|
135
|
+
url.searchParams.set("state", state);
|
|
136
|
+
if (codeVerifier !== undefined) {
|
|
137
|
+
url.searchParams.set("code_challenge", codeChallengeOf(codeVerifier));
|
|
138
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
139
|
+
}
|
|
140
|
+
for (const [key, value] of Object.entries(deps.config.extraAuthorizationParams ?? {})) {
|
|
141
|
+
url.searchParams.set(key, value);
|
|
142
|
+
}
|
|
143
|
+
return { url: url.toString(), state };
|
|
144
|
+
},
|
|
145
|
+
/**
|
|
146
|
+
* Verifies the callback and exchanges the code.
|
|
147
|
+
*
|
|
148
|
+
* **The state is verified before the code is touched.** Every check that can be made without contacting the
|
|
149
|
+
* provider is made first, so an attacker probing the callback never causes an outbound request.
|
|
150
|
+
*/
|
|
151
|
+
async callback(input) {
|
|
152
|
+
const attempt = await deps.attempts.consume(input.state);
|
|
153
|
+
/**
|
|
154
|
+
* One message for every state failure — unknown, replayed, expired, wrong tenant.
|
|
155
|
+
*
|
|
156
|
+
* A callback that distinguishes them is an oracle: it tells an attacker whether a state existed, which is
|
|
157
|
+
* enough to confirm a guess. The server-side log can say which; the response cannot.
|
|
158
|
+
*/
|
|
159
|
+
if (attempt === null)
|
|
160
|
+
refuse("this authorization attempt is not valid", "forbidden");
|
|
161
|
+
// Expiry judged here, with the clock that wrote it. Same message as every other state failure, so the
|
|
162
|
+
// callback stays free of an oracle.
|
|
163
|
+
if (attempt.expiresAt <= now())
|
|
164
|
+
refuse("this authorization attempt is not valid", "forbidden");
|
|
165
|
+
if (attempt.tenantId !== String(input.context.tenantId) ||
|
|
166
|
+
attempt.principalId !== String(input.context.principalId)) {
|
|
167
|
+
refuse("this authorization attempt is not valid", "forbidden");
|
|
168
|
+
}
|
|
169
|
+
if (input.code === "")
|
|
170
|
+
refuse("the callback carried no authorization code");
|
|
171
|
+
const body = new URLSearchParams({
|
|
172
|
+
grant_type: "authorization_code",
|
|
173
|
+
code: input.code,
|
|
174
|
+
// The *recorded* redirect URI, never one from the request. A provider checks it matches what the
|
|
175
|
+
// authorization used, and taking it from the request would let an attacker choose both halves.
|
|
176
|
+
redirect_uri: attempt.redirectUri,
|
|
177
|
+
client_id: deps.config.clientId,
|
|
178
|
+
});
|
|
179
|
+
if (deps.config.clientSecret !== undefined)
|
|
180
|
+
body.set("client_secret", deps.config.clientSecret);
|
|
181
|
+
if (attempt.codeVerifier !== undefined)
|
|
182
|
+
body.set("code_verifier", attempt.codeVerifier);
|
|
183
|
+
const response = await fetchImpl(deps.config.tokenUrl, {
|
|
184
|
+
method: "POST",
|
|
185
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
186
|
+
body: body.toString(),
|
|
187
|
+
});
|
|
188
|
+
if (!response.ok) {
|
|
189
|
+
// The provider's body is not echoed: it routinely quotes the request, which carries the client secret
|
|
190
|
+
// and the code.
|
|
191
|
+
refuse(`the provider refused the token exchange with status ${response.status}`, "provider_error");
|
|
192
|
+
}
|
|
193
|
+
const payload = (await response.json());
|
|
194
|
+
const accessToken = payload["access_token"];
|
|
195
|
+
if (typeof accessToken !== "string" || accessToken === "") {
|
|
196
|
+
refuse("the provider's token response carried no access_token", "provider_error");
|
|
197
|
+
}
|
|
198
|
+
const expiresIn = payload["expires_in"];
|
|
199
|
+
const scope = payload["scope"];
|
|
200
|
+
return {
|
|
201
|
+
attempt,
|
|
202
|
+
tokens: {
|
|
203
|
+
accessToken: accessToken,
|
|
204
|
+
...(typeof payload["refresh_token"] === "string" ? { refreshToken: payload["refresh_token"] } : {}),
|
|
205
|
+
...(typeof expiresIn === "number"
|
|
206
|
+
? { expiresAt: new Date(now() + expiresIn * 1000).toISOString() }
|
|
207
|
+
: {}),
|
|
208
|
+
/**
|
|
209
|
+
* The scopes the provider **granted**, which is not what we asked for.
|
|
210
|
+
*
|
|
211
|
+
* A provider may grant fewer, and #259's AC-7 — telling somebody to *reconnect and grant X* rather
|
|
212
|
+
* than surfacing a 403 — is answerable only from what was actually granted.
|
|
213
|
+
*/
|
|
214
|
+
...(typeof scope === "string" ? { grantedScopes: scope.split(" ").filter(Boolean) } : {}),
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
};
|
|
220
|
+
/** Constant-time state comparison, for a caller checking one against another outside the store. */
|
|
221
|
+
export const stateEquals = (a, b) => {
|
|
222
|
+
const left = Buffer.from(a);
|
|
223
|
+
const right = Buffer.from(b);
|
|
224
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
225
|
+
};
|
|
226
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connect, disconnect, list — the operations a host mounts — task #262, AC-1, AC-7, AC-8 and AC-9.
|
|
3
|
+
*
|
|
4
|
+
* The flow in `./index.ts` produces a URL and consumes a callback. This is what turns that into a stored
|
|
5
|
+
* connection, and what takes one away.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately not a router. A host mounts these on its own paths with its own authentication, exactly as
|
|
8
|
+
* `./mcp-server` leaves the transport alone: every method here takes an already-authenticated
|
|
9
|
+
* `ExecutionContext`, so a host that has not authenticated has nothing to pass.
|
|
10
|
+
*/
|
|
11
|
+
import type { ExecutionContext } from "../../core/context.js";
|
|
12
|
+
import type { AuthMode, CredentialScheme } from "../../tools/credentials.js";
|
|
13
|
+
import type { SecretCipher } from "../cipher.js";
|
|
14
|
+
import type { Connection, ConnectionId, ConnectionStore } from "../index.js";
|
|
15
|
+
import type { OAuthProviderConfig, TokenResponse } from "./index.js";
|
|
16
|
+
/**
|
|
17
|
+
* Where a provider's token may be revoked, when it has such an endpoint.
|
|
18
|
+
*
|
|
19
|
+
* Optional because many providers do not offer one, and #262's AC-7 asks that the absence be *documented*
|
|
20
|
+
* rather than silently skipped — which is what `revocationUrl: undefined` plus this comment is.
|
|
21
|
+
*/
|
|
22
|
+
export type RevocationConfig = {
|
|
23
|
+
readonly revocationUrl?: string;
|
|
24
|
+
/** RFC 7009 calls it `token`; some providers differ. */
|
|
25
|
+
readonly tokenParam?: string;
|
|
26
|
+
};
|
|
27
|
+
export type OAuthConnectionServiceDeps = {
|
|
28
|
+
readonly store: ConnectionStore;
|
|
29
|
+
readonly cipher: SecretCipher;
|
|
30
|
+
readonly config: OAuthProviderConfig & RevocationConfig;
|
|
31
|
+
readonly fetchImpl?: typeof fetch;
|
|
32
|
+
readonly newId?: () => string;
|
|
33
|
+
readonly now?: () => string;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* What a connection is missing, for a caller deciding whether to send somebody back through consent — AC-9.
|
|
37
|
+
*
|
|
38
|
+
* Reads what was **granted**, never what the deployment's app requests: a tenant using their own OAuth app
|
|
39
|
+
* (#263) may have granted a different set, and the whole point is to say *reconnect and grant X* rather than
|
|
40
|
+
* surfacing a vendor 403 that names nothing actionable.
|
|
41
|
+
*
|
|
42
|
+
* A connection with no recorded grant returns `[]` rather than "everything is missing" — the provider did not
|
|
43
|
+
* tell us, and inventing a refusal from an absence would block working connections.
|
|
44
|
+
*/
|
|
45
|
+
export declare const missingScopes: (connection: Pick<Connection, "grantedScopes">, required: readonly string[]) => readonly string[];
|
|
46
|
+
export declare const createOAuthConnectionService: (deps: OAuthConnectionServiceDeps) => {
|
|
47
|
+
/**
|
|
48
|
+
* Stores the result of a completed exchange — AC-8.
|
|
49
|
+
*
|
|
50
|
+
* The token is sealed on the way in and the non-secret parts are not, which is what lets a connection list
|
|
51
|
+
* render without a key. `metadata` carries whatever the provider disclosed at consent time — Atlassian's
|
|
52
|
+
* cloud id, Slack's team id — because those are needed on every subsequent request and are per connection.
|
|
53
|
+
*/
|
|
54
|
+
complete(input: {
|
|
55
|
+
readonly context: ExecutionContext;
|
|
56
|
+
readonly tokens: TokenResponse;
|
|
57
|
+
readonly label?: string;
|
|
58
|
+
readonly scheme?: CredentialScheme;
|
|
59
|
+
readonly mode?: AuthMode;
|
|
60
|
+
readonly metadata?: Readonly<Record<string, string>>;
|
|
61
|
+
}): Promise<Connection>;
|
|
62
|
+
/** A tenant's live connections for this provider. Never decrypts anything. */
|
|
63
|
+
list(context: ExecutionContext): Promise<readonly Connection[]>;
|
|
64
|
+
/**
|
|
65
|
+
* Revokes at the provider, then locally — AC-7.
|
|
66
|
+
*
|
|
67
|
+
* **In that order.** Deleting first and then failing to revoke leaves a live token nobody can see and
|
|
68
|
+
* nobody can stop, which is strictly the worst outcome: the credential still works and the record of it is
|
|
69
|
+
* gone. Revoking first means a provider failure leaves a connection that is still listed and still
|
|
70
|
+
* revocable, which a person can retry.
|
|
71
|
+
*
|
|
72
|
+
* A provider with no revocation endpoint is **reported**, not silently skipped: a caller that believes a
|
|
73
|
+
* token was revoked when it was not will not go and remove it by hand.
|
|
74
|
+
*/
|
|
75
|
+
disconnect(input: {
|
|
76
|
+
readonly context: ExecutionContext;
|
|
77
|
+
readonly id: ConnectionId;
|
|
78
|
+
}): Promise<{
|
|
79
|
+
readonly revokedAtProvider: boolean;
|
|
80
|
+
readonly reason?: string;
|
|
81
|
+
}>;
|
|
82
|
+
/**
|
|
83
|
+
* What a caller should tell somebody when a tool needs a scope this connection lacks — AC-9.
|
|
84
|
+
*
|
|
85
|
+
* Returns the message rather than throwing, because the caller decides what to do with it: #264 turns it
|
|
86
|
+
* into a login URL and pauses the run, and a simpler host may just show it.
|
|
87
|
+
*/
|
|
88
|
+
scopeGap(connection: Connection, required: readonly string[]): string | null;
|
|
89
|
+
};
|
|
90
|
+
//# sourceMappingURL=service.d.ts.map
|