@the-open-engine/zeroshot 6.26.0 → 6.28.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/cli/index.js +211 -2
- package/lib/cluster/client.cjs +30 -5
- package/lib/cluster/client.d.ts +11 -1
- package/lib/cluster/client.mjs +29 -5
- package/lib/cluster/connection.cjs +38 -2
- package/lib/cluster/connection.d.ts +3 -0
- package/lib/cluster/connection.mjs +37 -1
- package/lib/cluster/index.cjs +3 -1
- package/lib/cluster/index.d.ts +3 -3
- package/lib/cluster/index.mjs +2 -2
- package/lib/hosted-session/coordinator.cjs +101 -0
- package/lib/hosted-session/coordinator.d.ts +9 -0
- package/lib/hosted-session/coordinator.mjs +97 -0
- package/lib/hosted-session/index.cjs +5 -0
- package/lib/hosted-session/index.d.ts +2 -0
- package/lib/hosted-session/index.mjs +1 -0
- package/lib/hosted-session/types.cjs +2 -0
- package/lib/hosted-session/types.d.ts +20 -0
- package/lib/hosted-session/types.mjs +1 -0
- package/lib/target/credential-lock.d.ts +1 -0
- package/lib/target/credential-lock.js +38 -0
- package/lib/target/credential-store.d.ts +27 -0
- package/lib/target/credential-store.js +113 -0
- package/lib/target/device-flow.d.ts +38 -0
- package/lib/target/device-flow.js +104 -0
- package/lib/target/discovery.d.ts +11 -0
- package/lib/target/discovery.js +120 -0
- package/lib/target/index.d.ts +6 -0
- package/lib/target/index.js +38 -0
- package/lib/target/target-registry.d.ts +45 -0
- package/lib/target/target-registry.js +132 -0
- package/lib/target/target-session.d.ts +39 -0
- package/lib/target/target-session.js +162 -0
- package/package.json +20 -8
- package/scripts/build-cluster.js +21 -7
- package/src/cluster/client.ts +45 -5
- package/src/cluster/connection.ts +32 -1
- package/src/cluster/index.ts +4 -2
- package/src/cluster/ws.d.ts +1 -0
- package/src/hosted-session/coordinator.ts +110 -0
- package/src/hosted-session/index.ts +2 -0
- package/src/hosted-session/types.ts +21 -0
- package/src/target/credential-lock.ts +35 -0
- package/src/target/credential-store.ts +107 -0
- package/src/target/device-flow.ts +157 -0
- package/src/target/discovery.ts +149 -0
- package/src/target/index.ts +55 -0
- package/src/target/target-registry.ts +174 -0
- package/src/target/target-session.ts +249 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { HttpTransport } from './device-flow.ts';
|
|
2
|
+
|
|
3
|
+
const DISCOVERY_PATH = '/.well-known/openengine-hosted-target';
|
|
4
|
+
const MAX_DISCOVERY_BYTES = 64 * 1024;
|
|
5
|
+
|
|
6
|
+
export interface TargetSessionEndpoints {
|
|
7
|
+
readonly deviceAuthorizationEndpoint: string;
|
|
8
|
+
readonly tokenEndpoint: string;
|
|
9
|
+
readonly revocationEndpoint?: string;
|
|
10
|
+
readonly clientId: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class TargetDiscoveryError extends Error {
|
|
14
|
+
constructor(message: string) {
|
|
15
|
+
super(`Target discovery failed: ${message}`);
|
|
16
|
+
this.name = 'TargetDiscoveryError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function record(value: unknown, field: string): Record<string, unknown> {
|
|
21
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
22
|
+
throw new TargetDiscoveryError(`${field} must be an object`);
|
|
23
|
+
}
|
|
24
|
+
return value as Record<string, unknown>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function stringField(source: Record<string, unknown>, field: string): string {
|
|
28
|
+
const value = source[field];
|
|
29
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
30
|
+
throw new TargetDiscoveryError(`${field} must be a non-empty string`);
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function safeEndpoint(value: unknown, field: string, serviceOrigin: string): string {
|
|
36
|
+
if (typeof value !== 'string') {
|
|
37
|
+
throw new TargetDiscoveryError(`${field} must be an absolute URL`);
|
|
38
|
+
}
|
|
39
|
+
let endpoint: URL;
|
|
40
|
+
try {
|
|
41
|
+
endpoint = new URL(value);
|
|
42
|
+
} catch {
|
|
43
|
+
throw new TargetDiscoveryError(`${field} must be an absolute URL`);
|
|
44
|
+
}
|
|
45
|
+
if (endpoint.username || endpoint.password || endpoint.search || endpoint.hash) {
|
|
46
|
+
throw new TargetDiscoveryError(`${field} contains forbidden URL components`);
|
|
47
|
+
}
|
|
48
|
+
if (endpoint.origin !== serviceOrigin) {
|
|
49
|
+
throw new TargetDiscoveryError(`${field} must remain on the target origin`);
|
|
50
|
+
}
|
|
51
|
+
return endpoint.href;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function readBoundedJson(response: Response): Promise<unknown> {
|
|
55
|
+
const declaredLength = response.headers.get('content-length');
|
|
56
|
+
if (declaredLength !== null && Number(declaredLength) > MAX_DISCOVERY_BYTES) {
|
|
57
|
+
throw new TargetDiscoveryError('response exceeds the size limit');
|
|
58
|
+
}
|
|
59
|
+
if (!response.body) return response.json();
|
|
60
|
+
|
|
61
|
+
const reader = response.body.getReader();
|
|
62
|
+
const chunks: Uint8Array[] = [];
|
|
63
|
+
let total = 0;
|
|
64
|
+
while (true) {
|
|
65
|
+
const { done, value } = await reader.read();
|
|
66
|
+
if (done) break;
|
|
67
|
+
total += value.byteLength;
|
|
68
|
+
if (total > MAX_DISCOVERY_BYTES) {
|
|
69
|
+
await reader.cancel();
|
|
70
|
+
throw new TargetDiscoveryError('response exceeds the size limit');
|
|
71
|
+
}
|
|
72
|
+
chunks.push(value);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const bytes = new Uint8Array(total);
|
|
76
|
+
let offset = 0;
|
|
77
|
+
for (const chunk of chunks) {
|
|
78
|
+
bytes.set(chunk, offset);
|
|
79
|
+
offset += chunk.byteLength;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
83
|
+
} catch {
|
|
84
|
+
throw new TargetDiscoveryError('response is not valid JSON');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function fetchDocument(http: HttpTransport, url: string): Promise<Record<string, unknown>> {
|
|
89
|
+
const response = await http.fetch(url, {
|
|
90
|
+
method: 'GET',
|
|
91
|
+
headers: { Accept: 'application/json' },
|
|
92
|
+
redirect: 'error',
|
|
93
|
+
});
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
throw new TargetDiscoveryError(`request failed with status ${response.status}`);
|
|
96
|
+
}
|
|
97
|
+
return record(await readBoundedJson(response), 'response');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function discoverTargetSessionEndpoints(
|
|
101
|
+
targetUrl: string,
|
|
102
|
+
http: HttpTransport
|
|
103
|
+
): Promise<TargetSessionEndpoints> {
|
|
104
|
+
const target = new URL(targetUrl);
|
|
105
|
+
const discovery = await fetchDocument(http, new URL(DISCOVERY_PATH, target).href);
|
|
106
|
+
if (discovery.kind !== 'openengine.hosted-target/v1') {
|
|
107
|
+
throw new TargetDiscoveryError('unsupported hosted-target version');
|
|
108
|
+
}
|
|
109
|
+
if (discovery.organization_binding !== 'device_approval') {
|
|
110
|
+
throw new TargetDiscoveryError('unsupported organization binding');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const oauth = record(discovery.oauth, 'oauth');
|
|
114
|
+
const metadataUrl = safeEndpoint(oauth.metadata_url, 'oauth.metadata_url', target.origin);
|
|
115
|
+
const deviceEndpoint = safeEndpoint(
|
|
116
|
+
oauth.device_authorization_endpoint,
|
|
117
|
+
'oauth.device_authorization_endpoint',
|
|
118
|
+
target.origin
|
|
119
|
+
);
|
|
120
|
+
const tokenEndpoint = safeEndpoint(oauth.token_endpoint, 'oauth.token_endpoint', target.origin);
|
|
121
|
+
const clientId = stringField(oauth, 'client_id');
|
|
122
|
+
|
|
123
|
+
const metadata = await fetchDocument(http, metadataUrl);
|
|
124
|
+
const metadataDeviceEndpoint = safeEndpoint(
|
|
125
|
+
metadata.device_authorization_endpoint,
|
|
126
|
+
'device_authorization_endpoint',
|
|
127
|
+
target.origin
|
|
128
|
+
);
|
|
129
|
+
const metadataTokenEndpoint = safeEndpoint(
|
|
130
|
+
metadata.token_endpoint,
|
|
131
|
+
'token_endpoint',
|
|
132
|
+
target.origin
|
|
133
|
+
);
|
|
134
|
+
if (metadataDeviceEndpoint !== deviceEndpoint || metadataTokenEndpoint !== tokenEndpoint) {
|
|
135
|
+
throw new TargetDiscoveryError('OAuth metadata does not match hosted-target discovery');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const revocationEndpoint =
|
|
139
|
+
metadata.revocation_endpoint === undefined
|
|
140
|
+
? undefined
|
|
141
|
+
: safeEndpoint(metadata.revocation_endpoint, 'revocation_endpoint', target.origin);
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
deviceAuthorizationEndpoint: deviceEndpoint,
|
|
145
|
+
tokenEndpoint,
|
|
146
|
+
...(revocationEndpoint === undefined ? {} : { revocationEndpoint }),
|
|
147
|
+
clientId,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export {
|
|
2
|
+
CredentialStoreUnavailableError,
|
|
3
|
+
KeyringCredentialStore,
|
|
4
|
+
FakeCredentialStore,
|
|
5
|
+
targetServiceKey,
|
|
6
|
+
TARGET_ACCOUNT,
|
|
7
|
+
type TargetCredentialStore,
|
|
8
|
+
} from './credential-store.ts';
|
|
9
|
+
|
|
10
|
+
export { acquireTargetLock } from './credential-lock.ts';
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
requestDeviceCode,
|
|
14
|
+
pollForToken,
|
|
15
|
+
DeviceFlowDeniedError,
|
|
16
|
+
DeviceFlowExpiredError,
|
|
17
|
+
UnboundSessionError,
|
|
18
|
+
type DeviceCodeResponse,
|
|
19
|
+
type TokenResponse,
|
|
20
|
+
type HttpTransport,
|
|
21
|
+
type Clock,
|
|
22
|
+
} from './device-flow.ts';
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
addTarget,
|
|
26
|
+
removeTarget,
|
|
27
|
+
getTarget,
|
|
28
|
+
listTargets,
|
|
29
|
+
updateTargetOrganization,
|
|
30
|
+
validateTargetName,
|
|
31
|
+
normalizeAndValidateUrl,
|
|
32
|
+
TargetNameInvalidError,
|
|
33
|
+
TargetNameExistsError,
|
|
34
|
+
TargetNotFoundError,
|
|
35
|
+
TargetUrlInvalidError,
|
|
36
|
+
type TargetRecord,
|
|
37
|
+
type SettingsPort,
|
|
38
|
+
} from './target-registry.ts';
|
|
39
|
+
|
|
40
|
+
export {
|
|
41
|
+
targetLogin,
|
|
42
|
+
refreshAccessToken,
|
|
43
|
+
getAccessTokenProvider,
|
|
44
|
+
revokeAndCleanup,
|
|
45
|
+
LoginRequiredError,
|
|
46
|
+
type BrowserOpener,
|
|
47
|
+
type TargetSessionDeps,
|
|
48
|
+
type TargetAccessTokenProvider,
|
|
49
|
+
} from './target-session.ts';
|
|
50
|
+
|
|
51
|
+
export {
|
|
52
|
+
discoverTargetSessionEndpoints,
|
|
53
|
+
TargetDiscoveryError,
|
|
54
|
+
type TargetSessionEndpoints,
|
|
55
|
+
} from './discovery.ts';
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export interface TargetRecord {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly url: string;
|
|
6
|
+
readonly adapterVersion: string;
|
|
7
|
+
readonly deviceToken: string;
|
|
8
|
+
readonly organization?: { readonly id: string; readonly name: string };
|
|
9
|
+
readonly createdAt: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class TargetNameInvalidError extends Error {
|
|
13
|
+
constructor(name: string) {
|
|
14
|
+
super(
|
|
15
|
+
`Invalid target name "${name}". Must be 1-64 characters, alphanumeric and hyphens only.`,
|
|
16
|
+
);
|
|
17
|
+
this.name = 'TargetNameInvalidError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class TargetNameExistsError extends Error {
|
|
22
|
+
constructor(name: string) {
|
|
23
|
+
super(`Target "${name}" already exists. Remove it first or choose a different name.`);
|
|
24
|
+
this.name = 'TargetNameExistsError';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class TargetNotFoundError extends Error {
|
|
29
|
+
constructor(name: string) {
|
|
30
|
+
super(`Target "${name}" not found.`);
|
|
31
|
+
this.name = 'TargetNotFoundError';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class TargetUrlInvalidError extends Error {
|
|
36
|
+
constructor(url: string, reason: string) {
|
|
37
|
+
super(`Invalid target URL "${url}": ${reason}`);
|
|
38
|
+
this.name = 'TargetUrlInvalidError';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const TARGET_NAME_PATTERN = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}[a-zA-Z0-9])?$/;
|
|
43
|
+
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
|
|
44
|
+
|
|
45
|
+
export function validateTargetName(name: string): void {
|
|
46
|
+
if (!TARGET_NAME_PATTERN.test(name) || name.length > 64) {
|
|
47
|
+
throw new TargetNameInvalidError(name);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function normalizeAndValidateUrl(rawUrl: string): string {
|
|
52
|
+
let parsed: URL;
|
|
53
|
+
try {
|
|
54
|
+
parsed = new URL(rawUrl);
|
|
55
|
+
} catch {
|
|
56
|
+
throw new TargetUrlInvalidError(rawUrl, 'not a valid URL');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (parsed.username || parsed.password) {
|
|
60
|
+
throw new TargetUrlInvalidError(rawUrl, 'URL must not contain userinfo');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (parsed.search || parsed.hash) {
|
|
64
|
+
throw new TargetUrlInvalidError(rawUrl, 'URL must not contain query or fragment');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const isLoopback = LOOPBACK_HOSTS.has(parsed.hostname);
|
|
68
|
+
if (parsed.protocol !== 'https:' && !isLoopback) {
|
|
69
|
+
throw new TargetUrlInvalidError(rawUrl, 'HTTPS required for non-loopback targets');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let normalized = `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
|
|
73
|
+
if (normalized.endsWith('/') && normalized.length > 1) {
|
|
74
|
+
normalized = normalized.slice(0, -1);
|
|
75
|
+
}
|
|
76
|
+
return normalized;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface SettingsWithTargets {
|
|
80
|
+
_targets?: Record<string, TargetRecord>;
|
|
81
|
+
[key: string]: unknown;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface SettingsPort {
|
|
85
|
+
load(): SettingsWithTargets;
|
|
86
|
+
mutate(mutator: (settings: SettingsWithTargets) => void): void;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function addTarget(
|
|
90
|
+
name: string,
|
|
91
|
+
rawUrl: string,
|
|
92
|
+
settings: SettingsPort,
|
|
93
|
+
): TargetRecord {
|
|
94
|
+
validateTargetName(name);
|
|
95
|
+
const url = normalizeAndValidateUrl(rawUrl);
|
|
96
|
+
|
|
97
|
+
const existing = settings.load();
|
|
98
|
+
if (existing._targets?.[name]) {
|
|
99
|
+
throw new TargetNameExistsError(name);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const record: TargetRecord = {
|
|
103
|
+
id: crypto.randomUUID(),
|
|
104
|
+
url,
|
|
105
|
+
adapterVersion: 'v1',
|
|
106
|
+
deviceToken: crypto.randomUUID(),
|
|
107
|
+
createdAt: new Date().toISOString(),
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
settings.mutate((s) => {
|
|
111
|
+
if (!s._targets) {
|
|
112
|
+
s._targets = {};
|
|
113
|
+
}
|
|
114
|
+
s._targets[name] = record;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return record;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function removeTarget(
|
|
121
|
+
name: string,
|
|
122
|
+
settings: SettingsPort,
|
|
123
|
+
): TargetRecord {
|
|
124
|
+
const existing = settings.load();
|
|
125
|
+
const record = existing._targets?.[name];
|
|
126
|
+
if (!record) {
|
|
127
|
+
throw new TargetNotFoundError(name);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
settings.mutate((s) => {
|
|
131
|
+
if (s._targets) {
|
|
132
|
+
delete s._targets[name];
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return record;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function getTarget(
|
|
140
|
+
name: string,
|
|
141
|
+
settings: SettingsPort,
|
|
142
|
+
): TargetRecord | null {
|
|
143
|
+
const existing = settings.load();
|
|
144
|
+
return existing._targets?.[name] ?? null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function listTargets(
|
|
148
|
+
settings: SettingsPort,
|
|
149
|
+
): Array<{ name: string; record: TargetRecord }> {
|
|
150
|
+
const existing = settings.load();
|
|
151
|
+
const targets = existing._targets ?? {};
|
|
152
|
+
return Object.entries(targets).map(([name, record]) => ({ name, record }));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function updateTargetOrganization(
|
|
156
|
+
name: string,
|
|
157
|
+
organization: { id: string; name: string },
|
|
158
|
+
settings: SettingsPort,
|
|
159
|
+
): void {
|
|
160
|
+
const existing = settings.load();
|
|
161
|
+
if (!existing._targets?.[name]) {
|
|
162
|
+
throw new TargetNotFoundError(name);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
settings.mutate((s) => {
|
|
166
|
+
const target = s._targets?.[name];
|
|
167
|
+
if (target) {
|
|
168
|
+
(s._targets as Record<string, TargetRecord>)[name] = {
|
|
169
|
+
...target,
|
|
170
|
+
organization,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import type { TargetCredentialStore } from './credential-store.ts';
|
|
2
|
+
import type { TargetRecord, SettingsPort } from './target-registry.ts';
|
|
3
|
+
import {
|
|
4
|
+
requestDeviceCode,
|
|
5
|
+
pollForToken,
|
|
6
|
+
UnboundSessionError,
|
|
7
|
+
type HttpTransport,
|
|
8
|
+
type Clock,
|
|
9
|
+
type TokenResponse,
|
|
10
|
+
} from './device-flow.ts';
|
|
11
|
+
import { targetServiceKey, TARGET_ACCOUNT } from './credential-store.ts';
|
|
12
|
+
import { updateTargetOrganization } from './target-registry.ts';
|
|
13
|
+
|
|
14
|
+
export class LoginRequiredError extends Error {
|
|
15
|
+
readonly targetName: string;
|
|
16
|
+
constructor(targetName: string) {
|
|
17
|
+
super(`Login required. Run: zeroshot target login ${targetName}`);
|
|
18
|
+
this.name = 'LoginRequiredError';
|
|
19
|
+
this.targetName = targetName;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface BrowserOpener {
|
|
24
|
+
open(url: string): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface TargetSessionDeps {
|
|
28
|
+
readonly http: HttpTransport;
|
|
29
|
+
readonly clock: Clock;
|
|
30
|
+
readonly browserOpener: BrowserOpener;
|
|
31
|
+
readonly stderr: { write(s: string): void };
|
|
32
|
+
readonly discoveryEndpoints: {
|
|
33
|
+
readonly deviceAuthorizationEndpoint: string;
|
|
34
|
+
readonly tokenEndpoint: string;
|
|
35
|
+
readonly revocationEndpoint?: string;
|
|
36
|
+
readonly clientId: string;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function targetLogin(
|
|
41
|
+
targetName: string,
|
|
42
|
+
target: TargetRecord,
|
|
43
|
+
credentialStore: TargetCredentialStore,
|
|
44
|
+
acquireLock: () => Promise<() => Promise<void>>,
|
|
45
|
+
settings: SettingsPort,
|
|
46
|
+
deps: TargetSessionDeps
|
|
47
|
+
): Promise<{ organization: { id: string; name: string } }> {
|
|
48
|
+
const { http, clock, browserOpener, stderr, discoveryEndpoints } = deps;
|
|
49
|
+
const { deviceAuthorizationEndpoint, tokenEndpoint, clientId } = discoveryEndpoints;
|
|
50
|
+
|
|
51
|
+
const codeResponse = await requestDeviceCode(deviceAuthorizationEndpoint, clientId, http);
|
|
52
|
+
|
|
53
|
+
stderr.write(
|
|
54
|
+
`\nOpen this URL to authorize:\n ${codeResponse.verification_uri}\n\nEnter code: ${codeResponse.user_code}\n\n`
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
if (codeResponse.verification_uri_complete) {
|
|
58
|
+
try {
|
|
59
|
+
await browserOpener.open(codeResponse.verification_uri_complete);
|
|
60
|
+
} catch {
|
|
61
|
+
// Browser open is best-effort
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const tokenResponse = await pollForToken(
|
|
66
|
+
tokenEndpoint,
|
|
67
|
+
clientId,
|
|
68
|
+
codeResponse.device_code,
|
|
69
|
+
codeResponse.interval,
|
|
70
|
+
codeResponse.expires_in,
|
|
71
|
+
http,
|
|
72
|
+
clock
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
if (!tokenResponse.organization) {
|
|
76
|
+
throw new UnboundSessionError(codeResponse.verification_uri);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const serviceKey = targetServiceKey(target.id);
|
|
80
|
+
const release = await acquireLock();
|
|
81
|
+
try {
|
|
82
|
+
await credentialStore.set(serviceKey, TARGET_ACCOUNT, tokenResponse.refresh_token);
|
|
83
|
+
} finally {
|
|
84
|
+
await release();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
updateTargetOrganization(targetName, tokenResponse.organization, settings);
|
|
88
|
+
|
|
89
|
+
return { organization: tokenResponse.organization };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function refreshAccessToken(
|
|
93
|
+
targetName: string,
|
|
94
|
+
target: TargetRecord,
|
|
95
|
+
credentialStore: TargetCredentialStore,
|
|
96
|
+
acquireLock: () => Promise<() => Promise<void>>,
|
|
97
|
+
deps: Pick<TargetSessionDeps, 'http' | 'discoveryEndpoints'>
|
|
98
|
+
): Promise<{ accessToken: string; expiresIn: number }> {
|
|
99
|
+
const { http, discoveryEndpoints } = deps;
|
|
100
|
+
const { tokenEndpoint, revocationEndpoint, clientId } = discoveryEndpoints;
|
|
101
|
+
const serviceKey = targetServiceKey(target.id);
|
|
102
|
+
|
|
103
|
+
const release = await acquireLock();
|
|
104
|
+
try {
|
|
105
|
+
const currentRefreshToken = await credentialStore.get(serviceKey, TARGET_ACCOUNT);
|
|
106
|
+
if (!currentRefreshToken) {
|
|
107
|
+
throw new LoginRequiredError(targetName);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const body = new URLSearchParams({
|
|
111
|
+
grant_type: 'refresh_token',
|
|
112
|
+
refresh_token: currentRefreshToken,
|
|
113
|
+
client_id: clientId,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
let tokenResponse: TokenResponse;
|
|
117
|
+
const response = await http.fetch(tokenEndpoint, {
|
|
118
|
+
method: 'POST',
|
|
119
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
120
|
+
body: body.toString(),
|
|
121
|
+
redirect: 'error',
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (!response.ok) {
|
|
125
|
+
const errorBody = (await response.json().catch(() => ({ error: 'unknown' }))) as {
|
|
126
|
+
error: string;
|
|
127
|
+
};
|
|
128
|
+
if (errorBody.error === 'invalid_grant') {
|
|
129
|
+
await credentialStore.delete(serviceKey, TARGET_ACCOUNT);
|
|
130
|
+
throw new LoginRequiredError(targetName);
|
|
131
|
+
}
|
|
132
|
+
throw new Error(`Token refresh failed (${response.status}): ${errorBody.error}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
tokenResponse = (await response.json()) as TokenResponse;
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
await credentialStore.set(serviceKey, TARGET_ACCOUNT, tokenResponse.refresh_token);
|
|
139
|
+
} catch {
|
|
140
|
+
await bestEffortRevoke(tokenResponse.refresh_token, revocationEndpoint, clientId, http);
|
|
141
|
+
await credentialStore.delete(serviceKey, TARGET_ACCOUNT);
|
|
142
|
+
throw new LoginRequiredError(targetName);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { accessToken: tokenResponse.access_token, expiresIn: tokenResponse.expires_in };
|
|
146
|
+
} finally {
|
|
147
|
+
await release();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function bestEffortRevoke(
|
|
152
|
+
token: string,
|
|
153
|
+
revocationEndpoint: string | undefined,
|
|
154
|
+
clientId: string,
|
|
155
|
+
http: HttpTransport
|
|
156
|
+
): Promise<void> {
|
|
157
|
+
if (!revocationEndpoint) return;
|
|
158
|
+
try {
|
|
159
|
+
const body = new URLSearchParams({
|
|
160
|
+
token,
|
|
161
|
+
client_id: clientId,
|
|
162
|
+
token_type_hint: 'refresh_token',
|
|
163
|
+
});
|
|
164
|
+
await http.fetch(revocationEndpoint, {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
167
|
+
body: body.toString(),
|
|
168
|
+
redirect: 'error',
|
|
169
|
+
});
|
|
170
|
+
} catch {
|
|
171
|
+
// Best-effort
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface TargetAccessTokenProvider {
|
|
176
|
+
getAccessToken(signal?: AbortSignal): Promise<string>;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function getAccessTokenProvider(
|
|
180
|
+
targetName: string,
|
|
181
|
+
target: TargetRecord,
|
|
182
|
+
credentialStore: TargetCredentialStore,
|
|
183
|
+
acquireLock: () => Promise<() => Promise<void>>,
|
|
184
|
+
deps: Pick<TargetSessionDeps, 'http' | 'discoveryEndpoints'>,
|
|
185
|
+
clock: Clock = { now: () => Date.now() }
|
|
186
|
+
): TargetAccessTokenProvider {
|
|
187
|
+
let cachedToken: string | null = null;
|
|
188
|
+
let expiresAt = 0;
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
async getAccessToken(_signal?: AbortSignal): Promise<string> {
|
|
192
|
+
if (cachedToken && clock.now() < expiresAt - 30_000) {
|
|
193
|
+
return cachedToken;
|
|
194
|
+
}
|
|
195
|
+
const result = await refreshAccessToken(
|
|
196
|
+
targetName,
|
|
197
|
+
target,
|
|
198
|
+
credentialStore,
|
|
199
|
+
acquireLock,
|
|
200
|
+
deps
|
|
201
|
+
);
|
|
202
|
+
cachedToken = result.accessToken;
|
|
203
|
+
expiresAt = clock.now() + result.expiresIn * 1000;
|
|
204
|
+
return result.accessToken;
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function revokeAndCleanup(
|
|
210
|
+
target: TargetRecord,
|
|
211
|
+
credentialStore: TargetCredentialStore,
|
|
212
|
+
acquireLock: () => Promise<() => Promise<void>>,
|
|
213
|
+
deps: Pick<TargetSessionDeps, 'http' | 'discoveryEndpoints'>,
|
|
214
|
+
force: boolean
|
|
215
|
+
): Promise<void> {
|
|
216
|
+
const { http, discoveryEndpoints } = deps;
|
|
217
|
+
const { revocationEndpoint, clientId } = discoveryEndpoints;
|
|
218
|
+
const serviceKey = targetServiceKey(target.id);
|
|
219
|
+
|
|
220
|
+
const release = await acquireLock();
|
|
221
|
+
try {
|
|
222
|
+
const refreshToken = await credentialStore.get(serviceKey, TARGET_ACCOUNT);
|
|
223
|
+
if (refreshToken && revocationEndpoint) {
|
|
224
|
+
const body = new URLSearchParams({
|
|
225
|
+
token: refreshToken,
|
|
226
|
+
client_id: clientId,
|
|
227
|
+
token_type_hint: 'refresh_token',
|
|
228
|
+
});
|
|
229
|
+
try {
|
|
230
|
+
const response = await http.fetch(revocationEndpoint, {
|
|
231
|
+
method: 'POST',
|
|
232
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
233
|
+
body: body.toString(),
|
|
234
|
+
redirect: 'error',
|
|
235
|
+
});
|
|
236
|
+
if (!response.ok && !force) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
`Remote revocation failed (${response.status}). Use --force to remove anyway.`
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
} catch (err) {
|
|
242
|
+
if (!force) throw err;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
await credentialStore.delete(serviceKey, TARGET_ACCOUNT);
|
|
246
|
+
} finally {
|
|
247
|
+
await release();
|
|
248
|
+
}
|
|
249
|
+
}
|