synomem 0.7.2 → 0.9.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/CHANGELOG.md +38 -0
- package/README.md +47 -68
- package/dist/backend.d.ts +18 -6
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js +55 -41
- package/dist/backend.js.map +1 -1
- package/dist/cli.d.ts +20 -25
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1394 -1281
- package/dist/cli.js.map +1 -1
- package/dist/configure.d.ts +12 -46
- package/dist/configure.d.ts.map +1 -1
- package/dist/configure.js +51 -192
- package/dist/configure.js.map +1 -1
- package/dist/credentials.d.ts +73 -33
- package/dist/credentials.d.ts.map +1 -1
- package/dist/credentials.js +167 -43
- package/dist/credentials.js.map +1 -1
- package/dist/discover.d.ts +10 -13
- package/dist/discover.d.ts.map +1 -1
- package/dist/discover.js +45 -30
- package/dist/discover.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +5 -0
- package/dist/errors.js.map +1 -1
- package/dist/import.d.ts +3 -0
- package/dist/import.d.ts.map +1 -1
- package/dist/import.js +3 -0
- package/dist/import.js.map +1 -1
- package/dist/index.d.ts +9 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -5
- package/dist/index.js.map +1 -1
- package/dist/mcp/index.d.ts +18 -7
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +402 -183
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp-server.d.ts +5 -1
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +27 -105
- package/dist/mcp-server.js.map +1 -1
- package/dist/oauth.d.ts +31 -33
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +178 -125
- package/dist/oauth.js.map +1 -1
- package/dist/profiles.d.ts +243 -0
- package/dist/profiles.d.ts.map +1 -0
- package/dist/profiles.js +465 -0
- package/dist/profiles.js.map +1 -0
- package/dist/project.d.ts +8 -39
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +36 -94
- package/dist/project.js.map +1 -1
- package/dist/remote.d.ts +23 -15
- package/dist/remote.d.ts.map +1 -1
- package/dist/remote.js +54 -49
- package/dist/remote.js.map +1 -1
- package/dist/resolvers.d.ts +47 -0
- package/dist/resolvers.d.ts.map +1 -0
- package/dist/resolvers.js +255 -0
- package/dist/resolvers.js.map +1 -0
- package/dist/service.d.ts +2 -0
- package/dist/service.d.ts.map +1 -1
- package/dist/skill-install.d.ts +4 -6
- package/dist/skill-install.d.ts.map +1 -1
- package/dist/skill-install.js +13 -12
- package/dist/skill-install.js.map +1 -1
- package/dist/types.d.ts +51 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/cli.md +173 -196
- package/docs/mcp.md +69 -65
- package/package.json +1 -1
- package/skills/synomem/SKILL.md +30 -4
- package/skills/synomem/references/examples.md +14 -0
- package/src/backend.ts +66 -64
- package/src/cli.ts +2137 -2163
- package/src/configure.ts +62 -241
- package/src/credentials.ts +208 -84
- package/src/discover.ts +60 -36
- package/src/errors.ts +5 -0
- package/src/import.ts +5 -0
- package/src/index.ts +14 -12
- package/src/mcp/index.ts +473 -194
- package/src/mcp-server.ts +32 -114
- package/src/oauth.ts +229 -130
- package/src/profiles.ts +644 -0
- package/src/project.ts +42 -108
- package/src/remote.ts +69 -58
- package/src/resolvers.ts +299 -0
- package/src/service.ts +2 -0
- package/src/skill-install.ts +17 -18
- package/src/types.ts +46 -0
package/src/mcp-server.ts
CHANGED
|
@@ -1,123 +1,41 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { parseArgs } from 'node:util';
|
|
3
|
-
import { createConfiguredService } from './backend.js';
|
|
4
|
-
import { resolveWorkspaceSelection } from './project.js';
|
|
5
|
-
import { SynomemError } from './errors.js';
|
|
6
|
-
import { actorSchema } from './schemas.js';
|
|
7
|
-
import { startMcpServer } from './mcp/index.js';
|
|
8
|
-
import type { ActorIdentity } from './types.js';
|
|
9
|
-
import { packageVersion } from './version.js';
|
|
10
|
-
|
|
11
|
-
const version = packageVersion();
|
|
12
|
-
|
|
13
|
-
const { values } = parseArgs({
|
|
14
|
-
options: {
|
|
15
|
-
home: { type: 'string' },
|
|
16
|
-
workspace: { type: 'string' },
|
|
17
|
-
'agent-id': { type: 'string' },
|
|
18
|
-
'actor-id': { type: 'string' },
|
|
19
|
-
'actor-kind': { type: 'string' },
|
|
20
|
-
'actor-name': { type: 'string' },
|
|
21
|
-
help: { type: 'boolean', short: 'h' },
|
|
22
|
-
version: { type: 'boolean', short: 'v' },
|
|
23
|
-
},
|
|
24
|
-
});
|
|
25
|
-
|
|
26
2
|
/**
|
|
27
|
-
*
|
|
3
|
+
* The stdio MCP entry point.
|
|
28
4
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
5
|
+
* `startMcpServer` runs the shared tool catalog for one context resolver: a fixed
|
|
6
|
+
* profile (one pinned workspace/actor) or an explicit preset (several, selected per
|
|
7
|
+
* call). Which resolver is decided by the CLI's profile resolution (identity contract
|
|
8
|
+
* §6.1), so `synomem-mcp <args>` is exactly `synomem mcp <args>` — one resolution
|
|
9
|
+
* implementation for every entry point, never a second set of identity flags here.
|
|
34
10
|
*/
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
11
|
+
import { realpathSync } from 'node:fs';
|
|
12
|
+
import { pathToFileURL } from 'node:url';
|
|
13
|
+
import { serveStdio } from './mcp/index.js';
|
|
14
|
+
import type { ContextResolver } from './resolvers.js';
|
|
15
|
+
|
|
16
|
+
export async function startMcpServer(options: {
|
|
17
|
+
resolver: ContextResolver;
|
|
18
|
+
instructions?: string;
|
|
19
|
+
}): Promise<void> {
|
|
20
|
+
const runtime = await serveStdio(options.resolver, {
|
|
21
|
+
...(options.instructions ? { instructions: options.instructions } : {}),
|
|
39
22
|
});
|
|
40
|
-
await
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
'AGENT_NOT_FOUND',
|
|
46
|
-
resolution.candidates.length
|
|
47
|
-
? `"${agentId}" matches ${resolution.candidates.length} agents: ${resolution.candidates
|
|
48
|
-
.map((candidate) => candidate.id)
|
|
49
|
-
.join(', ')}. Register the MCP server with a canonical agent ID.`
|
|
50
|
-
: `Unknown agent: ${agentId}. Create it with \`synomem agent create\` first.`,
|
|
51
|
-
);
|
|
52
|
-
}
|
|
53
|
-
return {
|
|
54
|
-
kind: 'agent',
|
|
55
|
-
id: resolution.match.id,
|
|
56
|
-
...(resolution.match.displayName ? { displayName: resolution.match.displayName } : {}),
|
|
23
|
+
await new Promise<void>((resolve) => {
|
|
24
|
+
const previous = runtime.server.server.onclose;
|
|
25
|
+
runtime.server.server.onclose = () => {
|
|
26
|
+
previous?.();
|
|
27
|
+
resolve();
|
|
57
28
|
};
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
}
|
|
29
|
+
});
|
|
30
|
+
await options.resolver.close?.();
|
|
61
31
|
}
|
|
62
32
|
|
|
63
|
-
if (
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
--workspace <name> Local workspace to act in. Defaults to the workspace
|
|
72
|
-
named by .synomem/config.json in the working directory
|
|
73
|
-
or any directory above it, then to SYNOMEM_WORKSPACE,
|
|
74
|
-
then to the default workspace.
|
|
75
|
-
--agent-id <id> Bound agent, whose identity is read from Synomem
|
|
76
|
-
(or SYNOMEM_AGENT_ID)
|
|
77
|
-
--actor-id <id> Bound non-agent actor ID (or SYNOMEM_ACTOR_ID)
|
|
78
|
-
--actor-kind <kind> human or system (or SYNOMEM_ACTOR_KIND)
|
|
79
|
-
--actor-name <name> Display name for a non-agent actor
|
|
80
|
-
(or SYNOMEM_ACTOR_NAME)
|
|
81
|
-
-h, --help Show help
|
|
82
|
-
-v, --version Show version
|
|
83
|
-
|
|
84
|
-
Prefer --agent-id for an agent runtime: the display name and kind then come
|
|
85
|
-
from the agent's profile instead of from whatever the harness was told to pass.
|
|
86
|
-
`,
|
|
87
|
-
);
|
|
88
|
-
} else if (values.version) {
|
|
89
|
-
process.stdout.write(`${version}\n`);
|
|
90
|
-
} else {
|
|
91
|
-
/*
|
|
92
|
-
* The workspace is resolved from where the server was STARTED, which is what
|
|
93
|
-
* makes a project binding work at all.
|
|
94
|
-
*
|
|
95
|
-
* A harness launches this process in the repository it opened, so a
|
|
96
|
-
* `.synomem/config.json` there selects the workspace for the whole session
|
|
97
|
-
* without the harness knowing anything about workspaces, and without anybody
|
|
98
|
-
* repeating a flag. `--home` still wins, because it names a home outright
|
|
99
|
-
* rather than a workspace inside one.
|
|
100
|
-
*/
|
|
101
|
-
const selection = values.home
|
|
102
|
-
? undefined
|
|
103
|
-
: resolveWorkspaceSelection({
|
|
104
|
-
...(values.workspace ? { flag: values.workspace } : {}),
|
|
105
|
-
env: process.env,
|
|
106
|
-
});
|
|
107
|
-
const home = values.home ?? selection?.home;
|
|
108
|
-
|
|
109
|
-
const agentId =
|
|
110
|
-
values['agent-id'] ?? process.env.SYNOMEM_AGENT_ID ?? selection?.actor ?? undefined;
|
|
111
|
-
const actor = agentId
|
|
112
|
-
? await resolveAgentActor(agentId, home)
|
|
113
|
-
: actorSchema.parse({
|
|
114
|
-
id: values['actor-id'] ?? process.env.SYNOMEM_ACTOR_ID,
|
|
115
|
-
kind: values['actor-kind'] ?? process.env.SYNOMEM_ACTOR_KIND,
|
|
116
|
-
displayName: values['actor-name'] ?? process.env.SYNOMEM_ACTOR_NAME,
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
await startMcpServer({
|
|
120
|
-
actor,
|
|
121
|
-
...(home ? { home } : {}),
|
|
122
|
-
});
|
|
33
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
|
|
34
|
+
const { runCli } = await import('./cli.js');
|
|
35
|
+
process.exitCode = await runCli([
|
|
36
|
+
process.argv[0] ?? 'node',
|
|
37
|
+
process.argv[1],
|
|
38
|
+
'mcp',
|
|
39
|
+
...process.argv.slice(2),
|
|
40
|
+
]);
|
|
123
41
|
}
|
package/src/oauth.ts
CHANGED
|
@@ -1,16 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CLI's own OAuth 2.1 client (RFC 8252 native app).
|
|
3
|
+
*
|
|
4
|
+
* Authorization code + PKCE S256 through the system browser and a loopback
|
|
5
|
+
* redirect, against the pre-registered public client `synomem-cli`. The token
|
|
6
|
+
* is audienced to the Synomem API itself: discovery starts from the API's own
|
|
7
|
+
* protected-resource metadata (RFC 9728), never from the MCP gateway, and the
|
|
8
|
+
* discovered issuer and resource are validated before any browser opens.
|
|
9
|
+
*
|
|
10
|
+
* Which agent and workspace the resulting connection may act as is chosen on
|
|
11
|
+
* the consent screen and enforced by the API; nothing here names an actor.
|
|
12
|
+
*/
|
|
1
13
|
import { createServer } from 'node:http';
|
|
2
14
|
import { spawn } from 'node:child_process';
|
|
3
|
-
import { randomBytes } from 'node:crypto';
|
|
4
|
-
import {
|
|
5
|
-
discoverOAuthServerInfo,
|
|
6
|
-
startAuthorization,
|
|
7
|
-
} from '@modelcontextprotocol/sdk/client/auth.js';
|
|
15
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
8
16
|
import { SynomemError } from './errors.js';
|
|
9
|
-
import type {
|
|
10
|
-
import type { SynomemCredentialProvider } from './remote.js';
|
|
11
|
-
import { readCredentialFile } from './configure.js';
|
|
17
|
+
import type { StoredOAuthCredential } from './credentials.js';
|
|
12
18
|
|
|
13
|
-
const
|
|
19
|
+
export const DEFAULT_CLI_CLIENT_ID = 'synomem-cli';
|
|
20
|
+
export const DEFAULT_CALLBACK_PORT = 43_817;
|
|
21
|
+
export const DEFAULT_CLI_SCOPE = 'openid offline_access synomem:read synomem:write';
|
|
22
|
+
const maximumDocumentBytes = 64 * 1024;
|
|
23
|
+
/** Access tokens are treated as expired this long before they actually are. */
|
|
24
|
+
const expirySafetyMs = 30_000;
|
|
14
25
|
|
|
15
26
|
interface TokenResponse {
|
|
16
27
|
access_token: string;
|
|
@@ -19,35 +30,151 @@ interface TokenResponse {
|
|
|
19
30
|
scope?: string;
|
|
20
31
|
}
|
|
21
32
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
33
|
+
export interface ApiAuthorizationMetadata {
|
|
34
|
+
resource: string;
|
|
35
|
+
issuer: string;
|
|
36
|
+
authorizationEndpoint: string;
|
|
37
|
+
tokenEndpoint: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function trimSlash(value: string): string {
|
|
41
|
+
return value.replace(/\/+$/, '');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** HTTPS, or loopback HTTP (development against a local stack). */
|
|
45
|
+
export function secureUrl(value: string, label: string): URL {
|
|
46
|
+
let url: URL;
|
|
47
|
+
try {
|
|
48
|
+
url = new URL(value);
|
|
49
|
+
} catch {
|
|
50
|
+
throw new SynomemError('AUTH_REQUIRED', `${label} is not a valid URL.`);
|
|
51
|
+
}
|
|
52
|
+
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
|
|
53
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
|
|
25
54
|
throw new SynomemError('AUTH_REQUIRED', `${label} must use HTTPS.`);
|
|
26
55
|
}
|
|
56
|
+
if (url.username || url.password) {
|
|
57
|
+
throw new SynomemError('AUTH_REQUIRED', `${label} must not carry credentials.`);
|
|
58
|
+
}
|
|
27
59
|
return url;
|
|
28
60
|
}
|
|
29
61
|
|
|
62
|
+
async function readJson(
|
|
63
|
+
url: URL,
|
|
64
|
+
fetchImplementation: typeof fetch,
|
|
65
|
+
label: string,
|
|
66
|
+
): Promise<Record<string, unknown>> {
|
|
67
|
+
let response: Response;
|
|
68
|
+
try {
|
|
69
|
+
response = await fetchImplementation(url, {
|
|
70
|
+
redirect: 'error',
|
|
71
|
+
headers: { accept: 'application/json' },
|
|
72
|
+
});
|
|
73
|
+
} catch {
|
|
74
|
+
throw new SynomemError('REMOTE_UNAVAILABLE', `${label} is unavailable.`);
|
|
75
|
+
}
|
|
76
|
+
const text = await response.text();
|
|
77
|
+
if (Buffer.byteLength(text) > maximumDocumentBytes) {
|
|
78
|
+
throw new SynomemError('REMOTE_PROTOCOL', `${label} exceeded the safe size limit.`);
|
|
79
|
+
}
|
|
80
|
+
if (!response.ok) {
|
|
81
|
+
throw new SynomemError('REMOTE_PROTOCOL', `${label} returned HTTP ${response.status}.`);
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const parsed = JSON.parse(text) as unknown;
|
|
85
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error();
|
|
86
|
+
return parsed as Record<string, unknown>;
|
|
87
|
+
} catch {
|
|
88
|
+
throw new SynomemError('REMOTE_PROTOCOL', `${label} is not valid JSON.`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Where to sign in for this API, validated.
|
|
94
|
+
*
|
|
95
|
+
* The protected-resource metadata must name THIS API as its resource and at
|
|
96
|
+
* least one authorization server; the authorization server's metadata must
|
|
97
|
+
* name itself as issuer, publish HTTPS endpoints and support S256. A mismatch
|
|
98
|
+
* anywhere stops the login before a browser opens.
|
|
99
|
+
*/
|
|
100
|
+
export async function discoverApiAuthorization(
|
|
101
|
+
apiUrl: string,
|
|
102
|
+
fetchImplementation: typeof fetch = fetch,
|
|
103
|
+
): Promise<ApiAuthorizationMetadata> {
|
|
104
|
+
const api = secureUrl(apiUrl, 'Synomem API URL');
|
|
105
|
+
const resourceMetadata = await readJson(
|
|
106
|
+
new URL('/.well-known/oauth-protected-resource', api.origin),
|
|
107
|
+
fetchImplementation,
|
|
108
|
+
'API protected-resource metadata',
|
|
109
|
+
);
|
|
110
|
+
const resource = resourceMetadata.resource;
|
|
111
|
+
if (typeof resource !== 'string' || trimSlash(resource) !== trimSlash(api.origin)) {
|
|
112
|
+
throw new SynomemError(
|
|
113
|
+
'AUTH_REQUIRED',
|
|
114
|
+
'The API protected-resource metadata does not describe this API.',
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
const servers = resourceMetadata.authorization_servers;
|
|
118
|
+
const issuerValue: unknown = Array.isArray(servers) ? (servers as unknown[])[0] : undefined;
|
|
119
|
+
if (typeof issuerValue !== 'string') {
|
|
120
|
+
throw new SynomemError('AUTH_REQUIRED', 'The API names no authorization server.');
|
|
121
|
+
}
|
|
122
|
+
const issuer = secureUrl(issuerValue, 'Authorization server');
|
|
123
|
+
const asMetadata = await readJson(
|
|
124
|
+
new URL(
|
|
125
|
+
`/.well-known/oauth-authorization-server${issuer.pathname === '/' ? '' : trimSlash(issuer.pathname)}`,
|
|
126
|
+
issuer.origin,
|
|
127
|
+
),
|
|
128
|
+
fetchImplementation,
|
|
129
|
+
'Authorization server metadata',
|
|
130
|
+
);
|
|
131
|
+
if (
|
|
132
|
+
typeof asMetadata.issuer !== 'string' ||
|
|
133
|
+
trimSlash(asMetadata.issuer) !== trimSlash(issuer.href)
|
|
134
|
+
) {
|
|
135
|
+
throw new SynomemError(
|
|
136
|
+
'AUTH_REQUIRED',
|
|
137
|
+
'The authorization server metadata issuer does not match.',
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
const authorizationEndpoint = asMetadata.authorization_endpoint;
|
|
141
|
+
const tokenEndpoint = asMetadata.token_endpoint;
|
|
142
|
+
if (typeof authorizationEndpoint !== 'string' || typeof tokenEndpoint !== 'string') {
|
|
143
|
+
throw new SynomemError('AUTH_REQUIRED', 'Authorization server discovery is incomplete.');
|
|
144
|
+
}
|
|
145
|
+
secureUrl(authorizationEndpoint, 'OAuth authorization endpoint');
|
|
146
|
+
secureUrl(tokenEndpoint, 'OAuth token endpoint');
|
|
147
|
+
const methods = asMetadata.code_challenge_methods_supported;
|
|
148
|
+
if (!Array.isArray(methods) || !methods.includes('S256')) {
|
|
149
|
+
throw new SynomemError('AUTH_REQUIRED', 'The authorization server must support PKCE S256.');
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
resource: trimSlash(resource),
|
|
153
|
+
issuer: trimSlash(asMetadata.issuer),
|
|
154
|
+
authorizationEndpoint,
|
|
155
|
+
tokenEndpoint,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
30
159
|
async function tokenRequest(
|
|
31
160
|
endpoint: string,
|
|
32
161
|
parameters: URLSearchParams,
|
|
33
162
|
fetchImplementation: typeof fetch,
|
|
34
|
-
signal?: AbortSignal,
|
|
35
163
|
): Promise<TokenResponse> {
|
|
36
164
|
let response: Response;
|
|
37
165
|
try {
|
|
38
|
-
response = await fetchImplementation(
|
|
166
|
+
response = await fetchImplementation(secureUrl(endpoint, 'OAuth token endpoint'), {
|
|
39
167
|
method: 'POST',
|
|
40
168
|
redirect: 'error',
|
|
41
169
|
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
|
42
170
|
body: parameters,
|
|
43
|
-
...(signal ? { signal } : {}),
|
|
44
171
|
});
|
|
45
172
|
} catch (error) {
|
|
46
173
|
if (error instanceof SynomemError) throw error;
|
|
47
174
|
throw new SynomemError('REMOTE_UNAVAILABLE', 'The OAuth token endpoint is unavailable.');
|
|
48
175
|
}
|
|
49
176
|
const text = await response.text();
|
|
50
|
-
if (Buffer.byteLength(text) >
|
|
177
|
+
if (Buffer.byteLength(text) > maximumDocumentBytes) {
|
|
51
178
|
throw new SynomemError('REMOTE_PROTOCOL', 'OAuth token response exceeded the safe limit.');
|
|
52
179
|
}
|
|
53
180
|
let parsed: Partial<TokenResponse>;
|
|
@@ -62,11 +189,17 @@ async function tokenRequest(
|
|
|
62
189
|
return parsed as TokenResponse;
|
|
63
190
|
}
|
|
64
191
|
|
|
192
|
+
function expiresAt(expiresIn: number | undefined): number {
|
|
193
|
+
// A token without a lifetime is still refreshed on the API's schedule: 10
|
|
194
|
+
// minutes is the contract's maximum access-token lifetime.
|
|
195
|
+
return Date.now() + Math.max(0, (expiresIn ?? 600) * 1_000 - expirySafetyMs);
|
|
196
|
+
}
|
|
197
|
+
|
|
65
198
|
function launchBrowser(url: URL): void {
|
|
66
|
-
const command = process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
67
199
|
if (process.platform !== 'darwin' && process.platform !== 'linux') {
|
|
68
200
|
throw new SynomemError('CONFIG_INVALID', `Open this URL in a browser: ${url.href}`);
|
|
69
201
|
}
|
|
202
|
+
const command = process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
70
203
|
const child = spawn(command, [url.href], { detached: true, stdio: 'ignore' });
|
|
71
204
|
child.once('error', () => undefined);
|
|
72
205
|
child.unref();
|
|
@@ -92,7 +225,14 @@ async function authorizationCode(
|
|
|
92
225
|
if (oauthError || !code || returnedState !== state) {
|
|
93
226
|
response.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' });
|
|
94
227
|
response.end('Synomem authorization failed. Return to your terminal.');
|
|
95
|
-
finish(
|
|
228
|
+
finish(
|
|
229
|
+
new SynomemError(
|
|
230
|
+
'AUTH_REQUIRED',
|
|
231
|
+
oauthError === 'access_denied'
|
|
232
|
+
? 'Authorization was declined.'
|
|
233
|
+
: 'OAuth callback validation failed.',
|
|
234
|
+
),
|
|
235
|
+
);
|
|
96
236
|
return;
|
|
97
237
|
}
|
|
98
238
|
response.writeHead(200, {
|
|
@@ -124,10 +264,8 @@ async function authorizationCode(
|
|
|
124
264
|
}
|
|
125
265
|
|
|
126
266
|
export interface OAuthLoginOptions {
|
|
127
|
-
|
|
128
|
-
clientId
|
|
129
|
-
credentialReference: string;
|
|
130
|
-
credentialStore: CredentialStore;
|
|
267
|
+
apiUrl: string;
|
|
268
|
+
clientId?: string;
|
|
131
269
|
scope?: string;
|
|
132
270
|
callbackPort?: number;
|
|
133
271
|
fetch?: typeof fetch;
|
|
@@ -135,140 +273,101 @@ export interface OAuthLoginOptions {
|
|
|
135
273
|
timeoutMs?: number;
|
|
136
274
|
}
|
|
137
275
|
|
|
138
|
-
|
|
276
|
+
/** Runs the browser flow and returns the credential for the caller to store. */
|
|
277
|
+
export async function loginWithOAuth(options: OAuthLoginOptions): Promise<StoredOAuthCredential> {
|
|
139
278
|
const fetchImplementation = options.fetch ?? fetch;
|
|
140
|
-
const
|
|
141
|
-
const
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
secureEndpoint(metadata.authorization_endpoint, 'OAuth authorization endpoint');
|
|
147
|
-
secureEndpoint(metadata.token_endpoint, 'OAuth token endpoint');
|
|
148
|
-
if (!metadata.code_challenge_methods_supported?.includes('S256')) {
|
|
149
|
-
throw new SynomemError('AUTH_REQUIRED', 'The OAuth server must support PKCE S256.');
|
|
150
|
-
}
|
|
151
|
-
const callbackPort = options.callbackPort ?? 43_817;
|
|
152
|
-
const redirectUrl = new URL(`http://127.0.0.1:${callbackPort}/callback`);
|
|
279
|
+
const metadata = await discoverApiAuthorization(options.apiUrl, fetchImplementation);
|
|
280
|
+
const clientId = options.clientId ?? DEFAULT_CLI_CLIENT_ID;
|
|
281
|
+
const scope = options.scope ?? DEFAULT_CLI_SCOPE;
|
|
282
|
+
const callbackPort = options.callbackPort ?? DEFAULT_CALLBACK_PORT;
|
|
283
|
+
const redirectUri = `http://127.0.0.1:${callbackPort}/callback`;
|
|
153
284
|
const state = randomBytes(32).toString('base64url');
|
|
154
|
-
const
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
285
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
286
|
+
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
|
287
|
+
|
|
288
|
+
const authorizationUrl = new URL(metadata.authorizationEndpoint);
|
|
289
|
+
authorizationUrl.searchParams.set('response_type', 'code');
|
|
290
|
+
authorizationUrl.searchParams.set('client_id', clientId);
|
|
291
|
+
authorizationUrl.searchParams.set('redirect_uri', redirectUri);
|
|
292
|
+
authorizationUrl.searchParams.set('scope', scope);
|
|
293
|
+
authorizationUrl.searchParams.set('state', state);
|
|
294
|
+
authorizationUrl.searchParams.set('code_challenge', challenge);
|
|
295
|
+
authorizationUrl.searchParams.set('code_challenge_method', 'S256');
|
|
296
|
+
authorizationUrl.searchParams.set('resource', metadata.resource);
|
|
297
|
+
|
|
164
298
|
const code = await authorizationCode(
|
|
165
|
-
|
|
299
|
+
authorizationUrl,
|
|
166
300
|
state,
|
|
167
301
|
callbackPort,
|
|
168
302
|
options.openBrowser ?? launchBrowser,
|
|
169
303
|
options.timeoutMs ?? 5 * 60_000,
|
|
170
304
|
);
|
|
171
305
|
const tokens = await tokenRequest(
|
|
172
|
-
metadata.
|
|
306
|
+
metadata.tokenEndpoint,
|
|
173
307
|
new URLSearchParams({
|
|
174
308
|
grant_type: 'authorization_code',
|
|
175
|
-
client_id:
|
|
309
|
+
client_id: clientId,
|
|
176
310
|
code,
|
|
177
|
-
code_verifier:
|
|
178
|
-
redirect_uri:
|
|
179
|
-
resource: resource
|
|
311
|
+
code_verifier: verifier,
|
|
312
|
+
redirect_uri: redirectUri,
|
|
313
|
+
resource: metadata.resource,
|
|
180
314
|
}),
|
|
181
315
|
fetchImplementation,
|
|
182
316
|
);
|
|
183
|
-
|
|
317
|
+
return {
|
|
318
|
+
kind: 'oauth',
|
|
319
|
+
issuer: metadata.issuer,
|
|
320
|
+
resource: metadata.resource,
|
|
321
|
+
clientId,
|
|
322
|
+
tokenEndpoint: metadata.tokenEndpoint,
|
|
323
|
+
scope: tokens.scope ?? scope,
|
|
184
324
|
accessToken: tokens.access_token,
|
|
185
325
|
...(tokens.refresh_token ? { refreshToken: tokens.refresh_token } : {}),
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
tokenEndpoint: metadata.token_endpoint,
|
|
190
|
-
clientId: options.clientId,
|
|
191
|
-
resource: resource.href,
|
|
192
|
-
scope: tokens.scope ?? scope,
|
|
193
|
-
});
|
|
326
|
+
expiresAt: expiresAt(tokens.expires_in),
|
|
327
|
+
generation: 0,
|
|
328
|
+
};
|
|
194
329
|
}
|
|
195
330
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
* would have written an access key. Optional because local-only and
|
|
211
|
-
* environment-only callers have no such file to fall back to.
|
|
212
|
-
*/
|
|
213
|
-
private readonly home?: string,
|
|
214
|
-
) {}
|
|
215
|
-
|
|
216
|
-
async getAccessToken(signal?: AbortSignal): Promise<string | undefined> {
|
|
217
|
-
if (this.env.SYNOMEM_ACCESS_TOKEN) return this.env.SYNOMEM_ACCESS_TOKEN;
|
|
218
|
-
if (!this.loaded) {
|
|
219
|
-
const stored = await this.store.get(this.reference);
|
|
220
|
-
/*
|
|
221
|
-
* An access key is not an OAuth credential: it cannot be refreshed and
|
|
222
|
-
* has no client or token endpoint. It is used exactly as stored,
|
|
223
|
-
* whichever of the two places it was found.
|
|
224
|
-
*/
|
|
225
|
-
if (stored && 'kind' in stored) {
|
|
226
|
-
this.staticAccessToken = stored.accessToken;
|
|
227
|
-
} else if (stored) {
|
|
228
|
-
this.credential = stored;
|
|
229
|
-
} else if (this.home) {
|
|
230
|
-
this.staticAccessToken = readCredentialFile(this.home);
|
|
231
|
-
}
|
|
232
|
-
this.loaded = true;
|
|
233
|
-
}
|
|
234
|
-
if (this.staticAccessToken) return this.staticAccessToken;
|
|
235
|
-
const credential = this.credential;
|
|
236
|
-
if (!credential) return undefined;
|
|
237
|
-
if (!credential.expiresAt || credential.expiresAt > Date.now()) return credential.accessToken;
|
|
238
|
-
if (!credential.refreshToken) return undefined;
|
|
239
|
-
this.refresh ??= this.refreshCredential(credential, signal).finally(() => {
|
|
240
|
-
this.refresh = undefined;
|
|
241
|
-
});
|
|
242
|
-
return await this.refresh;
|
|
331
|
+
/**
|
|
332
|
+
* Spends the refresh token once. Callers hold the credential lock and write the
|
|
333
|
+
* result before releasing it; a failure here is never retried with the same
|
|
334
|
+
* (possibly consumed) refresh token.
|
|
335
|
+
*/
|
|
336
|
+
export async function refreshOAuthCredential(
|
|
337
|
+
credential: StoredOAuthCredential,
|
|
338
|
+
fetchImplementation: typeof fetch = fetch,
|
|
339
|
+
): Promise<StoredOAuthCredential> {
|
|
340
|
+
if (!credential.refreshToken) {
|
|
341
|
+
throw new SynomemError(
|
|
342
|
+
'REAUTHORIZATION_REQUIRED',
|
|
343
|
+
'The stored credential cannot be refreshed.',
|
|
344
|
+
);
|
|
243
345
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
signal?: AbortSignal,
|
|
248
|
-
): Promise<string> {
|
|
249
|
-
const tokens = await tokenRequest(
|
|
346
|
+
let tokens: TokenResponse;
|
|
347
|
+
try {
|
|
348
|
+
tokens = await tokenRequest(
|
|
250
349
|
credential.tokenEndpoint,
|
|
251
350
|
new URLSearchParams({
|
|
252
351
|
grant_type: 'refresh_token',
|
|
253
352
|
client_id: credential.clientId,
|
|
254
|
-
refresh_token: credential.refreshToken
|
|
353
|
+
refresh_token: credential.refreshToken,
|
|
255
354
|
resource: credential.resource,
|
|
256
|
-
scope: credential.scope,
|
|
257
355
|
}),
|
|
258
|
-
|
|
259
|
-
|
|
356
|
+
fetchImplementation,
|
|
357
|
+
);
|
|
358
|
+
} catch (error) {
|
|
359
|
+
if (error instanceof SynomemError && error.code === 'REMOTE_UNAVAILABLE') throw error;
|
|
360
|
+
throw new SynomemError(
|
|
361
|
+
'REAUTHORIZATION_REQUIRED',
|
|
362
|
+
'The stored credential was refused when refreshing.',
|
|
260
363
|
);
|
|
261
|
-
const updated: StoredOAuthCredential = {
|
|
262
|
-
...credential,
|
|
263
|
-
accessToken: tokens.access_token,
|
|
264
|
-
refreshToken: tokens.refresh_token ?? credential.refreshToken,
|
|
265
|
-
...(tokens.expires_in
|
|
266
|
-
? { expiresAt: Date.now() + Math.max(0, tokens.expires_in - 30) * 1_000 }
|
|
267
|
-
: { expiresAt: undefined }),
|
|
268
|
-
scope: tokens.scope ?? credential.scope,
|
|
269
|
-
};
|
|
270
|
-
await this.store.set(this.reference, updated);
|
|
271
|
-
this.credential = updated;
|
|
272
|
-
return updated.accessToken;
|
|
273
364
|
}
|
|
365
|
+
return {
|
|
366
|
+
...credential,
|
|
367
|
+
accessToken: tokens.access_token,
|
|
368
|
+
...(tokens.refresh_token ? { refreshToken: tokens.refresh_token } : {}),
|
|
369
|
+
scope: tokens.scope ?? credential.scope,
|
|
370
|
+
expiresAt: expiresAt(tokens.expires_in),
|
|
371
|
+
generation: credential.generation + 1,
|
|
372
|
+
};
|
|
274
373
|
}
|