@doow/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +75 -0
- package/dist/cjs/auth/api-key.js +159 -0
- package/dist/cjs/auth/api-key.js.map +1 -0
- package/dist/cjs/auth/detect.js +173 -0
- package/dist/cjs/auth/detect.js.map +1 -0
- package/dist/cjs/auth/device-flow.js +135 -0
- package/dist/cjs/auth/device-flow.js.map +1 -0
- package/dist/cjs/auth/keyring.js +118 -0
- package/dist/cjs/auth/keyring.js.map +1 -0
- package/dist/cjs/auth/pkce.js +243 -0
- package/dist/cjs/auth/pkce.js.map +1 -0
- package/dist/cjs/auth/refresh.js +203 -0
- package/dist/cjs/auth/refresh.js.map +1 -0
- package/dist/cjs/config/env.js +44 -0
- package/dist/cjs/config/env.js.map +1 -0
- package/dist/cjs/config/store.js +178 -0
- package/dist/cjs/config/store.js.map +1 -0
- package/dist/cjs/index.js +48 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cli.cjs +34372 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/esm/auth/api-key.js +154 -0
- package/dist/esm/auth/api-key.js.map +1 -0
- package/dist/esm/auth/detect.js +150 -0
- package/dist/esm/auth/detect.js.map +1 -0
- package/dist/esm/auth/device-flow.js +132 -0
- package/dist/esm/auth/device-flow.js.map +1 -0
- package/dist/esm/auth/keyring.js +116 -0
- package/dist/esm/auth/keyring.js.map +1 -0
- package/dist/esm/auth/pkce.js +220 -0
- package/dist/esm/auth/pkce.js.map +1 -0
- package/dist/esm/auth/refresh.js +198 -0
- package/dist/esm/auth/refresh.js.map +1 -0
- package/dist/esm/config/env.js +38 -0
- package/dist/esm/config/env.js.map +1 -0
- package/dist/esm/config/store.js +166 -0
- package/dist/esm/config/store.js.map +1 -0
- package/dist/esm/index.js +15 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/mcp.cjs +8 -0
- package/dist/mcp.cjs.map +1 -0
- package/dist/types/index.d.ts +369 -0
- package/package.json +62 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { getApiUrl } from '../config/env.js';
|
|
2
|
+
import { createCredentialStore } from './keyring.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* api-key.ts
|
|
6
|
+
*
|
|
7
|
+
* PAT (Personal Access Token) authentication for CI/scripting contexts.
|
|
8
|
+
*
|
|
9
|
+
* Provides:
|
|
10
|
+
* - validateApiKey — pure format check (dak_ prefix, length ≥ 20)
|
|
11
|
+
* - authenticateWithApiKey — store key + optionally verify against API
|
|
12
|
+
* - readTokenFromStdin — read a piped token from stdin
|
|
13
|
+
* - resolveAuth — precedence chain: flag > env > stdin > stored > none
|
|
14
|
+
*/
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Constants
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
const DAK_PREFIX = 'dak_';
|
|
19
|
+
const DAK_MIN_LENGTH = 20;
|
|
20
|
+
const REFRESH_BUFFER_SECONDS = 60;
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// validateApiKey
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
/**
|
|
25
|
+
* Returns true if key starts with 'dak_' (case-sensitive) and is at least
|
|
26
|
+
* 20 characters total. Pure function — no network call.
|
|
27
|
+
*/
|
|
28
|
+
function validateApiKey(key) {
|
|
29
|
+
return key.startsWith(DAK_PREFIX) && key.length >= DAK_MIN_LENGTH;
|
|
30
|
+
}
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// authenticateWithApiKey
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
/**
|
|
35
|
+
* Validates the key format, stores it in the credential store, and optionally
|
|
36
|
+
* verifies it works by hitting GET /v1/auth/capabilities.
|
|
37
|
+
*
|
|
38
|
+
* @throws {Error} if the key does not have the dak_ prefix
|
|
39
|
+
* @throws {Error} if verify is true and the capabilities request fails
|
|
40
|
+
*/
|
|
41
|
+
async function authenticateWithApiKey(options) {
|
|
42
|
+
const { key, profileName = 'default', verify = true } = options;
|
|
43
|
+
const apiUrl = options.apiUrl ?? getApiUrl();
|
|
44
|
+
const store = options.credentialStore ?? (await createCredentialStore());
|
|
45
|
+
// Validate format first
|
|
46
|
+
if (!validateApiKey(key)) {
|
|
47
|
+
throw new Error(`Invalid API key format. Keys must start with '${DAK_PREFIX}' and be at least ${DAK_MIN_LENGTH} characters long.`);
|
|
48
|
+
}
|
|
49
|
+
// Optionally verify before storing
|
|
50
|
+
if (verify) {
|
|
51
|
+
const res = await fetch(`${apiUrl}/v1/auth/capabilities`, {
|
|
52
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
53
|
+
});
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
const body = await res.text().catch(() => '');
|
|
56
|
+
throw new Error(`API key verification failed: HTTP ${res.status}${body ? ` — ${body}` : ''}`);
|
|
57
|
+
}
|
|
58
|
+
await store.set(profileName, { apiKey: key });
|
|
59
|
+
return { apiKey: key, verified: true };
|
|
60
|
+
}
|
|
61
|
+
await store.set(profileName, { apiKey: key });
|
|
62
|
+
return { apiKey: key, verified: false };
|
|
63
|
+
}
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// readTokenFromStdin
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
/**
|
|
68
|
+
* Reads a token from piped stdin input.
|
|
69
|
+
*
|
|
70
|
+
* @throws {Error} if stdin is a TTY (not piped)
|
|
71
|
+
* @throws {Error} if the resulting string is empty after trim
|
|
72
|
+
*/
|
|
73
|
+
async function readTokenFromStdin() {
|
|
74
|
+
if (process.stdin.isTTY) {
|
|
75
|
+
throw new Error('--token-stdin requires piped input (e.g., echo $TOKEN | doow login --token-stdin)');
|
|
76
|
+
}
|
|
77
|
+
const parts = [];
|
|
78
|
+
for await (const chunk of process.stdin) {
|
|
79
|
+
if (Buffer.isBuffer(chunk)) {
|
|
80
|
+
parts.push(chunk.toString('utf-8'));
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
parts.push(String(chunk));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const token = parts.join('').trim();
|
|
87
|
+
if (!token) {
|
|
88
|
+
throw new Error('No token received on stdin');
|
|
89
|
+
}
|
|
90
|
+
return token;
|
|
91
|
+
}
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// resolveAuth
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
/**
|
|
96
|
+
* Resolves the active auth context by walking the precedence chain:
|
|
97
|
+
* --api-key flag > DOOW_API_KEY env > --token-stdin > stored profile > none
|
|
98
|
+
*/
|
|
99
|
+
async function resolveAuth(options = {}) {
|
|
100
|
+
const { apiKeyFlag, tokenStdin = false, profileName = 'default' } = options;
|
|
101
|
+
const store = options.credentialStore ?? (await createCredentialStore());
|
|
102
|
+
// 1. --api-key flag
|
|
103
|
+
if (apiKeyFlag !== undefined && apiKeyFlag !== '') {
|
|
104
|
+
if (!validateApiKey(apiKeyFlag)) {
|
|
105
|
+
throw new Error(`Invalid API key format. Keys must start with '${DAK_PREFIX}' and be at least ${DAK_MIN_LENGTH} characters long.`);
|
|
106
|
+
}
|
|
107
|
+
return { type: 'api-key', token: apiKeyFlag, source: '--api-key flag' };
|
|
108
|
+
}
|
|
109
|
+
// 2. DOOW_API_KEY env var
|
|
110
|
+
const envKey = process.env['DOOW_API_KEY'];
|
|
111
|
+
if (envKey && validateApiKey(envKey)) {
|
|
112
|
+
return { type: 'api-key', token: envKey, source: 'DOOW_API_KEY env' };
|
|
113
|
+
}
|
|
114
|
+
// 3. --token-stdin
|
|
115
|
+
if (tokenStdin) {
|
|
116
|
+
const token = await readTokenFromStdin();
|
|
117
|
+
const type = validateApiKey(token) ? 'api-key' : 'oauth-token';
|
|
118
|
+
return { type, token, source: 'stdin' };
|
|
119
|
+
}
|
|
120
|
+
// 4. Stored credentials for the profile
|
|
121
|
+
const creds = await store.get(profileName);
|
|
122
|
+
if (creds?.apiKey) {
|
|
123
|
+
return { type: 'api-key', token: creds.apiKey, source: 'stored profile' };
|
|
124
|
+
}
|
|
125
|
+
if (creds?.accessToken) {
|
|
126
|
+
const token = creds.accessToken;
|
|
127
|
+
const needsRefresh = isTokenExpiredOrExpiring(creds.expiresAt);
|
|
128
|
+
return {
|
|
129
|
+
type: 'oauth-token',
|
|
130
|
+
token,
|
|
131
|
+
source: 'stored profile',
|
|
132
|
+
...(needsRefresh ? { needsRefresh: true } : {}),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
// 5. Nothing found
|
|
136
|
+
return { type: 'none', source: 'none' };
|
|
137
|
+
}
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Private helpers
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
/**
|
|
142
|
+
* Returns true if the ISO-8601 expiresAt string is either absent, already
|
|
143
|
+
* past, or within 60 seconds of now.
|
|
144
|
+
*/
|
|
145
|
+
function isTokenExpiredOrExpiring(expiresAt) {
|
|
146
|
+
if (!expiresAt)
|
|
147
|
+
return true;
|
|
148
|
+
const expiryMs = new Date(expiresAt).getTime();
|
|
149
|
+
const nowPlusBuffer = Date.now() + REFRESH_BUFFER_SECONDS * 1000;
|
|
150
|
+
return expiryMs <= nowPlusBuffer;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export { authenticateWithApiKey, readTokenFromStdin, resolveAuth, validateApiKey };
|
|
154
|
+
//# sourceMappingURL=api-key.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api-key.js","sources":["../../../../src/auth/api-key.ts"],"sourcesContent":[null],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import * as http from 'node:http';
|
|
2
|
+
import { isCI, isTTY } from '../config/env.js';
|
|
3
|
+
import { validateApiKey, authenticateWithApiKey } from './api-key.js';
|
|
4
|
+
import { executePkceFlow } from './pkce.js';
|
|
5
|
+
import { executeDeviceFlow } from './device-flow.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* detect.ts
|
|
9
|
+
*
|
|
10
|
+
* S122 — Auth auto-detection for the Doow CLI.
|
|
11
|
+
*
|
|
12
|
+
* Determines whether to use PKCE, device, or API-key authentication based on
|
|
13
|
+
* the current runtime environment, then executes the appropriate flow.
|
|
14
|
+
*
|
|
15
|
+
* Detection order:
|
|
16
|
+
* 1. --api-key provided + valid format → api-key
|
|
17
|
+
* 2. --device flag → device
|
|
18
|
+
* 3. Running inside CI ($CI set) → device
|
|
19
|
+
* 4. Non-interactive stdout (!isTTY) → device
|
|
20
|
+
* 5. Can bind 127.0.0.1 on a free port → pkce
|
|
21
|
+
* 6. Fallback → device
|
|
22
|
+
*/
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// canBindLocalhost
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
/**
|
|
27
|
+
* Tests whether the process can bind a TCP server on 127.0.0.1 using an
|
|
28
|
+
* OS-assigned ephemeral port. The server is closed immediately on success.
|
|
29
|
+
*
|
|
30
|
+
* Returns true → PKCE callback server will work.
|
|
31
|
+
* Returns false → Network stack can't bind (containers with restricted network
|
|
32
|
+
* policies, permission denied, etc.) — use device flow instead.
|
|
33
|
+
*/
|
|
34
|
+
function canBindLocalhost() {
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
const server = http.createServer();
|
|
37
|
+
server.once('error', () => {
|
|
38
|
+
resolve(false);
|
|
39
|
+
});
|
|
40
|
+
server.listen(0, '127.0.0.1', () => {
|
|
41
|
+
server.close(() => resolve(true));
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// detectAuthMethod
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
/**
|
|
49
|
+
* Returns the best authentication method for the current environment.
|
|
50
|
+
*/
|
|
51
|
+
async function detectAuthMethod(options = {}) {
|
|
52
|
+
const { forceDevice = false, apiKey } = options;
|
|
53
|
+
// 1. API key provided and format-valid → skip OAuth entirely
|
|
54
|
+
if (apiKey !== undefined && apiKey !== '' && validateApiKey(apiKey)) {
|
|
55
|
+
return 'api-key';
|
|
56
|
+
}
|
|
57
|
+
// 2. Explicit --device flag
|
|
58
|
+
if (forceDevice) {
|
|
59
|
+
return 'device';
|
|
60
|
+
}
|
|
61
|
+
// 3. CI environment — browser is not available
|
|
62
|
+
if (isCI()) {
|
|
63
|
+
return 'device';
|
|
64
|
+
}
|
|
65
|
+
// 4. Non-interactive (stdout is not a TTY) — can't drive a browser login
|
|
66
|
+
if (!isTTY()) {
|
|
67
|
+
return 'device';
|
|
68
|
+
}
|
|
69
|
+
// 5. Try to bind a local port — if it works, PKCE callback server will too
|
|
70
|
+
const canBind = await canBindLocalhost();
|
|
71
|
+
if (canBind) {
|
|
72
|
+
return 'pkce';
|
|
73
|
+
}
|
|
74
|
+
// 6. Safe fallback
|
|
75
|
+
return 'device';
|
|
76
|
+
}
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// isServerBindError
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
/**
|
|
81
|
+
* Returns true when an error looks like a localhost server bind failure —
|
|
82
|
+
* the only case where PKCE should automatically fall back to device flow.
|
|
83
|
+
*
|
|
84
|
+
* We intentionally do NOT fall back on application-level errors such as
|
|
85
|
+
* CSRF mismatch, token exchange failures, or user cancellation.
|
|
86
|
+
*/
|
|
87
|
+
function isServerBindError(err) {
|
|
88
|
+
if (!(err instanceof Error))
|
|
89
|
+
return false;
|
|
90
|
+
const msg = err.message.toLowerCase();
|
|
91
|
+
return (msg.includes('failed to start local callback server') ||
|
|
92
|
+
msg.includes('failed to determine callback server port') ||
|
|
93
|
+
msg.includes('failed to open browser') ||
|
|
94
|
+
msg.includes('eaddrinuse') ||
|
|
95
|
+
msg.includes('eacces') ||
|
|
96
|
+
msg.includes('permission denied'));
|
|
97
|
+
}
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// executeAutoLogin
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
/**
|
|
102
|
+
* The main login orchestrator.
|
|
103
|
+
*
|
|
104
|
+
* 1. Detects the best auth method.
|
|
105
|
+
* 2. Executes the corresponding flow.
|
|
106
|
+
* 3. If PKCE fails with a server bind error, automatically retries with device flow.
|
|
107
|
+
*/
|
|
108
|
+
async function executeAutoLogin(options = {}) {
|
|
109
|
+
const { forceDevice, apiKey, apiUrl, profileName, credentialStore, timeout } = options;
|
|
110
|
+
const method = await detectAuthMethod({ forceDevice, apiKey });
|
|
111
|
+
if (method === 'api-key') {
|
|
112
|
+
// apiKey is guaranteed non-empty here (detectAuthMethod validated it)
|
|
113
|
+
await authenticateWithApiKey({
|
|
114
|
+
key: apiKey,
|
|
115
|
+
apiUrl,
|
|
116
|
+
profileName,
|
|
117
|
+
credentialStore,
|
|
118
|
+
});
|
|
119
|
+
return { method: 'api-key', apiKey };
|
|
120
|
+
}
|
|
121
|
+
if (method === 'pkce') {
|
|
122
|
+
try {
|
|
123
|
+
const result = await executePkceFlow({ apiUrl, profileName, credentialStore, timeout });
|
|
124
|
+
return {
|
|
125
|
+
method: 'pkce',
|
|
126
|
+
accessToken: result.accessToken,
|
|
127
|
+
refreshToken: result.refreshToken,
|
|
128
|
+
expiresAt: result.expiresAt,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
// Only fall back on server bind errors — not on CSRF or token exchange errors
|
|
133
|
+
if (!isServerBindError(err)) {
|
|
134
|
+
throw err;
|
|
135
|
+
}
|
|
136
|
+
// Fall through to device flow below
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// device flow (either detected or PKCE fallback)
|
|
140
|
+
const result = await executeDeviceFlow({ apiUrl, profileName, credentialStore });
|
|
141
|
+
return {
|
|
142
|
+
method: 'device',
|
|
143
|
+
accessToken: result.accessToken,
|
|
144
|
+
refreshToken: result.refreshToken,
|
|
145
|
+
expiresAt: result.expiresAt,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export { canBindLocalhost, detectAuthMethod, executeAutoLogin };
|
|
150
|
+
//# sourceMappingURL=detect.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"detect.js","sources":["../../../../src/auth/detect.ts"],"sourcesContent":[null],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { getApiUrl, shouldShowUI } from '../config/env.js';
|
|
2
|
+
import { createCredentialStore } from './keyring.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* device-flow.ts
|
|
6
|
+
*
|
|
7
|
+
* RFC 8628 OAuth 2.0 Device Authorization flow for headless/SSH/container
|
|
8
|
+
* environments where a browser cannot be opened on the same machine.
|
|
9
|
+
*
|
|
10
|
+
* Steps:
|
|
11
|
+
* 1. POST /v1/auth/device/authorize → get device_code + user_code
|
|
12
|
+
* 2. Display verification URI + user_code on stderr
|
|
13
|
+
* 3. Optionally open the browser (best-effort)
|
|
14
|
+
* 4. Poll POST /v1/auth/device/token until granted or expired
|
|
15
|
+
* 5. Store tokens in the credential store
|
|
16
|
+
*/
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Helpers
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
/** Sleep for `ms` milliseconds — real timer in production, override in tests. */
|
|
21
|
+
function sleep(ms) {
|
|
22
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Build an ISO-8601 expiry timestamp from a seconds-from-now value.
|
|
26
|
+
* Exported for test visibility only.
|
|
27
|
+
*/
|
|
28
|
+
function expiresAtFromSecondsIn(secondsIn) {
|
|
29
|
+
return new Date(Date.now() + secondsIn * 1000).toISOString();
|
|
30
|
+
}
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Core function
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
/**
|
|
35
|
+
* Execute the RFC 8628 device authorization flow.
|
|
36
|
+
*
|
|
37
|
+
* All user-facing output goes to stderr so stdout stays clean for piping.
|
|
38
|
+
*
|
|
39
|
+
* @throws {Error} if authorization fails or the device code expires.
|
|
40
|
+
*/
|
|
41
|
+
async function executeDeviceFlow(options = {}) {
|
|
42
|
+
const apiUrl = options.apiUrl ?? getApiUrl();
|
|
43
|
+
const profileName = options.profileName ?? 'default';
|
|
44
|
+
const store = options.credentialStore ?? (await createCredentialStore());
|
|
45
|
+
const openUrl = options.openUrl ??
|
|
46
|
+
(async (url) => {
|
|
47
|
+
const { default: open } = await import('open');
|
|
48
|
+
await open(url);
|
|
49
|
+
});
|
|
50
|
+
// -------------------------------------------------------------------------
|
|
51
|
+
// Step 1: Request device authorization
|
|
52
|
+
// -------------------------------------------------------------------------
|
|
53
|
+
const authorizeRes = await fetch(`${apiUrl}/v1/auth/device/authorize`, {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: { 'Content-Type': 'application/json' },
|
|
56
|
+
body: JSON.stringify({ client_id: 'doow-cli' }),
|
|
57
|
+
});
|
|
58
|
+
if (!authorizeRes.ok) {
|
|
59
|
+
const body = await authorizeRes.text().catch(() => '');
|
|
60
|
+
throw new Error(`Device authorization request failed: HTTP ${authorizeRes.status}${body ? ` — ${body}` : ''}`);
|
|
61
|
+
}
|
|
62
|
+
const auth = (await authorizeRes.json());
|
|
63
|
+
// -------------------------------------------------------------------------
|
|
64
|
+
// Step 2: Display instructions on stderr
|
|
65
|
+
// -------------------------------------------------------------------------
|
|
66
|
+
process.stderr.write(`\nTo sign in, open this URL in any browser:\n ${auth.verification_uri}\n\nThen enter code: ${auth.user_code}\n\n`);
|
|
67
|
+
// Step 2b: Best-effort browser open — never throw on failure
|
|
68
|
+
if (shouldShowUI()) {
|
|
69
|
+
try {
|
|
70
|
+
await openUrl(auth.verification_uri);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// Silently ignore — user can open manually
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// -------------------------------------------------------------------------
|
|
77
|
+
// Step 3: Poll for token
|
|
78
|
+
// -------------------------------------------------------------------------
|
|
79
|
+
let pollInterval = auth.interval; // seconds; RFC 8628 §3.5
|
|
80
|
+
while (true) {
|
|
81
|
+
await sleep(pollInterval * 1000);
|
|
82
|
+
const tokenRes = await fetch(`${apiUrl}/v1/auth/device/token`, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: { 'Content-Type': 'application/json' },
|
|
85
|
+
body: JSON.stringify({
|
|
86
|
+
device_code: auth.device_code,
|
|
87
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
88
|
+
}),
|
|
89
|
+
});
|
|
90
|
+
if (tokenRes.ok) {
|
|
91
|
+
// Success — stop polling
|
|
92
|
+
const tokens = (await tokenRes.json());
|
|
93
|
+
// -----------------------------------------------------------------------
|
|
94
|
+
// Step 4: Store tokens
|
|
95
|
+
// -----------------------------------------------------------------------
|
|
96
|
+
const expiresAt = expiresAtFromSecondsIn(tokens.expires_in);
|
|
97
|
+
await store.set(profileName, {
|
|
98
|
+
accessToken: tokens.access_token,
|
|
99
|
+
refreshToken: tokens.refresh_token,
|
|
100
|
+
expiresAt,
|
|
101
|
+
});
|
|
102
|
+
// -----------------------------------------------------------------------
|
|
103
|
+
// Step 5: Return result
|
|
104
|
+
// -----------------------------------------------------------------------
|
|
105
|
+
return {
|
|
106
|
+
accessToken: tokens.access_token,
|
|
107
|
+
refreshToken: tokens.refresh_token,
|
|
108
|
+
expiresAt,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
// Non-200 — parse RFC 8628 error body
|
|
112
|
+
const errBody = (await tokenRes.json().catch(() => ({ error: 'unknown' })));
|
|
113
|
+
switch (errBody.error) {
|
|
114
|
+
case 'authorization_pending':
|
|
115
|
+
// User hasn't approved yet — keep polling at current interval
|
|
116
|
+
continue;
|
|
117
|
+
case 'slow_down':
|
|
118
|
+
// RFC 8628 §3.5: increase interval by 5 seconds
|
|
119
|
+
pollInterval += 5;
|
|
120
|
+
continue;
|
|
121
|
+
case 'expired_token':
|
|
122
|
+
throw new Error('Device code expired. Run doow login --device again.');
|
|
123
|
+
case 'access_denied':
|
|
124
|
+
throw new Error('Authorization denied by user.');
|
|
125
|
+
default:
|
|
126
|
+
throw new Error(`Token polling failed: ${errBody.error}${errBody.error_description ? ` — ${errBody.error_description}` : ''}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export { executeDeviceFlow, expiresAtFromSecondsIn };
|
|
132
|
+
//# sourceMappingURL=device-flow.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"device-flow.js","sources":["../../../../src/auth/device-flow.ts"],"sourcesContent":[null],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { clearProfileCredentials, setProfileCredentials, getProfileCredentials } from '../config/store.js';
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Constants
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
const SERVICE_NAME = 'doow-cli';
|
|
7
|
+
const PROBE_TIMEOUT_MS = 3_000;
|
|
8
|
+
const OP_TIMEOUT_MS = 3_000;
|
|
9
|
+
// Module-level flag — only warn once per process lifetime.
|
|
10
|
+
let hasPrintedFallbackWarning = false;
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Timeout helper
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
/**
|
|
15
|
+
* Races `promise` against a rejection that fires after `ms` milliseconds.
|
|
16
|
+
* Avoids AbortSignal.timeout which requires Node >=17.3.
|
|
17
|
+
*/
|
|
18
|
+
function withTimeout(promise, ms) {
|
|
19
|
+
return Promise.race([
|
|
20
|
+
promise,
|
|
21
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(`Operation timed out after ${ms}ms`)), ms)),
|
|
22
|
+
]);
|
|
23
|
+
}
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// One-time fallback warning
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
function printFallbackWarning() {
|
|
28
|
+
if (hasPrintedFallbackWarning)
|
|
29
|
+
return;
|
|
30
|
+
hasPrintedFallbackWarning = true;
|
|
31
|
+
process.stderr.write('⚠ System keyring unavailable — credentials stored in ~/.doow/credentials.json (chmod 0600)\n');
|
|
32
|
+
}
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// File-backed store (wraps S118 store.ts functions)
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
function createFileStore() {
|
|
37
|
+
return {
|
|
38
|
+
async get(profileName) {
|
|
39
|
+
return getProfileCredentials(profileName);
|
|
40
|
+
},
|
|
41
|
+
async set(profileName, creds) {
|
|
42
|
+
return setProfileCredentials(profileName, creds);
|
|
43
|
+
},
|
|
44
|
+
async clear(profileName) {
|
|
45
|
+
return clearProfileCredentials(profileName);
|
|
46
|
+
},
|
|
47
|
+
backend() {
|
|
48
|
+
return 'file';
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Keyring-backed store
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
function createKeyringStore(keytar) {
|
|
56
|
+
return {
|
|
57
|
+
async get(profileName) {
|
|
58
|
+
try {
|
|
59
|
+
const raw = await withTimeout(keytar.getPassword(SERVICE_NAME, profileName), OP_TIMEOUT_MS);
|
|
60
|
+
if (raw == null)
|
|
61
|
+
return undefined;
|
|
62
|
+
return JSON.parse(raw);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
async set(profileName, creds) {
|
|
69
|
+
await withTimeout(keytar.setPassword(SERVICE_NAME, profileName, JSON.stringify(creds)), OP_TIMEOUT_MS);
|
|
70
|
+
},
|
|
71
|
+
async clear(profileName) {
|
|
72
|
+
await withTimeout(keytar.deletePassword(SERVICE_NAME, profileName), OP_TIMEOUT_MS);
|
|
73
|
+
},
|
|
74
|
+
backend() {
|
|
75
|
+
return 'keyring';
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Factory
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
/**
|
|
83
|
+
* Creates a CredentialStore backed by the system keyring when available,
|
|
84
|
+
* falling back to file storage silently when keytar is not installed or
|
|
85
|
+
* the system keyring does not respond within 3 seconds.
|
|
86
|
+
*
|
|
87
|
+
* Call this once at startup and reuse the returned instance.
|
|
88
|
+
*/
|
|
89
|
+
async function createCredentialStore() {
|
|
90
|
+
// Step 1: Try loading keytar (optional peer dependency).
|
|
91
|
+
let keytar;
|
|
92
|
+
try {
|
|
93
|
+
// Dynamic import so missing keytar never crashes the module at load time.
|
|
94
|
+
// @ts-expect-error — keytar is an optional peer dep and may not be installed.
|
|
95
|
+
const mod = await import('keytar');
|
|
96
|
+
// Support both default-export and named-export module shapes.
|
|
97
|
+
keytar = (mod.default ?? mod);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// keytar not installed — silent, no warning needed (not an error condition).
|
|
101
|
+
return createFileStore();
|
|
102
|
+
}
|
|
103
|
+
// Step 2: Probe the keyring with a 3s timeout to confirm it's responsive.
|
|
104
|
+
try {
|
|
105
|
+
await withTimeout(keytar.findPassword(SERVICE_NAME), PROBE_TIMEOUT_MS);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
printFallbackWarning();
|
|
109
|
+
return createFileStore();
|
|
110
|
+
}
|
|
111
|
+
// Step 3: Keyring is available and responsive — use it.
|
|
112
|
+
return createKeyringStore(keytar);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export { createCredentialStore };
|
|
116
|
+
//# sourceMappingURL=keyring.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"keyring.js","sources":["../../../../src/auth/keyring.ts"],"sourcesContent":[null],"names":[],"mappings":"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"}
|