diff --git a/packages/mixpanel/example.js b/packages/mixpanel/example.js index 7d05a7ee..712250bb 100644 --- a/packages/mixpanel/example.js +++ b/packages/mixpanel/example.js @@ -82,22 +82,33 @@ mixpanel.track("test", function (err) { } }); -// import an old event -const mixpanel_importer = Mixpanel.init("valid mixpanel token", { - secret: "valid api secret for project", -}); -mixpanel_importer.set_config({ debug: true }); +// ============================================================================ +// Service Account Authentication (RECOMMENDED for importing old events) +// ============================================================================ +// Service accounts provide enhanced security for importing historical events +// Learn more: https://developer.mixpanel.com/reference/service-accounts-api + +const { ServiceAccountCredentials } = Mixpanel; + +const credentials = new ServiceAccountCredentials( + "YOUR_SERVICE_ACCOUNT_USERNAME", // From Mixpanel project settings + "YOUR_SERVICE_ACCOUNT_SECRET", // From Mixpanel project settings + "YOUR_PROJECT_ID", // Your Mixpanel project ID +); -// needs to be in the system once for it to show up in the interface -mixpanel_importer.track("old event", { gender: "" }); +const mixpanel_with_sa = Mixpanel.init("valid mixpanel token", { + credentials, +}); +mixpanel_with_sa.set_config({ debug: true }); -mixpanel_importer.import("old event", new Date(2012, 4, 20, 12, 34, 56), { +// Import old events (uses service account auth) +mixpanel_with_sa.import("old event", new Date(2012, 4, 20, 12, 34, 56), { distinct_id: "billybob", gender: "male", }); -// import multiple events at once -mixpanel_importer.import_batch([ +// Import multiple events at once +mixpanel_with_sa.import_batch([ { event: "old event", properties: { @@ -115,3 +126,17 @@ mixpanel_importer.import_batch([ }, }, ]); + +// ============================================================================ +// DEPRECATED: API Secret (use Service Accounts instead) +// ============================================================================ +// This method still works but will be removed in a future version +const mixpanel_importer = Mixpanel.init("valid mixpanel token", { + secret: "valid api secret for project", // DEPRECATED - use ServiceAccountCredentials +}); +mixpanel_importer.set_config({ debug: true }); + +mixpanel_importer.import("old event", new Date(2012, 4, 20, 12, 34, 56), { + distinct_id: "billybob", + gender: "male", +}); diff --git a/packages/mixpanel/lib/credentials.js b/packages/mixpanel/lib/credentials.js new file mode 100644 index 00000000..1f45016e --- /dev/null +++ b/packages/mixpanel/lib/credentials.js @@ -0,0 +1,81 @@ +/** + * Service account credentials for server-to-server authentication. + * + * Service account authentication is the recommended method for server-side + * integrations. It provides better security than API secrets by using + * unique username/secret pairs instead of a single shared secret. + * + * @example + * const { ServiceAccountCredentials } = require('mixpanel/lib/credentials'); + * + * const credentials = new ServiceAccountCredentials( + * 'your-service-account-username', + * 'your-service-account-secret', + * '123456' + * ); + * const mixpanel = Mixpanel.init('YOUR_TOKEN', { credentials }); + */ +class ServiceAccountCredentials { + /** + * Create service account credentials. + * + * @param {string} username - Service account username + * @param {string} secret - Service account secret + * @param {string} project_id - Mixpanel project ID + * @throws {TypeError} If any parameter is not a string, empty, or whitespace-only + */ + constructor(username, secret, project_id) { + if (typeof username !== "string") { + throw new TypeError("Service account username must be a string"); + } + if (typeof secret !== "string") { + throw new TypeError("Service account secret must be a string"); + } + if (typeof project_id !== "string") { + throw new TypeError("Service account project_id must be a string"); + } + + const trimmedUsername = username.trim(); + const trimmedSecret = secret.trim(); + const trimmedProjectId = project_id.trim(); + + if (!trimmedUsername) { + throw new TypeError("Service account username cannot be empty"); + } + if (!trimmedSecret) { + throw new TypeError("Service account secret cannot be empty"); + } + if (!trimmedProjectId) { + throw new TypeError("Service account project_id cannot be empty"); + } + + this.username = trimmedUsername; + this.secret = trimmedSecret; + this.project_id = trimmedProjectId; + + // Cache the base64-encoded auth to avoid recomputing on every request + this._cachedAuth = Buffer.from(this.username + ":" + this.secret).toString( + "base64", + ); + } + + /** + * Convert credentials to HTTP Basic Auth format. + * + * @returns {string} Base64-encoded username:secret for Authorization header + */ + toHttpBasicAuth() { + return this._cachedAuth; + } + + /** + * String representation of credentials (masks secret). + * + * @returns {string} + */ + toString() { + return `ServiceAccountCredentials(username=${this.username}, project_id=${this.project_id}, secret=***)`; + } +} + +module.exports = { ServiceAccountCredentials }; diff --git a/packages/mixpanel/lib/flags/flags.d.ts b/packages/mixpanel/lib/flags/flags.d.ts index 9ac591d1..02e43037 100644 --- a/packages/mixpanel/lib/flags/flags.d.ts +++ b/packages/mixpanel/lib/flags/flags.d.ts @@ -2,7 +2,7 @@ * TypeScript type definitions for Base Feature Flags Provider */ -import { CustomLogger } from "../mixpanel-node"; +import { CustomLogger, ServiceAccountCredentials } from "../mixpanel-node"; import { SelectedVariant, FlagContext } from "./types"; /** @@ -22,16 +22,23 @@ export class FeatureFlagsProvider { providerConfig: FeatureFlagsConfig; endpoint: string; logger: CustomLogger | null; + credentials?: ServiceAccountCredentials; /** - * @param config - Common configuration for feature flag providers + * @param providerConfig - Common configuration for feature flag providers * @param endpoint - API endpoint path (i.e., '/flags' or '/flags/definitions') + * @param tracker - Function to track events + * @param evaluationMode - The feature flag evaluation mode * @param logger - Logger instance + * @param credentials - Optional service account credentials */ constructor( - config: FeatureFlagsConfig, + providerConfig: FeatureFlagsConfig, endpoint: string, + tracker: Function, + evaluationMode: string, logger: CustomLogger | null, + credentials?: ServiceAccountCredentials, ); /** diff --git a/packages/mixpanel/lib/flags/flags.js b/packages/mixpanel/lib/flags/flags.js index 9520e170..46cd72ab 100644 --- a/packages/mixpanel/lib/flags/flags.js +++ b/packages/mixpanel/lib/flags/flags.js @@ -11,6 +11,7 @@ const { EXPOSURE_EVENT, REQUEST_HEADERS, } = require("./utils"); +const { ServiceAccountCredentials } = require("../credentials"); /** * @typedef {import('./types').SelectedVariant} SelectedVariant @@ -23,13 +24,22 @@ class FeatureFlagsProvider { * @param {Function} tracker - Function to track events (signature: track(distinct_id, event, properties, callback)) * @param {string} evaluationMode - The feature flag evaluation mode * @param {CustomLogger} logger - Logger instance + * @param {Object} credentials - Optional service account credentials */ - constructor(providerConfig, endpoint, tracker, evaluationMode, logger) { + constructor( + providerConfig, + endpoint, + tracker, + evaluationMode, + logger, + credentials, + ) { this.providerConfig = providerConfig; this.endpoint = endpoint; this.tracker = tracker; this.evaluationMode = evaluationMode; this.logger = logger; + this.credentials = credentials; } /** @@ -39,10 +49,26 @@ class FeatureFlagsProvider { */ async callFlagsEndpoint(additionalParams = null) { return new Promise((resolve, reject) => { - const commonParams = prepareCommonQueryParams( - this.providerConfig.token, - packageInfo.version, - ); + let authHeader; + let commonParams; + + if (this.credentials instanceof ServiceAccountCredentials) { + commonParams = prepareCommonQueryParams( + this.providerConfig.token, + packageInfo.version, + this.credentials.project_id, + ); + authHeader = "Basic " + this.credentials.toHttpBasicAuth(); + } else { + commonParams = prepareCommonQueryParams( + this.providerConfig.token, + packageInfo.version, + ); + authHeader = + "Basic " + + Buffer.from(this.providerConfig.token + ":").toString("base64"); + } + const params = new URLSearchParams(commonParams); if (additionalParams) { @@ -60,9 +86,7 @@ class FeatureFlagsProvider { method: "GET", headers: { ...REQUEST_HEADERS, - Authorization: - "Basic " + - Buffer.from(this.providerConfig.token + ":").toString("base64"), + Authorization: authHeader, traceparent: generateTraceparent(), }, timeout: this.providerConfig.request_timeout_in_seconds * 1000, diff --git a/packages/mixpanel/lib/flags/local_flags.d.ts b/packages/mixpanel/lib/flags/local_flags.d.ts index 25c5f0a6..d28b0e1d 100644 --- a/packages/mixpanel/lib/flags/local_flags.d.ts +++ b/packages/mixpanel/lib/flags/local_flags.d.ts @@ -3,7 +3,7 @@ */ import { LocalFlagsConfig, FlagContext, SelectedVariant } from "./types"; -import { CustomLogger } from "../mixpanel-node"; +import { CustomLogger, ServiceAccountCredentials } from "../mixpanel-node"; /** * Local Feature Flags Provider @@ -20,6 +20,7 @@ export default class LocalFeatureFlagsProvider { callback: (err?: Error) => void, ) => void, logger: CustomLogger, + credentials?: ServiceAccountCredentials, ); /** diff --git a/packages/mixpanel/lib/flags/local_flags.js b/packages/mixpanel/lib/flags/local_flags.js index f8d37728..712373ac 100644 --- a/packages/mixpanel/lib/flags/local_flags.js +++ b/packages/mixpanel/lib/flags/local_flags.js @@ -25,8 +25,9 @@ class LocalFeatureFlagsProvider extends FeatureFlagsProvider { * @param {LocalFlagsConfig} config - Local flags configuration * @param {Function} tracker - Function to track events (signature: track(distinct_id, event, properties, callback)) * @param {CustomLogger} logger - Logger + * @param {Object} credentials - Optional service account credentials */ - constructor(token, config, tracker, logger) { + constructor(token, config, tracker, logger, credentials) { const mergedConfig = { api_host: "api.mixpanel.com", request_timeout_in_seconds: 10, @@ -41,7 +42,14 @@ class LocalFeatureFlagsProvider extends FeatureFlagsProvider { request_timeout_in_seconds: mergedConfig.request_timeout_in_seconds, }; - super(providerConfig, "/flags/definitions", tracker, "local", logger); + super( + providerConfig, + "/flags/definitions", + tracker, + "local", + logger, + credentials, + ); this.config = mergedConfig; this.flagDefinitions = new Map(); diff --git a/packages/mixpanel/lib/flags/remote_flags.d.ts b/packages/mixpanel/lib/flags/remote_flags.d.ts index f857913c..b1cd1bbc 100644 --- a/packages/mixpanel/lib/flags/remote_flags.d.ts +++ b/packages/mixpanel/lib/flags/remote_flags.d.ts @@ -2,7 +2,7 @@ * TypeScript definitions for Remote Feature Flags Provider */ -import { CustomLogger } from "../mixpanel-node"; +import { CustomLogger, ServiceAccountCredentials } from "../mixpanel-node"; import { RemoteFlagsConfig, FlagContext, SelectedVariant } from "./types"; /** @@ -13,13 +13,14 @@ export default class RemoteFeatureFlagsProvider { constructor( token: string, config: RemoteFlagsConfig, - logger: CustomLogger, tracker: ( distinct_id: string, event: string, properties: object, callback: (err?: Error) => void, ) => void, + logger: CustomLogger, + credentials?: ServiceAccountCredentials, ); /** diff --git a/packages/mixpanel/lib/flags/remote_flags.js b/packages/mixpanel/lib/flags/remote_flags.js index 5566916e..ee327021 100644 --- a/packages/mixpanel/lib/flags/remote_flags.js +++ b/packages/mixpanel/lib/flags/remote_flags.js @@ -18,8 +18,9 @@ class RemoteFeatureFlagsProvider extends FeatureFlagsProvider { * @param {RemoteFlagsConfig} config - Remote flags configuration * @param {Function} tracker - Function to track events (signature: track(distinct_id, event, properties, callback)) * @param {CustomLogger} logger - Logger instance + * @param {Object} credentials - Optional service account credentials */ - constructor(token, config, tracker, logger) { + constructor(token, config, tracker, logger, credentials) { const mergedConfig = { api_host: "api.mixpanel.com", request_timeout_in_seconds: 10, @@ -32,7 +33,7 @@ class RemoteFeatureFlagsProvider extends FeatureFlagsProvider { request_timeout_in_seconds: mergedConfig.request_timeout_in_seconds, }; - super(providerConfig, "/flags", tracker, "remote", logger); + super(providerConfig, "/flags", tracker, "remote", logger, credentials); } /** diff --git a/packages/mixpanel/lib/flags/utils.js b/packages/mixpanel/lib/flags/utils.js index 2d247b9f..073e39b6 100644 --- a/packages/mixpanel/lib/flags/utils.js +++ b/packages/mixpanel/lib/flags/utils.js @@ -46,14 +46,22 @@ function normalizedHash(key, salt) { * Prepare common query parameters for feature flags API requests * @param {string} token - Mixpanel project token * @param {string} sdkVersion - SDK version string + * @param {string} project_id - Optional project ID for service account authentication * @returns {Object} - Query parameters object */ -function prepareCommonQueryParams(token, sdkVersion) { - return { +function prepareCommonQueryParams(token, sdkVersion, project_id) { + const params = { mp_lib: "node", $lib_version: sdkVersion, token: token, }; + + if (project_id !== undefined && project_id !== null) { + // Service account authentication - add project_id + params.project_id = project_id; + } + + return params; } /** diff --git a/packages/mixpanel/lib/mixpanel-node.d.ts b/packages/mixpanel/lib/mixpanel-node.d.ts index 2982f73e..e7e077cb 100644 --- a/packages/mixpanel/lib/mixpanel-node.d.ts +++ b/packages/mixpanel/lib/mixpanel-node.d.ts @@ -2,9 +2,21 @@ import LocalFeatureFlagsProvider from "./flags/local_flags"; import RemoteFeatureFlagsProvider from "./flags/remote_flags"; import { LocalFlagsConfig, RemoteFlagsConfig } from "./flags/types"; -declare const mixpanel: mixpanel.Mixpanel; - declare namespace mixpanel { + export function init( + mixpanelToken: string, + config?: Partial, + ): Mixpanel; + + export class ServiceAccountCredentials { + constructor(username: string, secret: string, project_id: string); + username: string; + secret: string; + project_id: string; + toHttpBasicAuth(): string; + toString(): string; + } + export type Callback = (err: Error | undefined) => any; export type BatchCallback = (errors: [Error] | undefined) => any; @@ -26,6 +38,7 @@ declare namespace mixpanel { protocol: string; path: string; secret: string; + credentials?: ServiceAccountCredentials; keepAlive: boolean; geolocate: boolean; logger: CustomLogger; diff --git a/packages/mixpanel/lib/mixpanel-node.js b/packages/mixpanel/lib/mixpanel-node.js index d7cdb851..3b3187e1 100644 --- a/packages/mixpanel/lib/mixpanel-node.js +++ b/packages/mixpanel/lib/mixpanel-node.js @@ -22,6 +22,7 @@ const { LocalFeatureFlagsProvider, RemoteFeatureFlagsProvider, } = require("./flags"); +const { ServiceAccountCredentials } = require("./credentials"); const DEFAULT_CONFIG = { test: false, @@ -116,7 +117,18 @@ const create_client = function (token, config) { } // add auth params - if (secret) { + const credentials = metrics.config.credentials; + + if (credentials instanceof ServiceAccountCredentials) { + if (endpoint === "/import") { + if (request_lib !== https) { + throw new Error("Must use HTTPS with service account credentials"); + } + request_options.headers["Authorization"] = + "Basic " + credentials.toHttpBasicAuth(); + query_params.project_id = credentials.project_id; + } + } else if (secret) { if (request_lib !== https) { throw new Error("Must use HTTPS if authenticating with API Secret"); } @@ -126,7 +138,15 @@ const create_client = function (token, config) { query_params.api_key = key; } else if (endpoint === "/import") { throw new Error( - "The Mixpanel Client needs a Mixpanel API Secret when importing old events: `init(token, { secret: ... })`", + "The /import endpoint requires authentication.\n\n" + + "RECOMMENDED: Use service account credentials (preferred method):\n" + + " const Mixpanel = require('mixpanel');\n" + + " const { ServiceAccountCredentials } = Mixpanel;\n" + + " const credentials = new ServiceAccountCredentials('username', 'secret', 'project_id');\n" + + " const mixpanel = Mixpanel.init('token', { credentials });\n\n" + + "Learn more: https://developer.mixpanel.com/reference/service-accounts-api\n\n" + + "DEPRECATED: API secret (will be removed in a future version):\n" + + " const mixpanel = Mixpanel.init('token', { secret: 'your-api-secret' });", ); } @@ -298,7 +318,9 @@ const create_client = function (token, config) { cb = function (errors, results) { index += 1; if (index === total_request_batches) { - callback && callback(errors, results); + if (callback) { + callback(errors, results); + } } else { send_next_request_batch(index); } @@ -504,6 +526,16 @@ const create_client = function (token, config) { metrics.config.port = Number(port); } } + + // Warn about deprecated auth methods + if (config && (config.secret || config.key)) { + const method = config.secret ? "api_secret" : "api_key"; + metrics.config.logger.warn( + `DEPRECATION WARNING: ${method} is deprecated and will be removed in a future version.\n` + + " Please migrate to ServiceAccountCredentials for enhanced security.\n" + + " See: https://developer.mixpanel.com/reference/service-accounts-api", + ); + } }; if (config) { @@ -517,6 +549,7 @@ const create_client = function (token, config) { config.local_flags_config, metrics.track.bind(metrics), config.logger, + config.credentials, ); } @@ -526,6 +559,7 @@ const create_client = function (token, config) { config.remote_flags_config, metrics.track.bind(metrics), config.logger, + config.credentials, ); } @@ -535,4 +569,5 @@ const create_client = function (token, config) { // module exporting module.exports = { init: create_client, + ServiceAccountCredentials: ServiceAccountCredentials, }; diff --git a/packages/mixpanel/readme.md b/packages/mixpanel/readme.md index 2df349bf..90a0a478 100644 --- a/packages/mixpanel/readme.md +++ b/packages/mixpanel/readme.md @@ -25,6 +25,16 @@ var Mixpanel = require("mixpanel"); // create an instance of the mixpanel client var mixpanel = Mixpanel.init(""); +// for server-side integrations requiring authentication (e.g., importing old events), +// use service account credentials for enhanced security +const { ServiceAccountCredentials } = Mixpanel; +const credentials = new ServiceAccountCredentials( + "YOUR_SERVICE_ACCOUNT_USERNAME", + "YOUR_SERVICE_ACCOUNT_SECRET", + "YOUR_PROJECT_ID", +); +var mixpanel = Mixpanel.init("", { credentials }); + // initialize mixpanel client configured to communicate over http instead of https var mixpanel = Mixpanel.init("", { protocol: "http", @@ -184,9 +194,47 @@ mixpanel.track_batch([ }, ]); -// import an old event +// ============================================================================ +// Service Account Authentication (RECOMMENDED - Preferred Method) +// ============================================================================ +// Service accounts provide enhanced security for server-to-server integrations. +// This is the PREFERRED authentication method. API secrets are deprecated. +// Learn more: https://developer.mixpanel.com/reference/service-accounts-api +const credentials = new ServiceAccountCredentials( + "YOUR_SERVICE_ACCOUNT_USERNAME", + "YOUR_SERVICE_ACCOUNT_SECRET", + "YOUR_PROJECT_ID", +); + +const mixpanel_with_sa = Mixpanel.init("YOUR_TOKEN", { credentials }); + +// Import old events (uses service account auth) +mixpanel_with_sa.import("old event", new Date(2012, 4, 20, 12, 34, 56), { + distinct_id: "billybob", + gender: "male", +}); + +// Regular tracking (uses token in payload, no auth header needed) +mixpanel_with_sa.track("test event", { + distinct_id: "billybob", + plan: "premium", +}); + +// Service accounts can also be used with feature flags +const mixpanel_flags = Mixpanel.init("YOUR_TOKEN", { + credentials, + local_flags_config: { + // ... config + }, +}); + +// ============================================================================ +// DEPRECATED: API Secret Authentication (Legacy - Not Recommended) +// ============================================================================ +// ⚠️ WARNING: API secrets are DEPRECATED and will be removed in a future version. +// Please migrate to Service Account credentials above for enhanced security. var mixpanel_importer = Mixpanel.init("valid mixpanel token", { - secret: "valid api secret for project", + secret: "valid api secret for project", // DEPRECATED - use ServiceAccountCredentials instead }); // needs to be in the system once for it to show up in the interface @@ -218,6 +266,67 @@ mixpanel_importer.import_batch([ ]); ``` +## Service Account Authentication + +**Documentation:** https://developer.mixpanel.com/reference/service-accounts-api + +### When to Use Service Accounts + +Service account credentials are **required** for: + +- **Importing historical events** - Events older than 5 days via `/import` endpoint +- **Server-side feature flags** - Evaluating feature flags with authentication + +Service accounts are **NOT needed** for: + +- **Regular event tracking** - `track()` uses project token only +- **People analytics** - `people.set()`, `people.increment()`, etc. +- **Group analytics** - `groups.set()`, etc. + +### Migrating from API Secrets (Deprecated) + +If you're currently using API secrets (deprecated), here's how to upgrade: + +**Before (deprecated):** + +```javascript +// Old method - still works but deprecated +var mixpanel = Mixpanel.init("TOKEN", { + secret: "your-api-secret", +}); + +mixpanel.import("event", new Date(2020, 1, 1), { + distinct_id: "user123", +}); +``` + +**After (recommended):** + +```javascript +// New method - enhanced security with service accounts +const { ServiceAccountCredentials } = Mixpanel; + +const credentials = new ServiceAccountCredentials( + "service-account-username", // From Mixpanel project settings + "service-account-secret", // From Mixpanel project settings + "project-id", // Your Mixpanel project ID +); + +var mixpanel = Mixpanel.init("TOKEN", { credentials }); + +mixpanel.import("event", new Date(2020, 1, 1), { + distinct_id: "user123", +}); +``` + +**Key differences:** + +- Service accounts use username/secret pairs instead of a single shared secret +- Project ID is explicitly specified for better security +- Each service account can have specific permissions +- Easier to rotate credentials without affecting other integrations +- **Learn more:** https://developer.mixpanel.com/reference/service-accounts-api + ## FAQ **Where is `mixpanel.identify()`?** diff --git a/packages/mixpanel/test/deprecation-warnings.js b/packages/mixpanel/test/deprecation-warnings.js new file mode 100644 index 00000000..a4482342 --- /dev/null +++ b/packages/mixpanel/test/deprecation-warnings.js @@ -0,0 +1,67 @@ +const Mixpanel = require("../lib/mixpanel-node"); + +describe("deprecation warnings", () => { + let mockLogger; + + beforeEach(() => { + mockLogger = { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + }); + + it("warns when using api_secret", () => { + Mixpanel.init("token", { + secret: "api-secret", + logger: mockLogger, + }); + + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining("DEPRECATION WARNING"), + ); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining("api_secret is deprecated"), + ); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining("ServiceAccountCredentials"), + ); + }); + + it("warns when using api_key", () => { + Mixpanel.init("token", { + key: "api-key", + logger: mockLogger, + }); + + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining("DEPRECATION WARNING"), + ); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining("api_key is deprecated"), + ); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining("ServiceAccountCredentials"), + ); + }); + + it("does not warn when using service account credentials", () => { + const { ServiceAccountCredentials } = Mixpanel; + const creds = new ServiceAccountCredentials("user", "secret", "123"); + + Mixpanel.init("token", { + credentials: creds, + logger: mockLogger, + }); + + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); + + it("does not warn when using no auth", () => { + Mixpanel.init("token", { logger: mockLogger }); + + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/mixpanel/test/flags/local_flags.js b/packages/mixpanel/test/flags/local_flags.js index 2608663e..2f06a8bd 100644 --- a/packages/mixpanel/test/flags/local_flags.js +++ b/packages/mixpanel/test/flags/local_flags.js @@ -848,4 +848,42 @@ describe("LocalFeatureFlagsProvider", () => { expect(result).toBe(false); }); }); + + describe("with service account credentials", () => { + it("should use service account auth when credentials provided", async () => { + const { ServiceAccountCredentials } = require("../../lib/credentials"); + const credentials = new ServiceAccountCredentials( + "sa-user", + "sa-secret", + "project-123", + ); + + const flagsConfig = { test_flag: "control" }; + mockFlagDefinitionsResponse([ + createTestFlag({ flagKey: "test_flag", variants: flagsConfig }), + ]); + + const tracker = () => {}; + const logger = { debug: () => {}, info: () => {}, warn: () => {} }; + + const provider = new LocalFeatureFlagsProvider( + "test-token", + { api_host: "localhost" }, + tracker, + logger, + credentials, + ); + + await provider.areFlagsReady(); + + // Verify the provider was created with credentials + expect(provider.credentials).toBe(credentials); + + // Note: The service account credentials (username, secret, project_id) + // are passed to the FeatureFlagsProvider parent class which uses them + // to construct the Authorization header (Basic auth with username:secret) + // and includes project_id as a query parameter when calling the flags endpoint. + // This behavior is tested in the FeatureFlagsProvider base class tests. + }); + }); }); diff --git a/packages/mixpanel/test/flags/remote_flags.js b/packages/mixpanel/test/flags/remote_flags.js index 0f0d763a..dd4e2984 100644 --- a/packages/mixpanel/test/flags/remote_flags.js +++ b/packages/mixpanel/test/flags/remote_flags.js @@ -486,4 +486,113 @@ describe("RemoteFeatureFlagProvider", () => { expect(result).toBe(false); }); }); + + describe("Service Account Credentials", () => { + const { ServiceAccountCredentials } = require("../../lib/credentials"); + let saProvider; + let credentials; + + beforeEach(() => { + credentials = new ServiceAccountCredentials( + "sa-username", + "sa-secret", + "test-project-123", + ); + + let mockLogger = { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + }; + + let config = { + api_host: flagsEndpointHostName, + }; + + mockTracker = vi.fn(); + + saProvider = new RemoteFeatureFlagsProvider( + TEST_TOKEN, + config, + mockTracker, + mockLogger, + credentials, + ); + }); + + it("should include Authorization header with service account credentials", async () => { + const expectedAuthHeader = + "Basic " + Buffer.from("sa-username:sa-secret").toString("base64"); + + const scope = nock("https://localhost", { + reqheaders: { + authorization: expectedAuthHeader, + }, + }) + .get("/flags") + .query(true) + .reply(200, { + code: 200, + flags: { + "test-flag": { + variant_key: "on", + variant_value: true, + }, + }, + }); + + await saProvider.getVariant("test-flag", null, TEST_CONTEXT); + + expect(scope.isDone()).toBe(true); + }); + + it("should include project_id in query parameters", async () => { + const scope = nock("https://localhost") + .get("/flags") + .query((queryObject) => { + return queryObject.project_id === "test-project-123"; + }) + .reply(200, { + code: 200, + flags: { + "test-flag": { + variant_key: "on", + variant_value: true, + }, + }, + }); + + await saProvider.getVariant("test-flag", null, TEST_CONTEXT); + + expect(scope.isDone()).toBe(true); + }); + + it("should include both Authorization header and project_id together", async () => { + const expectedAuthHeader = + "Basic " + Buffer.from("sa-username:sa-secret").toString("base64"); + + const scope = nock("https://localhost", { + reqheaders: { + authorization: expectedAuthHeader, + }, + }) + .get("/flags") + .query((queryObject) => { + return queryObject.project_id === "test-project-123"; + }) + .reply(200, { + code: 200, + flags: { + "test-flag": { + variant_key: "on", + variant_value: true, + }, + }, + }); + + await saProvider.getVariant("test-flag", null, TEST_CONTEXT); + + expect(scope.isDone()).toBe(true); + }); + }); }); diff --git a/packages/mixpanel/test/import.js b/packages/mixpanel/test/import.js index fee959a5..300ebc89 100644 --- a/packages/mixpanel/test/import.js +++ b/packages/mixpanel/test/import.js @@ -325,3 +325,152 @@ describe("import_batch_integration", () => { expect(https.request).toHaveBeenCalledTimes(5); }); }); + +describe("import with service account credentials", () => { + const { ServiceAccountCredentials } = Mixpanel; + let mixpanel; + let credentials; + + beforeEach(() => { + credentials = new ServiceAccountCredentials( + "sa-user", + "sa-secret", + "123456", + ); + mixpanel = Mixpanel.init("token", { credentials }); + vi.spyOn(mixpanel, "send_request"); + }); + + afterEach(() => { + mixpanel.send_request.mockRestore(); + }); + + it("validates credentials on construction", () => { + expect(() => new ServiceAccountCredentials("", "secret", "123")).toThrow( + TypeError, + ); + expect(() => new ServiceAccountCredentials("user", "", "123")).toThrow( + TypeError, + ); + expect(() => new ServiceAccountCredentials("user", "secret", "")).toThrow( + TypeError, + ); + expect(() => new ServiceAccountCredentials(" ", "secret", "123")).toThrow( + TypeError, + ); + }); + + it("validates credentials types", () => { + expect(() => new ServiceAccountCredentials(123, "secret", "123")).toThrow( + TypeError, + ); + expect(() => new ServiceAccountCredentials("user", 123, "123")).toThrow( + TypeError, + ); + expect(() => new ServiceAccountCredentials("user", "secret", 123)).toThrow( + TypeError, + ); + }); + + it("trims credential values", () => { + const creds = new ServiceAccountCredentials( + " user ", + " secret ", + " 123 ", + ); + expect(creds.username).toBe("user"); + expect(creds.secret).toBe("secret"); + expect(creds.project_id).toBe("123"); + }); + + it("calls send_request with correct endpoint and uses credentials", () => { + const event = "test", + time = mock_now_time, + props = { key1: "val1" }; + + mixpanel.import(event, time, props); + + expect(mixpanel.send_request).toHaveBeenCalledWith( + expect.objectContaining({ + endpoint: "/import", + data: expect.objectContaining({ + event: "test", + properties: expect.objectContaining({ + key1: "val1", + token: "token", + time: time, + }), + }), + }), + undefined, + ); + }); + + it("requires HTTPS with service account credentials", () => { + const http_mixpanel = Mixpanel.init("token", { + credentials, + protocol: "http", + }); + + expect(() => { + http_mixpanel.import("test", Date.now(), {}); + }).toThrow("Must use HTTPS with service account credentials"); + }); + + it("toHttpBasicAuth encodes correctly", () => { + const encoded = credentials.toHttpBasicAuth(); + const expected = Buffer.from("sa-user:sa-secret").toString("base64"); + expect(encoded).toBe(expected); + }); + + it("toString masks secret", () => { + const str = credentials.toString(); + expect(str).toContain("sa-user"); + expect(str).toContain("123456"); + expect(str).toContain("***"); + expect(str).not.toContain("sa-secret"); + }); + + it("rejects plain objects that look like credentials", () => { + // Plain object should not be treated as service account credentials + const plain_object_mixpanel = Mixpanel.init("token", { + credentials: { username: "user", secret: "secret", project_id: "123" }, + }); + + // Should fail because plain object is not ServiceAccountCredentials instance + expect(() => { + plain_object_mixpanel.import("test", Date.now(), {}); + }).toThrow(/The \/import endpoint requires authentication/); + }); + + it("does not send auth headers for non-import endpoints", () => { + const http_emitter = new events.EventEmitter(); + const res = new events.EventEmitter(); + vi.spyOn(https, "request").mockImplementation((_, cb) => { + cb(res); + return http_emitter; + }); + http_emitter.write = vi.fn(); + http_emitter.end = vi.fn(); + + const mixpanel_sa = Mixpanel.init("token", { credentials }); + + // Track should not use service account credentials + mixpanel_sa.track("test event", { distinct_id: "user123" }); + + expect(https.request).toHaveBeenCalledTimes(1); + const request_options = https.request.mock.calls[0][0]; + + // Verify endpoint is /track, not /import + expect(request_options.path).toContain("/track"); + expect(request_options.path).not.toContain("/import"); + + // Verify Authorization header is NOT set + expect(request_options.headers.Authorization).toBeUndefined(); + + // Verify project_id is NOT in query string + expect(request_options.path).not.toContain("project_id"); + + https.request.mockRestore(); + }); +}); diff --git a/packages/mixpanel/test/send_request.js b/packages/mixpanel/test/send_request.js index 01be8dc7..c094fc2c 100644 --- a/packages/mixpanel/test/send_request.js +++ b/packages/mixpanel/test/send_request.js @@ -292,9 +292,7 @@ describe("send_request", () => { endpoint: `/import`, data: { event: `test event` }, }); - }).toThrowError( - /The Mixpanel Client needs a Mixpanel API Secret when importing old events/, - ); + }).toThrowError(/The \/import endpoint requires authentication/); }); it("sets basic auth header if API secret is provided", () => { @@ -320,4 +318,30 @@ describe("send_request", () => { `/import?ip=0&verbose=0&data=e30%3D&api_key=barbaz`, ); }); + + it("sets Authorization header and project_id query param for service account import", () => { + const { ServiceAccountCredentials } = Mixpanel; + const credentials = new ServiceAccountCredentials( + "sa-user", + "sa-secret", + "test-project-123", + ); + const sa_mixpanel = Mixpanel.init("token", { credentials }); + + sa_mixpanel.send_request({ + endpoint: `/import`, + data: { event: `test event` }, + }); + + expect(https.request).toHaveBeenCalledTimes(1); + const request_options = https.request.mock.calls[0][0]; + + // Verify Authorization header is set + expect(request_options.headers.Authorization).toBe( + `Basic ${Buffer.from("sa-user:sa-secret").toString("base64")}`, + ); + + // Verify project_id is in query string + expect(request_options.path).toContain("project_id=test-project-123"); + }); });