Skip to content

Commit 99a1106

Browse files
authored
include the nylas connect header (#21)
1 parent 0ba7885 commit 99a1106

File tree

2 files changed

+156
-80
lines changed

2 files changed

+156
-80
lines changed

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

Lines changed: 114 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,13 @@ describe("NylasConnect (custom code exchange)", () => {
835875
expect(result.accessToken).toBe("custom_access_token");
836876
expect(result.grantId).toBe("custom_grant_123");
837877

878+
// Trigger a token validation request so we can assert headers
879+
await auth.getConnectionStatus();
880+
881+
// Verify header present on token validation call
882+
const lastCallCustom = (fetch as any).mock.calls.at(-1);
883+
expect(lastCallCustom[1].headers["x-nylas-connect"]).toBe(pkg.version);
884+
838885
// Verify events were emitted
839886
const events = spy.mock.calls.map((call) => call[0]);
840887
expect(events).toContain("CONNECT_SUCCESS");
@@ -1163,13 +1210,11 @@ describe("NylasConnect (custom code exchange)", () => {
11631210
// Verify built-in exchange was used
11641211
expect(result.accessToken).toBe("builtin_access_token");
11651212
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-
);
1213+
const lastCall = (fetch as any).mock.calls.at(-1);
1214+
expect(lastCall[0]).toBe("https://api.us.nylas.com/v3/connect/token");
1215+
expect(lastCall[1].method).toBe("POST");
1216+
expect(lastCall[1].headers["Content-Type"]).toBe("application/json");
1217+
expect(lastCall[1].headers["x-nylas-connect"]).toBe(pkg.version);
11731218
});
11741219

11751220
function createValidIdToken(): string {
@@ -1245,21 +1290,21 @@ describe("NylasConnect (Identity Provider Token)", () => {
12451290
expect(mockIdentityProviderToken).toHaveBeenCalledTimes(1);
12461291

12471292
// Verify the fetch was called with JSON content type and idp_claims
1248-
expect(mockFetch).toHaveBeenCalledWith(
1293+
const lastCallInclude = mockFetch.mock.calls.at(-1);
1294+
expect(lastCallInclude[0]).toBe(
12491295
"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-
}),
1296+
);
1297+
expect(lastCallInclude[1].method).toBe("POST");
1298+
expect(lastCallInclude[1].headers["Content-Type"]).toBe("application/json");
1299+
expect(lastCallInclude[1].headers["x-nylas-connect"]).toBe(pkg.version);
1300+
expect(lastCallInclude[1].body).toBe(
1301+
JSON.stringify({
1302+
client_id: clientId,
1303+
redirect_uri: redirectUri,
1304+
code: "auth_code_1",
1305+
grant_type: "authorization_code",
1306+
code_verifier: "verifier123",
1307+
idp_claims: mockIdpToken,
12631308
}),
12641309
);
12651310

@@ -1310,21 +1355,19 @@ describe("NylasConnect (Identity Provider Token)", () => {
13101355
);
13111356

13121357
// 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-
}),
1358+
const lastCallNull = mockFetch.mock.calls.at(-1);
1359+
expect(lastCallNull[0]).toBe("https://api.us.nylas.com/v3/connect/token");
1360+
expect(lastCallNull[1].method).toBe("POST");
1361+
expect(lastCallNull[1].headers["Content-Type"]).toBe("application/json");
1362+
expect(lastCallNull[1].headers["x-nylas-connect"]).toBe(pkg.version);
1363+
expect(lastCallNull[1].body).toBe(
1364+
JSON.stringify({
1365+
client_id: clientId,
1366+
redirect_uri: redirectUri,
1367+
code: "auth_code_1",
1368+
grant_type: "authorization_code",
1369+
code_verifier: "verifier123",
1370+
// No idp_claims field
13281371
}),
13291372
);
13301373

@@ -1378,17 +1421,17 @@ describe("NylasConnect (Identity Provider Token)", () => {
13781421
expect(mockIdentityProviderToken).toHaveBeenCalledTimes(1);
13791422

13801423
// 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-
}),
1424+
const lastCallEmpty = mockFetch.mock.calls.at(-1);
1425+
expect(lastCallEmpty[0]).toBe("https://api.us.nylas.com/v3/connect/token");
1426+
expect(lastCallEmpty[1].headers["x-nylas-connect"]).toBe(pkg.version);
1427+
expect(lastCallEmpty[1].body).toBe(
1428+
JSON.stringify({
1429+
client_id: clientId,
1430+
redirect_uri: redirectUri,
1431+
code: "auth_code_1",
1432+
grant_type: "authorization_code",
1433+
code_verifier: "verifier123",
1434+
// No idp_claims field should be present for empty string
13921435
}),
13931436
);
13941437
});
@@ -1433,21 +1476,19 @@ describe("NylasConnect (Identity Provider Token)", () => {
14331476
);
14341477

14351478
// 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-
}),
1479+
const lastCallNoCb = mockFetch.mock.calls.at(-1);
1480+
expect(lastCallNoCb[0]).toBe("https://api.us.nylas.com/v3/connect/token");
1481+
expect(lastCallNoCb[1].method).toBe("POST");
1482+
expect(lastCallNoCb[1].headers["Content-Type"]).toBe("application/json");
1483+
expect(lastCallNoCb[1].headers["x-nylas-connect"]).toBe(pkg.version);
1484+
expect(lastCallNoCb[1].body).toBe(
1485+
JSON.stringify({
1486+
client_id: clientId,
1487+
redirect_uri: redirectUri,
1488+
code: "auth_code_1",
1489+
grant_type: "authorization_code",
1490+
code_verifier: "verifier123",
1491+
// No idp_claims field
14511492
}),
14521493
);
14531494

@@ -1502,17 +1543,17 @@ describe("NylasConnect (Identity Provider Token)", () => {
15021543
expect(mockIdentityProviderToken).toHaveBeenCalledTimes(1);
15031544

15041545
// 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-
}),
1546+
const lastCallSync = mockFetch.mock.calls.at(-1);
1547+
expect(lastCallSync[0]).toBe("https://api.us.nylas.com/v3/connect/token");
1548+
expect(lastCallSync[1].headers["x-nylas-connect"]).toBe(pkg.version);
1549+
expect(lastCallSync[1].body).toBe(
1550+
JSON.stringify({
1551+
client_id: clientId,
1552+
redirect_uri: redirectUri,
1553+
code: "auth_code_1",
1554+
grant_type: "authorization_code",
1555+
code_verifier: "verifier123",
1556+
idp_claims: mockIdpToken,
15161557
}),
15171558
);
15181559
});

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

Lines changed: 42 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,12 @@ 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+
private static readonly NYLAS_APPLICATION_ID_HEADER =
64+
"x-nylas-application-id" as const;
65+
5966
constructor(config: ConnectConfig = {}) {
6067
// Resolve configuration with environment variables and defaults
6168
const resolvedConfig = this.resolveConfig(config);
@@ -719,7 +726,7 @@ export class NylasConnect {
719726
}
720727

721728
try {
722-
const response = await fetch(
729+
const response = await this.apiClient(
723730
`${this.config.apiUrl}/connect/tokeninfo?access_token=${encodeURIComponent(accessToken)}`,
724731
);
725732

@@ -1022,13 +1029,16 @@ export class NylasConnect {
10221029
}
10231030

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

10331043
if (!response.ok) {
10341044
const errorData = await response.json().catch(() => ({}));
@@ -1196,4 +1206,29 @@ export class NylasConnect {
11961206
private authStateKey(): string {
11971207
return `nylas_auth_state_${this.config.clientId}`;
11981208
}
1209+
1210+
/**
1211+
* Internal API client to ensure common headers are sent with every request
1212+
*/
1213+
private apiClient(
1214+
input: RequestInfo | URL,
1215+
init: RequestInit = {},
1216+
): Promise<Response> {
1217+
const connectHeader = NylasConnect.NYLAS_CONNECT_HEADER;
1218+
const connectVersion = NylasConnect.NYLAS_CONNECT_VERSION;
1219+
const appIdHeader = NylasConnect.NYLAS_APPLICATION_ID_HEADER;
1220+
const appId = this.config.clientId;
1221+
1222+
const existingHeaders =
1223+
(init.headers as Record<string, string> | undefined) || {};
1224+
1225+
return fetch(input as RequestInfo, {
1226+
...init,
1227+
headers: {
1228+
...existingHeaders,
1229+
[connectHeader]: connectVersion,
1230+
[appIdHeader]: appId,
1231+
},
1232+
});
1233+
}
11991234
}

0 commit comments

Comments
 (0)