claude-aws-mfa 1.0.0 → 1.1.0-rc.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/package.json +1 -1
- package/src/config.ts +73 -0
- package/src/flags.ts +42 -0
- package/src/index.ts +170 -10
- package/src/sts.ts +1 -0
- package/test/index.test.ts +53 -0
- package/test/lock.test.ts +47 -0
- package/test/session-cache.test.ts +89 -0
- package/test/sts.test.ts +2 -0
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -6,11 +6,20 @@ import {
|
|
|
6
6
|
mkdirSync,
|
|
7
7
|
openSync,
|
|
8
8
|
readFileSync,
|
|
9
|
+
rmSync,
|
|
9
10
|
statSync,
|
|
10
11
|
writeFileSync,
|
|
12
|
+
writeSync,
|
|
11
13
|
} from "fs";
|
|
12
14
|
import { STSClient } from "@aws-sdk/client-sts";
|
|
13
15
|
|
|
16
|
+
export interface CachedSession {
|
|
17
|
+
AccessKeyId: string;
|
|
18
|
+
SecretAccessKey: string;
|
|
19
|
+
SessionToken: string;
|
|
20
|
+
Expiration: string; // ISO 8601
|
|
21
|
+
}
|
|
22
|
+
|
|
14
23
|
export interface Config {
|
|
15
24
|
region: string;
|
|
16
25
|
accessKeyId: string;
|
|
@@ -20,6 +29,10 @@ export interface Config {
|
|
|
20
29
|
duration: number;
|
|
21
30
|
mfaMode?: "code" | "command";
|
|
22
31
|
mfaCommand?: string;
|
|
32
|
+
cacheSession?: boolean;
|
|
33
|
+
autoMfa?: boolean;
|
|
34
|
+
singleInstanceLock?: boolean;
|
|
35
|
+
cachedSession?: CachedSession;
|
|
23
36
|
}
|
|
24
37
|
|
|
25
38
|
const CONFIG_PATH = join(homedir(), ".config", "claude-aws-mfa.json");
|
|
@@ -104,6 +117,66 @@ export function saveConfig(config: Config) {
|
|
|
104
117
|
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n");
|
|
105
118
|
}
|
|
106
119
|
|
|
120
|
+
// --- Single-instance lock ---
|
|
121
|
+
|
|
122
|
+
const LOCK_PATH = join(homedir(), ".config", "claude-aws-mfa.lock");
|
|
123
|
+
const LOCK_POLL_MS = 500;
|
|
124
|
+
const LOCK_STALE_MS = 120_000; // 2 minutes — assume stale if holder crashed
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Attempt to acquire an exclusive lock. Returns true if acquired.
|
|
128
|
+
* Uses O_EXCL to atomically create the lock file.
|
|
129
|
+
*/
|
|
130
|
+
export function tryAcquireLock(): boolean {
|
|
131
|
+
ensureConfigDir();
|
|
132
|
+
try {
|
|
133
|
+
const fd = openSync(LOCK_PATH, "wx", 0o600);
|
|
134
|
+
writeSync(fd, String(process.pid));
|
|
135
|
+
closeSync(fd);
|
|
136
|
+
return true;
|
|
137
|
+
} catch (err: any) {
|
|
138
|
+
if (err?.code === "EEXIST") {
|
|
139
|
+
// Check for stale lock
|
|
140
|
+
try {
|
|
141
|
+
const st = statSync(LOCK_PATH);
|
|
142
|
+
if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
|
|
143
|
+
// Stale lock — remove and retry once
|
|
144
|
+
try { rmSync(LOCK_PATH); } catch {}
|
|
145
|
+
return tryAcquireLock();
|
|
146
|
+
}
|
|
147
|
+
} catch {}
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
throw err;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Release the lock file. */
|
|
155
|
+
export function releaseLock(): void {
|
|
156
|
+
try { rmSync(LOCK_PATH); } catch {}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Wait until the lock is released, polling every LOCK_POLL_MS.
|
|
161
|
+
* Returns once the lock file disappears (or becomes stale).
|
|
162
|
+
*/
|
|
163
|
+
export async function waitForLock(): Promise<void> {
|
|
164
|
+
while (true) {
|
|
165
|
+
try {
|
|
166
|
+
const st = statSync(LOCK_PATH);
|
|
167
|
+
if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
|
|
168
|
+
// Stale — break out so caller can acquire
|
|
169
|
+
try { rmSync(LOCK_PATH); } catch {}
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
} catch {
|
|
173
|
+
// Lock file gone — we can proceed
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
await Bun.sleep(LOCK_POLL_MS);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
107
180
|
export async function seedDefaults(): Promise<Partial<Config>> {
|
|
108
181
|
const defaults: Partial<Config> = {};
|
|
109
182
|
try {
|
package/src/flags.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export interface CliFlags {
|
|
2
|
+
setup: boolean;
|
|
3
|
+
cacheSession: boolean | undefined; // undefined = use config default
|
|
4
|
+
autoMfa: boolean | undefined;
|
|
5
|
+
singleInstanceLock: boolean | undefined;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function parseFlags(argv: string[]): CliFlags {
|
|
9
|
+
const flags: CliFlags = {
|
|
10
|
+
setup: false,
|
|
11
|
+
cacheSession: undefined,
|
|
12
|
+
autoMfa: undefined,
|
|
13
|
+
singleInstanceLock: undefined,
|
|
14
|
+
};
|
|
15
|
+
for (const arg of argv.slice(2)) {
|
|
16
|
+
switch (arg) {
|
|
17
|
+
case "setup":
|
|
18
|
+
case "--setup":
|
|
19
|
+
flags.setup = true;
|
|
20
|
+
break;
|
|
21
|
+
case "--cache-session":
|
|
22
|
+
flags.cacheSession = true;
|
|
23
|
+
break;
|
|
24
|
+
case "--no-cache-session":
|
|
25
|
+
flags.cacheSession = false;
|
|
26
|
+
break;
|
|
27
|
+
case "--auto-mfa":
|
|
28
|
+
flags.autoMfa = true;
|
|
29
|
+
break;
|
|
30
|
+
case "--no-auto-mfa":
|
|
31
|
+
flags.autoMfa = false;
|
|
32
|
+
break;
|
|
33
|
+
case "--single-instance-lock":
|
|
34
|
+
flags.singleInstanceLock = true;
|
|
35
|
+
break;
|
|
36
|
+
case "--no-single-instance-lock":
|
|
37
|
+
flags.singleInstanceLock = false;
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return flags;
|
|
42
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -15,25 +15,107 @@ if (process.platform === "linux" && !process.env.WEBKIT_DISABLE_SANDBOX_THIS_IS_
|
|
|
15
15
|
process.exit(proc.exitCode);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
import { parseFlags } from "./flags";
|
|
19
|
+
|
|
20
|
+
const flags = parseFlags(process.argv);
|
|
21
|
+
|
|
22
|
+
if (flags.setup) {
|
|
20
23
|
const { runSetup } = await import("./setup");
|
|
21
24
|
process.exit(runSetup() ? 0 : 2);
|
|
22
25
|
}
|
|
23
26
|
|
|
24
|
-
import {
|
|
27
|
+
import type { CachedSession, Config } from "./config";
|
|
28
|
+
import { loadConfig, saveConfig, seedDefaults, tryAcquireLock, releaseLock, waitForLock } from "./config";
|
|
25
29
|
import { showDialog } from "./dialog";
|
|
26
30
|
import { assumeRoleWithMfa, STANDARD_DURATIONS } from "./sts";
|
|
27
31
|
|
|
28
|
-
|
|
32
|
+
// --- Resolve effective flags (CLI overrides config defaults) ---
|
|
29
33
|
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
const config = loadConfig();
|
|
35
|
+
const defaults: Partial<Config> = config ?? await seedDefaults();
|
|
36
|
+
|
|
37
|
+
const useCache = flags.cacheSession ?? defaults.cacheSession ?? false;
|
|
38
|
+
const useAutoMfa = flags.autoMfa ?? defaults.autoMfa ?? false;
|
|
39
|
+
const useLock = flags.singleInstanceLock ?? defaults.singleInstanceLock ?? false;
|
|
40
|
+
|
|
41
|
+
// --- Helper: check if a cached session is still valid ---
|
|
42
|
+
|
|
43
|
+
function isSessionValid(session: CachedSession | undefined): session is CachedSession {
|
|
44
|
+
if (!session) return false;
|
|
45
|
+
return new Date(session.Expiration).getTime() > Date.now();
|
|
34
46
|
}
|
|
35
47
|
|
|
36
|
-
|
|
48
|
+
// --- Helper: output credentials and save config ---
|
|
49
|
+
|
|
50
|
+
function outputCredentials(session: CachedSession): void {
|
|
51
|
+
console.log(JSON.stringify({
|
|
52
|
+
Credentials: {
|
|
53
|
+
AccessKeyId: session.AccessKeyId,
|
|
54
|
+
SecretAccessKey: session.SecretAccessKey,
|
|
55
|
+
SessionToken: session.SessionToken,
|
|
56
|
+
},
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// --- Helper: run MFA command and attempt to obtain credentials without dialog ---
|
|
61
|
+
|
|
62
|
+
async function tryAutoMfa(cfg: Partial<Config>): Promise<CachedSession | null> {
|
|
63
|
+
if (!cfg.mfaCommand || cfg.mfaMode !== "command") return null;
|
|
64
|
+
if (!cfg.region || !cfg.accessKeyId || !cfg.secretAccessKey || !cfg.mfaArn || !cfg.roleArn) return null;
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const proc = Bun.spawnSync(["sh", "-c", cfg.mfaCommand]);
|
|
68
|
+
if (proc.exitCode !== 0) return null;
|
|
69
|
+
const mfaCode = proc.stdout.toString().trim();
|
|
70
|
+
if (!mfaCode) return null;
|
|
71
|
+
|
|
72
|
+
const { credentials, duration } = await assumeRoleWithMfa({
|
|
73
|
+
region: cfg.region,
|
|
74
|
+
accessKeyId: cfg.accessKeyId,
|
|
75
|
+
secretAccessKey: cfg.secretAccessKey,
|
|
76
|
+
mfaArn: cfg.mfaArn,
|
|
77
|
+
roleArn: cfg.roleArn,
|
|
78
|
+
mfaCode,
|
|
79
|
+
duration: cfg.duration ?? STANDARD_DURATIONS[0],
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const session: CachedSession = {
|
|
83
|
+
AccessKeyId: credentials.AccessKeyId,
|
|
84
|
+
SecretAccessKey: credentials.SecretAccessKey,
|
|
85
|
+
SessionToken: credentials.SessionToken,
|
|
86
|
+
Expiration: credentials.Expiration,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
saveConfig({
|
|
90
|
+
region: cfg.region,
|
|
91
|
+
accessKeyId: cfg.accessKeyId,
|
|
92
|
+
secretAccessKey: cfg.secretAccessKey,
|
|
93
|
+
mfaArn: cfg.mfaArn,
|
|
94
|
+
roleArn: cfg.roleArn,
|
|
95
|
+
duration,
|
|
96
|
+
mfaMode: cfg.mfaMode,
|
|
97
|
+
mfaCommand: cfg.mfaCommand,
|
|
98
|
+
cacheSession: cfg.cacheSession,
|
|
99
|
+
autoMfa: cfg.autoMfa,
|
|
100
|
+
singleInstanceLock: cfg.singleInstanceLock,
|
|
101
|
+
...(useCache ? { cachedSession: session } : {}),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return session;
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// --- Helper: show dialog and obtain credentials ---
|
|
111
|
+
|
|
112
|
+
async function obtainViaDialog(cfg: Partial<Config>): Promise<void> {
|
|
113
|
+
const result = showDialog(cfg);
|
|
114
|
+
if (!result) {
|
|
115
|
+
process.stderr.write("User cancelled dialog.\n");
|
|
116
|
+
process.exit(2);
|
|
117
|
+
}
|
|
118
|
+
|
|
37
119
|
let mfaCode = result.mfaCode;
|
|
38
120
|
if (result.mfaMode === "command") {
|
|
39
121
|
const proc = Bun.spawnSync(["sh", "-c", result.mfaCommand]);
|
|
@@ -54,6 +136,13 @@ try {
|
|
|
54
136
|
duration: parseInt(result.duration, 10) || STANDARD_DURATIONS[0],
|
|
55
137
|
});
|
|
56
138
|
|
|
139
|
+
const session: CachedSession = {
|
|
140
|
+
AccessKeyId: credentials.AccessKeyId,
|
|
141
|
+
SecretAccessKey: credentials.SecretAccessKey,
|
|
142
|
+
SessionToken: credentials.SessionToken,
|
|
143
|
+
Expiration: credentials.Expiration,
|
|
144
|
+
};
|
|
145
|
+
|
|
57
146
|
saveConfig({
|
|
58
147
|
region: result.region,
|
|
59
148
|
accessKeyId: result.accessKeyId,
|
|
@@ -63,10 +152,81 @@ try {
|
|
|
63
152
|
duration,
|
|
64
153
|
mfaMode: result.mfaMode,
|
|
65
154
|
mfaCommand: result.mfaCommand || undefined,
|
|
155
|
+
cacheSession: defaults.cacheSession,
|
|
156
|
+
autoMfa: defaults.autoMfa,
|
|
157
|
+
singleInstanceLock: defaults.singleInstanceLock,
|
|
158
|
+
...(useCache ? { cachedSession: session } : {}),
|
|
66
159
|
});
|
|
67
160
|
|
|
68
|
-
|
|
161
|
+
outputCredentials(session);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// --- Main flow ---
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
// 1. If cache-session is enabled, check for unexpired cached credentials
|
|
168
|
+
if (useCache && config?.cachedSession) {
|
|
169
|
+
if (isSessionValid(config.cachedSession)) {
|
|
170
|
+
// Return cached credentials without showing dialog
|
|
171
|
+
outputCredentials(config.cachedSession);
|
|
172
|
+
process.exit(0);
|
|
173
|
+
}
|
|
174
|
+
// Expired — clear the cached session
|
|
175
|
+
saveConfig({ ...config, cachedSession: undefined });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 2. If auto-mfa is enabled, try to obtain credentials without dialog
|
|
179
|
+
if (useAutoMfa) {
|
|
180
|
+
const session = await tryAutoMfa(defaults);
|
|
181
|
+
if (session) {
|
|
182
|
+
outputCredentials(session);
|
|
183
|
+
process.exit(0);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 3. If single-instance-lock is enabled, acquire lock before showing dialog
|
|
188
|
+
if (useLock) {
|
|
189
|
+
if (!tryAcquireLock()) {
|
|
190
|
+
// Another instance holds the lock — wait for it
|
|
191
|
+
process.stderr.write("Waiting for another instance to finish...\n");
|
|
192
|
+
await waitForLock();
|
|
193
|
+
|
|
194
|
+
// After lock releases, re-check cached session (another instance may have refreshed it)
|
|
195
|
+
if (useCache) {
|
|
196
|
+
const freshConfig = loadConfig();
|
|
197
|
+
if (freshConfig?.cachedSession && isSessionValid(freshConfig.cachedSession)) {
|
|
198
|
+
outputCredentials(freshConfig.cachedSession);
|
|
199
|
+
process.exit(0);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Still need credentials — acquire lock and show dialog
|
|
204
|
+
if (!tryAcquireLock()) {
|
|
205
|
+
// Edge case: another waiter grabbed the lock first — wait again
|
|
206
|
+
await waitForLock();
|
|
207
|
+
if (useCache) {
|
|
208
|
+
const freshConfig = loadConfig();
|
|
209
|
+
if (freshConfig?.cachedSession && isSessionValid(freshConfig.cachedSession)) {
|
|
210
|
+
outputCredentials(freshConfig.cachedSession);
|
|
211
|
+
process.exit(0);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// Final attempt to acquire — proceed regardless
|
|
215
|
+
tryAcquireLock();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
await obtainViaDialog(defaults);
|
|
221
|
+
} finally {
|
|
222
|
+
releaseLock();
|
|
223
|
+
}
|
|
224
|
+
} else {
|
|
225
|
+
// No lock — just show dialog directly
|
|
226
|
+
await obtainViaDialog(defaults);
|
|
227
|
+
}
|
|
69
228
|
} catch (err) {
|
|
229
|
+
if (useLock) releaseLock();
|
|
70
230
|
// Mask any AWS access-key IDs or secret keys that may appear in error messages
|
|
71
231
|
const raw = String(err);
|
|
72
232
|
const masked = raw
|
package/src/sts.ts
CHANGED
|
@@ -47,6 +47,7 @@ export async function assumeRoleWithMfa(params: {
|
|
|
47
47
|
AccessKeyId: c!.AccessKeyId!,
|
|
48
48
|
SecretAccessKey: c!.SecretAccessKey!,
|
|
49
49
|
SessionToken: c!.SessionToken!,
|
|
50
|
+
Expiration: c!.Expiration ? c!.Expiration.toISOString() : new Date(Date.now() + ladder[i] * 1000).toISOString(),
|
|
50
51
|
},
|
|
51
52
|
duration: ladder[i],
|
|
52
53
|
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { parseFlags } from "../src/flags";
|
|
3
|
+
|
|
4
|
+
describe("parseFlags", () => {
|
|
5
|
+
// argv[0] is the runtime, argv[1] is the script — flags start at index 2
|
|
6
|
+
const base = ["/usr/bin/bun", "/path/to/index.ts"];
|
|
7
|
+
|
|
8
|
+
test("returns defaults when no flags", () => {
|
|
9
|
+
const flags = parseFlags(base);
|
|
10
|
+
expect(flags.setup).toBe(false);
|
|
11
|
+
expect(flags.cacheSession).toBeUndefined();
|
|
12
|
+
expect(flags.autoMfa).toBeUndefined();
|
|
13
|
+
expect(flags.singleInstanceLock).toBeUndefined();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("parses --setup", () => {
|
|
17
|
+
expect(parseFlags([...base, "--setup"]).setup).toBe(true);
|
|
18
|
+
expect(parseFlags([...base, "setup"]).setup).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("parses --cache-session and --no-cache-session", () => {
|
|
22
|
+
expect(parseFlags([...base, "--cache-session"]).cacheSession).toBe(true);
|
|
23
|
+
expect(parseFlags([...base, "--no-cache-session"]).cacheSession).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("parses --auto-mfa and --no-auto-mfa", () => {
|
|
27
|
+
expect(parseFlags([...base, "--auto-mfa"]).autoMfa).toBe(true);
|
|
28
|
+
expect(parseFlags([...base, "--no-auto-mfa"]).autoMfa).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("parses --single-instance-lock and --no-single-instance-lock", () => {
|
|
32
|
+
expect(parseFlags([...base, "--single-instance-lock"]).singleInstanceLock).toBe(true);
|
|
33
|
+
expect(parseFlags([...base, "--no-single-instance-lock"]).singleInstanceLock).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("last flag wins for conflicting flags", () => {
|
|
37
|
+
const flags = parseFlags([...base, "--cache-session", "--no-cache-session"]);
|
|
38
|
+
expect(flags.cacheSession).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("parses multiple flags together", () => {
|
|
42
|
+
const flags = parseFlags([...base, "--cache-session", "--auto-mfa", "--single-instance-lock"]);
|
|
43
|
+
expect(flags.cacheSession).toBe(true);
|
|
44
|
+
expect(flags.autoMfa).toBe(true);
|
|
45
|
+
expect(flags.singleInstanceLock).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("ignores unknown flags", () => {
|
|
49
|
+
const flags = parseFlags([...base, "--unknown-flag", "--cache-session"]);
|
|
50
|
+
expect(flags.cacheSession).toBe(true);
|
|
51
|
+
expect(flags.setup).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, test, expect, afterEach } from "bun:test";
|
|
2
|
+
import { tryAcquireLock, releaseLock, waitForLock } from "../src/config";
|
|
3
|
+
|
|
4
|
+
describe("single-instance lock", () => {
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
releaseLock();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("tryAcquireLock succeeds on first call", () => {
|
|
10
|
+
expect(tryAcquireLock()).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("tryAcquireLock fails when lock is already held", () => {
|
|
14
|
+
expect(tryAcquireLock()).toBe(true);
|
|
15
|
+
expect(tryAcquireLock()).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("releaseLock allows re-acquisition", () => {
|
|
19
|
+
expect(tryAcquireLock()).toBe(true);
|
|
20
|
+
releaseLock();
|
|
21
|
+
expect(tryAcquireLock()).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("releaseLock is safe to call when no lock exists", () => {
|
|
25
|
+
releaseLock(); // should not throw
|
|
26
|
+
releaseLock(); // double release should not throw
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("waitForLock resolves immediately when no lock exists", async () => {
|
|
30
|
+
const start = Date.now();
|
|
31
|
+
await waitForLock();
|
|
32
|
+
expect(Date.now() - start).toBeLessThan(100);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("waitForLock resolves after lock is released", async () => {
|
|
36
|
+
expect(tryAcquireLock()).toBe(true);
|
|
37
|
+
|
|
38
|
+
// Release the lock after a short delay
|
|
39
|
+
setTimeout(() => releaseLock(), 200);
|
|
40
|
+
|
|
41
|
+
const start = Date.now();
|
|
42
|
+
await waitForLock();
|
|
43
|
+
const elapsed = Date.now() - start;
|
|
44
|
+
// Should resolve within ~200ms + poll interval (500ms)
|
|
45
|
+
expect(elapsed).toBeLessThan(1000);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import type { CachedSession, Config } from "../src/config";
|
|
3
|
+
|
|
4
|
+
describe("session cache validation", () => {
|
|
5
|
+
function isSessionValid(session: CachedSession | undefined): session is CachedSession {
|
|
6
|
+
if (!session) return false;
|
|
7
|
+
return new Date(session.Expiration).getTime() > Date.now();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const validSession: CachedSession = {
|
|
11
|
+
AccessKeyId: "ASIATEMP",
|
|
12
|
+
SecretAccessKey: "TempSecret",
|
|
13
|
+
SessionToken: "TempToken",
|
|
14
|
+
Expiration: new Date(Date.now() + 3600_000).toISOString(), // 1 hour from now
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const expiredSession: CachedSession = {
|
|
18
|
+
AccessKeyId: "ASIATEMP",
|
|
19
|
+
SecretAccessKey: "TempSecret",
|
|
20
|
+
SessionToken: "TempToken",
|
|
21
|
+
Expiration: new Date(Date.now() - 1000).toISOString(), // 1 second ago
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
test("returns true for unexpired session", () => {
|
|
25
|
+
expect(isSessionValid(validSession)).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("returns false for expired session", () => {
|
|
29
|
+
expect(isSessionValid(expiredSession)).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("returns false for undefined session", () => {
|
|
33
|
+
expect(isSessionValid(undefined)).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("cached session round-trips through config JSON", () => {
|
|
37
|
+
const config: Config = {
|
|
38
|
+
region: "us-west-2",
|
|
39
|
+
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
|
|
40
|
+
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
|
41
|
+
mfaArn: "arn:aws:iam::123456789012:mfa/user",
|
|
42
|
+
roleArn: "arn:aws:iam::123456789012:role/TestRole",
|
|
43
|
+
duration: 7200,
|
|
44
|
+
cacheSession: true,
|
|
45
|
+
cachedSession: validSession,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const json = JSON.stringify(config, null, 2);
|
|
49
|
+
const parsed = JSON.parse(json) as Config;
|
|
50
|
+
expect(parsed.cachedSession).toEqual(validSession);
|
|
51
|
+
expect(parsed.cacheSession).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("config without cached session is valid", () => {
|
|
55
|
+
const config: Config = {
|
|
56
|
+
region: "us-west-2",
|
|
57
|
+
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
|
|
58
|
+
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
|
59
|
+
mfaArn: "arn:aws:iam::123456789012:mfa/user",
|
|
60
|
+
roleArn: "arn:aws:iam::123456789012:role/TestRole",
|
|
61
|
+
duration: 7200,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const json = JSON.stringify(config, null, 2);
|
|
65
|
+
const parsed = JSON.parse(json) as Config;
|
|
66
|
+
expect(parsed.cachedSession).toBeUndefined();
|
|
67
|
+
expect(parsed.cacheSession).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("feature flags round-trip through config JSON", () => {
|
|
71
|
+
const config: Config = {
|
|
72
|
+
region: "us-west-2",
|
|
73
|
+
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
|
|
74
|
+
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
|
75
|
+
mfaArn: "arn:aws:iam::123456789012:mfa/user",
|
|
76
|
+
roleArn: "arn:aws:iam::123456789012:role/TestRole",
|
|
77
|
+
duration: 7200,
|
|
78
|
+
cacheSession: true,
|
|
79
|
+
autoMfa: true,
|
|
80
|
+
singleInstanceLock: false,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const json = JSON.stringify(config, null, 2);
|
|
84
|
+
const parsed = JSON.parse(json) as Config;
|
|
85
|
+
expect(parsed.cacheSession).toBe(true);
|
|
86
|
+
expect(parsed.autoMfa).toBe(true);
|
|
87
|
+
expect(parsed.singleInstanceLock).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
});
|
package/test/sts.test.ts
CHANGED
|
@@ -37,6 +37,7 @@ const MOCK_CREDENTIALS = {
|
|
|
37
37
|
AccessKeyId: "ASIATEMP",
|
|
38
38
|
SecretAccessKey: "TempSecret",
|
|
39
39
|
SessionToken: "TempToken",
|
|
40
|
+
Expiration: new Date("2099-01-01T00:00:00Z"),
|
|
40
41
|
},
|
|
41
42
|
};
|
|
42
43
|
|
|
@@ -98,6 +99,7 @@ describe("assumeRoleWithMfa", () => {
|
|
|
98
99
|
AccessKeyId: "ASIATEMP",
|
|
99
100
|
SecretAccessKey: "TempSecret",
|
|
100
101
|
SessionToken: "TempToken",
|
|
102
|
+
Expiration: "2099-01-01T00:00:00.000Z",
|
|
101
103
|
});
|
|
102
104
|
expect(result.duration).toBe(43200);
|
|
103
105
|
});
|