diff --git a/packages/nylas-connect/src/connect-client.test.ts b/packages/nylas-connect/src/connect-client.test.ts index 23c6909..895ea4c 100644 --- a/packages/nylas-connect/src/connect-client.test.ts +++ b/packages/nylas-connect/src/connect-client.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; +import pkg from "../package.json"; import { NylasConnect } from "./connect-client"; import { logger } from "./utils/logger"; import { LogLevel } from "./types"; @@ -108,6 +109,40 @@ describe("NylasConnect (fundamentals)", () => { expect(localStorage.getItem("@nylas/connect:token_default")).toBeTruthy(); }); + it("sends x-nylas-connect header on token exchange", async () => { + const auth = new NylasConnect({ + clientId, + redirectUri, + apiUrl: "https://api.us.nylas.com", + }); + + await auth.connect(); + + // Minimal successful token response + const header = base64url({ alg: "none", typ: "JWT" }); + const payload = base64url({ sub: "u" }); + const idToken = `${header}.${payload}.sig`; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + access_token: "a", + id_token: idToken, + grant_id: "g", + expires_in: 3600, + scope: "s", + }), + }); + vi.stubGlobal("fetch", mockFetch); + + await auth.handleRedirectCallback( + `${redirectUri}?code=auth_code_1&state=stateXYZ`, + ); + + const lastCall = mockFetch.mock.calls.at(-1); + expect(lastCall[1].headers["x-nylas-connect"]).toBe(pkg.version); + }); + it("logout(grantId) removes the specific session and emits SIGNED_OUT", async () => { const auth = new NylasConnect({ clientId, @@ -718,6 +753,9 @@ describe("NylasConnect (sessions, validation, and events)", () => { const status = await auth.getConnectionStatus(); expect(status).toBe("connected"); + const lastCall = (fetch as any).mock.calls.at(-1); + expect(lastCall[1]).toBeDefined(); + expect(lastCall[1].headers["x-nylas-connect"]).toBe(pkg.version); const emitted = spy.mock.calls.map((c) => c[0]); expect(emitted).not.toContain("CONNECTION_STATUS_CHANGED"); }); @@ -754,6 +792,8 @@ describe("NylasConnect (sessions, validation, and events)", () => { const status = await auth.getConnectionStatus(); expect(status).toBe("invalid"); + const lastCall = (fetch as any).mock.calls.at(-1); + expect(lastCall[1].headers["x-nylas-connect"]).toBe(pkg.version); const emitted = spy.mock.calls.map((c) => c[0]); expect(emitted).not.toContain("CONNECTION_STATUS_CHANGED"); }); @@ -835,6 +875,13 @@ describe("NylasConnect (custom code exchange)", () => { expect(result.accessToken).toBe("custom_access_token"); expect(result.grantId).toBe("custom_grant_123"); + // Trigger a token validation request so we can assert headers + await auth.getConnectionStatus(); + + // Verify header present on token validation call + const lastCallCustom = (fetch as any).mock.calls.at(-1); + expect(lastCallCustom[1].headers["x-nylas-connect"]).toBe(pkg.version); + // Verify events were emitted const events = spy.mock.calls.map((call) => call[0]); expect(events).toContain("CONNECT_SUCCESS"); @@ -1163,13 +1210,11 @@ describe("NylasConnect (custom code exchange)", () => { // Verify built-in exchange was used expect(result.accessToken).toBe("builtin_access_token"); expect(result.grantId).toBe("builtin_grant_123"); - expect(fetch).toHaveBeenCalledWith( - "https://api.us.nylas.com/v3/connect/token", - expect.objectContaining({ - method: "POST", - headers: { "Content-Type": "application/json" }, - }), - ); + const lastCall = (fetch as any).mock.calls.at(-1); + expect(lastCall[0]).toBe("https://api.us.nylas.com/v3/connect/token"); + expect(lastCall[1].method).toBe("POST"); + expect(lastCall[1].headers["Content-Type"]).toBe("application/json"); + expect(lastCall[1].headers["x-nylas-connect"]).toBe(pkg.version); }); function createValidIdToken(): string { @@ -1245,21 +1290,21 @@ describe("NylasConnect (Identity Provider Token)", () => { expect(mockIdentityProviderToken).toHaveBeenCalledTimes(1); // Verify the fetch was called with JSON content type and idp_claims - expect(mockFetch).toHaveBeenCalledWith( + const lastCallInclude = mockFetch.mock.calls.at(-1); + expect(lastCallInclude[0]).toBe( "https://api.us.nylas.com/v3/connect/token", - expect.objectContaining({ - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - client_id: clientId, - redirect_uri: redirectUri, - code: "auth_code_1", - grant_type: "authorization_code", - code_verifier: "verifier123", - idp_claims: mockIdpToken, - }), + ); + expect(lastCallInclude[1].method).toBe("POST"); + expect(lastCallInclude[1].headers["Content-Type"]).toBe("application/json"); + expect(lastCallInclude[1].headers["x-nylas-connect"]).toBe(pkg.version); + expect(lastCallInclude[1].body).toBe( + JSON.stringify({ + client_id: clientId, + redirect_uri: redirectUri, + code: "auth_code_1", + grant_type: "authorization_code", + code_verifier: "verifier123", + idp_claims: mockIdpToken, }), ); @@ -1310,21 +1355,19 @@ describe("NylasConnect (Identity Provider Token)", () => { ); // Verify the fetch was called with JSON format but no idp_claims - expect(mockFetch).toHaveBeenCalledWith( - "https://api.us.nylas.com/v3/connect/token", - expect.objectContaining({ - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - client_id: clientId, - redirect_uri: redirectUri, - code: "auth_code_1", - grant_type: "authorization_code", - code_verifier: "verifier123", - // No idp_claims field - }), + const lastCallNull = mockFetch.mock.calls.at(-1); + expect(lastCallNull[0]).toBe("https://api.us.nylas.com/v3/connect/token"); + expect(lastCallNull[1].method).toBe("POST"); + expect(lastCallNull[1].headers["Content-Type"]).toBe("application/json"); + expect(lastCallNull[1].headers["x-nylas-connect"]).toBe(pkg.version); + expect(lastCallNull[1].body).toBe( + JSON.stringify({ + client_id: clientId, + redirect_uri: redirectUri, + code: "auth_code_1", + grant_type: "authorization_code", + code_verifier: "verifier123", + // No idp_claims field }), ); @@ -1378,17 +1421,17 @@ describe("NylasConnect (Identity Provider Token)", () => { expect(mockIdentityProviderToken).toHaveBeenCalledTimes(1); // Verify the fetch was called without idp_claims (empty string is falsy) - expect(mockFetch).toHaveBeenCalledWith( - "https://api.us.nylas.com/v3/connect/token", - expect.objectContaining({ - body: JSON.stringify({ - client_id: clientId, - redirect_uri: redirectUri, - code: "auth_code_1", - grant_type: "authorization_code", - code_verifier: "verifier123", - // No idp_claims field should be present for empty string - }), + const lastCallEmpty = mockFetch.mock.calls.at(-1); + expect(lastCallEmpty[0]).toBe("https://api.us.nylas.com/v3/connect/token"); + expect(lastCallEmpty[1].headers["x-nylas-connect"]).toBe(pkg.version); + expect(lastCallEmpty[1].body).toBe( + JSON.stringify({ + client_id: clientId, + redirect_uri: redirectUri, + code: "auth_code_1", + grant_type: "authorization_code", + code_verifier: "verifier123", + // No idp_claims field should be present for empty string }), ); }); @@ -1433,21 +1476,19 @@ describe("NylasConnect (Identity Provider Token)", () => { ); // Verify the fetch was called with JSON format but no idp_claims - expect(mockFetch).toHaveBeenCalledWith( - "https://api.us.nylas.com/v3/connect/token", - expect.objectContaining({ - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - client_id: clientId, - redirect_uri: redirectUri, - code: "auth_code_1", - grant_type: "authorization_code", - code_verifier: "verifier123", - // No idp_claims field - }), + const lastCallNoCb = mockFetch.mock.calls.at(-1); + expect(lastCallNoCb[0]).toBe("https://api.us.nylas.com/v3/connect/token"); + expect(lastCallNoCb[1].method).toBe("POST"); + expect(lastCallNoCb[1].headers["Content-Type"]).toBe("application/json"); + expect(lastCallNoCb[1].headers["x-nylas-connect"]).toBe(pkg.version); + expect(lastCallNoCb[1].body).toBe( + JSON.stringify({ + client_id: clientId, + redirect_uri: redirectUri, + code: "auth_code_1", + grant_type: "authorization_code", + code_verifier: "verifier123", + // No idp_claims field }), ); @@ -1502,17 +1543,17 @@ describe("NylasConnect (Identity Provider Token)", () => { expect(mockIdentityProviderToken).toHaveBeenCalledTimes(1); // Verify the fetch was called with the sync token - expect(mockFetch).toHaveBeenCalledWith( - "https://api.us.nylas.com/v3/connect/token", - expect.objectContaining({ - body: JSON.stringify({ - client_id: clientId, - redirect_uri: redirectUri, - code: "auth_code_1", - grant_type: "authorization_code", - code_verifier: "verifier123", - idp_claims: mockIdpToken, - }), + const lastCallSync = mockFetch.mock.calls.at(-1); + expect(lastCallSync[0]).toBe("https://api.us.nylas.com/v3/connect/token"); + expect(lastCallSync[1].headers["x-nylas-connect"]).toBe(pkg.version); + expect(lastCallSync[1].body).toBe( + JSON.stringify({ + client_id: clientId, + redirect_uri: redirectUri, + code: "auth_code_1", + grant_type: "authorization_code", + code_verifier: "verifier123", + idp_claims: mockIdpToken, }), ); }); diff --git a/packages/nylas-connect/src/connect-client.ts b/packages/nylas-connect/src/connect-client.ts index bf0abd0..af2ce7d 100644 --- a/packages/nylas-connect/src/connect-client.ts +++ b/packages/nylas-connect/src/connect-client.ts @@ -36,6 +36,7 @@ import { cleanUrl, isConnectCallback, } from "./utils/redirect"; +import pkg from "../package.json"; /** * Modern Nylas authentication client @@ -56,6 +57,12 @@ export class NylasConnect { lastCleanup: Date.now(), }; + // Header constants + private static readonly NYLAS_CONNECT_VERSION: string = pkg.version; + private static readonly NYLAS_CONNECT_HEADER = "x-nylas-connect" as const; + private static readonly NYLAS_APPLICATION_ID_HEADER = + "x-nylas-application-id" as const; + constructor(config: ConnectConfig = {}) { // Resolve configuration with environment variables and defaults const resolvedConfig = this.resolveConfig(config); @@ -719,7 +726,7 @@ export class NylasConnect { } try { - const response = await fetch( + const response = await this.apiClient( `${this.config.apiUrl}/connect/tokeninfo?access_token=${encodeURIComponent(accessToken)}`, ); @@ -1022,13 +1029,16 @@ export class NylasConnect { } try { - const response = await fetch(`${this.config.apiUrl}/connect/token`, { - method: "POST", - headers: { - "Content-Type": "application/json", + const response = await this.apiClient( + `${this.config.apiUrl}/connect/token`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), }, - body: JSON.stringify(payload), - }); + ); if (!response.ok) { const errorData = await response.json().catch(() => ({})); @@ -1196,4 +1206,29 @@ export class NylasConnect { private authStateKey(): string { return `nylas_auth_state_${this.config.clientId}`; } + + /** + * Internal API client to ensure common headers are sent with every request + */ + private apiClient( + input: RequestInfo | URL, + init: RequestInit = {}, + ): Promise { + const connectHeader = NylasConnect.NYLAS_CONNECT_HEADER; + const connectVersion = NylasConnect.NYLAS_CONNECT_VERSION; + const appIdHeader = NylasConnect.NYLAS_APPLICATION_ID_HEADER; + const appId = this.config.clientId; + + const existingHeaders = + (init.headers as Record | undefined) || {}; + + return fetch(input as RequestInfo, { + ...init, + headers: { + ...existingHeaders, + [connectHeader]: connectVersion, + [appIdHeader]: appId, + }, + }); + } }