Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@elfsquad/configurator",
"version": "3.6.10",
"version": "3.6.11",
"description": "",
"scripts": {
"test": "jest",
Expand Down
98 changes: 98 additions & 0 deletions src/configurator/ConfiguratorContext.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,104 @@ describe("ConfiguratorContext", () => {
});
});

describe("accessTokenProvider", () => {
it("should send the provided token as the bearer instead of the anonymous id header", async () => {
const ctx = new ConfiguratorContext({
apiUrl: API_URL,
tenantId: "<TENANT_ID>",
accessTokenProvider: () => "provided-token",
});

mockNextFetchResponse({});
await ctx.getSettings();

expect(lastRequest.headers.get("authorization")).toBe("Bearer provided-token");
expect(lastRequest.headers.get("x-elfsquad-id")).toBeNull();
});

it("should await an asynchronous provider", async () => {
const ctx = new ConfiguratorContext({
apiUrl: API_URL,
tenantId: "<TENANT_ID>",
accessTokenProvider: () => Promise.resolve("async-token"),
});

mockNextFetchResponse({});
await ctx.getSettings();

expect(lastRequest.headers.get("authorization")).toBe("Bearer async-token");
});

it("should fall back to the normal resolution when the provider returns null", async () => {
const ctx = new ConfiguratorContext({
apiUrl: API_URL,
tenantId: "<TENANT_ID>",
accessTokenProvider: () => null,
});

mockNextFetchResponse({});
await ctx.getSettings();

expect(lastRequest.headers.get("authorization")).toBeNull();
expect(lastRequest.headers.get("x-elfsquad-id")).toBe("<TENANT_ID>");
});
});

describe("additionalHeaders", () => {
it("should add the resolved headers to requests", async () => {
const ctx = new ConfiguratorContext({
apiUrl: API_URL,
tenantId: "<TENANT_ID>",
additionalHeaders: () => ({ "x-elf-orgid": "<ORG_ID>", "x-elf-tenantid": "<TENANT_ID>" }),
});

mockNextFetchResponse({});
await ctx.getSettings();

expect(lastRequest.headers.get("x-elf-orgid")).toBe("<ORG_ID>");
expect(lastRequest.headers.get("x-elf-tenantid")).toBe("<TENANT_ID>");
});

it("should be resolved per request", async () => {
let orgId = "first";
const ctx = new ConfiguratorContext({
apiUrl: API_URL,
tenantId: "<TENANT_ID>",
additionalHeaders: () => ({ "x-elf-orgid": orgId }),
});

mockNextFetchResponse({});
await ctx.getSettings();
expect(lastRequest.headers.get("x-elf-orgid")).toBe("first");

orgId = "second";
mockNextFetchResponse({});
await ctx.getSettings();
expect(lastRequest.headers.get("x-elf-orgid")).toBe("second");
});

it("should overwrite headers this library sets itself", async () => {
const ctx = new ConfiguratorContext({
apiUrl: API_URL,
tenantDomain: "test.example.com",
additionalHeaders: () => ({ "x-elfsquad-domain": "override.example.com" }),
});

mockNextFetchResponse({});
await ctx.getSettings();

expect(lastRequest.headers.get("x-elfsquad-domain")).toBe("override.example.com");
});

it("should leave requests untouched when no option is supplied", async () => {
mockNextFetchResponse({});
await configuratorContext.getSettings();

expect(lastRequest.headers.get("x-elf-orgid")).toBeNull();
expect(lastRequest.headers.get("x-elfsquad-id")).toBe("<TENANT_ID>");
});
});

describe("HTTP error handling", () => {
it("should throw ConfiguratorHttpError on non-ok response with JSON body", async () => {
mockNextFetchErrorResponse(401, JSON.stringify({ error: "Unauthorized" }));
Expand Down
12 changes: 11 additions & 1 deletion src/configurator/ConfiguratorContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,12 @@ export class ConfiguratorContext extends EventTarget {
input.headers.append("x-elfsquad-domain", this.options.tenantDomain);
}

if (await this.useElfsquadIdHeader()) {
// A host-supplied token stands in for a signed-in user, so it takes the authenticated
// path even when the configured method would otherwise fall back to anonymous.
const providedToken = await this.options.accessTokenProvider?.();
if (providedToken) {
input.headers.set("authorization", `Bearer ${providedToken}`);
} else if (await this.useElfsquadIdHeader()) {
if (this.options.tenantId) {
input.headers.append("x-elfsquad-id", this.options.tenantId);
}
Expand All @@ -434,6 +439,11 @@ export class ConfiguratorContext extends EventTarget {
);
}

const additionalHeaders = await this.options.additionalHeaders?.();
for (const [name, value] of Object.entries(additionalHeaders ?? {})) {
input.headers.set(name, value);
}

let response: Response;
try {
response = await fetch(input);
Expand Down
28 changes: 28 additions & 0 deletions src/configurator/IConfiguratorOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,34 @@ export interface IConfiguratorOptions {
* api.elfsquad.io.
*/
apiUrl?: string | undefined;

/**
* Optionally supply the bearer token for every request, instead of
* taking it from the @link{AuthenticationContext}. Use this when the
* host application receives a token by another route than the OAuth
* flow — for example a token handed over by the embedding
* application.
*
* Returning null or undefined falls back to the normal resolution,
* so the host decides per request whether its own token applies.
*
* Called on every request; return a cached value if resolving it is
* expensive.
*/
accessTokenProvider?:
| (() => string | null | undefined | Promise<string | null | undefined>)
| undefined;

/**
* Optional headers added to every request, resolved per request.
* Use for context the host resolves at runtime, such as the
* `x-elf-orgid` and `x-elf-tenantid` headers that select the selling
* organization and the tenant.
*
* These are applied last and overwrite headers this library sets
* itself.
*/
additionalHeaders?: (() => Record<string, string> | Promise<Record<string, string>>) | undefined;
}

export type AuthenticationMethod = (typeof AuthenticationMethod)[keyof typeof AuthenticationMethod];
Expand Down
Loading