@lanes-sh/link 0.2.2 → 0.3.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/README.md +22 -8
- package/instructions/skills/lanes-link/SKILL.md +42 -14
- package/package.json +1 -1
- 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 +63 -164
- package/src/cli/commands/operate/outputs.ts +38 -11
- package/src/cli/commands/operate/policy.ts +7 -7
- package/src/cli/commands/operate/serve.ts +3 -0
- package/src/cli/commands/operate/status.ts +108 -1
- 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/remove.ts +5 -5
- package/src/cli/commands/profile.ts +83 -35
- package/src/cli/commands/secrets.ts +6 -6
- package/src/cli/commands/setup.ts +22 -6
- package/src/cli/commands/sync.ts +262 -0
- package/src/cli/commands/target.ts +65 -83
- package/src/cli/config-edit.ts +53 -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/dispatch-owner.ts +93 -0
- package/src/cli/identity.ts +12 -1
- package/src/cli/main.ts +90 -61
- package/src/cli/nearest.ts +45 -0
- 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 +74 -51
- 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 +365 -0
- package/src/cli/usage.ts +42 -11
- 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 +94 -114
- package/src/deployments/discover.ts +103 -0
- package/src/deployments/driver.ts +8 -1
- package/src/deployments/gcp/driver.ts +3 -1
- package/src/deployments/knowledge.ts +119 -0
- package/src/deployments/prepare.ts +12 -6
- package/src/deployments/report.ts +117 -0
- package/src/deployments/servable.ts +82 -0
- package/src/deployments/serving.ts +165 -0
- package/src/deployments/sync-apply.ts +276 -0
- package/src/deployments/sync.ts +136 -0
- package/src/deployments/target.ts +3 -2
- package/src/deployments/upload.ts +19 -12
- package/src/dispatch/dispatch.ts +1 -1
- package/src/profile/deployments.ts +80 -0
- package/src/profile/identity.ts +60 -0
- package/src/profile/index.ts +23 -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 +81 -3
- package/src/profile/targets.ts +122 -109
- package/src/profile/workspace.ts +139 -79
- 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 +21 -3
- 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 +45 -1
- package/src/server/generations.ts +11 -2
- package/src/server/harness.ts +7 -0
- package/src/server/index.ts +45 -6
- package/src/server/mcp/index.ts +1 -0
- package/src/server/mcp/instructions.ts +28 -1
- package/src/server/mcp/visibility.ts +33 -0
- package/src/stores/blobs/route.ts +123 -0
package/src/cli/usage.ts
CHANGED
|
@@ -24,11 +24,13 @@ ${style.bold('Everyday')}
|
|
|
24
24
|
${PROGRAM} connect <provider> add an account (run once per account)
|
|
25
25
|
${PROGRAM} connect <provider>.<id> re-authorise one existing account
|
|
26
26
|
${PROGRAM} connect <...> --replace ask for the stored password or key again
|
|
27
|
+
${PROGRAM} connect <...> --auth <method> pick how, where there is a choice
|
|
27
28
|
${PROGRAM} connect <...> --non-interactive [--json]
|
|
28
29
|
answer nothing from a terminal: take every value
|
|
29
30
|
from the credential store, or say what is missing
|
|
30
31
|
${PROGRAM} start [--only] reconcile and serve every profile on one endpoint
|
|
31
32
|
${PROGRAM} outputs [--show] [--json] the endpoint an agent needs
|
|
33
|
+
${PROGRAM} dashboard [--print] open the local endpoint's page in a browser
|
|
32
34
|
${PROGRAM} mcp add [claude|codex] register this endpoint, and install the agent skill
|
|
33
35
|
${PROGRAM} mcp add --no-skill register only, leaving the agent's own files alone
|
|
34
36
|
${PROGRAM} mcp list where it is registered, and whether the skill is current
|
|
@@ -37,16 +39,27 @@ ${style.bold('Everyday')}
|
|
|
37
39
|
${PROGRAM} status [--json] connections, reachable capabilities, endpoint
|
|
38
40
|
|
|
39
41
|
${style.bold('Profiles')}
|
|
40
|
-
${PROGRAM} profile add <name> [--
|
|
42
|
+
${PROGRAM} profile add <name> --target <name> [--target <name>] [--json]
|
|
43
|
+
a target per place it runs; local is derived, the
|
|
44
|
+
rest are copied from a sibling profile
|
|
41
45
|
${PROGRAM} profile list [--json]
|
|
42
|
-
${PROGRAM} profile default <name>
|
|
43
46
|
${PROGRAM} profile remove <name> [--target t] [--dry-run] [--yes] [--json]
|
|
44
47
|
the profile, its credentials, and its data
|
|
45
48
|
|
|
46
49
|
${style.bold('Targets')}
|
|
47
|
-
${PROGRAM} target list [--urls] where this profile can run
|
|
48
|
-
${PROGRAM} target show
|
|
49
|
-
${PROGRAM}
|
|
50
|
+
${PROGRAM} target list [--urls] where this profile can run
|
|
51
|
+
${PROGRAM} target show <name> one target's adapters, and the address it answers on
|
|
52
|
+
${PROGRAM} sync targets --target t [--from gs://bucket] [--discover]
|
|
53
|
+
[--prefer local|remote] [--dry-run]
|
|
54
|
+
reconcile this workspace with the copy the
|
|
55
|
+
deployment reads; recovers a target a profile
|
|
56
|
+
has lost
|
|
57
|
+
|
|
58
|
+
${style.bold('Who you are')}
|
|
59
|
+
${PROGRAM} identity add <kind> <value> [--note text] [--json]
|
|
60
|
+
e.g. name, email, github — any kind you like
|
|
61
|
+
${PROGRAM} identity list [--json]
|
|
62
|
+
${PROGRAM} identity remove <kind> <value> [--json]
|
|
50
63
|
|
|
51
64
|
${style.bold('Permissions')}
|
|
52
65
|
${PROGRAM} policy list
|
|
@@ -66,6 +79,14 @@ ${style.bold('Your own context')}
|
|
|
66
79
|
${PROGRAM} skills add <name> [--file f] document on stdin
|
|
67
80
|
${PROGRAM} skills remove <name>
|
|
68
81
|
|
|
82
|
+
${PROGRAM} knowledge show where memory and skills are kept, and how many
|
|
83
|
+
${PROGRAM} knowledge use github --repo <owner/name> [--branch b] [--path p]
|
|
84
|
+
keep both in a private repository, over the GitHub API
|
|
85
|
+
[--migrate] moves what is already stored, in one commit
|
|
86
|
+
[--no-migrate] switches and leaves it where it is
|
|
87
|
+
[--keep] moves it, and leaves the local copies unread
|
|
88
|
+
${PROGRAM} knowledge use local [--migrate] bring them back onto this target
|
|
89
|
+
|
|
69
90
|
${PROGRAM} vault list names only, never values
|
|
70
91
|
${PROGRAM} vault get <id> [--show|--raw]
|
|
71
92
|
${PROGRAM} vault set <id> [--description d] value on stdin
|
|
@@ -73,10 +94,13 @@ ${style.bold('Your own context')}
|
|
|
73
94
|
${PROGRAM} vault key generate a fresh LANES_LINK_VAULT_KEY, printed once
|
|
74
95
|
|
|
75
96
|
${style.bold('Deploying')}
|
|
76
|
-
${PROGRAM} deploy [--dry-run]
|
|
97
|
+
${PROGRAM} deploy --target t [--dry-run]
|
|
98
|
+
set up, build, and roll one revision serving
|
|
99
|
+
every profile that declares the target
|
|
100
|
+
${PROGRAM} deploy --target t --profile a --profile b
|
|
101
|
+
only these; the first owns the endpoint token
|
|
77
102
|
${PROGRAM} deploy --non-interactive take the stored answers, never prompt
|
|
78
103
|
${PROGRAM} deploy --access iam|public who gets past the platform's own door
|
|
79
|
-
${PROGRAM} deploy --target <name> deploy a second one, under its own name
|
|
80
104
|
${PROGRAM} secrets list credential references in this target
|
|
81
105
|
${PROGRAM} secrets set <ref> store one value, read from stdin
|
|
82
106
|
${PROGRAM} secrets push --from local --to cloud
|
|
@@ -96,9 +120,13 @@ ${style.bold('Attachments')}
|
|
|
96
120
|
${PROGRAM} attach <file> --connection <provider>.<account>
|
|
97
121
|
stage a file, print a handle to send it by
|
|
98
122
|
|
|
99
|
-
${style.bold('
|
|
100
|
-
--profile <name>
|
|
101
|
-
--target <name>
|
|
123
|
+
${style.bold('Naming what a command acts on')}
|
|
124
|
+
--profile <name> required by every command that reads or writes a profile
|
|
125
|
+
--target <name> required by every command that opens a target's stores.
|
|
126
|
+
There is no default and no environment variable: a
|
|
127
|
+
command that names neither refuses and lists what exists.
|
|
128
|
+
|
|
129
|
+
${style.bold('Other flags')}
|
|
102
130
|
--connection <id> which memory/skills/vault connection, if a profile has several
|
|
103
131
|
--yes skip the confirmation a destructive command would ask for
|
|
104
132
|
--json machine-readable output, where a command offers it
|
|
@@ -107,7 +135,10 @@ ${style.bold('Global flags')}
|
|
|
107
135
|
--accept-broad-scopes agree in advance to scopes broader than a provider needs
|
|
108
136
|
--own-client register your own OAuth client instead of using the
|
|
109
137
|
one this project operates (connect only)
|
|
138
|
+
--auth <method> which way in, where a provider offers two (connect
|
|
139
|
+
only). "oauth" is the browser; the other is named
|
|
140
|
+
in the choice connect prints
|
|
110
141
|
--port <n> override the configured port (start only)
|
|
111
142
|
|
|
112
|
-
Every command prints the
|
|
143
|
+
Every command prints the profile and target it is acting on, before it acts.
|
|
113
144
|
`;
|
|
@@ -14,6 +14,7 @@ transport asks when it has a token to send and no request to attach it to.
|
|
|
14
14
|
| `api-key/` | `api_key` | a key, in a header or the query string |
|
|
15
15
|
| `basic/` | `basic` | `username:password`, RFC 7617's own encoding |
|
|
16
16
|
| `oauth-authcode/` | `oauth` | a refresh token, exchanged on every use |
|
|
17
|
+
| `oauth-jwt/` | `oauth` + `assertion` | a private key, signed into an assertion per exchange |
|
|
17
18
|
| `strategy/` | `strategy` | the escape hatch — per-vendor code, none registered |
|
|
18
19
|
|
|
19
20
|
## Adding one
|
|
@@ -22,13 +23,18 @@ A folder, a member of `authSchema` in `../manifest/auth.ts`, and a case in
|
|
|
22
23
|
`resolve.ts` (plus `authorize.ts` if it touches the request). Nothing else in
|
|
23
24
|
the codebase learns about it — that is the point of the split.
|
|
24
25
|
|
|
26
|
+
`oauth-jwt/` is the exception that proves the shape rather than breaking it. It
|
|
27
|
+
is not a `kind`, because it is a second way into a provider that already has
|
|
28
|
+
one, so it hangs off the OAuth block as `auth.assertion` and is selected by the
|
|
29
|
+
shape of the stored credential. Everything else about it is an ordinary folder
|
|
30
|
+
here.
|
|
31
|
+
|
|
25
32
|
These are named in the credential-type list this design is measured against and
|
|
26
33
|
are **not built**:
|
|
27
34
|
|
|
28
35
|
- `sigv4/` — AWS SigV4 request signing
|
|
29
36
|
- `gcp-token/` — service account → GCP access token
|
|
30
37
|
- `gcp-iap/` — service account → an IAP-signed JWT
|
|
31
|
-
- `oauth-jwt/` — OAuth 2.0 JWT bearer (RFC 7523)
|
|
32
38
|
- `oauth-client-creds/` — OAuth 2.0 client credentials
|
|
33
39
|
- `body-param/` — the credential as a request body parameter
|
|
34
40
|
|
|
@@ -15,7 +15,7 @@ export function resolveBasic(value: string, ref: string, providerId: string): Re
|
|
|
15
15
|
if (colon === -1) {
|
|
16
16
|
throw new Error(
|
|
17
17
|
`The credential at ${ref} is not a "username:password" pair, which basic auth requires. ` +
|
|
18
|
-
`
|
|
18
|
+
`Connect "${providerId}" again for this profile and target.`,
|
|
19
19
|
);
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
* know the whole set: `resolve.ts` and `authorize.ts` for anything HTTP-shaped,
|
|
8
8
|
* and `token.ts` for a transport that takes a bare token instead of a request.
|
|
9
9
|
*
|
|
10
|
+
* `oauth-jwt/` is the one folder that is not a `kind`: it is a second way into
|
|
11
|
+
* a provider that already declares `oauth`, selected by the shape of what is
|
|
12
|
+
* stored rather than by the manifest. Its own README says why.
|
|
13
|
+
*
|
|
10
14
|
* This is the axis the manifest's `auth:` block selects, and it is deliberately
|
|
11
15
|
* independent of `../transports/` — which is why iCloud can speak IMAP with a
|
|
12
16
|
* password while Gmail speaks HTTP with OAuth, and neither costs the other any
|
|
@@ -24,9 +28,19 @@ export {
|
|
|
24
28
|
type OAuthProviderOptions,
|
|
25
29
|
} from './oauth-authcode/provider.ts';
|
|
26
30
|
export { resolveUpstreamToken } from './oauth-authcode/index.ts';
|
|
31
|
+
export {
|
|
32
|
+
ASSERTION_GRANT,
|
|
33
|
+
clearMintedTokens,
|
|
34
|
+
isStoredAssertion,
|
|
35
|
+
resolveAssertionToken,
|
|
36
|
+
storedAssertionFor,
|
|
37
|
+
type StoredAssertion,
|
|
38
|
+
} from './oauth-jwt/index.ts';
|
|
39
|
+
export { assertionKeySchema, parseAssertionKey, signAssertion, type AssertionKey } from './oauth-jwt/key.ts';
|
|
27
40
|
export {
|
|
28
41
|
BROKER_ORIGIN_ENV,
|
|
29
42
|
BROKERED,
|
|
43
|
+
PASTED,
|
|
30
44
|
BrokerError,
|
|
31
45
|
brokerConfig,
|
|
32
46
|
brokerExchange,
|
|
@@ -20,6 +20,17 @@
|
|
|
20
20
|
*/
|
|
21
21
|
export const BROKERED = 'broker';
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Stamped on a credential nobody here minted — the operator pasted it.
|
|
25
|
+
*
|
|
26
|
+
* The third answer, and the one that is not a client at all. It matters for the
|
|
27
|
+
* same reason as the other two and one more: a pasted token cannot be
|
|
28
|
+
* refreshed, cannot be attributed to a registration, and cannot be re-obtained
|
|
29
|
+
* by re-running a flow. `doctor` reads it to say so rather than offering a
|
|
30
|
+
* re-authorisation that would not apply.
|
|
31
|
+
*/
|
|
32
|
+
export const PASTED = 'pasted';
|
|
33
|
+
|
|
23
34
|
/** What the broker will authorise, and whether it is currently doing so. */
|
|
24
35
|
export interface BrokerConfig {
|
|
25
36
|
readonly clientId: string;
|
|
@@ -27,6 +38,20 @@ export interface BrokerConfig {
|
|
|
27
38
|
readonly scopesSupported: readonly string[];
|
|
28
39
|
/** Added to every request so the exchange returns an identity assertion. */
|
|
29
40
|
readonly identityScopes: readonly string[];
|
|
41
|
+
/**
|
|
42
|
+
* Where the vendor sends the browser back, when it will not send it here.
|
|
43
|
+
*
|
|
44
|
+
* Absent for a vendor that accepts a loopback redirect, which is all of them
|
|
45
|
+
* but Slack: the listener names itself and the broker is only asked to
|
|
46
|
+
* redeem. Present where the vendor demands HTTPS — then the redirect lands on
|
|
47
|
+
* the broker's own origin and is bounced down to the listener, and this is
|
|
48
|
+
* the URL both legs of the flow have to agree on.
|
|
49
|
+
*
|
|
50
|
+
* Published rather than derived, because which URL is correct depends on
|
|
51
|
+
* which deployment answered `/config` — and a broker running on loopback for
|
|
52
|
+
* a test would otherwise need a flag of its own.
|
|
53
|
+
*/
|
|
54
|
+
readonly redirectUri: string | undefined;
|
|
30
55
|
readonly open: boolean;
|
|
31
56
|
/** Why it is closed, or near capacity. The broker's words, printed verbatim. */
|
|
32
57
|
readonly notice: string | undefined;
|
|
@@ -217,6 +242,7 @@ export async function brokerConfig(
|
|
|
217
242
|
clientId,
|
|
218
243
|
scopesSupported: strings(data['scopes_supported']),
|
|
219
244
|
identityScopes: strings(data['identity_scopes']),
|
|
245
|
+
redirectUri: str(data['redirect_uri']),
|
|
220
246
|
open: data['status'] !== 'closed',
|
|
221
247
|
notice: str(data['notice']),
|
|
222
248
|
docsUrl: str(data['docs_url']),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { auth } from '@modelcontextprotocol/client';
|
|
2
2
|
import type { ProviderManifest } from '#connectivity';
|
|
3
3
|
import type { SecretStore } from '#secrets';
|
|
4
|
+
import { resolveAssertionToken, storedAssertionFor } from '../oauth-jwt/index.ts';
|
|
4
5
|
import { CredentialOAuthProvider, upstreamAccessToken } from './provider.ts';
|
|
5
6
|
import { refreshDirectly } from './refresh.ts';
|
|
6
7
|
|
|
@@ -12,8 +13,11 @@ import { refreshDirectly } from './refresh.ts';
|
|
|
12
13
|
* trip at connect time, a refresh on every use, and two different ways to run
|
|
13
14
|
* that refresh depending on whether the provider has a metadata document.
|
|
14
15
|
*
|
|
15
|
-
* The other flows the credential-type list names —
|
|
16
|
-
*
|
|
16
|
+
* The other flows the credential-type list names — client credentials, SigV4 —
|
|
17
|
+
* are sibling folders that do not exist yet. See ../README.md. JWT bearer now
|
|
18
|
+
* does exist, in `../oauth-jwt/`, and is reached from here rather than from
|
|
19
|
+
* `resolve.ts`: a provider offering both declares one `auth.kind`, so the fork
|
|
20
|
+
* belongs at the point where the stored credential is first read.
|
|
17
21
|
*/
|
|
18
22
|
|
|
19
23
|
export async function resolveUpstreamToken(
|
|
@@ -23,6 +27,16 @@ export async function resolveUpstreamToken(
|
|
|
23
27
|
): Promise<string | null> {
|
|
24
28
|
if (manifest.auth.kind !== 'oauth') return null;
|
|
25
29
|
|
|
30
|
+
// Before anything is built, because the two arrangements share a ref and only
|
|
31
|
+
// what is stored there tells them apart. A `CredentialOAuthProvider` over an
|
|
32
|
+
// assertion pointer would find no `access_token`, conclude the connection was
|
|
33
|
+
// never authorised, and advise a browser flow the operator deliberately
|
|
34
|
+
// declined.
|
|
35
|
+
const assertion = await storedAssertionFor(manifest, connectionId, credentials);
|
|
36
|
+
if (assertion) {
|
|
37
|
+
return resolveAssertionToken({ manifest, connectionId, stored: assertion, credentials });
|
|
38
|
+
}
|
|
39
|
+
|
|
26
40
|
const provider = new CredentialOAuthProvider({
|
|
27
41
|
manifest,
|
|
28
42
|
connectionId,
|
|
@@ -133,7 +133,7 @@ export class CredentialOAuthProvider {
|
|
|
133
133
|
if (!this.#options.openBrowser) {
|
|
134
134
|
throw new Error(
|
|
135
135
|
`Connection ${this.#options.manifest.id}.${this.#options.connectionId} needs re-authorisation, ` +
|
|
136
|
-
`which requires a browser.
|
|
136
|
+
`which requires a browser. Connect ${this.#options.manifest.id}.${this.#options.connectionId} again for this profile and target.`,
|
|
137
137
|
);
|
|
138
138
|
}
|
|
139
139
|
this.#options.openBrowser(authorizationUrl);
|
|
@@ -24,7 +24,7 @@ export async function refreshDirectly(
|
|
|
24
24
|
|
|
25
25
|
if (!refreshToken) {
|
|
26
26
|
throw new Error(
|
|
27
|
-
`No refresh token stored for ${manifest.id}.
|
|
27
|
+
`No refresh token stored for ${manifest.id}. Connecting it again for this profile and target would store one.`,
|
|
28
28
|
);
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -72,7 +72,7 @@ async function viaBroker(
|
|
|
72
72
|
const notice = cause instanceof BrokerError && cause.notice ? `\n${cause.notice}` : '';
|
|
73
73
|
throw new Error(
|
|
74
74
|
`The credential for ${manifest.id} could not be refreshed. ` +
|
|
75
|
-
`Re-authorise
|
|
75
|
+
`Re-authorise ${manifest.id} for this profile and target.\n${String(
|
|
76
76
|
cause instanceof Error ? cause.message : cause,
|
|
77
77
|
).slice(0, 200)}${notice}`,
|
|
78
78
|
);
|
|
@@ -110,7 +110,7 @@ async function viaStoredClient(
|
|
|
110
110
|
// is always the same, so say it rather than surfacing the raw grant error.
|
|
111
111
|
throw new Error(
|
|
112
112
|
`The credential for ${manifest.id} could not be refreshed (${response.status}). ` +
|
|
113
|
-
`Re-authorise
|
|
113
|
+
`Re-authorise ${manifest.id} for this profile and target.\n${text.slice(0, 200)}`,
|
|
114
114
|
);
|
|
115
115
|
}
|
|
116
116
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# OAuth 2.0 JWT bearer (RFC 7523)
|
|
2
|
+
|
|
3
|
+
A key the operator holds, signed into a short-lived assertion and exchanged for
|
|
4
|
+
an access token. No browser, and **no refresh token** — there is nothing to
|
|
5
|
+
refresh, because a new assertion is signed whenever the last token ages out.
|
|
6
|
+
That is the point: an authorization-code refresh token lives or dies by the
|
|
7
|
+
issuer's policy, and a key does not.
|
|
8
|
+
|
|
9
|
+
Not a `kind` of its own. It is declared as `auth.assertion` on an existing
|
|
10
|
+
`oauth` block, because it is a second arrangement for the same provider rather
|
|
11
|
+
than a different provider — so `credentialRefForConnection`, `setupRequirements`
|
|
12
|
+
and the deploy grants all stay as they were. Which arrangement a connection uses
|
|
13
|
+
is decided by the *shape* of what is stored at `<provider>/<connection>`:
|
|
14
|
+
`isStoredAssertion` is that test, and `resolve.ts` asks it before building an
|
|
15
|
+
authorization-code provider.
|
|
16
|
+
|
|
17
|
+
| File | What it owns |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `key.ts` | the key file's layout, PEM to DER, and the signed claim set — no I/O |
|
|
20
|
+
| `index.ts` | reading the pointer, the exchange, the process-lifetime token cache |
|
|
21
|
+
|
|
22
|
+
The endpoint comes from `token_uri` **inside the key file**, never from a
|
|
23
|
+
constant here, which is what keeps this folder free of any vendor. A second
|
|
24
|
+
vendor offering the same grant is a manifest and no code.
|
|
25
|
+
|
|
26
|
+
## What it cannot do
|
|
27
|
+
|
|
28
|
+
An assertion authenticates the key, and a key is not a person. It reaches only
|
|
29
|
+
what has been shared with its address — unless the identity provider is
|
|
30
|
+
configured to let it act as someone, which is an administrator's grant and not
|
|
31
|
+
the operator's. `auth.assertion.delegation` says which of the two a provider is,
|
|
32
|
+
and `cli/commands/connect/method.ts` is where that becomes a sentence someone
|
|
33
|
+
reads before choosing.
|
|
@@ -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
|
+
}
|