diff --git a/credentials/credentials.ts b/credentials/credentials.ts index 7a7fa96..a8c3409 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; @@ -37,6 +38,11 @@ interface ClientAssertionRequest { audience: string; } +const HTTP_SCHEME = "http://"; +const HTTPS_SCHEME = "https://"; + +export const DEFAULT_TOKEN_ENDPOINT_PATH = "oauth/token"; + export class Credentials { private accessToken?: string; private accessTokenExpiryDate?: Date; @@ -87,16 +93,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(`https://${authConfig.config?.apiTokenIssuer}`)) { + const normalizedApiTokenIssuer = this.normalizeApiTokenIssuer(authConfig.config?.apiTokenIssuer); + if (!isWellFormedUriString(normalizedApiTokenIssuer)) { throw new FgaValidationError( - `Configuration.apiTokenIssuer does not form a valid URI (https://${authConfig.config?.apiTokenIssuer})`); + `Configuration.apiTokenIssuer does not form a valid URI (${normalizedApiTokenIssuer})`); } + } break; } } @@ -138,13 +146,47 @@ 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_SCHEME) || apiTokenIssuer.startsWith(HTTPS_SCHEME)) { + return apiTokenIssuer; + } + return `${HTTPS_SCHEME}${apiTokenIssuer}`; + } + + /** + * Constructs the token endpoint URL from the provided API token issuer. + * @private + * @param apiTokenIssuer + * @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 { + const normalizedApiTokenIssuer = this.normalizeApiTokenIssuer(apiTokenIssuer); + + try { + const url = new URL(normalizedApiTokenIssuer); + if (!url.pathname || url.pathname.match(/^\/+$/)) { + url.pathname = `/${DEFAULT_TOKEN_ENDPOINT_PATH}`; + } + return url.toString(); // Query params are preserved in the URL + } catch { + throw new FgaValidationError(`Invalid API token issuer URL: ${normalizedApiTokenIssuer}`); + } + } + /** * 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 { @@ -216,13 +258,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}/`) // 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 new file mode 100644 index 0000000..a465c79 --- /dev/null +++ b/tests/credentials.test.ts @@ -0,0 +1,541 @@ +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"; + +describe("Credentials", () => { + const mockTelemetryConfig: TelemetryConfiguration = new TelemetryConfiguration({}); + + describe("Refreshing access token", () => { + beforeEach(() => { + nock.disableNetConnect(); + }); + + afterEach(() => { + nock.cleanAll(); + nock.enableNetConnect(); + }); + + 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( + { + 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.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, + apiAudience: OPENFGA_API_AUDIENCE, + clientId: OPENFGA_CLIENT_ID, + clientSecret: OPENFGA_CLIENT_SECRET, + }, + } as AuthCredentialsConfig, + undefined, + mockTelemetryConfig, + )).toThrow(FgaValidationError); + }); + + 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); + 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); + }); + }); +}); diff --git a/tests/index.test.ts b/tests/index.test.ts index 5be22a4..8cfc718 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 or default when scheme is missing (%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"] })