@indigoai-us/hq-cloud 5.1.9 → 5.1.10

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.
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Unit tests for cognito-auth.ts — focus on the `expiresAt` shape contract.
3
3
  *
4
- * Canonical on-disk shape is ISO 8601 (what both writers emit). The reader
5
- * also tolerates a raw number (ms since epoch) for forward/backward compat
6
- * during rollouts, and fails safe on anything unparseable.
4
+ * Canonical on-disk shape is epoch milliseconds (number). The reader also
5
+ * tolerates ISO 8601 strings for backward compatibility with pre-migration
6
+ * token files, and fails safe on anything unparseable.
7
7
  */
8
8
 
9
9
  import * as fs from "fs";
@@ -100,11 +100,21 @@ describe("isExpiring — expiresAt shape tolerance", () => {
100
100
  });
101
101
 
102
102
  // ---------------------------------------------------------------------------
103
- // Round-trip: writers emit ISO, readers read ISO
103
+ // Round-trip: writers emit epoch-ms, readers read epoch-ms
104
104
  // ---------------------------------------------------------------------------
105
105
 
106
106
  describe("expiresAt shape round-trip", () => {
107
- it("saveCachedTokens + loadCachedTokens preserves ISO string shape", async () => {
107
+ it("saveCachedTokens + loadCachedTokens preserves epoch-ms number shape", async () => {
108
+ const { saveCachedTokens, loadCachedTokens } = await importModule();
109
+ const epochMs = Date.now() + 3600 * 1000;
110
+ saveCachedTokens({ ...baseTokens, expiresAt: epochMs });
111
+ const loaded = loadCachedTokens();
112
+ expect(loaded).not.toBeNull();
113
+ expect(typeof loaded?.expiresAt).toBe("number");
114
+ expect(loaded?.expiresAt).toBe(epochMs);
115
+ });
116
+
117
+ it("saveCachedTokens + loadCachedTokens tolerates legacy ISO string", async () => {
108
118
  const { saveCachedTokens, loadCachedTokens } = await importModule();
109
119
  const iso = new Date(Date.now() + 3600 * 1000).toISOString();
110
120
  saveCachedTokens({ ...baseTokens, expiresAt: iso });
@@ -112,12 +122,9 @@ describe("expiresAt shape round-trip", () => {
112
122
  expect(loaded).not.toBeNull();
113
123
  expect(typeof loaded?.expiresAt).toBe("string");
114
124
  expect(loaded?.expiresAt).toBe(iso);
115
- expect(loaded?.expiresAt).toMatch(
116
- /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/,
117
- );
118
125
  });
119
126
 
120
- it("refreshTokens writes an ISO string to cache", async () => {
127
+ it("refreshTokens writes epoch milliseconds to cache", async () => {
121
128
  vi.stubGlobal(
122
129
  "fetch",
123
130
  vi.fn(async () =>
@@ -135,6 +142,7 @@ describe("expiresAt shape round-trip", () => {
135
142
  );
136
143
 
137
144
  const { refreshTokens, loadCachedTokens } = await importModule();
145
+ const before = Date.now();
138
146
  const result = await refreshTokens(
139
147
  {
140
148
  region: "us-east-1",
@@ -143,14 +151,14 @@ describe("expiresAt shape round-trip", () => {
143
151
  },
144
152
  "prior-refresh-token",
145
153
  );
154
+ const after = Date.now();
146
155
 
147
- expect(typeof result.expiresAt).toBe("string");
148
- expect(result.expiresAt).toMatch(
149
- /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/,
150
- );
156
+ expect(typeof result.expiresAt).toBe("number");
157
+ expect(result.expiresAt).toBeGreaterThanOrEqual(before + 3600 * 1000);
158
+ expect(result.expiresAt).toBeLessThanOrEqual(after + 3600 * 1000);
151
159
 
152
160
  const onDisk = loadCachedTokens();
153
161
  expect(onDisk?.expiresAt).toBe(result.expiresAt);
154
- expect(typeof onDisk?.expiresAt).toBe("string");
162
+ expect(typeof onDisk?.expiresAt).toBe("number");
155
163
  });
156
164
  });
@@ -38,14 +38,25 @@ export interface CognitoAuthConfig {
38
38
  port?: number;
39
39
  /** OAuth scopes. Defaults to ["openid", "email", "profile"]. */
40
40
  scopes?: string[];
41
+ /**
42
+ * Force a federated IdP (e.g. "Google"). When set, the Hosted UI IdP picker
43
+ * is bypassed and Cognito redirects straight to the provider. When omitted,
44
+ * Cognito shows its default picker.
45
+ */
46
+ identityProvider?: string;
47
+ /**
48
+ * OAuth `prompt` param (e.g. "select_account"). Only meaningful when the IdP
49
+ * honors it — Google uses it to force account re-selection.
50
+ */
51
+ prompt?: string;
41
52
  }
42
53
 
43
54
  export interface CognitoTokens {
44
55
  accessToken: string;
45
56
  idToken: string;
46
57
  refreshToken: string;
47
- /** ISO 8601 timestamp when the access token expires. */
48
- expiresAt: string;
58
+ /** Epoch milliseconds when the access token expires. Writers MUST emit a number. Readers accept ISO 8601 strings for backward compatibility with pre-migration token files. */
59
+ expiresAt: string | number;
49
60
  tokenType: "Bearer";
50
61
  }
51
62
 
@@ -78,7 +89,9 @@ export function saveCachedTokens(tokens: CognitoTokens): void {
78
89
  if (!fs.existsSync(HQ_DIR)) {
79
90
  fs.mkdirSync(HQ_DIR, { recursive: true, mode: 0o700 });
80
91
  }
81
- fs.writeFileSync(TOKEN_FILE, JSON.stringify(tokens, null, 2), { mode: 0o600 });
92
+ const tmpPath = path.join(HQ_DIR, `.cognito-tokens.json.tmp.${process.pid}`);
93
+ fs.writeFileSync(tmpPath, JSON.stringify(tokens, null, 2), { mode: 0o600 });
94
+ fs.renameSync(tmpPath, TOKEN_FILE);
82
95
  }
83
96
 
84
97
  export function clearCachedTokens(): void {
@@ -86,11 +99,10 @@ export function clearCachedTokens(): void {
86
99
  }
87
100
 
88
101
  /**
89
- * Parse `expiresAt` to epoch-ms. Canonical on-disk shape is ISO 8601 (what
90
- * both writers in this file emit), but older/external writers may have left a
91
- * raw number. Accept both so a shape mismatch during rollout doesn't wedge
92
- * sign-in. Returns null for anything unparseable callers should treat that
93
- * as "expired" and force a refresh.
102
+ * Parse `expiresAt` to epoch-ms. Canonical on-disk shape is epoch milliseconds
103
+ * (number). Older token files may contain ISO 8601 strings. Accept both for
104
+ * migration safety. Returns null for anything unparseable callers should
105
+ * treat that as "expired" and force a refresh.
94
106
  */
95
107
  function parseExpiresAtMs(raw: unknown): number | null {
96
108
  if (typeof raw === "number") return Number.isFinite(raw) ? raw : null;
@@ -158,7 +170,9 @@ export async function browserLogin(
158
170
  const { verifier, challenge } = generatePkce();
159
171
  const state = base64UrlEncode(crypto.randomBytes(16));
160
172
 
161
- const authUrl = new URL(`${authBaseUrl(config)}/login`);
173
+ // Use `/oauth2/authorize` (not `/login`) so `identity_provider` + `prompt`
174
+ // are honored. `/login` ignores those params and always shows the IdP picker.
175
+ const authUrl = new URL(`${authBaseUrl(config)}/oauth2/authorize`);
162
176
  authUrl.searchParams.set("client_id", config.clientId);
163
177
  authUrl.searchParams.set("response_type", "code");
164
178
  authUrl.searchParams.set("scope", scopes);
@@ -166,6 +180,12 @@ export async function browserLogin(
166
180
  authUrl.searchParams.set("code_challenge", challenge);
167
181
  authUrl.searchParams.set("code_challenge_method", "S256");
168
182
  authUrl.searchParams.set("state", state);
183
+ if (config.identityProvider) {
184
+ authUrl.searchParams.set("identity_provider", config.identityProvider);
185
+ }
186
+ if (config.prompt) {
187
+ authUrl.searchParams.set("prompt", config.prompt);
188
+ }
169
189
 
170
190
  const code = await waitForAuthCode(port, state);
171
191
  const tokens = await exchangeCodeForTokens(config, code, verifier, port);
@@ -300,7 +320,7 @@ async function exchangeCodeForTokens(
300
320
  accessToken: data.access_token,
301
321
  idToken: data.id_token,
302
322
  refreshToken: data.refresh_token,
303
- expiresAt: new Date(Date.now() + data.expires_in * 1000).toISOString(),
323
+ expiresAt: Date.now() + data.expires_in * 1000,
304
324
  tokenType: "Bearer",
305
325
  };
306
326
  }
@@ -336,7 +356,7 @@ export async function refreshTokens(
336
356
  accessToken: data.access_token,
337
357
  idToken: data.id_token,
338
358
  refreshToken: data.refresh_token ?? currentRefreshToken,
339
- expiresAt: new Date(Date.now() + data.expires_in * 1000).toISOString(),
359
+ expiresAt: Date.now() + data.expires_in * 1000,
340
360
  tokenType: "Bearer",
341
361
  };
342
362
  saveCachedTokens(tokens);
@@ -57,7 +57,7 @@ describe("parseToken", () => {
57
57
  });
58
58
 
59
59
  it("extracts token from https:// URL", () => {
60
- expect(parseToken("https://hq.indigoai.com/accept/tok_xyz")).toBe("tok_xyz");
60
+ expect(parseToken("https://example.com/accept/tok_xyz")).toBe("tok_xyz");
61
61
  });
62
62
 
63
63
  it("returns raw token unchanged", () => {