From 414e4d2b9a02aed1600f26c4fda7846e9b60e7de Mon Sep 17 00:00:00 2001 From: Mike Souza Date: Wed, 12 Nov 2025 12:30:30 -0500 Subject: [PATCH 1/7] feat: support custom OIDC token endpoint URL --- credentials/credentials.ts | 33 +++++++++- tests/credentials.test.ts | 120 +++++++++++++++++++++++++++++++++++++ tests/index.test.ts | 19 +++++- 3 files changed, 167 insertions(+), 5 deletions(-) create mode 100644 tests/credentials.test.ts diff --git a/credentials/credentials.ts b/credentials/credentials.ts index 7a7fa96..a75bd4b 100644 --- a/credentials/credentials.ts +++ b/credentials/credentials.ts @@ -37,6 +37,8 @@ interface ClientAssertionRequest { audience: string; } +export const DEFAULT_TOKEN_ENDPOINT_PATH = "oauth/token"; + export class Credentials { private accessToken?: string; private accessTokenExpiryDate?: Date; @@ -93,9 +95,9 @@ export class Credentials { assertParamExists("Credentials", "config.apiAudience", authConfig.config?.apiAudience); assertParamExists("Credentials", "config.clientSecret or config.clientAssertionSigningKey", (authConfig.config as ClientSecretConfig).clientSecret || (authConfig.config as PrivateKeyJWTConfig).clientAssertionSigningKey); - if (!isWellFormedUriString(`https://${authConfig.config?.apiTokenIssuer}`)) { + if (!isWellFormedUriString(this.buildApiTokenUrl(authConfig.config?.apiTokenIssuer))) { throw new FgaValidationError( - `Configuration.apiTokenIssuer does not form a valid URI (https://${authConfig.config?.apiTokenIssuer})`); + `Configuration.apiTokenIssuer does not form a valid URI (${authConfig.config?.apiTokenIssuer})`); } break; } @@ -138,13 +140,38 @@ export class Credentials { } } + /** + * Constructs the token endpoint URL from the provided API token issuer. + * Defaults to https:// scheme if none provided and appends the default + * token endpoint path when the issuer has no path or only a root path. + * + * @param apiTokenIssuer + * @return string The constructed token endpoint URL, or empty string if invalid + */ + private buildApiTokenUrl(apiTokenIssuer: string): string { + let url = URL.parse(apiTokenIssuer); + if (!url && !apiTokenIssuer.startsWith("https://") && !apiTokenIssuer.startsWith("http://")) { + url = URL.parse(`https://${apiTokenIssuer}`); + } + + if (url) { + if (url.pathname === "" || url.pathname === "/") { + url.pathname = `/${DEFAULT_TOKEN_ENDPOINT_PATH}`; + } + + return url.toString(); + } + + return ""; + } + /** * Request new access token * @return string */ private async refreshAccessToken() { const clientCredentials = (this.authConfig as { method: CredentialsMethod.ClientCredentials; config: ClientCredentialsConfig })?.config; - const url = `https://${clientCredentials.apiTokenIssuer}/oauth/token`; + const url = this.buildApiTokenUrl(clientCredentials.apiTokenIssuer); const credentialsPayload = await this.buildClientAuthenticationPayload(); try { diff --git a/tests/credentials.test.ts b/tests/credentials.test.ts new file mode 100644 index 0000000..621e50e --- /dev/null +++ b/tests/credentials.test.ts @@ -0,0 +1,120 @@ +import * as nock from "nock"; +import { Credentials, CredentialsMethod, DEFAULT_TOKEN_ENDPOINT_PATH } from "../credentials"; +import { AuthCredentialsConfig } from "../credentials/types"; +import { TelemetryConfiguration } from "../telemetry/configuration"; +import { + OPENFGA_API_AUDIENCE, + OPENFGA_CLIENT_ID, + OPENFGA_CLIENT_SECRET, +} from "./helpers/default-config"; + +nock.disableNetConnect(); + +describe("Credentials", () => { + const mockTelemetryConfig: TelemetryConfiguration = new TelemetryConfiguration({}); + + describe("Refreshing access token", () => { + interface TestCase { + description: string; + apiTokenIssuer: string; + expectedBaseUrl: string; + expectedPath: string; + queryParams?: Record; + } + + const testCases: TestCase[] = [ + { + description: "should use default scheme and token endpoint path when apiTokenIssuer has no scheme and no path", + apiTokenIssuer: "issuer.fga.example", + expectedBaseUrl: "https://issuer.fga.example", + expectedPath: `/${DEFAULT_TOKEN_ENDPOINT_PATH}`, + }, + { + description: "should use default token endpoint path when apiTokenIssuer has root path and no scheme", + apiTokenIssuer: "https://issuer.fga.example/", + expectedBaseUrl: "https://issuer.fga.example", + expectedPath: `/${DEFAULT_TOKEN_ENDPOINT_PATH}`, + }, + { + description: "should preserve custom token endpoint path when provided", + apiTokenIssuer: "https://issuer.fga.example/some_endpoint", + expectedBaseUrl: "https://issuer.fga.example", + expectedPath: "/some_endpoint", + }, + { + description: "should preserve custom token endpoint path with nested path when provided", + apiTokenIssuer: "https://issuer.fga.example/api/v1/oauth/token", + expectedBaseUrl: "https://issuer.fga.example", + expectedPath: "/api/v1/oauth/token", + }, + { + description: "should add https:// prefix when apiTokenIssuer has no scheme", + apiTokenIssuer: "issuer.fga.example/some_endpoint", + expectedBaseUrl: "https://issuer.fga.example", + expectedPath: "/some_endpoint", + }, + { + description: "should preserve http:// scheme when provided", + apiTokenIssuer: "http://issuer.fga.example/some_endpoint", + expectedBaseUrl: "http://issuer.fga.example", + expectedPath: "/some_endpoint", + }, + { + description: "should use default path when apiTokenIssuer has https:// scheme but no path", + apiTokenIssuer: "https://issuer.fga.example", + expectedBaseUrl: "https://issuer.fga.example", + expectedPath: `/${DEFAULT_TOKEN_ENDPOINT_PATH}`, + }, + { + description: "should preserve custom path with query parameters", + apiTokenIssuer: "https://issuer.fga.example/some_endpoint?param=value", + expectedBaseUrl: "https://issuer.fga.example", + expectedPath: "/some_endpoint", + queryParams: { param: "value" }, + }, + { + description: "should preserve custom path with port number", + apiTokenIssuer: "https://issuer.fga.example:8080/some_endpoint", + expectedBaseUrl: "https://issuer.fga.example:8080", + expectedPath: "/some_endpoint", + }, + ]; + + test.each(testCases)("$description", async ({ apiTokenIssuer, expectedBaseUrl, expectedPath, queryParams }) => { + const scope = queryParams + ? nock(expectedBaseUrl) + .post(expectedPath) + .query(queryParams) + .reply(200, { + access_token: "test-token", + expires_in: 300, + }) + : nock(expectedBaseUrl) + .post(expectedPath) + .reply(200, { + access_token: "test-token", + expires_in: 300, + }); + + const credentials = new Credentials( + { + method: CredentialsMethod.ClientCredentials, + config: { + apiTokenIssuer, + apiAudience: OPENFGA_API_AUDIENCE, + clientId: OPENFGA_CLIENT_ID, + clientSecret: OPENFGA_CLIENT_SECRET, + }, + } as AuthCredentialsConfig, + undefined, + mockTelemetryConfig, + ); + + await credentials.getAccessTokenHeader(); + + expect(scope.isDone()).toBe(true); + nock.cleanAll(); + }); + }); +}); + diff --git a/tests/index.test.ts b/tests/index.test.ts index 5be22a4..58c3527 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -61,7 +61,7 @@ describe("OpenFGA SDK", function () { ).not.toThrowError(); }); - it("should validate apiTokenIssuer in configuration (should not allow scheme as part of the apiTokenIssuer)", () => { + it.each(["https://", "http://", ""])("should allow valid schemes as part of the apiTokenIssuer in configuration (%s)", (scheme) => { expect( () => new OpenFgaApi({ ...baseConfig, @@ -69,7 +69,22 @@ describe("OpenFGA SDK", function () { method: CredentialsMethod.ClientCredentials, config: { ...(baseConfig.credentials as any).config, - apiTokenIssuer: "https://tokenissuer.fga.example" + apiTokenIssuer: `${scheme}tokenissuer.fga.example` + } + } as Configuration["credentials"] + }) + ).not.toThrowError(); + }); + + it.each(["tcp://", "grpc://", "file://"])("should not allow invalid schemes as part of the apiTokenIssuer in configuration (%s)", (scheme) => { + expect( + () => new OpenFgaApi({ + ...baseConfig, + credentials: { + method: CredentialsMethod.ClientCredentials, + config: { + ...(baseConfig.credentials as any).config, + apiTokenIssuer: `${scheme}tokenissuer.fga.example` } } as Configuration["credentials"] }) From 3173c02373a790176e7dcbf438ee8ad01aeb8abc Mon Sep 17 00:00:00 2001 From: Mike Souza Date: Fri, 14 Nov 2025 16:13:45 -0500 Subject: [PATCH 2/7] Resolve requested changes --- credentials/credentials.ts | 39 +++++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/credentials/credentials.ts b/credentials/credentials.ts index a75bd4b..5514360 100644 --- a/credentials/credentials.ts +++ b/credentials/credentials.ts @@ -22,6 +22,7 @@ import { TelemetryAttributes } from "../telemetry/attributes"; import { TelemetryCounters } from "../telemetry/counters"; import { TelemetryConfiguration } from "../telemetry/configuration"; import { randomUUID } from "crypto"; +import { URL } from "url"; interface ClientSecretRequest { client_id: string; @@ -95,9 +96,9 @@ export class Credentials { assertParamExists("Credentials", "config.apiAudience", authConfig.config?.apiAudience); assertParamExists("Credentials", "config.clientSecret or config.clientAssertionSigningKey", (authConfig.config as ClientSecretConfig).clientSecret || (authConfig.config as PrivateKeyJWTConfig).clientAssertionSigningKey); - if (!isWellFormedUriString(this.buildApiTokenUrl(authConfig.config?.apiTokenIssuer))) { + if (!isWellFormedUriString(this.normalizeApiTokenIssuer(authConfig.config?.apiTokenIssuer))) { throw new FgaValidationError( - `Configuration.apiTokenIssuer does not form a valid URI (${authConfig.config?.apiTokenIssuer})`); + `Configuration.apiTokenIssuer does not form a valid URI (${this.normalizeApiTokenIssuer(authConfig.config?.apiTokenIssuer)})`); } break; } @@ -140,29 +141,40 @@ export class Credentials { } } + /** + * Normalize API token issuer URL by ensuring it has a scheme + * @private + * @param apiTokenIssuer + * @return string The normalized API token issuer URL + */ + private normalizeApiTokenIssuer(apiTokenIssuer: string): string { + if (apiTokenIssuer.startsWith("http://") || apiTokenIssuer.startsWith("https://")) { + return apiTokenIssuer; + } + return `https://${apiTokenIssuer}`; + } + /** * Constructs the token endpoint URL from the provided API token issuer. * Defaults to https:// scheme if none provided and appends the default * token endpoint path when the issuer has no path or only a root path. - * + * @private * @param apiTokenIssuer - * @return string The constructed token endpoint URL, or empty string if invalid + * @return string The constructed token endpoint URL if valid, otherwise throws an error + * @throws {FgaValidationError} If the API token issuer URL is invalid */ private buildApiTokenUrl(apiTokenIssuer: string): string { - let url = URL.parse(apiTokenIssuer); - if (!url && !apiTokenIssuer.startsWith("https://") && !apiTokenIssuer.startsWith("http://")) { - url = URL.parse(`https://${apiTokenIssuer}`); - } + const normalizedApiTokenIssuer = this.normalizeApiTokenIssuer(apiTokenIssuer); - if (url) { + try { + const url = new URL(normalizedApiTokenIssuer); if (url.pathname === "" || url.pathname === "/") { url.pathname = `/${DEFAULT_TOKEN_ENDPOINT_PATH}`; } - return url.toString(); + } catch { + throw new FgaValidationError(`Invalid API token issuer URL: ${normalizedApiTokenIssuer}`); } - - return ""; } /** @@ -243,13 +255,14 @@ export class Credentials { if ((config as PrivateKeyJWTConfig).clientAssertionSigningKey) { const alg = (config as PrivateKeyJWTConfig).clientAssertionSigningAlgorithm || "RS256"; const privateKey = await jose.importPKCS8((config as PrivateKeyJWTConfig).clientAssertionSigningKey, alg); + const audienceIssuer = this.normalizeApiTokenIssuer(config.apiTokenIssuer); const assertion = await new jose.SignJWT({}) .setProtectedHeader({ alg }) .setIssuedAt() .setSubject(config.clientId) .setJti(randomUUID()) .setIssuer(config.clientId) - .setAudience(`https://${config.apiTokenIssuer}/`) + .setAudience(`${audienceIssuer}/`) .setExpirationTime("2m") .sign(privateKey); return { From 5e0d1537994b0d1b2353b638b679542ed3617c33 Mon Sep 17 00:00:00 2001 From: Mike Souza Date: Tue, 18 Nov 2025 18:46:48 -0500 Subject: [PATCH 3/7] Fix additional requested changes --- credentials/credentials.ts | 12 ++++--- tests/credentials.test.ts | 72 ++++++++++++++++++++++++++++++++++++++ tests/index.test.ts | 2 +- 3 files changed, 80 insertions(+), 6 deletions(-) diff --git a/credentials/credentials.ts b/credentials/credentials.ts index 5514360..03fcaa4 100644 --- a/credentials/credentials.ts +++ b/credentials/credentials.ts @@ -90,16 +90,18 @@ export class Credentials { assertParamExists("Credentials", "config.headerName", authConfig.config?.headerName); assertParamExists("Credentials", "config.headerName", authConfig.config?.headerName); break; - case CredentialsMethod.ClientCredentials: + case CredentialsMethod.ClientCredentials: { assertParamExists("Credentials", "config.clientId", authConfig.config?.clientId); assertParamExists("Credentials", "config.apiTokenIssuer", authConfig.config?.apiTokenIssuer); assertParamExists("Credentials", "config.apiAudience", authConfig.config?.apiAudience); assertParamExists("Credentials", "config.clientSecret or config.clientAssertionSigningKey", (authConfig.config as ClientSecretConfig).clientSecret || (authConfig.config as PrivateKeyJWTConfig).clientAssertionSigningKey); - if (!isWellFormedUriString(this.normalizeApiTokenIssuer(authConfig.config?.apiTokenIssuer))) { + const normalizedApiTokenIssuer = this.normalizeApiTokenIssuer(authConfig.config?.apiTokenIssuer); + if (!isWellFormedUriString(normalizedApiTokenIssuer)) { throw new FgaValidationError( - `Configuration.apiTokenIssuer does not form a valid URI (${this.normalizeApiTokenIssuer(authConfig.config?.apiTokenIssuer)})`); + `Configuration.apiTokenIssuer does not form a valid URI (${normalizedApiTokenIssuer})`); } + } break; } } @@ -171,7 +173,7 @@ export class Credentials { if (url.pathname === "" || url.pathname === "/") { url.pathname = `/${DEFAULT_TOKEN_ENDPOINT_PATH}`; } - return url.toString(); + return url.toString(); // Query params are preserved in the URL } catch { throw new FgaValidationError(`Invalid API token issuer URL: ${normalizedApiTokenIssuer}`); } @@ -262,7 +264,7 @@ export class Credentials { .setSubject(config.clientId) .setJti(randomUUID()) .setIssuer(config.clientId) - .setAudience(`${audienceIssuer}/`) + .setAudience(`${audienceIssuer}/`) // Trailing slash is required by the OAuth 2.0 specification .setExpirationTime("2m") .sign(privateKey); return { diff --git a/tests/credentials.test.ts b/tests/credentials.test.ts index 621e50e..8566232 100644 --- a/tests/credentials.test.ts +++ b/tests/credentials.test.ts @@ -1,12 +1,15 @@ import * as nock from "nock"; +import * as jose from "jose"; import { Credentials, CredentialsMethod, DEFAULT_TOKEN_ENDPOINT_PATH } from "../credentials"; import { AuthCredentialsConfig } from "../credentials/types"; import { TelemetryConfiguration } from "../telemetry/configuration"; import { OPENFGA_API_AUDIENCE, + OPENFGA_CLIENT_ASSERTION_SIGNING_KEY, OPENFGA_CLIENT_ID, OPENFGA_CLIENT_SECRET, } from "./helpers/default-config"; +import {FgaValidationError} from "../errors"; nock.disableNetConnect(); @@ -115,6 +118,75 @@ describe("Credentials", () => { expect(scope.isDone()).toBe(true); nock.cleanAll(); }); + + it("should throw FgaValidationError for malformed apiTokenIssuer", () => { + expect(() => new Credentials( + { + method: CredentialsMethod.ClientCredentials, + config: { + apiTokenIssuer: "not a valid url::::", + apiAudience: OPENFGA_API_AUDIENCE, + clientId: OPENFGA_CLIENT_ID, + clientSecret: OPENFGA_CLIENT_SECRET, + }, + } as AuthCredentialsConfig, + undefined, + mockTelemetryConfig, + )).toThrowError(FgaValidationError); + }); + + test.each([ + { + description: "HTTPS scheme", + apiTokenIssuer: "https://issuer.fga.example/some_endpoint", + expectedBaseUrl: "https://issuer.fga.example", + expectedAudience: "https://issuer.fga.example/some_endpoint/", + }, + { + description: "HTTP scheme", + apiTokenIssuer: "http://issuer.fga.example/some_endpoint", + expectedBaseUrl: "http://issuer.fga.example", + expectedAudience: "http://issuer.fga.example/some_endpoint/", + }, + { + description: "No scheme", + apiTokenIssuer: "issuer.fga.example/some_endpoint", + expectedBaseUrl: "https://issuer.fga.example", + expectedAudience: "https://issuer.fga.example/some_endpoint/", + } + ])("should normalize audience from apiTokenIssuer when using PrivateKeyJWT client credentials ($description)", async ({ apiTokenIssuer, expectedBaseUrl, expectedAudience }) => { + const scope = nock(expectedBaseUrl) + .post("/some_endpoint", (body: string) => { + const params = new URLSearchParams(body); + const clientAssertion = params.get("client_assertion") as string; + const decoded = jose.decodeJwt(clientAssertion); + expect(decoded.aud).toBe(`${expectedAudience}`); + return true; + }) + .reply(200, { + access_token: "test-token", + expires_in: 300, + }); + + const credentials = new Credentials( + { + method: CredentialsMethod.ClientCredentials, + config: { + apiTokenIssuer, + apiAudience: OPENFGA_API_AUDIENCE, + clientId: OPENFGA_CLIENT_ID, + clientAssertionSigningKey: OPENFGA_CLIENT_ASSERTION_SIGNING_KEY, + }, + } as AuthCredentialsConfig, + undefined, + mockTelemetryConfig, + ); + + await credentials.getAccessTokenHeader(); + + expect(scope.isDone()).toBe(true); + nock.cleanAll(); + }); }); }); diff --git a/tests/index.test.ts b/tests/index.test.ts index 58c3527..8cfc718 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -61,7 +61,7 @@ describe("OpenFGA SDK", function () { ).not.toThrowError(); }); - it.each(["https://", "http://", ""])("should allow valid schemes as part of the apiTokenIssuer in configuration (%s)", (scheme) => { + it.each(["https://", "http://", ""])("should allow valid schemes or default when scheme is missing (%s)", (scheme) => { expect( () => new OpenFgaApi({ ...baseConfig, From e1619b9800af61e64c6beaa78c1518662f927caa Mon Sep 17 00:00:00 2001 From: Mike Souza Date: Wed, 19 Nov 2025 08:29:09 -0500 Subject: [PATCH 4/7] Add more tests and refactor constants --- credentials/credentials.ts | 11 ++++++----- tests/credentials.test.ts | 25 ++++++++++++++++++++++--- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/credentials/credentials.ts b/credentials/credentials.ts index 03fcaa4..a8c3409 100644 --- a/credentials/credentials.ts +++ b/credentials/credentials.ts @@ -38,6 +38,9 @@ interface ClientAssertionRequest { audience: string; } +const HTTP_SCHEME = "http://"; +const HTTPS_SCHEME = "https://"; + export const DEFAULT_TOKEN_ENDPOINT_PATH = "oauth/token"; export class Credentials { @@ -150,16 +153,14 @@ export class Credentials { * @return string The normalized API token issuer URL */ private normalizeApiTokenIssuer(apiTokenIssuer: string): string { - if (apiTokenIssuer.startsWith("http://") || apiTokenIssuer.startsWith("https://")) { + if (apiTokenIssuer.startsWith(HTTP_SCHEME) || apiTokenIssuer.startsWith(HTTPS_SCHEME)) { return apiTokenIssuer; } - return `https://${apiTokenIssuer}`; + return `${HTTPS_SCHEME}${apiTokenIssuer}`; } /** * Constructs the token endpoint URL from the provided API token issuer. - * Defaults to https:// scheme if none provided and appends the default - * token endpoint path when the issuer has no path or only a root path. * @private * @param apiTokenIssuer * @return string The constructed token endpoint URL if valid, otherwise throws an error @@ -170,7 +171,7 @@ export class Credentials { try { const url = new URL(normalizedApiTokenIssuer); - if (url.pathname === "" || url.pathname === "/") { + if (!url.pathname || url.pathname.match(/^\/+$/)) { url.pathname = `/${DEFAULT_TOKEN_ENDPOINT_PATH}`; } return url.toString(); // Query params are preserved in the URL diff --git a/tests/credentials.test.ts b/tests/credentials.test.ts index 8566232..873155b 100644 --- a/tests/credentials.test.ts +++ b/tests/credentials.test.ts @@ -81,6 +81,12 @@ describe("Credentials", () => { expectedBaseUrl: "https://issuer.fga.example:8080", expectedPath: "/some_endpoint", }, + { + description: "should use default path when multiple trailing slashes", + apiTokenIssuer: "https://issuer.fga.example///", + expectedBaseUrl: "https://issuer.fga.example", + expectedPath: `/${DEFAULT_TOKEN_ENDPOINT_PATH}`, + }, ]; test.each(testCases)("$description", async ({ apiTokenIssuer, expectedBaseUrl, expectedPath, queryParams }) => { @@ -119,12 +125,25 @@ describe("Credentials", () => { nock.cleanAll(); }); - it("should throw FgaValidationError for malformed apiTokenIssuer", () => { + test.each([ + { + description: "malformed url", + apiTokenIssuer: "not a valid url::::", + }, + { + description: "empty string", + apiTokenIssuer: "", + }, + { + description: "whitespace-only issuer", + apiTokenIssuer: " ", + }, + ])("should throw FgaValidationError when $description", ({ apiTokenIssuer }) => { expect(() => new Credentials( { method: CredentialsMethod.ClientCredentials, config: { - apiTokenIssuer: "not a valid url::::", + apiTokenIssuer, apiAudience: OPENFGA_API_AUDIENCE, clientId: OPENFGA_CLIENT_ID, clientSecret: OPENFGA_CLIENT_SECRET, @@ -132,7 +151,7 @@ describe("Credentials", () => { } as AuthCredentialsConfig, undefined, mockTelemetryConfig, - )).toThrowError(FgaValidationError); + )).toThrow(FgaValidationError); }); test.each([ From 05f0dbbc24a9d48540e16d3baac9c860a5b138c7 Mon Sep 17 00:00:00 2001 From: Mike Souza Date: Wed, 19 Nov 2025 08:49:30 -0500 Subject: [PATCH 5/7] Add more test cases --- tests/credentials.test.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/credentials.test.ts b/tests/credentials.test.ts index 873155b..2f1e75e 100644 --- a/tests/credentials.test.ts +++ b/tests/credentials.test.ts @@ -82,11 +82,23 @@ describe("Credentials", () => { expectedPath: "/some_endpoint", }, { - description: "should use default path when multiple trailing slashes", + description: "should use default path when path has multiple trailing slashes", apiTokenIssuer: "https://issuer.fga.example///", expectedBaseUrl: "https://issuer.fga.example", expectedPath: `/${DEFAULT_TOKEN_ENDPOINT_PATH}`, }, + { + description: "should use default path when path only consists of slashes", + apiTokenIssuer: "https://issuer.fga.example//", + expectedBaseUrl: "https://issuer.fga.example", + expectedPath: `/${DEFAULT_TOKEN_ENDPOINT_PATH}`, + }, + { + description: "should preserve custom path with consecutive/trailing slashes", + apiTokenIssuer: "https://issuer.fga.example/oauth//token///", + expectedBaseUrl: "https://issuer.fga.example", + expectedPath: "/oauth//token///", + }, ]; test.each(testCases)("$description", async ({ apiTokenIssuer, expectedBaseUrl, expectedPath, queryParams }) => { From c218edbe4a2ed3b276a55e9eb623698b15037570 Mon Sep 17 00:00:00 2001 From: Mike Souza Date: Fri, 21 Nov 2025 12:37:52 -0500 Subject: [PATCH 6/7] Fix nock issue in CI --- tests/credentials.test.ts | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/tests/credentials.test.ts b/tests/credentials.test.ts index 2f1e75e..6ccf360 100644 --- a/tests/credentials.test.ts +++ b/tests/credentials.test.ts @@ -17,15 +17,11 @@ describe("Credentials", () => { const mockTelemetryConfig: TelemetryConfiguration = new TelemetryConfiguration({}); describe("Refreshing access token", () => { - interface TestCase { - description: string; - apiTokenIssuer: string; - expectedBaseUrl: string; - expectedPath: string; - queryParams?: Record; - } + afterEach(() => { + nock.cleanAll(); + }); - const testCases: TestCase[] = [ + test.each([ { description: "should use default scheme and token endpoint path when apiTokenIssuer has no scheme and no path", apiTokenIssuer: "issuer.fga.example", @@ -99,9 +95,7 @@ describe("Credentials", () => { expectedBaseUrl: "https://issuer.fga.example", expectedPath: "/oauth//token///", }, - ]; - - test.each(testCases)("$description", async ({ apiTokenIssuer, expectedBaseUrl, expectedPath, queryParams }) => { + ])("$description", async ({ apiTokenIssuer, expectedBaseUrl, expectedPath, queryParams }) => { const scope = queryParams ? nock(expectedBaseUrl) .post(expectedPath) @@ -134,7 +128,6 @@ describe("Credentials", () => { await credentials.getAccessTokenHeader(); expect(scope.isDone()).toBe(true); - nock.cleanAll(); }); test.each([ @@ -216,8 +209,6 @@ describe("Credentials", () => { await credentials.getAccessTokenHeader(); expect(scope.isDone()).toBe(true); - nock.cleanAll(); }); }); }); - From 685f2af2ddabfc04498f9d6fd22399362b71de36 Mon Sep 17 00:00:00 2001 From: Mike Souza Date: Tue, 2 Dec 2025 15:05:06 -0500 Subject: [PATCH 7/7] Attempt to fix nock issue in CI --- tests/credentials.test.ts | 549 ++++++++++++++++++++++++++++++-------- 1 file changed, 438 insertions(+), 111 deletions(-) diff --git a/tests/credentials.test.ts b/tests/credentials.test.ts index 6ccf360..a465c79 100644 --- a/tests/credentials.test.ts +++ b/tests/credentials.test.ts @@ -11,105 +11,373 @@ import { } from "./helpers/default-config"; import {FgaValidationError} from "../errors"; -nock.disableNetConnect(); - describe("Credentials", () => { const mockTelemetryConfig: TelemetryConfiguration = new TelemetryConfiguration({}); describe("Refreshing access token", () => { + beforeEach(() => { + nock.disableNetConnect(); + }); + afterEach(() => { nock.cleanAll(); + nock.enableNetConnect(); }); - test.each([ - { - description: "should use default scheme and token endpoint path when apiTokenIssuer has no scheme and no path", - apiTokenIssuer: "issuer.fga.example", - expectedBaseUrl: "https://issuer.fga.example", - expectedPath: `/${DEFAULT_TOKEN_ENDPOINT_PATH}`, - }, - { - description: "should use default token endpoint path when apiTokenIssuer has root path and no scheme", - apiTokenIssuer: "https://issuer.fga.example/", - expectedBaseUrl: "https://issuer.fga.example", - expectedPath: `/${DEFAULT_TOKEN_ENDPOINT_PATH}`, - }, - { - description: "should preserve custom token endpoint path when provided", - apiTokenIssuer: "https://issuer.fga.example/some_endpoint", - expectedBaseUrl: "https://issuer.fga.example", - expectedPath: "/some_endpoint", - }, - { - description: "should preserve custom token endpoint path with nested path when provided", - apiTokenIssuer: "https://issuer.fga.example/api/v1/oauth/token", - expectedBaseUrl: "https://issuer.fga.example", - expectedPath: "/api/v1/oauth/token", - }, - { - description: "should add https:// prefix when apiTokenIssuer has no scheme", - apiTokenIssuer: "issuer.fga.example/some_endpoint", - expectedBaseUrl: "https://issuer.fga.example", - expectedPath: "/some_endpoint", - }, - { - description: "should preserve http:// scheme when provided", - apiTokenIssuer: "http://issuer.fga.example/some_endpoint", - expectedBaseUrl: "http://issuer.fga.example", - expectedPath: "/some_endpoint", - }, - { - description: "should use default path when apiTokenIssuer has https:// scheme but no path", - apiTokenIssuer: "https://issuer.fga.example", - expectedBaseUrl: "https://issuer.fga.example", - expectedPath: `/${DEFAULT_TOKEN_ENDPOINT_PATH}`, - }, - { - description: "should preserve custom path with query parameters", - apiTokenIssuer: "https://issuer.fga.example/some_endpoint?param=value", - expectedBaseUrl: "https://issuer.fga.example", - expectedPath: "/some_endpoint", - queryParams: { param: "value" }, - }, - { - description: "should preserve custom path with port number", - apiTokenIssuer: "https://issuer.fga.example:8080/some_endpoint", - expectedBaseUrl: "https://issuer.fga.example:8080", - expectedPath: "/some_endpoint", - }, - { - description: "should use default path when path has multiple trailing slashes", - apiTokenIssuer: "https://issuer.fga.example///", - expectedBaseUrl: "https://issuer.fga.example", - expectedPath: `/${DEFAULT_TOKEN_ENDPOINT_PATH}`, - }, - { - description: "should use default path when path only consists of slashes", - apiTokenIssuer: "https://issuer.fga.example//", - expectedBaseUrl: "https://issuer.fga.example", - expectedPath: `/${DEFAULT_TOKEN_ENDPOINT_PATH}`, - }, - { - description: "should preserve custom path with consecutive/trailing slashes", - apiTokenIssuer: "https://issuer.fga.example/oauth//token///", - expectedBaseUrl: "https://issuer.fga.example", - expectedPath: "/oauth//token///", - }, - ])("$description", async ({ apiTokenIssuer, expectedBaseUrl, expectedPath, queryParams }) => { - const scope = queryParams - ? nock(expectedBaseUrl) - .post(expectedPath) - .query(queryParams) - .reply(200, { - access_token: "test-token", - expires_in: 300, - }) - : nock(expectedBaseUrl) - .post(expectedPath) - .reply(200, { - access_token: "test-token", - expires_in: 300, - }); + test("should use default scheme and token endpoint path when apiTokenIssuer has no scheme and no path", async () => { + const apiTokenIssuer = "issuer.fga.example"; + const expectedBaseUrl = "https://issuer.fga.example"; + const expectedPath = `/${DEFAULT_TOKEN_ENDPOINT_PATH}`; + + const scope = nock(expectedBaseUrl) + .post(expectedPath) + .reply(200, { + access_token: "test-token", + expires_in: 300, + }); + + const credentials = new Credentials( + { + method: CredentialsMethod.ClientCredentials, + config: { + apiTokenIssuer, + apiAudience: OPENFGA_API_AUDIENCE, + clientId: OPENFGA_CLIENT_ID, + clientSecret: OPENFGA_CLIENT_SECRET, + }, + } as AuthCredentialsConfig, + undefined, + mockTelemetryConfig, + ); + + await credentials.getAccessTokenHeader(); + + expect(scope.isDone()).toBe(true); + }); + + test("should use default token endpoint path when apiTokenIssuer has root path and no scheme", async () => { + const apiTokenIssuer = "https://issuer.fga.example/"; + const expectedBaseUrl = "https://issuer.fga.example"; + const expectedPath = `/${DEFAULT_TOKEN_ENDPOINT_PATH}`; + + const scope = nock(expectedBaseUrl) + .post(expectedPath) + .reply(200, { + access_token: "test-token", + expires_in: 300, + }); + + const credentials = new Credentials( + { + method: CredentialsMethod.ClientCredentials, + config: { + apiTokenIssuer, + apiAudience: OPENFGA_API_AUDIENCE, + clientId: OPENFGA_CLIENT_ID, + clientSecret: OPENFGA_CLIENT_SECRET, + }, + } as AuthCredentialsConfig, + undefined, + mockTelemetryConfig, + ); + + await credentials.getAccessTokenHeader(); + + expect(scope.isDone()).toBe(true); + }); + + test("should preserve custom token endpoint path when provided", async () => { + const apiTokenIssuer = "https://issuer.fga.example/some_endpoint"; + const expectedBaseUrl = "https://issuer.fga.example"; + const expectedPath = "/some_endpoint"; + + const scope = nock(expectedBaseUrl) + .post(expectedPath) + .reply(200, { + access_token: "test-token", + expires_in: 300, + }); + + const credentials = new Credentials( + { + method: CredentialsMethod.ClientCredentials, + config: { + apiTokenIssuer, + apiAudience: OPENFGA_API_AUDIENCE, + clientId: OPENFGA_CLIENT_ID, + clientSecret: OPENFGA_CLIENT_SECRET, + }, + } as AuthCredentialsConfig, + undefined, + mockTelemetryConfig, + ); + + await credentials.getAccessTokenHeader(); + + expect(scope.isDone()).toBe(true); + }); + + test("should preserve custom token endpoint path with nested path when provided", async () => { + const apiTokenIssuer = "https://issuer.fga.example/api/v1/oauth/token"; + const expectedBaseUrl = "https://issuer.fga.example"; + const expectedPath = "/api/v1/oauth/token"; + + const scope = nock(expectedBaseUrl) + .post(expectedPath) + .reply(200, { + access_token: "test-token", + expires_in: 300, + }); + + const credentials = new Credentials( + { + method: CredentialsMethod.ClientCredentials, + config: { + apiTokenIssuer, + apiAudience: OPENFGA_API_AUDIENCE, + clientId: OPENFGA_CLIENT_ID, + clientSecret: OPENFGA_CLIENT_SECRET, + }, + } as AuthCredentialsConfig, + undefined, + mockTelemetryConfig, + ); + + await credentials.getAccessTokenHeader(); + + expect(scope.isDone()).toBe(true); + }); + + test("should add https:// prefix when apiTokenIssuer has no scheme", async () => { + const apiTokenIssuer = "issuer.fga.example/some_endpoint"; + const expectedBaseUrl = "https://issuer.fga.example"; + const expectedPath = "/some_endpoint"; + + const scope = nock(expectedBaseUrl) + .post(expectedPath) + .reply(200, { + access_token: "test-token", + expires_in: 300, + }); + + const credentials = new Credentials( + { + method: CredentialsMethod.ClientCredentials, + config: { + apiTokenIssuer, + apiAudience: OPENFGA_API_AUDIENCE, + clientId: OPENFGA_CLIENT_ID, + clientSecret: OPENFGA_CLIENT_SECRET, + }, + } as AuthCredentialsConfig, + undefined, + mockTelemetryConfig, + ); + + await credentials.getAccessTokenHeader(); + + expect(scope.isDone()).toBe(true); + }); + + test("should preserve http:// scheme when provided", async () => { + const apiTokenIssuer = "http://issuer.fga.example/some_endpoint"; + const expectedBaseUrl = "http://issuer.fga.example"; + const expectedPath = "/some_endpoint"; + + const scope = nock(expectedBaseUrl) + .post(expectedPath) + .reply(200, { + access_token: "test-token", + expires_in: 300, + }); + + const credentials = new Credentials( + { + method: CredentialsMethod.ClientCredentials, + config: { + apiTokenIssuer, + apiAudience: OPENFGA_API_AUDIENCE, + clientId: OPENFGA_CLIENT_ID, + clientSecret: OPENFGA_CLIENT_SECRET, + }, + } as AuthCredentialsConfig, + undefined, + mockTelemetryConfig, + ); + + await credentials.getAccessTokenHeader(); + + expect(scope.isDone()).toBe(true); + }); + + test("should use default path when apiTokenIssuer has https:// scheme but no path", async () => { + const apiTokenIssuer = "https://issuer.fga.example"; + const expectedBaseUrl = "https://issuer.fga.example"; + const expectedPath = `/${DEFAULT_TOKEN_ENDPOINT_PATH}`; + + const scope = nock(expectedBaseUrl) + .post(expectedPath) + .reply(200, { + access_token: "test-token", + expires_in: 300, + }); + + const credentials = new Credentials( + { + method: CredentialsMethod.ClientCredentials, + config: { + apiTokenIssuer, + apiAudience: OPENFGA_API_AUDIENCE, + clientId: OPENFGA_CLIENT_ID, + clientSecret: OPENFGA_CLIENT_SECRET, + }, + } as AuthCredentialsConfig, + undefined, + mockTelemetryConfig, + ); + + await credentials.getAccessTokenHeader(); + + expect(scope.isDone()).toBe(true); + }); + + test("should preserve custom path with query parameters", async () => { + const apiTokenIssuer = "https://issuer.fga.example/some_endpoint?param=value"; + const expectedBaseUrl = "https://issuer.fga.example"; + const expectedPath = "/some_endpoint"; + const queryParams = { param: "value" }; + + const scope = nock(expectedBaseUrl) + .post(expectedPath) + .query(queryParams) + .reply(200, { + access_token: "test-token", + expires_in: 300, + }); + + const credentials = new Credentials( + { + method: CredentialsMethod.ClientCredentials, + config: { + apiTokenIssuer, + apiAudience: OPENFGA_API_AUDIENCE, + clientId: OPENFGA_CLIENT_ID, + clientSecret: OPENFGA_CLIENT_SECRET, + }, + } as AuthCredentialsConfig, + undefined, + mockTelemetryConfig, + ); + + await credentials.getAccessTokenHeader(); + + expect(scope.isDone()).toBe(true); + }); + + test("should preserve custom path with port number", async () => { + const apiTokenIssuer = "https://issuer.fga.example:8080/some_endpoint"; + const expectedBaseUrl = "https://issuer.fga.example:8080"; + const expectedPath = "/some_endpoint"; + + const scope = nock(expectedBaseUrl) + .post(expectedPath) + .reply(200, { + access_token: "test-token", + expires_in: 300, + }); + + const credentials = new Credentials( + { + method: CredentialsMethod.ClientCredentials, + config: { + apiTokenIssuer, + apiAudience: OPENFGA_API_AUDIENCE, + clientId: OPENFGA_CLIENT_ID, + clientSecret: OPENFGA_CLIENT_SECRET, + }, + } as AuthCredentialsConfig, + undefined, + mockTelemetryConfig, + ); + + await credentials.getAccessTokenHeader(); + + expect(scope.isDone()).toBe(true); + }); + + test("should use default path when path has multiple trailing slashes", async () => { + const apiTokenIssuer = "https://issuer.fga.example///"; + const expectedBaseUrl = "https://issuer.fga.example"; + const expectedPath = `/${DEFAULT_TOKEN_ENDPOINT_PATH}`; + + const scope = nock(expectedBaseUrl) + .post(expectedPath) + .reply(200, { + access_token: "test-token", + expires_in: 300, + }); + + const credentials = new Credentials( + { + method: CredentialsMethod.ClientCredentials, + config: { + apiTokenIssuer, + apiAudience: OPENFGA_API_AUDIENCE, + clientId: OPENFGA_CLIENT_ID, + clientSecret: OPENFGA_CLIENT_SECRET, + }, + } as AuthCredentialsConfig, + undefined, + mockTelemetryConfig, + ); + + await credentials.getAccessTokenHeader(); + + expect(scope.isDone()).toBe(true); + }); + + test("should use default path when path only consists of slashes", async () => { + const apiTokenIssuer = "https://issuer.fga.example//"; + const expectedBaseUrl = "https://issuer.fga.example"; + const expectedPath = `/${DEFAULT_TOKEN_ENDPOINT_PATH}`; + + const scope = nock(expectedBaseUrl) + .post(expectedPath) + .reply(200, { + access_token: "test-token", + expires_in: 300, + }); + + const credentials = new Credentials( + { + method: CredentialsMethod.ClientCredentials, + config: { + apiTokenIssuer, + apiAudience: OPENFGA_API_AUDIENCE, + clientId: OPENFGA_CLIENT_ID, + clientSecret: OPENFGA_CLIENT_SECRET, + }, + } as AuthCredentialsConfig, + undefined, + mockTelemetryConfig, + ); + + await credentials.getAccessTokenHeader(); + + expect(scope.isDone()).toBe(true); + }); + + test("should preserve custom path with consecutive/trailing slashes", async () => { + const apiTokenIssuer = "https://issuer.fga.example/oauth//token///"; + const expectedBaseUrl = "https://issuer.fga.example"; + const expectedPath = "/oauth//token///"; + + const scope = nock(expectedBaseUrl) + .post(expectedPath) + .reply(200, { + access_token: "test-token", + expires_in: 300, + }); const credentials = new Credentials( { @@ -159,26 +427,85 @@ describe("Credentials", () => { )).toThrow(FgaValidationError); }); - test.each([ - { - description: "HTTPS scheme", - apiTokenIssuer: "https://issuer.fga.example/some_endpoint", - expectedBaseUrl: "https://issuer.fga.example", - expectedAudience: "https://issuer.fga.example/some_endpoint/", - }, - { - description: "HTTP scheme", - apiTokenIssuer: "http://issuer.fga.example/some_endpoint", - expectedBaseUrl: "http://issuer.fga.example", - expectedAudience: "http://issuer.fga.example/some_endpoint/", - }, - { - description: "No scheme", - apiTokenIssuer: "issuer.fga.example/some_endpoint", - expectedBaseUrl: "https://issuer.fga.example", - expectedAudience: "https://issuer.fga.example/some_endpoint/", - } - ])("should normalize audience from apiTokenIssuer when using PrivateKeyJWT client credentials ($description)", async ({ apiTokenIssuer, expectedBaseUrl, expectedAudience }) => { + test("should normalize audience from apiTokenIssuer when using PrivateKeyJWT client credentials with HTTPS scheme", async () => { + const apiTokenIssuer = "https://issuer.fga.example/some_endpoint"; + const expectedBaseUrl = "https://issuer.fga.example"; + const expectedAudience = "https://issuer.fga.example/some_endpoint/"; + + const scope = nock(expectedBaseUrl) + .post("/some_endpoint", (body: string) => { + const params = new URLSearchParams(body); + const clientAssertion = params.get("client_assertion") as string; + const decoded = jose.decodeJwt(clientAssertion); + expect(decoded.aud).toBe(`${expectedAudience}`); + return true; + }) + .reply(200, { + access_token: "test-token", + expires_in: 300, + }); + + const credentials = new Credentials( + { + method: CredentialsMethod.ClientCredentials, + config: { + apiTokenIssuer, + apiAudience: OPENFGA_API_AUDIENCE, + clientId: OPENFGA_CLIENT_ID, + clientAssertionSigningKey: OPENFGA_CLIENT_ASSERTION_SIGNING_KEY, + }, + } as AuthCredentialsConfig, + undefined, + mockTelemetryConfig, + ); + + await credentials.getAccessTokenHeader(); + + expect(scope.isDone()).toBe(true); + }); + + test("should normalize audience from apiTokenIssuer when using PrivateKeyJWT client credentials with HTTP scheme", async () => { + const apiTokenIssuer = "http://issuer.fga.example/some_endpoint"; + const expectedBaseUrl = "http://issuer.fga.example"; + const expectedAudience = "http://issuer.fga.example/some_endpoint/"; + + const scope = nock(expectedBaseUrl) + .post("/some_endpoint", (body: string) => { + const params = new URLSearchParams(body); + const clientAssertion = params.get("client_assertion") as string; + const decoded = jose.decodeJwt(clientAssertion); + expect(decoded.aud).toBe(`${expectedAudience}`); + return true; + }) + .reply(200, { + access_token: "test-token", + expires_in: 300, + }); + + const credentials = new Credentials( + { + method: CredentialsMethod.ClientCredentials, + config: { + apiTokenIssuer, + apiAudience: OPENFGA_API_AUDIENCE, + clientId: OPENFGA_CLIENT_ID, + clientAssertionSigningKey: OPENFGA_CLIENT_ASSERTION_SIGNING_KEY, + }, + } as AuthCredentialsConfig, + undefined, + mockTelemetryConfig, + ); + + await credentials.getAccessTokenHeader(); + + expect(scope.isDone()).toBe(true); + }); + + test("should normalize audience from apiTokenIssuer when using PrivateKeyJWT client credentials with no scheme", async () => { + const apiTokenIssuer = "issuer.fga.example/some_endpoint"; + const expectedBaseUrl = "https://issuer.fga.example"; + const expectedAudience = "https://issuer.fga.example/some_endpoint/"; + const scope = nock(expectedBaseUrl) .post("/some_endpoint", (body: string) => { const params = new URLSearchParams(body);