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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-aws-mfa",
3
- "version": "1.0.0",
3
+ "version": "1.1.0-rc.1",
4
4
  "type": "module",
5
5
  "repository": "https://github.com/p120ph37/claude-aws-mfa",
6
6
  "bin": {
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
- const arg = process.argv[2];
19
- if (arg === "setup" || arg === "--setup") {
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 { loadConfig, saveConfig, seedDefaults } from "./config";
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
- const defaults = loadConfig() ?? await seedDefaults();
32
+ // --- Resolve effective flags (CLI overrides config defaults) ---
29
33
 
30
- const result = showDialog(defaults);
31
- if (!result) {
32
- process.stderr.write("User cancelled dialog.\n");
33
- process.exit(2);
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
- try {
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
- console.log(JSON.stringify({ Credentials: credentials }));
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
  });