diff --git a/products/userale/packages/flagon-userale-ext/src/background/messages/config_change.ts b/products/userale/packages/flagon-userale-ext/src/background/messages/config_change.ts index ec6b7c17..7ad138e0 100644 --- a/products/userale/packages/flagon-userale-ext/src/background/messages/config_change.ts +++ b/products/userale/packages/flagon-userale-ext/src/background/messages/config_change.ts @@ -32,6 +32,25 @@ const handler: PlasmoMessaging.MessageHandler = async (req, res) => { export function setOptions(options: StoredOptions) { userale.options({ url: options.loggingUrl }) allowListRegExp = new RegExp(options.allowList) + + switch (options.authMode) { + case "oauth": + userale.options({ + authHeader: options.accessToken + ? `Bearer ${options.accessToken}` + : null, + apiKey: null + }) + break + case "apikey": + userale.options({ + authHeader: null, + apiKey: options.apiKey || null + }) + break + default: + userale.options({ authHeader: null, apiKey: null }) + } } export function getAllowListRegExp() { diff --git a/products/userale/packages/flagon-userale-ext/src/options/auth.tsx b/products/userale/packages/flagon-userale-ext/src/options/auth.tsx index eea931a7..50f15957 100644 --- a/products/userale/packages/flagon-userale-ext/src/options/auth.tsx +++ b/products/userale/packages/flagon-userale-ext/src/options/auth.tsx @@ -18,16 +18,45 @@ */ import pkceChallenge from "pkce-challenge" -import { useState } from "react" +import { useEffect, useState } from "react" import browser from "webextension-polyfill" -import { setStoredOptions } from "~/utils/storage" +import { getStoredOptions, setStoredOptions } from "~/utils/storage" +import type { StoredOptions } from "~/utils/storage" function Auth() { + const [authMode, setAuthMode] = useState("none") + const [apiKey, setApiKey] = useState("") const [issuerUrl, setIssuerUrl] = useState("") const [clientId, setClientId] = useState("") const [message, setMessage] = useState("") + useEffect(() => { + getStoredOptions().then((opts) => { + setAuthMode(opts.authMode) + setApiKey(opts.apiKey) + }) + }, []) + + const handleAuthModeChange = async (mode: StoredOptions["authMode"]) => { + setAuthMode(mode) + setMessage("") + if (mode === "none") { + await setStoredOptions({ authMode: "none", accessToken: "", apiKey: "" }) + setApiKey("") + setMessage("Auth disabled.") + } + } + + const handleSaveApiKey = async () => { + await setStoredOptions({ + authMode: "apikey", + apiKey, + accessToken: "" + }) + setMessage("API key saved.") + } + const handleLogin = async () => { try { // Generate the PKCE challenge pair (code_verifier and code_challenge) @@ -74,7 +103,11 @@ function Auth() { ) const tokens = await tokenRes.json() - await setStoredOptions({ accessToken: tokens.access_token }) + await setStoredOptions({ + authMode: "oauth", + accessToken: tokens.access_token, + apiKey: "" + }) setMessage("Login successful!") } catch (err) { @@ -85,32 +118,91 @@ function Auth() { return (
-

OAuth Login

-
-
- +

Authentication

+
+
-
- + None + + + +
+ + {authMode === "oauth" && ( +
+

OAuth Login

+ +
+ + setIssuerUrl(e.target.value)} + placeholder="https://issuer.com/realms/myrealm" + required + /> +
+
+ + setClientId(e.target.value)} + placeholder="your-client-id" + required + /> +
+
+ +
+
-
- + )} + + {authMode === "apikey" && ( +
+

API Key

+
+ + setApiKey(e.target.value)} + placeholder="Enter your API key" + /> +
+
+ +
- + )} + {message &&

{message}

}
) diff --git a/products/userale/packages/flagon-userale-ext/src/utils/storage.ts b/products/userale/packages/flagon-userale-ext/src/utils/storage.ts index a8be357a..8a616592 100644 --- a/products/userale/packages/flagon-userale-ext/src/utils/storage.ts +++ b/products/userale/packages/flagon-userale-ext/src/utils/storage.ts @@ -23,6 +23,8 @@ import { sendToBackground } from "@plasmohq/messaging" export const STORAGE_KEYS = { accessToken: "accessToken", + apiKey: "apiKey", + authMode: "authMode", allowList: "allowList", loggingUrl: "loggingUrl" } as const @@ -31,12 +33,16 @@ export type StorageKeys = keyof typeof STORAGE_KEYS export type StoredOptions = { accessToken: string + apiKey: string + authMode: "oauth" | "apikey" | "none" allowList: string loggingUrl: string } const DEFAULT_OPTIONS: StoredOptions = { accessToken: "", + apiKey: "", + authMode: "none", allowList: "https://flagon.apache.org/", loggingUrl: "http://localhost:8000" } @@ -48,6 +54,8 @@ export async function getStoredOptions(): Promise { return { accessToken: stored.accessToken ?? DEFAULT_OPTIONS.accessToken, + apiKey: stored.apiKey ?? DEFAULT_OPTIONS.apiKey, + authMode: stored.authMode ?? DEFAULT_OPTIONS.authMode, allowList: stored.allowList ?? DEFAULT_OPTIONS.allowList, loggingUrl: stored.loggingUrl ?? DEFAULT_OPTIONS.loggingUrl } @@ -57,8 +65,8 @@ export async function getStoredOptions(): Promise { export async function setStoredOptions(values: Partial) { // Validate the new options try { - new RegExp(values.allowList) - new URL(values.loggingUrl) + if (values.allowList !== undefined) new RegExp(values.allowList) + if (values.loggingUrl !== undefined) new URL(values.loggingUrl) } catch (error) { return error } diff --git a/products/userale/packages/flagon-userale/src/configure.ts b/products/userale/packages/flagon-userale/src/configure.ts index a3d08ac7..6e427766 100644 --- a/products/userale/packages/flagon-userale/src/configure.ts +++ b/products/userale/packages/flagon-userale/src/configure.ts @@ -25,6 +25,7 @@ export class Configuration { private static instance: Configuration | null = null; // Public properties corresponding to fields in the Config interface + public apiKey: Settings.ApiKey = null; public autostart: boolean = false; public authHeader: Settings.AuthHeader = null; public browserSessionId: Settings.SessionId = null; diff --git a/products/userale/packages/flagon-userale/src/getInitialSettings.ts b/products/userale/packages/flagon-userale/src/getInitialSettings.ts index 324e9779..7fc56237 100644 --- a/products/userale/packages/flagon-userale/src/getInitialSettings.ts +++ b/products/userale/packages/flagon-userale/src/getInitialSettings.ts @@ -31,6 +31,7 @@ export function getInitialSettings(): Settings.Config { self instanceof WorkerGlobalScope ) { const settings: Settings.Config = { + apiKey: null, authHeader: null, autostart: true, browserSessionId: null, @@ -81,6 +82,7 @@ export function getInitialSettings(): Settings.Config { }; const headers = get("data-headers"); const settings: Settings.Config = { + apiKey: get("data-api-key") || null, authHeader: get("data-auth") || null, autostart: get("data-autostart") === "false" ? false : true, browserSessionId: null, diff --git a/products/userale/packages/flagon-userale/src/sendLogs.ts b/products/userale/packages/flagon-userale/src/sendLogs.ts index 89ed67fa..2356347c 100644 --- a/products/userale/packages/flagon-userale/src/sendLogs.ts +++ b/products/userale/packages/flagon-userale/src/sendLogs.ts @@ -92,6 +92,10 @@ export function sendOnClose( headers.set("Authorization", config.authHeader.toString()); } + if (config.apiKey) { + headers.set("x-api-key", config.apiKey); + } + fetch(config.url, { keepalive: true, method: "POST", @@ -140,6 +144,10 @@ export async function sendLogs( headers.set("Authorization", authHeaderValue); } + if (config.apiKey) { + headers.set("x-api-key", config.apiKey); + } + // Update custom headers last to allow them to over-write the defaults. This assumes // the user knows what they are doing and may want to over-write the defaults. updateCustomHeaders(config); diff --git a/products/userale/packages/flagon-userale/src/types.d.ts b/products/userale/packages/flagon-userale/src/types.d.ts index ec277aba..30e4181c 100644 --- a/products/userale/packages/flagon-userale/src/types.d.ts +++ b/products/userale/packages/flagon-userale/src/types.d.ts @@ -21,6 +21,7 @@ export declare namespace Settings { type UserFromParams = string | null; type ToolName = string | null; type AuthHeader = CallableFunction | string | null; + type ApiKey = string | null; type CustomIndex = string | null; type HeaderObject = { [key: string]: string }; type Headers = HeaderObject | null; @@ -35,6 +36,7 @@ export declare namespace Settings { | UserFromParams | ToolName | AuthHeader + | ApiKey | CustomIndex | Headers; @@ -46,6 +48,7 @@ export declare namespace Settings { export interface Config extends DefaultConfig { autostart: boolean; + apiKey: ApiKey; authHeader: AuthHeader; browserSessionId: SessionId; custIndex: CustomIndex; @@ -108,22 +111,12 @@ export declare namespace Events { | "drop" | "keydown"; export type IntervalEvents = - | "click" - | "focus" - | "blur" - | "input" - | "change" - | "mouseover" - | "submit"; + "click" | "focus" | "blur" | "input" | "change" | "mouseover" | "submit"; export type WindowEvents = "load" | "blur" | "focus"; export type BufferedEvents = "wheel" | "scroll" | "resize"; export type RefreshEvents = "submit"; export type AllowedEvents = - | RawEvents - | IntervalEvents - | WindowEvents - | BufferedEvents - | RefreshEvents; + RawEvents | IntervalEvents | WindowEvents | BufferedEvents | RefreshEvents; export type EventDetailsMap = Partial<{ [key in T]: diff --git a/products/userale/packages/flagon-userale/test/spec/configure.spec.ts b/products/userale/packages/flagon-userale/test/spec/configure.spec.ts index 2dd8d85c..52381215 100644 --- a/products/userale/packages/flagon-userale/test/spec/configure.spec.ts +++ b/products/userale/packages/flagon-userale/test/spec/configure.spec.ts @@ -63,6 +63,15 @@ describe("configure", () => { done(); }); + it("allows apiKey to be set via config.update()", (done) => { + config.update({ apiKey: "test-api-key-123" }); + expect(config.apiKey).toBe("test-api-key-123"); + + config.update({ apiKey: null }); + expect(config.apiKey).toBeNull(); + done(); + }); + describe("getUserIdFromParams", () => { it("fetches userId from URL params", (done) => { Object.defineProperty(global.window, "location", { diff --git a/products/userale/packages/flagon-userale/test/spec/sendLogs.spec.ts b/products/userale/packages/flagon-userale/test/spec/sendLogs.spec.ts index f7fc6022..01ed820a 100644 --- a/products/userale/packages/flagon-userale/test/spec/sendLogs.spec.ts +++ b/products/userale/packages/flagon-userale/test/spec/sendLogs.spec.ts @@ -163,6 +163,71 @@ describe("sendLogs", () => { done(); }); + it("sends x-api-key header when config.apiKey is set", () => { + const fetchSpy = jest + .spyOn(global, "fetch") + .mockResolvedValue(new Response()); + + config.update({ + on: true, + url: "http://test.com", + apiKey: "my-secret-key", + }); + sendOnClose([{ foo: "bar" }], config); + global.window.dispatchEvent(new window.CustomEvent("pagehide")); + + expect(fetchSpy).toHaveBeenCalled(); + const lastCall = fetchSpy.mock.calls[fetchSpy.mock.calls.length - 1]!; + const callHeaders = lastCall[1]!.headers as Headers; + expect(callHeaders.get("x-api-key")).toBe("my-secret-key"); + + fetchSpy.mockRestore(); + }); + + it("does not send x-api-key header when config.apiKey is null", () => { + const fetchSpy = jest + .spyOn(global, "fetch") + .mockResolvedValue(new Response()); + + config.update({ + on: true, + url: "http://test.com", + apiKey: null, + }); + sendOnClose([{ foo: "bar" }], config); + global.window.dispatchEvent(new window.CustomEvent("pagehide")); + + expect(fetchSpy).toHaveBeenCalled(); + const lastCall = fetchSpy.mock.calls[fetchSpy.mock.calls.length - 1]!; + const callHeaders = lastCall[1]!.headers as Headers; + expect(callHeaders.get("x-api-key")).toBeNull(); + + fetchSpy.mockRestore(); + }); + + it("sends both Authorization and x-api-key headers when both are configured", () => { + const fetchSpy = jest + .spyOn(global, "fetch") + .mockResolvedValue(new Response()); + + config.update({ + on: true, + url: "http://test.com", + authHeader: "Bearer some-token", + apiKey: "my-secret-key", + }); + sendOnClose([{ foo: "bar" }], config); + global.window.dispatchEvent(new window.CustomEvent("pagehide")); + + expect(fetchSpy).toHaveBeenCalled(); + const lastCall = fetchSpy.mock.calls[fetchSpy.mock.calls.length - 1]!; + const callHeaders = lastCall[1]!.headers as Headers; + expect(callHeaders.get("Authorization")).toBe("Bearer some-token"); + expect(callHeaders.get("x-api-key")).toBe("my-secret-key"); + + fetchSpy.mockRestore(); + }); + it("sends logs with proper custom headers when using registerHeadersCallback", (done) => { const logs: Array = []; config.update({