@lanes-sh/link 0.2.1 → 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 +21 -8
- package/instructions/skills/lanes-link/SKILL.md +59 -14
- package/package.json +1 -1
- package/src/auth/index.ts +3 -1
- package/src/auth/oauth/metadata.ts +83 -9
- package/src/auth/oauth/redirects.ts +70 -0
- package/src/auth/oauth/server.ts +49 -69
- package/src/auth/oauth/store.ts +19 -5
- package/src/cli/argv.ts +50 -0
- package/src/cli/brand.ts +178 -0
- package/src/cli/callback-page.ts +108 -128
- package/src/cli/commands/connect/accounts.ts +5 -0
- package/src/cli/commands/connect/assertion.ts +187 -0
- package/src/cli/commands/connect/authorise.ts +61 -17
- package/src/cli/commands/connect/client.ts +37 -9
- package/src/cli/commands/connect/discover.ts +94 -0
- package/src/cli/commands/connect/family.ts +72 -0
- package/src/cli/commands/connect/index.ts +113 -115
- package/src/cli/commands/connect/method.ts +237 -0
- package/src/cli/commands/connect/outcome.ts +42 -1
- package/src/cli/commands/connect/pasted-token.ts +66 -0
- package/src/cli/commands/connect/requirements.ts +60 -8
- package/src/cli/commands/connect/setup.ts +16 -5
- package/src/cli/commands/connect/target-note.ts +34 -0
- package/src/cli/commands/identity.ts +258 -0
- package/src/cli/commands/knowledge/index.ts +390 -0
- package/src/cli/commands/knowledge/migrate.ts +180 -0
- package/src/cli/commands/knowledge/setup.ts +144 -0
- package/src/cli/commands/knowledge.ts +10 -0
- package/src/cli/commands/mcp/harnesses.ts +16 -2
- package/src/cli/commands/mcp/register.ts +9 -1
- package/src/cli/commands/mcp/stdio.ts +21 -0
- package/src/cli/commands/operate/dashboard.ts +107 -0
- package/src/cli/commands/operate/findings.ts +151 -0
- package/src/cli/commands/operate/inspect.ts +56 -158
- package/src/cli/commands/operate/outputs.ts +38 -11
- package/src/cli/commands/operate/serve.ts +3 -0
- package/src/cli/commands/operate/token.ts +1 -1
- package/src/cli/commands/operate.ts +2 -0
- package/src/cli/commands/profile/declare.ts +154 -0
- package/src/cli/commands/profile/removal.ts +17 -0
- package/src/cli/commands/profile.ts +83 -35
- package/src/cli/commands/setup.ts +22 -6
- package/src/cli/commands/target.ts +65 -83
- package/src/cli/config-edit.ts +48 -144
- package/src/cli/config-repair.ts +186 -0
- package/src/cli/dashboard-page.ts +284 -0
- package/src/cli/dashboard-shell.ts +125 -0
- package/src/cli/identity.ts +12 -1
- package/src/cli/main.ts +68 -4
- package/src/cli/oauth-callback.ts +187 -0
- package/src/cli/oauth-exchange.ts +57 -15
- package/src/cli/oauth.ts +67 -177
- package/src/cli/output.ts +21 -5
- package/src/cli/provider-marks.ts +45 -0
- package/src/cli/runtime/open.ts +67 -49
- package/src/cli/runtime/registry.ts +60 -2
- package/src/cli/runtime/select.ts +26 -13
- package/src/cli/runtime/vault.ts +61 -0
- package/src/cli/runtime.ts +2 -1
- package/src/cli/selection.ts +357 -0
- package/src/cli/usage.ts +32 -9
- package/src/connectivity/auth/README.md +7 -1
- package/src/connectivity/auth/basic/index.ts +1 -1
- package/src/connectivity/auth/index.ts +14 -0
- package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
- package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
- package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
- package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
- package/src/connectivity/auth/oauth-jwt/README.md +33 -0
- package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
- package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
- package/src/connectivity/auth/resolve.ts +1 -1
- package/src/connectivity/auth/token.ts +11 -0
- package/src/connectivity/index.ts +2 -0
- package/src/connectivity/manifest/auth.ts +99 -2
- package/src/connectivity/manifest/identity.ts +12 -0
- package/src/connectivity/manifest/index.ts +3 -1
- package/src/connectivity/manifest/provider.ts +37 -8
- package/src/connectivity/manifest/requirements.ts +109 -6
- package/src/deployments/adapters/filesystem.ts +10 -1
- package/src/deployments/adapters/github-api.ts +106 -0
- package/src/deployments/adapters/github-commit.ts +103 -0
- package/src/deployments/adapters/github-repo.ts +356 -0
- package/src/deployments/adapters/github-testing.ts +258 -0
- package/src/deployments/adapters/github.ts +125 -0
- package/src/deployments/deploy.ts +48 -19
- package/src/deployments/driver.ts +8 -1
- package/src/deployments/gcp/driver.ts +9 -1
- package/src/deployments/gcp/survey.ts +3 -0
- package/src/deployments/knowledge.ts +119 -0
- package/src/deployments/prepare.ts +2 -2
- package/src/deployments/servable.ts +81 -0
- package/src/deployments/target.ts +3 -2
- package/src/deployments/upload.ts +2 -1
- package/src/dispatch/dispatch.ts +1 -1
- package/src/profile/authorization.ts +13 -4
- package/src/profile/identity.ts +60 -0
- package/src/profile/index.ts +15 -5
- package/src/profile/knowledge.ts +124 -0
- package/src/profile/load.ts +17 -5
- package/src/profile/primitives.ts +24 -1
- package/src/profile/schema.ts +72 -4
- package/src/profile/targets.ts +74 -114
- package/src/profile/workspace.ts +71 -84
- package/src/providers/google/calendar/index.ts +2 -0
- package/src/providers/google/contacts/index.ts +2 -0
- package/src/providers/google/docs/index.ts +2 -0
- package/src/providers/google/drive/index.ts +2 -0
- package/src/providers/google/gmail/index.ts +2 -0
- package/src/providers/google/gmail-imap/index.ts +125 -0
- package/src/providers/google/index.ts +2 -1
- package/src/providers/google/shared/oauth.ts +18 -6
- package/src/providers/google/shared/service-account.ts +110 -0
- package/src/providers/google/shared/setup.ts +5 -2
- package/src/providers/google/sheets/index.ts +2 -0
- package/src/providers/google/tasks/index.ts +2 -0
- package/src/providers/identity/provider.ts +166 -0
- package/src/providers/index.ts +3 -0
- package/src/providers/owner.ts +10 -2
- package/src/providers/scopes.ts +2 -0
- package/src/providers/setup/plan.ts +31 -9
- package/src/providers/setup/provider.ts +23 -0
- package/src/providers/slack/index.ts +81 -33
- package/src/providers/slack/oauth.ts +103 -0
- package/src/providers/slack/scopes.ts +37 -0
- package/src/server/container.ts +18 -1
- package/src/server/cors.ts +252 -0
- package/src/server/dashboard.ts +208 -0
- package/src/server/endpoint.ts +57 -4
- package/src/server/generation.ts +1 -0
- package/src/server/generations.ts +13 -2
- package/src/server/harness.ts +20 -3
- package/src/server/index.ts +76 -12
- package/src/server/mcp/build.ts +1 -1
- package/src/server/mcp/index.ts +1 -0
- package/src/server/mcp/instructions.ts +62 -5
- package/src/server/mcp/visibility.ts +42 -0
- package/src/stores/blobs/route.ts +123 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import type { ProviderManifest } from '#connectivity';
|
|
2
|
+
import type { SecretStore } from '#secrets';
|
|
3
|
+
import { credentialRefForConnection } from '../../manifest/credential-ref.ts';
|
|
4
|
+
import { parseAssertionKey, signAssertion } from './key.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* OAuth 2.0 JWT bearer (RFC 7523) — a key the operator holds, in place of a
|
|
8
|
+
* person approving a consent screen.
|
|
9
|
+
*
|
|
10
|
+
* The one property that earns this its own folder: there is no refresh token,
|
|
11
|
+
* because there is nothing to refresh. A fresh assertion is signed whenever the
|
|
12
|
+
* last access token ages out, so nothing an issuer can expire sits between the
|
|
13
|
+
* operator and their data. An authorization-code refresh token is subject to
|
|
14
|
+
* whatever policy the issuer applies to it — one such policy expires them after
|
|
15
|
+
* seven days, and re-approving a browser screen every week is the failure this
|
|
16
|
+
* folder exists to remove.
|
|
17
|
+
*
|
|
18
|
+
* What it costs is reach. An assertion authenticates the *key*, and a key is
|
|
19
|
+
* not a person: it holds only what has been shared with it, unless the identity
|
|
20
|
+
* provider has been configured to let it act as someone, which is an
|
|
21
|
+
* administrator's grant rather than the operator's. `auth.assertion.delegation`
|
|
22
|
+
* on the manifest is which of the two a provider is, and the CLI is where that
|
|
23
|
+
* becomes a sentence.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** RFC 7523's grant type, and the marker that identifies a stored credential as one. */
|
|
27
|
+
export const ASSERTION_GRANT = 'urn:ietf:params:oauth:grant-type:jwt-bearer';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What a connection stores when it authenticates this way.
|
|
31
|
+
*
|
|
32
|
+
* A pointer and not the key itself. One key covers every provider of a vendor,
|
|
33
|
+
* so it lives at a profile-shared ref and each connection records where to find
|
|
34
|
+
* it plus the one thing that genuinely differs per connection — who it acts as.
|
|
35
|
+
* Copying the key into seven connections would mean seven things to rotate.
|
|
36
|
+
*/
|
|
37
|
+
export interface StoredAssertion {
|
|
38
|
+
readonly grant: typeof ASSERTION_GRANT;
|
|
39
|
+
readonly key_ref: string;
|
|
40
|
+
readonly subject?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Whether a stored credential is one of these.
|
|
45
|
+
*
|
|
46
|
+
* Both methods write to the same ref — `<provider>/<connection>` — and this is
|
|
47
|
+
* what tells them apart. Shape rather than a flag in config, because
|
|
48
|
+
* `credentialResolver` is handed a registry and a store and never a connection
|
|
49
|
+
* row, so a declaration in config would be invisible exactly where the decision
|
|
50
|
+
* has to be made.
|
|
51
|
+
*/
|
|
52
|
+
export function isStoredAssertion(value: unknown): value is StoredAssertion {
|
|
53
|
+
return (
|
|
54
|
+
typeof value === 'object' &&
|
|
55
|
+
value !== null &&
|
|
56
|
+
(value as { grant?: unknown }).grant === ASSERTION_GRANT &&
|
|
57
|
+
typeof (value as { key_ref?: unknown }).key_ref === 'string'
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The stored credential for this connection, if it is an assertion pointer.
|
|
63
|
+
*
|
|
64
|
+
* `null` covers both "nothing stored" and "stored, but an authorization-code
|
|
65
|
+
* blob" — the caller wants the same thing in either case, which is to carry on
|
|
66
|
+
* down the path it was already on. Asked through `credentialRefForConnection`
|
|
67
|
+
* rather than by assembling the ref here, because two files deriving that
|
|
68
|
+
* separately is exactly the disagreement that function was extracted to end.
|
|
69
|
+
*/
|
|
70
|
+
export async function storedAssertionFor(
|
|
71
|
+
manifest: ProviderManifest,
|
|
72
|
+
connectionId: string,
|
|
73
|
+
credentials: SecretStore,
|
|
74
|
+
): Promise<StoredAssertion | null> {
|
|
75
|
+
const ref = credentialRefForConnection(manifest, connectionId);
|
|
76
|
+
if (!ref) return null;
|
|
77
|
+
|
|
78
|
+
const raw = await credentials.get(ref);
|
|
79
|
+
if (!raw) return null;
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const parsed: unknown = JSON.parse(raw);
|
|
83
|
+
return isStoredAssertion(parsed) ? parsed : null;
|
|
84
|
+
} catch {
|
|
85
|
+
// A credential that is not JSON at all is a pasted token, which is somebody
|
|
86
|
+
// else's case entirely. Not an error here.
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Minted tokens, for as long as this process lives.
|
|
93
|
+
*
|
|
94
|
+
* In memory rather than in the store, and that is a deliberate difference from
|
|
95
|
+
* the authorization-code path. There, the refresh token is the credential and
|
|
96
|
+
* persisting the rotation is the whole point. Here the credential is the key,
|
|
97
|
+
* which nothing at request time modifies — so writing the token back would make
|
|
98
|
+
* this ref rotatable, which a deployed revision would then need write access to
|
|
99
|
+
* bind, to cache something that costs one signature and one POST to remake.
|
|
100
|
+
*/
|
|
101
|
+
const minted = new Map<string, { token: string; expiresAt: number }>();
|
|
102
|
+
|
|
103
|
+
/** Re-mint slightly early: a token that expires mid-flight fails the call it was fetched for. */
|
|
104
|
+
const EXPIRY_SKEW_MS = 60_000;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Emptied when a reload lands, and by tests.
|
|
108
|
+
*
|
|
109
|
+
* The cache key is `<provider>.<connection>` with no subject in it, so a
|
|
110
|
+
* connection re-connected to act as somebody else — or re-connected to a route
|
|
111
|
+
* that is not this one at all — would otherwise keep serving the token minted
|
|
112
|
+
* for who it used to be, for up to an hour after the config said otherwise.
|
|
113
|
+
* `server/generations.ts` clears this beside `clearUpstreamTokens`, which
|
|
114
|
+
* exists for the same reason on the other path.
|
|
115
|
+
*/
|
|
116
|
+
export function clearMintedTokens(): void {
|
|
117
|
+
minted.clear();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface TokenResponse {
|
|
121
|
+
readonly access_token?: string;
|
|
122
|
+
readonly expires_in?: number;
|
|
123
|
+
readonly error?: string;
|
|
124
|
+
readonly error_description?: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* An access token for a connection that authenticates with a key.
|
|
129
|
+
*
|
|
130
|
+
* Reads the pointer, reads the key it names, signs, exchanges, caches. The
|
|
131
|
+
* manifest supplies the scopes and nothing else — where to exchange comes from
|
|
132
|
+
* the key file, so this stays a protocol implementation rather than a vendor's.
|
|
133
|
+
*/
|
|
134
|
+
export async function resolveAssertionToken(input: {
|
|
135
|
+
readonly manifest: ProviderManifest;
|
|
136
|
+
readonly connectionId: string;
|
|
137
|
+
readonly stored: StoredAssertion;
|
|
138
|
+
readonly credentials: SecretStore;
|
|
139
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
140
|
+
}): Promise<string> {
|
|
141
|
+
const { manifest, connectionId, stored, credentials } = input;
|
|
142
|
+
const cacheKey = `${manifest.id}.${connectionId}`;
|
|
143
|
+
|
|
144
|
+
const cached = minted.get(cacheKey);
|
|
145
|
+
if (cached && cached.expiresAt > Date.now() + EXPIRY_SKEW_MS) return cached.token;
|
|
146
|
+
|
|
147
|
+
const raw = await credentials.get(stored.key_ref);
|
|
148
|
+
if (!raw) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`No key stored at ${stored.key_ref}, which ${manifest.id}.${connectionId} authenticates with. ` +
|
|
151
|
+
`Run: lanes link connect ${manifest.id} --replace`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const key = parseAssertionKey(raw, stored.key_ref);
|
|
156
|
+
const scopes = manifest.auth.kind === 'oauth' ? manifest.auth.scopes : [];
|
|
157
|
+
|
|
158
|
+
const assertion = await signAssertion({
|
|
159
|
+
key,
|
|
160
|
+
scopes,
|
|
161
|
+
...(stored.subject ? { subject: stored.subject } : {}),
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const response = await (input.fetch ?? globalThis.fetch)(key.token_uri, {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
167
|
+
body: new URLSearchParams({ grant_type: ASSERTION_GRANT, assertion }),
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const body = (await response.json().catch(() => ({}))) as TokenResponse;
|
|
171
|
+
|
|
172
|
+
if (!response.ok || !body.access_token) {
|
|
173
|
+
throw new Error(refusalMessage(manifest, stored, body, response.status));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
minted.set(cacheKey, {
|
|
177
|
+
token: body.access_token,
|
|
178
|
+
expiresAt: Date.now() + (body.expires_in ?? 3600) * 1000,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
return body.access_token;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Why the exchange was refused, in terms of what the operator can act on.
|
|
186
|
+
*
|
|
187
|
+
* Three of these are the whole population in practice and each has a different
|
|
188
|
+
* fix in a different console, so the raw `invalid_grant` is worth translating.
|
|
189
|
+
* An operator who reads only the error code goes looking in the wrong place —
|
|
190
|
+
* most often at the key, when the actual gap is a grant an administrator has
|
|
191
|
+
* not made yet.
|
|
192
|
+
*/
|
|
193
|
+
function refusalMessage(
|
|
194
|
+
manifest: ProviderManifest,
|
|
195
|
+
stored: StoredAssertion,
|
|
196
|
+
body: TokenResponse,
|
|
197
|
+
status: number,
|
|
198
|
+
): string {
|
|
199
|
+
const detail = body.error_description ?? body.error ?? `HTTP ${status}`;
|
|
200
|
+
const scopes = manifest.auth.kind === 'oauth' ? manifest.auth.scopes : [];
|
|
201
|
+
|
|
202
|
+
const lines = [`${manifest.name} refused the key at ${stored.key_ref}: ${detail}`];
|
|
203
|
+
|
|
204
|
+
if (body.error === 'unauthorized_client') {
|
|
205
|
+
lines.push(
|
|
206
|
+
'',
|
|
207
|
+
stored.subject
|
|
208
|
+
? ` The key is not permitted to act as ${stored.subject}. An administrator of that` +
|
|
209
|
+
'\n domain has to authorise this key for these scopes, all of them, exactly:'
|
|
210
|
+
: ' The key is not authorised for these scopes:',
|
|
211
|
+
...scopes.map((scope) => ` ${scope}`),
|
|
212
|
+
'',
|
|
213
|
+
' A partial list is refused the same way a missing one is.',
|
|
214
|
+
);
|
|
215
|
+
} else if (body.error === 'invalid_grant') {
|
|
216
|
+
// Listed rather than diagnosed. This one code covers an account that does
|
|
217
|
+
// not exist, a key that was deleted, a clock that is wrong, and a missing
|
|
218
|
+
// subject — and the description above is the only thing that distinguishes
|
|
219
|
+
// them. Asserting one of the four would send the reader to the wrong
|
|
220
|
+
// console three times in four, which is worse than naming all of them.
|
|
221
|
+
lines.push(
|
|
222
|
+
'',
|
|
223
|
+
' The description above is the part that identifies which of these it is:',
|
|
224
|
+
' - the account in the key no longer exists, or the key was deleted or disabled;',
|
|
225
|
+
" - this machine's clock is wrong by more than a few minutes, and an assertion is",
|
|
226
|
+
' signed with a timestamp;',
|
|
227
|
+
...(stored.subject
|
|
228
|
+
? [` - ${stored.subject} is not an account the key may act as.`]
|
|
229
|
+
: [
|
|
230
|
+
' - this account has to be reached by acting as someone, and this connection acts',
|
|
231
|
+
` as nobody. Re-run and name one: lanes link connect ${manifest.id} --replace`,
|
|
232
|
+
]),
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return lines.join('\n');
|
|
237
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The key half of RFC 7523: a private key on disk, and the assertion it signs.
|
|
5
|
+
*
|
|
6
|
+
* Split from `index.ts` because it is the only part with no I/O — no store, no
|
|
7
|
+
* network — which is what makes the claim set and the signature testable
|
|
8
|
+
* against a locally generated key rather than against a live token endpoint.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here knows which vendor issued the key. The layout below is the one
|
|
11
|
+
* every authorization server that accepts this grant ships, and the endpoint to
|
|
12
|
+
* present the assertion at comes from the key file itself rather than from a
|
|
13
|
+
* constant, so adding a second vendor is a manifest and no code.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The issued key file, as the vendor's console writes it.
|
|
18
|
+
*
|
|
19
|
+
* Parsed rather than trusted: the common mistake is pasting the *client* JSON —
|
|
20
|
+
* the one with `installed` or `web` at the top level — which is a different
|
|
21
|
+
* file with a different purpose and would otherwise fail much later, at the
|
|
22
|
+
* token endpoint, as an unexplained 400.
|
|
23
|
+
*/
|
|
24
|
+
export const assertionKeySchema = z.object({
|
|
25
|
+
/** Who the assertion is from. Also the address a resource is shared with. */
|
|
26
|
+
client_email: z.string().min(1),
|
|
27
|
+
/** PKCS#8 PEM. */
|
|
28
|
+
private_key: z.string().min(1),
|
|
29
|
+
/** Where the assertion is exchanged. Read from the file so no vendor is named here. */
|
|
30
|
+
token_uri: z.url(),
|
|
31
|
+
/** Names which key signed it, for a server holding more than one. */
|
|
32
|
+
private_key_id: z.string().optional(),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export type AssertionKey = z.infer<typeof assertionKeySchema>;
|
|
36
|
+
|
|
37
|
+
export function parseAssertionKey(raw: string, ref: string): AssertionKey {
|
|
38
|
+
let json: unknown;
|
|
39
|
+
try {
|
|
40
|
+
json = JSON.parse(raw);
|
|
41
|
+
} catch {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`The key at ${ref} is not JSON. It should be the whole file the console downloaded, pasted verbatim.`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const parsed = assertionKeySchema.safeParse(json);
|
|
48
|
+
if (parsed.success) return parsed.data;
|
|
49
|
+
|
|
50
|
+
// The two files are easy to confuse and the console offers both on adjacent
|
|
51
|
+
// pages, so say which one is in hand rather than listing missing fields.
|
|
52
|
+
const shape = json as Record<string, unknown> | null;
|
|
53
|
+
if (shape && (shape['installed'] !== undefined || shape['web'] !== undefined)) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`The key at ${ref} is an OAuth *client* file, not an account key. That one is for the ` +
|
|
56
|
+
'browser flow. The key needed here is downloaded from the account itself and has ' +
|
|
57
|
+
'"private_key" in it.',
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
throw new Error(
|
|
62
|
+
`The key at ${ref} is missing ${parsed.error.issues.map((issue) => issue.path.join('.')).join(', ')}.`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** JWT's own encoding: base64 with a URL-safe alphabet and no padding. */
|
|
67
|
+
function base64url(bytes: Uint8Array | string): string {
|
|
68
|
+
const raw =
|
|
69
|
+
typeof bytes === 'string'
|
|
70
|
+
? btoa(bytes)
|
|
71
|
+
: btoa(String.fromCharCode(...new Uint8Array(bytes)));
|
|
72
|
+
return raw.replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* PKCS#8 PEM to the DER bytes `importKey` wants.
|
|
77
|
+
*
|
|
78
|
+
* Tolerant of how the key arrives: a console download carries real newlines, a
|
|
79
|
+
* value pasted through an environment variable or a JSON string often carries
|
|
80
|
+
* literal `\n` instead, and both are the same key. Rejecting the second would
|
|
81
|
+
* be a failure whose cause is invisible in a terminal.
|
|
82
|
+
*/
|
|
83
|
+
function derFromPem(pem: string): Uint8Array {
|
|
84
|
+
const body = pem
|
|
85
|
+
.replaceAll('\\n', '\n')
|
|
86
|
+
.replace(/-----[^-]+-----/g, '')
|
|
87
|
+
.replace(/\s+/g, '');
|
|
88
|
+
|
|
89
|
+
const binary = atob(body);
|
|
90
|
+
const der = new Uint8Array(binary.length);
|
|
91
|
+
for (let index = 0; index < binary.length; index += 1) der[index] = binary.charCodeAt(index);
|
|
92
|
+
return der;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** How long the assertion is good for. Kept short: it is minted per exchange. */
|
|
96
|
+
const ASSERTION_LIFETIME_SECONDS = 3600;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Sign the assertion this grant exchanges for a token.
|
|
100
|
+
*
|
|
101
|
+
* `sub` is what makes one identity act as another, and it is present only when
|
|
102
|
+
* the caller supplies one — an assertion carrying an empty `sub` is not the
|
|
103
|
+
* same request as one carrying none, and servers treat it as malformed rather
|
|
104
|
+
* than as absent.
|
|
105
|
+
*/
|
|
106
|
+
export async function signAssertion(input: {
|
|
107
|
+
readonly key: AssertionKey;
|
|
108
|
+
readonly scopes: readonly string[];
|
|
109
|
+
/** The account to act as, where the key is only permitted to borrow one. */
|
|
110
|
+
readonly subject?: string | undefined;
|
|
111
|
+
/** Injected by tests so the claim set is checkable. */
|
|
112
|
+
readonly now?: number;
|
|
113
|
+
}): Promise<string> {
|
|
114
|
+
const issuedAt = Math.floor((input.now ?? Date.now()) / 1000);
|
|
115
|
+
|
|
116
|
+
const header = {
|
|
117
|
+
alg: 'RS256',
|
|
118
|
+
typ: 'JWT',
|
|
119
|
+
...(input.key.private_key_id ? { kid: input.key.private_key_id } : {}),
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const claims = {
|
|
123
|
+
iss: input.key.client_email,
|
|
124
|
+
scope: input.scopes.join(' '),
|
|
125
|
+
aud: input.key.token_uri,
|
|
126
|
+
iat: issuedAt,
|
|
127
|
+
exp: issuedAt + ASSERTION_LIFETIME_SECONDS,
|
|
128
|
+
...(input.subject ? { sub: input.subject } : {}),
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const signingInput = `${base64url(JSON.stringify(header))}.${base64url(JSON.stringify(claims))}`;
|
|
132
|
+
|
|
133
|
+
const privateKey = await crypto.subtle.importKey(
|
|
134
|
+
'pkcs8',
|
|
135
|
+
derFromPem(input.key.private_key) as unknown as ArrayBuffer,
|
|
136
|
+
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
|
|
137
|
+
false,
|
|
138
|
+
['sign'],
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const signature = await crypto.subtle.sign(
|
|
142
|
+
'RSASSA-PKCS1-v1_5',
|
|
143
|
+
privateKey,
|
|
144
|
+
new TextEncoder().encode(signingInput) as unknown as ArrayBuffer,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
return `${signingInput}.${base64url(new Uint8Array(signature))}`;
|
|
148
|
+
}
|
|
@@ -42,7 +42,7 @@ export function credentialResolver(
|
|
|
42
42
|
const ref = credentialRefForConnection(manifest, connectionId)!;
|
|
43
43
|
const value = await secrets.get(ref);
|
|
44
44
|
if (!value) {
|
|
45
|
-
throw new Error(`No credential stored at ${ref}.
|
|
45
|
+
throw new Error(`No credential stored at ${ref}. Connecting "${providerId}" again for this profile and target would store one.`);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
switch (auth.kind) {
|
|
@@ -2,6 +2,7 @@ import { type ProviderManifest } from '#connectivity';
|
|
|
2
2
|
import type { ProviderRegistry } from '#registry';
|
|
3
3
|
import type { SecretStore } from '#secrets';
|
|
4
4
|
import { credentialResolver } from './resolve.ts';
|
|
5
|
+
import { resolveAssertionToken, storedAssertionFor } from './oauth-jwt/index.ts';
|
|
5
6
|
import { CredentialOAuthProvider } from './oauth-authcode/provider.ts';
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -81,6 +82,16 @@ export async function bearerTokenAsStored(
|
|
|
81
82
|
): Promise<string | null> {
|
|
82
83
|
if (manifest.auth.kind !== 'oauth') return bearerToken(manifest, connectionId, secrets);
|
|
83
84
|
|
|
85
|
+
// An assertion credential has no token stored to prefer — the token is minted
|
|
86
|
+
// from the key, which is what "as stored" means here. Without this branch the
|
|
87
|
+
// identity call `connect` makes immediately after writing the pointer reads
|
|
88
|
+
// `access_token` off a blob that has none, sends no Authorization header, and
|
|
89
|
+
// reports the credential rejected.
|
|
90
|
+
const assertion = await storedAssertionFor(manifest, connectionId, secrets);
|
|
91
|
+
if (assertion) {
|
|
92
|
+
return resolveAssertionToken({ manifest, connectionId, stored: assertion, credentials: secrets });
|
|
93
|
+
}
|
|
94
|
+
|
|
84
95
|
const provider = new CredentialOAuthProvider({
|
|
85
96
|
manifest,
|
|
86
97
|
connectionId,
|
|
@@ -53,6 +53,7 @@ export { defineLocalProvider, defineProviderWithCapabilities } from './provider.
|
|
|
53
53
|
export type {
|
|
54
54
|
ProviderManifest,
|
|
55
55
|
ConnectorConfig,
|
|
56
|
+
AuthAssertion,
|
|
56
57
|
AuthBroker,
|
|
57
58
|
AuthConfig,
|
|
58
59
|
SetupDeclaration,
|
|
@@ -69,6 +70,7 @@ export {
|
|
|
69
70
|
bundleSchema,
|
|
70
71
|
credentialRefForConnection,
|
|
71
72
|
rotatableCredentialRefs,
|
|
73
|
+
hasOwnClientPath,
|
|
72
74
|
setupRequirements,
|
|
73
75
|
UNNAMED_ID,
|
|
74
76
|
RESERVED_PROVIDER_IDS,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { credentialRef, identifier } from './primitives.ts';
|
|
3
|
+
import { setupSchema } from './setup.ts';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Credential types — how we prove who we are, orthogonal to how we connect.
|
|
@@ -34,14 +35,86 @@ export const authBrokerSchema = z.object({
|
|
|
34
35
|
|
|
35
36
|
export type AuthBroker = z.infer<typeof authBrokerSchema>;
|
|
36
37
|
|
|
38
|
+
/**
|
|
39
|
+
* A second way in, for an authorization server that also accepts an assertion.
|
|
40
|
+
*
|
|
41
|
+
* RFC 7523: instead of a person approving a consent screen, the operator holds
|
|
42
|
+
* a private key, signs a short-lived JWT with it, and exchanges that for an
|
|
43
|
+
* access token. There is no refresh token because there is nothing to refresh —
|
|
44
|
+
* a new assertion is signed whenever the last token ages out — which is the
|
|
45
|
+
* whole reason this exists beside `oauth`. An authorization-code refresh token
|
|
46
|
+
* can be expired by the issuer's own policy; a key the operator holds cannot.
|
|
47
|
+
*
|
|
48
|
+
* Declared *on* the OAuth block rather than as a fourth `kind`, because it is
|
|
49
|
+
* an alternative arrangement for the same provider rather than a different
|
|
50
|
+
* provider. Everything that branches on `kind` — where the credential lands,
|
|
51
|
+
* what setup requires, which refs a deployed revision may rewrite — is
|
|
52
|
+
* unchanged, and a manifest that omits this reads and behaves exactly as before.
|
|
53
|
+
*
|
|
54
|
+
* Which one a connection actually uses is not recorded here or in config. The
|
|
55
|
+
* stored credential's *shape* is the switch, the same way an `oauth_apps` entry
|
|
56
|
+
* is the switch between a broker's client and the operator's own.
|
|
57
|
+
*/
|
|
58
|
+
export const authAssertionSchema = z.object({
|
|
59
|
+
/**
|
|
60
|
+
* What the operator types after `--auth`, and how a chosen method is named
|
|
61
|
+
* back to them.
|
|
62
|
+
*
|
|
63
|
+
* The provider's word rather than the protocol's. "Assertion" is what this is
|
|
64
|
+
* to the authorization server and means nothing to the person holding the
|
|
65
|
+
* file; they downloaded a service account key, and that is what the prompt
|
|
66
|
+
* and the flag should say. Keeping it here is also what stops the CLI from
|
|
67
|
+
* learning a vendor's vocabulary in order to print it.
|
|
68
|
+
*/
|
|
69
|
+
method: identifier,
|
|
70
|
+
/** The short name shown beside the choice — a noun, not a sentence. */
|
|
71
|
+
label: z.string().min(1),
|
|
72
|
+
/**
|
|
73
|
+
* Whether the assertion may stand for itself, or must name a user to act as.
|
|
74
|
+
*
|
|
75
|
+
* `optional` — the key is an identity in its own right, and reaches whatever
|
|
76
|
+
* has been shared with it. `required` — it can only borrow someone else's,
|
|
77
|
+
* so a connection without a subject would authenticate cleanly and then find
|
|
78
|
+
* nothing there. The CLI refuses a blank subject on `required` for that
|
|
79
|
+
* reason: the failure is otherwise a 404 on every call with no explanation.
|
|
80
|
+
*/
|
|
81
|
+
delegation: z.enum(['optional', 'required']).default('optional'),
|
|
82
|
+
/**
|
|
83
|
+
* Where the profile-shared key lives.
|
|
84
|
+
*
|
|
85
|
+
* Shared, not per-connection: one key covers every provider of a vendor, and
|
|
86
|
+
* asking for it once per provider would be seven pastes of the same file.
|
|
87
|
+
* The per-connection half is the subject, which is not a secret and is small
|
|
88
|
+
* enough to sit in the pointer the connection stores.
|
|
89
|
+
*/
|
|
90
|
+
key_ref: credentialRef,
|
|
91
|
+
/** One line for the choice prompt: what this method reaches, and what it does not. */
|
|
92
|
+
reach: z.string().min(1),
|
|
93
|
+
/**
|
|
94
|
+
* What to call the account this acts as, when it acts as one.
|
|
95
|
+
*
|
|
96
|
+
* Asked per connection and stored beside the pointer. Not a secret — it is an
|
|
97
|
+
* address — but it lives in the credential store rather than in config
|
|
98
|
+
* because it is half of a credential, and splitting a credential across two
|
|
99
|
+
* files is how the halves come to disagree.
|
|
100
|
+
*/
|
|
101
|
+
subject_label: z.string().min(1),
|
|
102
|
+
/** The console walkthrough for this method, rendered by the same code as `setup`. */
|
|
103
|
+
setup: setupSchema,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
export type AuthAssertion = z.infer<typeof authAssertionSchema>;
|
|
107
|
+
|
|
37
108
|
export const authOAuthSchema = z.object({
|
|
38
109
|
kind: z.literal('oauth'),
|
|
39
110
|
/**
|
|
40
111
|
* `dynamic` — the authorization server offers Dynamic Client Registration, so
|
|
41
112
|
* we register ourselves and the operator does nothing at all (Notion, Linear).
|
|
42
113
|
*
|
|
43
|
-
* `manual` — the vendor requires a pre-registered client
|
|
44
|
-
*
|
|
114
|
+
* `manual` — the vendor requires a pre-registered client. Who supplies it is
|
|
115
|
+
* the profile's to decide and not this field's: an id and secret the operator
|
|
116
|
+
* registered (Google, if they choose to), or the client behind `broker`.
|
|
117
|
+
* `manual` says only that self-registration is not on offer.
|
|
45
118
|
*/
|
|
46
119
|
registration: z.enum(['dynamic', 'manual']).default('dynamic'),
|
|
47
120
|
/** Which `oauth_apps` entry holds the client, for `manual`. Shared across providers of a vendor. */
|
|
@@ -57,6 +130,30 @@ export const authOAuthSchema = z.object({
|
|
|
57
130
|
* that claimed one or the other would be wrong half the time.
|
|
58
131
|
*/
|
|
59
132
|
broker: authBrokerSchema.optional(),
|
|
133
|
+
/**
|
|
134
|
+
* The other way in, where the vendor offers one. Absent means browser or nothing.
|
|
135
|
+
*
|
|
136
|
+
* Additive and inert on its own: declaring it makes `connect` offer a choice
|
|
137
|
+
* and makes the resolver able to read an assertion credential. It changes
|
|
138
|
+
* nothing about a connection that authorised in a browser.
|
|
139
|
+
*/
|
|
140
|
+
assertion: authAssertionSchema.optional(),
|
|
141
|
+
/**
|
|
142
|
+
* Whether a token response carrying no refresh token is a failure.
|
|
143
|
+
*
|
|
144
|
+
* `required` — it is, and stopping is kinder than succeeding: the connection
|
|
145
|
+
* would work until the access token expires and then quietly stop. Google
|
|
146
|
+
* omits one when the account was already authorised for the app, which is a
|
|
147
|
+
* real and recoverable mistake.
|
|
148
|
+
*
|
|
149
|
+
* `optional` — the vendor issues a long-lived token and no refresh token is
|
|
150
|
+
* the normal, successful answer. Slack does this unless token rotation is
|
|
151
|
+
* enabled on the app, so demanding one would refuse every connection that
|
|
152
|
+
* worked.
|
|
153
|
+
*/
|
|
154
|
+
refresh_token: z.enum(['required', 'optional']).default('required'),
|
|
155
|
+
/** Where the operator withdraws a grant, named in the refusal above. */
|
|
156
|
+
revoke_url: z.url().optional(),
|
|
60
157
|
scopes: z.array(z.string()).default([]),
|
|
61
158
|
/**
|
|
62
159
|
* Usually discovered from the resource's metadata; set only to override.
|
|
@@ -30,6 +30,18 @@ export const identitySchema = z.discriminatedUnion('kind', [
|
|
|
30
30
|
url: z.url(),
|
|
31
31
|
/** Dotted path into the JSON body, e.g. `emailAddress` or `user.emailAddress`. */
|
|
32
32
|
field: z.string().min(1),
|
|
33
|
+
/**
|
|
34
|
+
* A second path, shown in brackets, where `field` alone is not unique.
|
|
35
|
+
*
|
|
36
|
+
* Almost no provider needs one: an address identifies a Google or iCloud
|
|
37
|
+
* account globally, and a GitHub login is unique across GitHub. Slack is
|
|
38
|
+
* the exception, because the thing it calls a user is scoped to a
|
|
39
|
+
* workspace — the same person in two workspaces answers `auth.test` with
|
|
40
|
+
* the same `user`, and one account string is how `settleIdentity` decides a
|
|
41
|
+
* connect is a *reconnect*. Without this, connecting a second workspace
|
|
42
|
+
* matches the first and overwrites its credential.
|
|
43
|
+
*/
|
|
44
|
+
qualifier: z.string().min(1).optional(),
|
|
33
45
|
}),
|
|
34
46
|
z.object({
|
|
35
47
|
kind: z.literal('tool'),
|
|
@@ -22,11 +22,13 @@ export {
|
|
|
22
22
|
|
|
23
23
|
export {
|
|
24
24
|
authNoneSchema,
|
|
25
|
+
authAssertionSchema,
|
|
25
26
|
authBrokerSchema,
|
|
26
27
|
authOAuthSchema,
|
|
27
28
|
authSchema,
|
|
28
29
|
authStrategySchema,
|
|
29
30
|
authTokenSchema,
|
|
31
|
+
type AuthAssertion,
|
|
30
32
|
type AuthBroker,
|
|
31
33
|
type AuthConfig,
|
|
32
34
|
} from './auth.ts';
|
|
@@ -43,4 +45,4 @@ export {
|
|
|
43
45
|
} from './provider.ts';
|
|
44
46
|
|
|
45
47
|
export type { SetupRequirement, SetupNeeds } from './requirements.ts';
|
|
46
|
-
export { setupRequirements, UNNAMED_ID } from './requirements.ts';
|
|
48
|
+
export { hasOwnClientPath, setupRequirements, UNNAMED_ID } from './requirements.ts';
|
|
@@ -65,7 +65,7 @@ export const providerManifestSchema = z.object({
|
|
|
65
65
|
export type ProviderManifest = z.infer<typeof providerManifestSchema>;
|
|
66
66
|
|
|
67
67
|
/** Provider ids reserved for the owner layer. */
|
|
68
|
-
export const RESERVED_PROVIDER_IDS: readonly string[] = ['memory', 'skills', 'vault', 'setup'];
|
|
68
|
+
export const RESERVED_PROVIDER_IDS: readonly string[] = ['memory', 'skills', 'vault', 'setup', 'identity'];
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
71
|
* Validate a manifest, with the cross-field rules the schema alone cannot
|
|
@@ -114,18 +114,47 @@ export function defineProvider(input: unknown): ProviderManifest {
|
|
|
114
114
|
`Provider "${manifest.id}": a broker supplies a pre-registered client, so auth must declare registration "manual" and an "app" naming the oauth_apps entry that overrides it.`,
|
|
115
115
|
);
|
|
116
116
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
117
|
+
if (!manifest.auth.authorize_url) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`Provider "${manifest.id}": a broker performs the exchange, but the browser still goes to the vendor, so auth.authorize_url is required.`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
// An MCP provider hands the whole flow to the SDK, which posts to the token
|
|
123
|
+
// endpoint with whatever `clientInformation()` returned and has nowhere to
|
|
124
|
+
// route an exchange somebody else performs. Declaring both endpoints is
|
|
125
|
+
// what opts it off that path and onto the direct one, where the exchange is
|
|
126
|
+
// ours — so on an mcp connector the two arrive together or the manifest is
|
|
127
|
+
// describing a flow that cannot run. Refused here rather than discovered
|
|
128
|
+
// after the operator has already approved a consent screen. See ADR-040.
|
|
129
|
+
if (manifest.connector.kind === 'mcp' && !manifest.auth.token_url) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`Provider "${manifest.id}": an mcp connector runs the exchange through the SDK, which cannot route it through a broker, unless the manifest declares its own endpoints. Add auth.token_url beside auth.authorize_url, or drop the broker and register dynamically.`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (manifest.auth.kind === 'oauth' && manifest.auth.assertion) {
|
|
137
|
+
// Same seam, same absence as the broker rule above. The SDK owns an mcp
|
|
138
|
+
// provider's exchange and takes a client, not a signed assertion — so the
|
|
139
|
+
// choice would be offered, accepted, and then have nowhere to go.
|
|
121
140
|
if (manifest.connector.kind === 'mcp') {
|
|
122
141
|
throw new Error(
|
|
123
|
-
`Provider "${manifest.id}": an mcp connector runs the exchange through the SDK, which cannot
|
|
142
|
+
`Provider "${manifest.id}": an mcp connector runs the exchange through the SDK, which cannot present a signed assertion. Remove auth.assertion.`,
|
|
124
143
|
);
|
|
125
144
|
}
|
|
126
|
-
|
|
145
|
+
// The assertion carries `aud` from the key file, but the *scopes* it claims
|
|
146
|
+
// come from the manifest. A provider requesting none would mint a token
|
|
147
|
+
// permitted to do nothing and only find out at the first call.
|
|
148
|
+
if (manifest.auth.scopes.length === 0) {
|
|
127
149
|
throw new Error(
|
|
128
|
-
`Provider "${manifest.id}":
|
|
150
|
+
`Provider "${manifest.id}": auth.assertion exchanges a signed assertion for a token scoped to auth.scopes, which is empty. There would be nothing to grant.`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
// The whole point of the alternative is that it asks for something. A block
|
|
154
|
+
// with no prompt reaches the walkthrough and then has nothing to collect.
|
|
155
|
+
if (manifest.auth.assertion.setup.prompts.length === 0) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`Provider "${manifest.id}": auth.assertion declares no setup prompts, so there is no way to learn what key to ask for.`,
|
|
129
158
|
);
|
|
130
159
|
}
|
|
131
160
|
}
|