swarmiq 0.2.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/LICENSE +21 -0
- package/README.md +176 -0
- package/package.json +41 -0
- package/src/api.mjs +99 -0
- package/src/config.mjs +165 -0
- package/src/github_auth.mjs +168 -0
- package/src/index.mjs +100 -0
- package/src/init.mjs +501 -0
- package/src/oauth.mjs +159 -0
package/src/oauth.mjs
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* oauth.mjs — Device-flow OAuth for SwarmIQ CLI.
|
|
3
|
+
*
|
|
4
|
+
* Implements RFC 8628 (OAuth 2.0 Device Authorization Grant):
|
|
5
|
+
* 1. POST /v1/auth/device/code → {device_code, user_code, verification_uri,
|
|
6
|
+
* verification_uri_complete, expires_in, interval}
|
|
7
|
+
* 2. Open verification_uri_complete in browser (best-effort); also print URL.
|
|
8
|
+
* 3. Poll POST /v1/auth/device/token every `interval` seconds:
|
|
9
|
+
* - 200 → {access_token, token_type, tenant_id, tier} → done
|
|
10
|
+
* - 400 {error:"authorization_pending"} → keep waiting
|
|
11
|
+
* - 400 {error:"slow_down"} → increase interval by 5 s
|
|
12
|
+
* - 400 {error:"expired_token"} → abort
|
|
13
|
+
* - 400 {error:"access_denied"} → abort
|
|
14
|
+
*
|
|
15
|
+
* All network calls are injected (defaultFetch) so tests never hit the network.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** OAuth base URL — overridable via SWARMIQ_OAUTH_URL env var. */
|
|
19
|
+
export const OAUTH_BASE_URL =
|
|
20
|
+
process.env.SWARMIQ_OAUTH_URL ?? 'https://api.swarmiq.dev';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Start the device-flow: POST /v1/auth/device/code.
|
|
24
|
+
*
|
|
25
|
+
* @param {object} opts
|
|
26
|
+
* @param {string} [opts.oauthBaseUrl] - defaults to OAUTH_BASE_URL
|
|
27
|
+
* @param {Function}[opts.fetch] - injectable fetch
|
|
28
|
+
* @returns {Promise<{device_code:string, user_code:string,
|
|
29
|
+
* verification_uri:string, verification_uri_complete:string,
|
|
30
|
+
* expires_in:number, interval:number}>}
|
|
31
|
+
*/
|
|
32
|
+
export async function requestDeviceCode({
|
|
33
|
+
oauthBaseUrl = OAUTH_BASE_URL,
|
|
34
|
+
fetch: fetchFn = globalThis.fetch,
|
|
35
|
+
} = {}) {
|
|
36
|
+
const url = `${oauthBaseUrl}/v1/auth/device/code`;
|
|
37
|
+
const res = await fetchFn(url, {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers: { 'Content-Type': 'application/json' },
|
|
40
|
+
body: JSON.stringify({}),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (!res.ok) {
|
|
44
|
+
const body = await res.text().catch(() => '');
|
|
45
|
+
throw new Error(`POST /v1/auth/device/code failed: HTTP ${res.status} — ${body}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const data = await res.json();
|
|
49
|
+
|
|
50
|
+
if (!data.device_code || !data.user_code || !data.verification_uri) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
'Unexpected response from /v1/auth/device/code: missing required fields'
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
device_code: data.device_code,
|
|
58
|
+
user_code: data.user_code,
|
|
59
|
+
verification_uri: data.verification_uri,
|
|
60
|
+
verification_uri_complete: data.verification_uri_complete ?? data.verification_uri,
|
|
61
|
+
expires_in: data.expires_in ?? 300,
|
|
62
|
+
interval: data.interval ?? 5,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Poll /v1/auth/device/token until the user completes authorisation.
|
|
68
|
+
*
|
|
69
|
+
* @param {object} opts
|
|
70
|
+
* @param {string} opts.device_code
|
|
71
|
+
* @param {number} opts.interval - initial poll interval in seconds
|
|
72
|
+
* @param {number} [opts.expires_in] - total expiry window in seconds
|
|
73
|
+
* @param {string} [opts.oauthBaseUrl]
|
|
74
|
+
* @param {Function}[opts.fetch]
|
|
75
|
+
* @param {Function}[opts.sleep] - injectable: (ms:number)=>Promise<void>
|
|
76
|
+
* @returns {Promise<{access_token:string, token_type:string,
|
|
77
|
+
* tenant_id:string, tier:string}>}
|
|
78
|
+
*/
|
|
79
|
+
export async function pollDeviceToken({
|
|
80
|
+
device_code,
|
|
81
|
+
interval,
|
|
82
|
+
expires_in = 300,
|
|
83
|
+
oauthBaseUrl = OAUTH_BASE_URL,
|
|
84
|
+
fetch: fetchFn = globalThis.fetch,
|
|
85
|
+
sleep = defaultSleep,
|
|
86
|
+
}) {
|
|
87
|
+
const url = `${oauthBaseUrl}/v1/auth/device/token`;
|
|
88
|
+
const deadline = Date.now() + expires_in * 1000;
|
|
89
|
+
let currentInterval = interval;
|
|
90
|
+
|
|
91
|
+
while (Date.now() < deadline) {
|
|
92
|
+
await sleep(currentInterval * 1000);
|
|
93
|
+
|
|
94
|
+
const res = await fetchFn(url, {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
headers: { 'Content-Type': 'application/json' },
|
|
97
|
+
body: JSON.stringify({
|
|
98
|
+
device_code,
|
|
99
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
100
|
+
}),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
if (res.ok) {
|
|
104
|
+
const data = await res.json();
|
|
105
|
+
if (!data.access_token || !data.tenant_id) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
'Unexpected response from /v1/auth/device/token: missing access_token or tenant_id'
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
access_token: data.access_token,
|
|
112
|
+
token_type: data.token_type ?? 'bearer',
|
|
113
|
+
tenant_id: data.tenant_id,
|
|
114
|
+
tier: data.tier ?? 'free',
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 4xx → inspect error code
|
|
119
|
+
let detail;
|
|
120
|
+
try {
|
|
121
|
+
const body = await res.json();
|
|
122
|
+
detail = body?.detail?.error ?? body?.error ?? 'unknown_error';
|
|
123
|
+
} catch {
|
|
124
|
+
detail = 'unknown_error';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
switch (detail) {
|
|
128
|
+
case 'authorization_pending':
|
|
129
|
+
// Still waiting — continue polling
|
|
130
|
+
break;
|
|
131
|
+
case 'slow_down':
|
|
132
|
+
// Server asked us to back off
|
|
133
|
+
currentInterval += 5;
|
|
134
|
+
break;
|
|
135
|
+
case 'expired_token':
|
|
136
|
+
throw new Error(
|
|
137
|
+
'Device authorisation expired. Please run `swarmiq` again to restart.'
|
|
138
|
+
);
|
|
139
|
+
case 'access_denied':
|
|
140
|
+
throw new Error(
|
|
141
|
+
'Authorisation was denied. Please run `swarmiq` again and approve access.'
|
|
142
|
+
);
|
|
143
|
+
default:
|
|
144
|
+
throw new Error(
|
|
145
|
+
`Unexpected error from /v1/auth/device/token: ${detail}`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
throw new Error(
|
|
151
|
+
'Device authorisation timed out. Please run `swarmiq` again to restart.'
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ─── internal helpers ─────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
function defaultSleep(ms) {
|
|
158
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
159
|
+
}
|