Skip to content

Commit 45bd1f7

Browse files
committed
include the nylas connect header
1 parent f01da14 commit 45bd1f7

File tree

2 files changed

+145
-80
lines changed

2 files changed

+145
-80
lines changed

packages/nylas-connect/src/connect-client.test.ts

Lines changed: 111 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, it, expect, beforeEach, vi } from "vitest";
2+
import pkg from "../package.json";
23
import { NylasConnect } from "./connect-client";
34
import { logger } from "./utils/logger";
45
import { LogLevel } from "./types";
@@ -108,6 +109,40 @@ describe("NylasConnect (fundamentals)", () => {
108109
expect(localStorage.getItem("@nylas/connect:token_default")).toBeTruthy();
109110
});
110111

112+
it("sends x-nylas-connect header on token exchange", async () => {
113+
const auth = new NylasConnect({
114+
clientId,
115+
redirectUri,
116+
apiUrl: "https://api.us.nylas.com",
117+
});
118+
119+
await auth.connect();
120+
121+
// Minimal successful token response
122+
const header = base64url({ alg: "none", typ: "JWT" });
123+
const payload = base64url({ sub: "u" });
124+
const idToken = `${header}.${payload}.sig`;
125+
126+
const mockFetch = vi.fn().mockResolvedValue({
127+
ok: true,
128+
json: async () => ({
129+
access_token: "a",
130+
id_token: idToken,
131+
grant_id: "g",
132+
expires_in: 3600,
133+
scope: "s",
134+
}),
135+
});
136+
vi.stubGlobal("fetch", mockFetch);
137+
138+
await auth.handleRedirectCallback(
139+
`${redirectUri}?code=auth_code_1&state=stateXYZ`,
140+
);
141+
142+
const lastCall = mockFetch.mock.calls.at(-1);
143+
expect(lastCall[1].headers["x-nylas-connect"]).toBe(pkg.version);
144+
});
145+
111146
it("logout(grantId) removes the specific session and emits SIGNED_OUT", async () => {
112147
const auth = new NylasConnect({
113148
clientId,
@@ -718,6 +753,9 @@ describe("NylasConnect (sessions, validation, and events)", () => {
718753

719754
const status = await auth.getConnectionStatus();
720755
expect(status).toBe("connected");
756+
const lastCall = (fetch as any).mock.calls.at(-1);
757+
expect(lastCall[1]).toBeDefined();
758+
expect(lastCall[1].headers["x-nylas-connect"]).toBe(pkg.version);
721759
const emitted = spy.mock.calls.map((c) => c[0]);
722760
expect(emitted).not.toContain("CONNECTION_STATUS_CHANGED");
723761
});
@@ -754,6 +792,8 @@ describe("NylasConnect (sessions, validation, and events)", () => {
754792

755793
const status = await auth.getConnectionStatus();
756794
expect(status).toBe("invalid");
795+
const lastCall = (fetch as any).mock.calls.at(-1);
796+
expect(lastCall[1].headers["x-nylas-connect"]).toBe(pkg.version);
757797
const emitted = spy.mock.calls.map((c) => c[0]);
758798
expect(emitted).not.toContain("CONNECTION_STATUS_CHANGED");
759799
});
@@ -835,6 +875,10 @@ describe("NylasConnect (custom code exchange)", () => {
835875
expect(result.accessToken).toBe("custom_access_token");
836876
expect(result.grantId).toBe("custom_grant_123");
837877

878+
// Verify header present on token validation call
879+
const lastCallCustom = (fetch as any).mock.calls.at(-1);
880+
expect(lastCallCustom[1].headers["x-nylas-connect"]).toBe(pkg.version);
881+
838882
// Verify events were emitted
839883
const events = spy.mock.calls.map((call) => call[0]);
840884
expect(events).toContain("CONNECT_SUCCESS");
@@ -1163,13 +1207,11 @@ describe("NylasConnect (custom code exchange)", () => {
11631207
// Verify built-in exchange was used
11641208
expect(result.accessToken).toBe("builtin_access_token");
11651209
expect(result.grantId).toBe("builtin_grant_123");
1166-
expect(fetch).toHaveBeenCalledWith(
1167-
"https://api.us.nylas.com/v3/connect/token",
1168-
expect.objectContaining({
1169-
method: "POST",
1170-
headers: { "Content-Type": "application/json" },
1171-
}),
1172-
);
1210+
const lastCall = (fetch as any).mock.calls.at(-1);
1211+
expect(lastCall[0]).toBe("https://api.us.nylas.com/v3/connect/token");
1212+
expect(lastCall[1].method).toBe("POST");
1213+
expect(lastCall[1].headers["Content-Type"]).toBe("application/json");
1214+
expect(lastCall[1].headers["x-nylas-connect"]).toBe(pkg.version);
11731215
});
11741216

11751217
function createValidIdToken(): string {
@@ -1245,21 +1287,21 @@ describe("NylasConnect (Identity Provider Token)", () => {
12451287
expect(mockIdentityProviderToken).toHaveBeenCalledTimes(1);
12461288

12471289
// Verify the fetch was called with JSON content type and idp_claims
1248-
expect(mockFetch).toHaveBeenCalledWith(
1290+
const lastCallInclude = mockFetch.mock.calls.at(-1);
1291+
expect(lastCallInclude[0]).toBe(
12491292
"https://api.us.nylas.com/v3/connect/token",
1250-
expect.objectContaining({
1251-
method: "POST",
1252-
headers: {
1253-
"Content-Type": "application/json",
1254-
},
1255-
body: JSON.stringify({
1256-
client_id: clientId,
1257-
redirect_uri: redirectUri,
1258-
code: "auth_code_1",
1259-
grant_type: "authorization_code",
1260-
code_verifier: "verifier123",
1261-
idp_claims: mockIdpToken,
1262-
}),
1293+
);
1294+
expect(lastCallInclude[1].method).toBe("POST");
1295+
expect(lastCallInclude[1].headers["Content-Type"]).toBe("application/json");
1296+
expect(lastCallInclude[1].headers["x-nylas-connect"]).toBe(pkg.version);
1297+
expect(lastCallInclude[1].body).toBe(
1298+
JSON.stringify({
1299+
client_id: clientId,
1300+
redirect_uri: redirectUri,
1301+
code: "auth_code_1",
1302+
grant_type: "authorization_code",
1303+
code_verifier: "verifier123",
1304+
idp_claims: mockIdpToken,
12631305
}),
12641306
);
12651307

@@ -1310,21 +1352,19 @@ describe("NylasConnect (Identity Provider Token)", () => {
13101352
);
13111353

13121354
// Verify the fetch was called with JSON format but no idp_claims
1313-
expect(mockFetch).toHaveBeenCalledWith(
1314-
"https://api.us.nylas.com/v3/connect/token",
1315-
expect.objectContaining({
1316-
method: "POST",
1317-
headers: {
1318-
"Content-Type": "application/json",
1319-
},
1320-
body: JSON.stringify({
1321-
client_id: clientId,
1322-
redirect_uri: redirectUri,
1323-
code: "auth_code_1",
1324-
grant_type: "authorization_code",
1325-
code_verifier: "verifier123",
1326-
// No idp_claims field
1327-
}),
1355+
const lastCallNull = mockFetch.mock.calls.at(-1);
1356+
expect(lastCallNull[0]).toBe("https://api.us.nylas.com/v3/connect/token");
1357+
expect(lastCallNull[1].method).toBe("POST");
1358+
expect(lastCallNull[1].headers["Content-Type"]).toBe("application/json");
1359+
expect(lastCallNull[1].headers["x-nylas-connect"]).toBe(pkg.version);
1360+
expect(lastCallNull[1].body).toBe(
1361+
JSON.stringify({
1362+
client_id: clientId,
1363+
redirect_uri: redirectUri,
1364+
code: "auth_code_1",
1365+
grant_type: "authorization_code",
1366+
code_verifier: "verifier123",
1367+
// No idp_claims field
13281368
}),
13291369
);
13301370

@@ -1378,17 +1418,17 @@ describe("NylasConnect (Identity Provider Token)", () => {
13781418
expect(mockIdentityProviderToken).toHaveBeenCalledTimes(1);
13791419

13801420
// Verify the fetch was called without idp_claims (empty string is falsy)
1381-
expect(mockFetch).toHaveBeenCalledWith(
1382-
"https://api.us.nylas.com/v3/connect/token",
1383-
expect.objectContaining({
1384-
body: JSON.stringify({
1385-
client_id: clientId,
1386-
redirect_uri: redirectUri,
1387-
code: "auth_code_1",
1388-
grant_type: "authorization_code",
1389-
code_verifier: "verifier123",
1390-
// No idp_claims field should be present for empty string
1391-
}),
1421+
const lastCallEmpty = mockFetch.mock.calls.at(-1);
1422+
expect(lastCallEmpty[0]).toBe("https://api.us.nylas.com/v3/connect/token");
1423+
expect(lastCallEmpty[1].headers["x-nylas-connect"]).toBe(pkg.version);
1424+
expect(lastCallEmpty[1].body).toBe(
1425+
JSON.stringify({
1426+
client_id: clientId,
1427+
redirect_uri: redirectUri,
1428+
code: "auth_code_1",
1429+
grant_type: "authorization_code",
1430+
code_verifier: "verifier123",
1431+
// No idp_claims field should be present for empty string
13921432
}),
13931433
);
13941434
});
@@ -1433,21 +1473,19 @@ describe("NylasConnect (Identity Provider Token)", () => {
14331473
);
14341474

14351475
// Verify the fetch was called with JSON format but no idp_claims
1436-
expect(mockFetch).toHaveBeenCalledWith(
1437-
"https://api.us.nylas.com/v3/connect/token",
1438-
expect.objectContaining({
1439-
method: "POST",
1440-
headers: {
1441-
"Content-Type": "application/json",
1442-
},
1443-
body: JSON.stringify({
1444-
client_id: clientId,
1445-
redirect_uri: redirectUri,
1446-
code: "auth_code_1",
1447-
grant_type: "authorization_code",
1448-
code_verifier: "verifier123",
1449-
// No idp_claims field
1450-
}),
1476+
const lastCallNoCb = mockFetch.mock.calls.at(-1);
1477+
expect(lastCallNoCb[0]).toBe("https://api.us.nylas.com/v3/connect/token");
1478+
expect(lastCallNoCb[1].method).toBe("POST");
1479+
expect(lastCallNoCb[1].headers["Content-Type"]).toBe("application/json");
1480+
expect(lastCallNoCb[1].headers["x-nylas-connect"]).toBe(pkg.version);
1481+
expect(lastCallNoCb[1].body).toBe(
1482+
JSON.stringify({
1483+
client_id: clientId,
1484+
redirect_uri: redirectUri,
1485+
code: "auth_code_1",
1486+
grant_type: "authorization_code",
1487+
code_verifier: "verifier123",
1488+
// No idp_claims field
14511489
}),
14521490
);
14531491

@@ -1502,17 +1540,17 @@ describe("NylasConnect (Identity Provider Token)", () => {
15021540
expect(mockIdentityProviderToken).toHaveBeenCalledTimes(1);
15031541

15041542
// Verify the fetch was called with the sync token
1505-
expect(mockFetch).toHaveBeenCalledWith(
1506-
"https://api.us.nylas.com/v3/connect/token",
1507-
expect.objectContaining({
1508-
body: JSON.stringify({
1509-
client_id: clientId,
1510-
redirect_uri: redirectUri,
1511-
code: "auth_code_1",
1512-
grant_type: "authorization_code",
1513-
code_verifier: "verifier123",
1514-
idp_claims: mockIdpToken,
1515-
}),
1543+
const lastCallSync = mockFetch.mock.calls.at(-1);
1544+
expect(lastCallSync[0]).toBe("https://api.us.nylas.com/v3/connect/token");
1545+
expect(lastCallSync[1].headers["x-nylas-connect"]).toBe(pkg.version);
1546+
expect(lastCallSync[1].body).toBe(
1547+
JSON.stringify({
1548+
client_id: clientId,
1549+
redirect_uri: redirectUri,
1550+
code: "auth_code_1",
1551+
grant_type: "authorization_code",
1552+
code_verifier: "verifier123",
1553+
idp_claims: mockIdpToken,
15161554
}),
15171555
);
15181556
});

packages/nylas-connect/src/connect-client.ts

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
cleanUrl,
3737
isConnectCallback,
3838
} from "./utils/redirect";
39+
import pkg from "../package.json";
3940

4041
/**
4142
* Modern Nylas authentication client
@@ -56,6 +57,10 @@ export class NylasConnect {
5657
lastCleanup: Date.now(),
5758
};
5859

60+
// Header constants
61+
private static readonly NYLAS_CONNECT_VERSION: string = pkg.version;
62+
private static readonly NYLAS_CONNECT_HEADER = "x-nylas-connect" as const;
63+
5964
constructor(config: ConnectConfig = {}) {
6065
// Resolve configuration with environment variables and defaults
6166
const resolvedConfig = this.resolveConfig(config);
@@ -719,7 +724,7 @@ export class NylasConnect {
719724
}
720725

721726
try {
722-
const response = await fetch(
727+
const response = await this.apiClient(
723728
`${this.config.apiUrl}/connect/tokeninfo?access_token=${encodeURIComponent(accessToken)}`,
724729
);
725730

@@ -1022,13 +1027,16 @@ export class NylasConnect {
10221027
}
10231028

10241029
try {
1025-
const response = await fetch(`${this.config.apiUrl}/connect/token`, {
1026-
method: "POST",
1027-
headers: {
1028-
"Content-Type": "application/json",
1030+
const response = await this.apiClient(
1031+
`${this.config.apiUrl}/connect/token`,
1032+
{
1033+
method: "POST",
1034+
headers: {
1035+
"Content-Type": "application/json",
1036+
},
1037+
body: JSON.stringify(payload),
10291038
},
1030-
body: JSON.stringify(payload),
1031-
});
1039+
);
10321040

10331041
if (!response.ok) {
10341042
const errorData = await response.json().catch(() => ({}));
@@ -1196,4 +1204,23 @@ export class NylasConnect {
11961204
private authStateKey(): string {
11971205
return `nylas_auth_state_${this.config.clientId}`;
11981206
}
1207+
1208+
/**
1209+
* Internal API client to ensure common headers are sent with every request
1210+
*/
1211+
private apiClient(
1212+
input: RequestInfo | URL,
1213+
init: RequestInit = {},
1214+
): Promise<Response> {
1215+
const headerName = NylasConnect.NYLAS_CONNECT_HEADER;
1216+
const headerValue = NylasConnect.NYLAS_CONNECT_VERSION;
1217+
1218+
const existingHeaders =
1219+
(init.headers as Record<string, string> | undefined) || {};
1220+
1221+
return fetch(input as RequestInfo, {
1222+
...init,
1223+
headers: { ...existingHeaders, [headerName]: headerValue },
1224+
});
1225+
}
11991226
}

0 commit comments

Comments
 (0)